From 4caa3c440baaf94c9fe9ec054e04b9528b0a07ef Mon Sep 17 00:00:00 2001 From: Panoplos Date: Fri, 14 Aug 2026 11:46:46 +0200 Subject: [PATCH] fix(tui): route selection copy through the host clipboard (#8110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TuiAltScreen.copySelectionToClipboard() wrote a bare OSC 52 sequence and flashed "Copied!" unconditionally. Terminals that ignore OSC 52 clipboard writes (macOS Terminal.app, VTE terminals, tmux without OSC 52 passthrough) left the system clipboard untouched while the toast reported success. Add an injectable copySelection option to TuiAltScreenOptions (an async handler returning whether the copy succeeded) and use it when provided, falling back to the existing OSC 52 write otherwise. The coding agent now injects its native clipboard implementation (clipboard addon + pbcopy / wl-copy / xclip / xsel), so selection copy reaches the real clipboard and the toast only fires on verified success, with "Copy failed" otherwise. Closes #7761 Co-authored-by: 富永 誠 --- .../src/modes/interactive/interactive-mode.ts | 8 +++ packages/tui/src/tui-alt-screen.ts | 20 +++++++- packages/tui/test/tui-alt-screen.test.ts | 51 +++++++++++++++++++ 3 files changed, 77 insertions(+), 2 deletions(-) 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);