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

@ -8,6 +8,7 @@
- Added transcript usage notices for compaction and branch summaries when cache miss notices are enabled.
- Added RPC `clear_queue` to retrieve and remove queued steering and follow-up messages ([#8432](https://github.com/earendil-works/pi/issues/8432)).
- Added environment variables and advanced settings for overriding auto-detected terminal hyperlink, image, and truecolor capabilities ([#8665](https://github.com/earendil-works/pi/issues/8665)).
- Added `fullscreenCopyOnSelect` to disable automatic fullscreen selection copy; when disabled, `Ctrl+X` copies the active text selection before falling back to the last assistant message, while `/tree` still copies the selected message ([#7720](https://github.com/earendil-works/pi/issues/7720)).
### Fixed

View file

@ -217,7 +217,7 @@ See `/hotkeys` for the full list. Customize via `~/.pi/agent/keybindings.json`.
| Shift+Tab | Cycle thinking level |
| Ctrl+O | Collapse/expand tool output |
| Ctrl+T | Collapse/expand thinking blocks |
| Ctrl+X | Copy the last assistant message |
| Ctrl+X | Copy the last assistant message; with fullscreen copy-on-select disabled, copy the active text selection |
### Message Queue

View file

@ -157,7 +157,7 @@ This routing remains configurable through the ordinary action bindings. For exam
| Keybinding id | Default | Description |
|--------|---------|-------------|
| `app.tools.expand` | `ctrl+o` | Collapse or expand tool output |
| `app.message.copy` | `ctrl+x` | Copy the last assistant message, or the selected message in `/tree` |
| `app.message.copy` | `ctrl+x` | Copy the selected message in `/tree`; otherwise copy the last assistant message, or the active fullscreen text selection when `fullscreenCopyOnSelect` is disabled |
| `app.message.followUp` | `alt+enter` (`ctrl+q` on Windows and WSL) | Queue follow-up message |
| `app.message.dequeue` | `alt+up` (`alt+q` on Windows and WSL) | Restore queued messages to editor |

View file

@ -69,6 +69,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses
| `tuiMode` | string | `"regular"` | Interactive TUI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply immediately; `--tui-mode` overrides this setting at startup |
| `fullscreenExitOutput` | string | `"transcript"` | Fullscreen exit output: `"transcript"` prints the final transcript and resume hint, while `"resume-hint"` restores the previous screen and prints only the resume hint. Has no effect in regular TUI mode |
| `fullscreenScrollbar` | string | `"auto"` | Fullscreen transcript scrollbar: `"auto"` shows it temporarily while scrolling, `"always"` reserves the rightmost column and keeps it visible, and `"hidden"` hides it. Has no effect in regular TUI mode |
| `fullscreenCopyOnSelect` | boolean | `true` | Automatically copy selected text in fullscreen mode. When disabled, selections stay highlighted and `Ctrl+X` copies the active selection |
For VS Code, include `--wait` so pi resumes after the editor exits:

View file

@ -22,7 +22,7 @@ The editor can be replaced temporarily by built-in UI such as `/settings` or by
| File reference | Type `@` to fuzzy-search project files |
| Path completion | Press Tab to complete paths |
| Multi-line input | Shift+Enter, or Ctrl+Enter on Windows Terminal |
| Copy response | Ctrl+X copies the last assistant message; in `/tree`, it copies the selected message |
| Copy response | Ctrl+X copies the selected message in `/tree`; otherwise it copies the last assistant message, or the active fullscreen text selection when `fullscreenCopyOnSelect` is disabled |
| Images | Paste with Ctrl+V, Alt+V on Windows, or drag into the terminal |
| Shell command | `!command` runs and sends output to the model |
| Hidden shell command | `!!command` runs without sending output to the model |

View file

@ -142,6 +142,7 @@ export interface Settings {
tuiMode?: TuiMode; // default: "regular"
fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode
fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode
fullscreenCopyOnSelect?: boolean; // default: true; no effect in regular TUI mode
}
function isMergeableObject(value: unknown): value is Record<string, unknown> {
@ -1229,6 +1230,16 @@ export class SettingsManager {
this.save();
}
getFullscreenCopyOnSelect(): boolean {
return this.settings.fullscreenCopyOnSelect ?? true;
}
setFullscreenCopyOnSelect(enabled: boolean): void {
this.globalSettings.fullscreenCopyOnSelect = enabled;
this.markModified("fullscreenCopyOnSelect");
this.save();
}
getImageAutoResize(): boolean {
return this.settings.images?.autoResize ?? true;
}

View file

@ -84,6 +84,7 @@ export interface SettingsConfig {
tuiMode: TuiMode;
fullscreenExitOutput: FullscreenExitOutput;
fullscreenScrollbar: ScrollViewScrollbar;
fullscreenCopyOnSelect: boolean;
warnings: WarningSettings;
}
@ -120,6 +121,7 @@ export interface SettingsCallbacks {
onTuiModeChange: (mode: TuiMode) => void;
onFullscreenExitOutputChange: (output: FullscreenExitOutput) => void;
onFullscreenScrollbarChange: (mode: ScrollViewScrollbar) => void;
onFullscreenCopyOnSelectChange: (enabled: boolean) => void;
onWarningsChange: (warnings: WarningSettings) => void;
onCancel: () => void;
}
@ -689,6 +691,13 @@ export class SettingsSelectorComponent extends Container {
currentValue: config.fullscreenScrollbar,
values: ["auto", "always", "hidden"],
},
{
id: "fullscreen-copy-on-select",
label: "Fullscreen copy on select",
description: "Automatically copy selected text in fullscreen mode; disable to copy selections with Ctrl+X",
currentValue: config.fullscreenCopyOnSelect ? "true" : "false",
values: ["true", "false"],
},
{
id: "theme",
label: "Theme",
@ -910,6 +919,9 @@ export class SettingsSelectorComponent extends Container {
case "fullscreen-scrollbar":
callbacks.onFullscreenScrollbarChange(newValue as ScrollViewScrollbar);
break;
case "fullscreen-copy-on-select":
callbacks.onFullscreenCopyOnSelectChange(newValue === "true");
break;
case "theme":
callbacks.onThemeChange(newValue);
break;

View file

@ -362,6 +362,7 @@ interface InteractiveTuiOptions {
logDirectory: string;
terminal?: Terminal;
onRightClickPaste?: () => void;
fullscreenCopyOnSelect?: boolean;
}
/** Composition root for selecting the interactive terminal renderer. */
@ -374,6 +375,7 @@ export function createInteractiveTui(options: InteractiveTuiOptions): TuiMainScr
searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
openUrl: openBrowser,
onRightClickPaste: options.onRightClickPaste,
copyOnSelect: options.fullscreenCopyOnSelect,
copySelection: async (text) => {
try {
await copyToClipboard(text);
@ -581,6 +583,7 @@ export class InteractiveMode {
showHardwareCursor: this.settingsManager.getShowHardwareCursor(),
logDirectory: getAgentDir(),
onRightClickPaste: this.onRightClickPaste,
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
});
this.ui = createInteractiveTuiReference(() => this.renderer);
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
@ -865,6 +868,7 @@ export class InteractiveMode {
logDirectory: getAgentDir(),
terminal,
onRightClickPaste: this.onRightClickPaste,
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
});
nextUi.setClearOnShrink(clearOnShrink);
nextUi.onDebug = onDebug;
@ -1987,6 +1991,9 @@ export class InteractiveMode {
setCapabilityOverrides(this.settingsManager.getTerminalCapabilityOverrides());
configureHttpDispatcher(this.settingsManager.getHttpIdleTimeoutMs());
this.applyFullscreenScrollbarSetting();
if (this.renderer instanceof TuiAltScreen) {
this.renderer.setCopyOnSelect(this.settingsManager.getFullscreenCopyOnSelect());
}
this.footer.setSession(this.session);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
this.footerDataProvider.setCwd(this.sessionManager.getCwd());
@ -2894,7 +2901,10 @@ export class InteractiveMode {
this.defaultEditor.onAction("app.tools.expand", () => this.toggleToolOutputExpansion());
this.defaultEditor.onAction("app.thinking.toggle", () => this.toggleThinkingBlockVisibility());
this.defaultEditor.onAction("app.editor.external", () => void this.handleOpenExternalEditor());
this.defaultEditor.onAction("app.message.copy", () => void this.handleCopyCommand({ flashConfirmation: true }));
this.defaultEditor.onAction(
"app.message.copy",
() => void this.handleCopyCommand({ flashConfirmation: true, preferSelection: true }),
);
this.defaultEditor.onAction("app.message.followUp", () => this.handleFollowUp());
this.defaultEditor.onAction("app.message.dequeue", () => this.handleDequeue());
this.defaultEditor.onAction("app.session.new", () => this.handleClearCommand());
@ -4568,6 +4578,7 @@ export class InteractiveMode {
tuiMode: this.ui.mode,
fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(),
fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(),
fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(),
warnings: this.settingsManager.getWarnings(),
},
{
@ -4740,6 +4751,10 @@ export class InteractiveMode {
this.settingsManager.setFullscreenScrollbar(mode);
this.applyFullscreenScrollbarSetting();
},
onFullscreenCopyOnSelectChange: (enabled) => {
this.settingsManager.setFullscreenCopyOnSelect(enabled);
if (this.renderer instanceof TuiAltScreen) this.renderer.setCopyOnSelect(enabled);
},
onWarningsChange: (warnings) => {
this.settingsManager.setWarnings(warnings);
},
@ -6096,7 +6111,19 @@ export class InteractiveMode {
});
}
private async handleCopyCommand(options: { flashConfirmation?: boolean } = {}): Promise<void> {
private async handleCopyCommand(
options: { flashConfirmation?: boolean; preferSelection?: boolean } = {},
): Promise<void> {
if (
options.preferSelection &&
this.ui instanceof TuiAltScreen &&
!this.ui.getCopyOnSelect() &&
this.ui.hasActiveSelection()
) {
await this.ui.copyActiveSelectionToClipboard();
return;
}
const text = this.session.getLastAssistantText();
if (!text) {
this.showError("No agent messages to copy yet.");

View file

@ -87,6 +87,7 @@ describe("createInteractiveTui", () => {
renderer.setFocus(component);
type SwitchContext = {
runtimeHost: { session: { settingsManager: { getFullscreenCopyOnSelect: () => boolean } } };
renderer: ReturnType<typeof createInteractiveTui>;
ui: TUI;
fullscreenLayoutRoot: Component;
@ -95,6 +96,7 @@ describe("createInteractiveTui", () => {
extensionTerminalInputSubscriptions: Set<never>;
};
const context = Object.assign(Object.create(InteractiveMode.prototype), {
runtimeHost: { session: { settingsManager: { getFullscreenCopyOnSelect: () => true } } },
renderer,
ui: undefined as unknown as TUI,
fullscreenLayoutRoot: component,
@ -156,7 +158,7 @@ type CopyCommandContext = {
showError: (message: string) => void;
};
type CopyCommandOptions = { flashConfirmation?: boolean };
type CopyCommandOptions = { flashConfirmation?: boolean; preferSelection?: boolean };
type CopyCommandPrototype = {
handleCopyCommand(this: CopyCommandContext, options?: CopyCommandOptions): Promise<void>;
@ -170,6 +172,91 @@ describe("InteractiveMode copy confirmation", () => {
clipboardMocks.copyToClipboard.mockResolvedValue(undefined);
});
it("copies an active fullscreen selection when copy-on-select is disabled", async () => {
const terminal = new RecordingTerminal(40, 4);
const ui = createInteractiveTui({
tuiMode: "fullscreen",
showHardwareCursor: false,
logDirectory: "/tmp",
terminal,
fullscreenCopyOnSelect: false,
});
const getLastAssistantText = vi.fn(() => "assistant response");
const showStatus = vi.fn();
const showError = vi.fn();
const context: CopyCommandContext = {
session: { getLastAssistantText },
ui,
showStatus,
showError,
};
ui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
ui.start();
try {
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
clipboardMocks.copyToClipboard.mockClear();
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
await terminal.waitForRender();
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledOnce();
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith("alpha\nbeta");
expect(getLastAssistantText).not.toHaveBeenCalled();
expect(showStatus).not.toHaveBeenCalled();
expect(showError).not.toHaveBeenCalled();
expect(terminal.getViewport().some((line) => line.includes("Copied!"))).toBe(true);
} finally {
ui.stop();
}
});
it("copies the last assistant message with an active fullscreen selection when copy-on-select is enabled", async () => {
const terminal = new RecordingTerminal(40, 4);
const ui = createInteractiveTui({
tuiMode: "fullscreen",
showHardwareCursor: false,
logDirectory: "/tmp",
terminal,
});
const getLastAssistantText = vi.fn(() => "assistant response");
const showStatus = vi.fn();
const showError = vi.fn();
const context: CopyCommandContext = {
session: { getLastAssistantText },
ui,
showStatus,
showError,
};
ui.addChild(new Text("alpha\nbeta\ngamma\ndelta", 0, 0));
ui.start();
try {
await terminal.waitForRender();
terminal.sendInput("\x1b[<0;1;1M");
terminal.sendInput("\x1b[<32;4;2M");
terminal.sendInput("\x1b[<0;4;2m");
await terminal.waitForRender();
clipboardMocks.copyToClipboard.mockClear();
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
await terminal.waitForRender();
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledOnce();
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith("assistant response");
expect(getLastAssistantText).toHaveBeenCalledOnce();
expect(showStatus).not.toHaveBeenCalled();
expect(showError).not.toHaveBeenCalled();
expect(terminal.getViewport().some((line) => line.includes("Copied!"))).toBe(true);
} finally {
ui.stop();
}
});
it("flashes Copied! for the copy shortcut in fullscreen mode", async () => {
const terminal = new RecordingTerminal(40, 4);
const ui = createInteractiveTui({
@ -190,7 +277,7 @@ describe("InteractiveMode copy confirmation", () => {
ui.start();
try {
await terminal.waitForRender();
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true });
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
await terminal.waitForRender();
expect(clipboardMocks.copyToClipboard).toHaveBeenCalledWith("assistant response");
@ -218,7 +305,7 @@ describe("InteractiveMode copy confirmation", () => {
showError,
};
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true });
await copyCommandPrototype.handleCopyCommand.call(context, { flashConfirmation: true, preferSelection: true });
expect(showStatus).toHaveBeenCalledWith("Copied last agent message to clipboard");
expect(showError).not.toHaveBeenCalled();

View file

@ -455,13 +455,16 @@ describe("SettingsManager", () => {
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getFullscreenExitOutput()).toBe("transcript");
expect(manager.getFullscreenScrollbar()).toBe("auto");
expect(manager.getFullscreenCopyOnSelect()).toBe(true);
manager.setFullscreenExitOutput("resume-hint");
manager.setFullscreenScrollbar("hidden");
manager.setFullscreenCopyOnSelect(false);
await manager.flush();
const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"));
expect(savedSettings.fullscreenExitOutput).toBe("resume-hint");
expect(savedSettings.fullscreenScrollbar).toBe("hidden");
expect(savedSettings.fullscreenCopyOnSelect).toBe(false);
writeFileSync(
join(agentDir, "settings.json"),
@ -470,6 +473,7 @@ describe("SettingsManager", () => {
const reloadedManager = SettingsManager.create(projectDir, agentDir);
expect(reloadedManager.getFullscreenExitOutput()).toBe("transcript");
expect(reloadedManager.getFullscreenScrollbar()).toBe("auto");
expect(reloadedManager.getFullscreenCopyOnSelect()).toBe(true);
});
describe("outputPad", () => {

View file

@ -17,9 +17,11 @@ describe("SettingsSelectorComponent", () => {
it("cycles through fullscreen settings", () => {
const onExitOutputChange = vi.fn();
const onScrollbarChange = vi.fn();
const onCopyOnSelectChange = vi.fn();
const config = {
fullscreenExitOutput: "transcript",
fullscreenScrollbar: "auto",
fullscreenCopyOnSelect: true,
warnings: {},
defaultModel: "not set",
availableDefaultModels: [],
@ -30,6 +32,7 @@ describe("SettingsSelectorComponent", () => {
const callbacks = {
onFullscreenExitOutputChange: onExitOutputChange,
onFullscreenScrollbarChange: onScrollbarChange,
onFullscreenCopyOnSelectChange: onCopyOnSelectChange,
} as unknown as SettingsCallbacks;
const cycle = (label: string, count: number) => {
@ -42,5 +45,7 @@ describe("SettingsSelectorComponent", () => {
expect(onExitOutputChange.mock.calls.flat()).toEqual(["resume-hint", "transcript"]);
cycle("Fullscreen scrollbar", 3);
expect(onScrollbarChange.mock.calls.flat()).toEqual(["always", "hidden", "auto"]);
cycle("Fullscreen copy on select", 2);
expect(onCopyOnSelectChange.mock.calls.flat()).toEqual([false, true]);
});
});

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, {