fix(coding-agent): support Windows fullscreen right-click paste

This commit is contained in:
Armin Ronacher 2026-08-06 22:39:09 +02:00
parent 7d046bcf21
commit c96bfaccd0
6 changed files with 93 additions and 1 deletions

View file

@ -6,6 +6,10 @@
- Added `pi auth check` provider/model auth preflight with optional credential output ([#7152](https://github.com/earendil-works/pi/issues/7152)).
### Fixed
- Fixed right-click not pasting clipboard text in fullscreen mode on Windows.
## [0.84.0] - 2026-08-06
### New Features

View file

@ -336,13 +336,17 @@ interface InteractiveTuiOptions {
showHardwareCursor: boolean;
logDirectory: string;
terminal?: Terminal;
onRightClickPaste?: () => void;
}
/** Composition root for selecting the interactive terminal renderer. */
export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScreen | TuiAltScreen {
const terminal = options.terminal ?? new ProcessTerminal();
if (options.tuiMode === "fullscreen") {
return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { openUrl: openBrowser });
return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, {
openUrl: openBrowser,
onRightClickPaste: options.onRightClickPaste,
});
}
return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory);
}
@ -493,6 +497,9 @@ export class InteractiveMode {
private customHeader: (Component & { dispose?(): void }) | undefined = undefined;
private options: InteractiveModeOptions;
private readonly onRightClickPaste = (): void => {
void this.handleRightClickPaste();
};
private autoTrustOnReloadCwd: string | undefined;
private themeController: InteractiveThemeController;
@ -526,6 +533,7 @@ export class InteractiveMode {
tuiMode,
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
logDirectory: getAgentDir(),
onRightClickPaste: this.onRightClickPaste,
});
this.ui = createInteractiveTuiReference(() => this.renderer);
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
@ -794,6 +802,7 @@ export class InteractiveMode {
showHardwareCursor,
logDirectory: getAgentDir(),
terminal,
onRightClickPaste: this.onRightClickPaste,
});
nextUi.setClearOnShrink(clearOnShrink);
nextUi.onDebug = onDebug;
@ -2811,6 +2820,20 @@ export class InteractiveMode {
};
}
private async handleRightClickPaste(): Promise<void> {
const target = this.renderer.getFocusedComponent();
const handleInput = target?.handleInput;
if (!target || !handleInput) return;
try {
const text = await readClipboardText();
if (!text || this.renderer.getFocusedComponent() !== target) return;
handleInput.call(target, `\x1b[200~${text}\x1b[201~`);
this.ui.requestRender();
} catch {
// Silently ignore clipboard errors (may not have permission, etc.)
}
}
private async handleClipboardPaste(): Promise<void> {
try {
const image = await readClipboardImage();

View file

@ -128,6 +128,27 @@ describe("createInteractiveTui", () => {
});
});
describe("InteractiveMode right-click paste", () => {
it("feeds clipboard text to the focused component as a bracketed paste", async () => {
clipboardMocks.readClipboardText.mockResolvedValue("clipboard text");
const handleInput = vi.fn<(data: string) => void>();
const target = { render: () => [], invalidate: () => {}, handleInput } satisfies Component;
const requestRender = vi.fn();
const context = {
renderer: { getFocusedComponent: () => target },
ui: { requestRender },
};
const prototype = InteractiveMode.prototype as unknown as {
handleRightClickPaste(this: typeof context): Promise<void>;
};
await prototype.handleRightClickPaste.call(context);
expect(handleInput).toHaveBeenCalledWith("\x1b[200~clipboard text\x1b[201~");
expect(requestRender).toHaveBeenCalledOnce();
});
});
type CopyCommandContext = {
session: { getLastAssistantText: () => string | undefined };
ui: ReturnType<typeof createInteractiveTui>;

View file

@ -6,6 +6,7 @@
- Added unbound half-page transcript scrolling actions, `tui.altScreen.halfPageUp` and `tui.altScreen.halfPageDown`, for fullscreen TUI keybindings ([#7735](https://github.com/earendil-works/pi/issues/7735)).
- Added double-click word selection, word-aware drag selection, and triple-click line selection in the fullscreen TUI ([#7725](https://github.com/earendil-works/pi/issues/7725)).
- Added an optional right-click paste handler to the alternate-screen TUI, currently enabled on Windows.
### Fixed

View file

@ -121,6 +121,8 @@ export interface TuiAltScreenOptions {
mouse?: boolean;
/** Open an OSC 8 hyperlink activated with a primary-button click. */
openUrl?: (url: string) => void;
/** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */
onRightClickPaste?: () => void;
}
/** Alternate-screen TUI with a scrollable, application-owned viewport. */
@ -156,6 +158,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
private readonly wheelScrollLines: number;
private readonly mouseEnabled: boolean;
private readonly openUrl?: (url: string) => void;
private readonly onRightClickPaste?: () => void;
constructor(
terminal: Terminal,
@ -175,6 +178,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1));
this.mouseEnabled = options.mouse ?? true;
this.openUrl = options.openUrl;
this.onRightClickPaste = options.onRightClickPaste;
this.addInputListener((data) => this.handleViewportInput(data));
}
@ -406,6 +410,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
}
const mouseEvent = this.parseSgrMouseEvent(data);
if (mouseEvent) {
if (this.handleRightClickPaste(mouseEvent)) return { consume: true };
const handled = this.handleScrollbarMouseEvent(mouseEvent);
if (!this.scrollbarDrag) this.updateScrollbarHover(mouseEvent.x, mouseEvent.y);
if (!handled) this.handleSelectionMouseEvent(mouseEvent);
@ -506,6 +511,18 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
};
}
private handleRightClickPaste(event: SgrMouseEvent): boolean {
if (!this.onRightClickPaste || process.platform !== "win32" || event.release || event.button !== 2) {
return false;
}
try {
this.onRightClickPaste();
} catch {
// Clipboard paste is best-effort.
}
return true;
}
private getScrollbarTargetAt(x: number, y: number): ScrollbarTarget | undefined {
if (this.hasOverlay() || !this.currentLayout) return undefined;
for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) {

View file

@ -209,6 +209,32 @@ describe("TuiAltScreen", () => {
}
});
it("invokes the right-click paste handler only on Windows", () => {
const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform");
assert.ok(platformDescriptor);
const terminal = new VirtualTerminal();
let pasteCount = 0;
const tui = new TuiAltScreen(terminal, undefined, undefined, {
onRightClickPaste: () => {
pasteCount += 1;
},
});
try {
Object.defineProperty(process, "platform", { configurable: true, value: "win32" });
tui.start();
terminal.sendInput("\x1b[<2;1;1M");
terminal.sendInput("\x1b[<2;1;1m");
assert.strictEqual(pasteCount, 1);
Object.defineProperty(process, "platform", { configurable: true, value: "linux" });
terminal.sendInput("\x1b[<2;1;1M");
assert.strictEqual(pasteCount, 1);
} finally {
tui.stop();
Object.defineProperty(process, "platform", platformDescriptor);
}
});
it("drags a visible scrollbar thumb and keeps it visible until release", async () => {
const terminal = new RecordingTerminal(10, 5);
const tui = new TuiAltScreen(terminal);