fix(tui): route selection copy through the host clipboard (#8110)

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: 富永 誠 <makoto@credify.one>
This commit is contained in:
Panoplos 2026-08-14 11:46:46 +02:00 • committed by GitHub
parent 48bd3f424d
commit 4caa3c440b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 77 additions and 2 deletions

View file

@ -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);

View file

@ -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<boolean>;
}
/** 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<boolean>;
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<void> {
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!");
}

View file

@ -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);