feat(tui): allow disable copy on fullscreen, ctrl + x copies selection (#8731)

This commit is contained in:
Cristina Poncela Cubeiro 2026-08-27 18:07:54 +02:00 • committed by GitHub
parent 1defa151e0
commit 4e49492999
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 260 additions and 16 deletions

View file

@ -5,6 +5,7 @@
### Added
- Added environment and programmatic overrides for OSC 8 hyperlinks, inline image protocols, and truecolor terminal capabilities ([#8665](https://github.com/earendil-works/pi/issues/8665)).
- Added a `TuiAltScreen` `copyOnSelect` option plus helpers to detect and copy the active fullscreen text selection programmatically ([#7720](https://github.com/earendil-works/pi/issues/7720)).
### Fixed

View file

@ -657,7 +657,7 @@ if (matchesKey(data, Key.enter)) {
2. **Width Changed or Change Above Viewport**: Clear screen and fully re-render
3. **Normal Update**: Move the cursor to the first changed line, clear to the end, and render changed lines
`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.
`TuiAltScreen` owns a terminal-height viewport. Without an explicit layout root it preserves the legacy single-document scrolling behavior. With `setLayoutRoot()`, `VStack`, `HStack`, and nested `ScrollView` components can reserve fixed regions and independently scroll constrained regions. It updates changed viewport rows in place, follows streaming output while at the bottom, and preserves a manually selected scroll position while content grows. Mouse-wheel and configurable keyboard navigation scroll without modifying terminal scrollback, including jumps between OSC 133 semantic prompt markers. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects text and, unless `TuiAltScreenOptions.copyOnSelect` is `false`, copies it to the clipboard with OSC 52; holding the drag at a scroll view's top or bottom edge auto-scrolls and extends the selection into off-screen content. Kitty images support vertical viewport cropping; iTerm2 inline images fall back to text because the iTerm2 protocol cannot delete or crop placements during viewport repainting.
Both renderers wrap updates in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering.

View file

@ -156,6 +156,8 @@ export interface TuiAltScreenOptions {
openUrl?: (url: string) => void;
/** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */
onRightClickPaste?: () => void;
/** Automatically copy selected text to the clipboard on mouse release (default: true). */
copyOnSelect?: boolean;
/**
* 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.
@ -200,6 +202,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 copyOnSelect: boolean;
private readonly copySelection?: (text: string) => Promise<boolean>;
constructor(
@ -223,6 +226,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.copyOnSelect = options.copyOnSelect ?? true;
this.copySelection = options.copySelection;
this.addInputListener((data) => this.handleViewportInput(data));
}
@ -235,6 +239,26 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
return this.getPrimaryScrollView().isFollowingEnd;
}
getCopyOnSelect(): boolean {
return this.copyOnSelect;
}
setCopyOnSelect(enabled: boolean): void {
this.copyOnSelect = enabled;
}
/** Whether the fullscreen viewport has a non-empty active text selection. */
hasActiveSelection(): boolean {
return this.getActiveSelectionText() !== undefined;
}
/** Copy the active fullscreen text selection, if any, using the configured selection clipboard path. */
async copyActiveSelectionToClipboard(): Promise<boolean> {
const text = this.getActiveSelectionText();
if (!text) return false;
return this.copyTextToClipboard(text);
}
setLayoutRoot(component: Component | undefined): void {
if (this.layoutRoot === component) return;
this.layoutRoot = component;
@ -1008,7 +1032,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
this.requestRender();
return;
}
void this.copySelectionToClipboard();
if (this.copyOnSelect) void this.copySelectionToClipboard();
this.requestRender();
return;
}
@ -1084,14 +1108,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) };
}
private async copySelectionToClipboard(): Promise<void> {
private getActiveSelectionText(): string | undefined {
const selection = this.getSelectionBounds();
if (!selection) return;
if (!selection) return undefined;
let sourceLines: readonly string[] = this.previousScreen;
if (selection.start.scrollView) {
if (!this.currentLayout) return;
if (!this.currentLayout) return undefined;
const box = getScrollViewBox(this.currentLayout, selection.start.scrollView);
if (!box?.scrollContentLines) return;
if (!box?.scrollContentLines) return undefined;
sourceLines = box.scrollContentLines;
}
const lines: string[] = [];
@ -1105,7 +1129,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
);
}
const text = lines.join("\n");
if (text.length === 0) return;
return text.length === 0 ? undefined : text;
}
private async copySelectionToClipboard(): Promise<boolean> {
const text = this.getActiveSelectionText();
if (!text) return false;
return this.copyTextToClipboard(text);
}
private async copyTextToClipboard(text: string): Promise<boolean> {
// 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
@ -1113,10 +1146,11 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI {
if (this.copySelection) {
const ok = await this.copySelection(text);
this.flash(ok ? "Copied!" : "Copy failed");
return;
return ok;
}
this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`);
this.flash("Copied!");
return true;
}
private applySearchTextHighlight(text: string, current: boolean): string {

View file

@ -1000,6 +1000,67 @@ describe("TuiAltScreen", () => {
tui.stop();
});
it("leaves selections visible without copying when copyOnSelect is disabled", async () => {
const terminal = new RecordingTerminal(20, 4);
const copied: string[] = [];
const tui = new TuiAltScreen(terminal, undefined, undefined, {
copyOnSelect: false,
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, []);
assert.strictEqual(tui.hasActiveSelection(), true);
assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m")));
assert.ok(terminal.getViewport().every((line) => !line.includes("Copied!")));
tui.stop();
});
it("copies an active selection programmatically", 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();
assert.strictEqual(tui.hasActiveSelection(), false);
assert.strictEqual(await tui.copyActiveSelectionToClipboard(), false);
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.strictEqual(tui.hasActiveSelection(), true);
copied.length = 0;
assert.strictEqual(await tui.copyActiveSelectionToClipboard(), true);
await terminal.waitForRender();
assert.deepStrictEqual(copied, ["alpha\nbeta"]);
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, {