diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9bbad2e7e..cd0f8afc8 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index a306d900b..912de637f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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 { + 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 { try { const image = await readClipboardImage(); diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index 996e5e56c..82a29edc6 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -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; + }; + + 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; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 058ffb444..c75535021 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index cff48a261..fa7063e4c 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -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)) { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 750b58b9b..dc84f2fd5 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -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);