diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 07fc5ea19..6d5e6b884 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -352,6 +352,14 @@ export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScr searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))), openUrl: openBrowser, onRightClickPaste: options.onRightClickPaste, + copySelection: async (text) => { + try { + await copyToClipboard(text); + return true; + } catch { + return false; + } + }, }); } return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory); diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 0dfc43c7f..783a92ab8 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -153,6 +153,11 @@ export interface TuiAltScreenOptions { openUrl?: (url: string) => void; /** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */ onRightClickPaste?: () => void; + /** + * Copy selected text to the system clipboard. Return `true` on success; the caller flashes + * an error otherwise. When omitted, the selection is copied via an OSC 52 write. + */ + copySelection?: (text: string) => Promise; } /** Alternate-screen TUI with a scrollable, application-owned viewport. */ @@ -192,6 +197,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private readonly searchCurrentMatchStyle: (text: string) => string; private readonly openUrl?: (url: string) => void; private readonly onRightClickPaste?: () => void; + private readonly copySelection?: (text: string) => Promise; constructor( terminal: Terminal, @@ -214,6 +220,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`); this.openUrl = options.openUrl; this.onRightClickPaste = options.onRightClickPaste; + this.copySelection = options.copySelection; this.addInputListener((data) => this.handleViewportInput(data)); } @@ -971,7 +978,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.requestRender(); return; } - this.copySelectionToClipboard(); + void this.copySelectionToClipboard(); this.requestRender(); return; } @@ -1047,7 +1054,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) }; } - private copySelectionToClipboard(): void { + private async copySelectionToClipboard(): Promise { const selection = this.getSelectionBounds(); if (!selection) return; let sourceLines: readonly string[] = this.previousScreen; @@ -1069,6 +1076,15 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } const text = lines.join("\n"); if (text.length === 0) return; + // Prefer an injected clipboard implementation (native clipboard + platform tools with a + // verified success path) when the host app provides one. A bare OSC 52 write can show + // "Copied!" while leaving the system clipboard untouched (e.g. macOS Terminal.app, tmux + // without OSC 52 clipboard passthrough), so only report success when it actually copies. + if (this.copySelection) { + const ok = await this.copySelection(text); + this.flash(ok ? "Copied!" : "Copy failed"); + return; + } this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`); this.flash("Copied!"); } diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 1e0d94c22..2166debe7 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -963,6 +963,57 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("uses an injected copySelection handler instead of OSC 52 and reports success", async () => { + const terminal = new RecordingTerminal(20, 4); + const copied: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async (text) => { + copied.push(text); + return true; + }, + }); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + + assert.deepStrictEqual(copied, ["alpha\nbeta"]); + assert.ok( + terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")), + "must not emit OSC 52 when a copySelection handler is provided", + ); + assert.ok(terminal.getViewport().some((line) => line.includes("Copied!"))); + + tui.stop(); + }); + + it("flashes an error when the injected copySelection handler fails", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal, undefined, undefined, { + copySelection: async () => false, + }); + tui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + + assert.ok(terminal.getViewport().some((line) => line.includes("Copy failed"))); + assert.ok( + terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;")), + "must not emit OSC 52 when a copySelection handler is provided", + ); + + tui.stop(); + }); + it("does not append whitespace to double-click word highlighting", async () => { const terminal = new RecordingTerminal(20, 1); const tui = new TuiAltScreen(terminal);