From 4e494929998d6bc4fccf75e0a233f727db4b70ee Mon Sep 17 00:00:00 2001 From: Cristina Poncela Cubeiro <140309543+cristinaponcela@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:07:54 +0200 Subject: [PATCH] feat(tui): allow disable copy on fullscreen, ctrl + x copies selection (#8731) --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/keybindings.md | 2 +- packages/coding-agent/docs/settings.md | 1 + packages/coding-agent/docs/usage.md | 2 +- .../coding-agent/src/core/settings-manager.ts | 11 +++ .../components/settings-selector.ts | 12 +++ .../src/modes/interactive/interactive-mode.ts | 31 ++++++- .../coding-agent/test/interactive-tui.test.ts | 93 ++++++++++++++++++- .../test/settings-manager.test.ts | 4 + .../test/settings-selector.test.ts | 5 + packages/tui/CHANGELOG.md | 1 + packages/tui/README.md | 2 +- packages/tui/src/tui-alt-screen.ts | 48 ++++++++-- packages/tui/test/tui-alt-screen.test.ts | 61 ++++++++++++ 15 files changed, 260 insertions(+), 16 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a5221a0d6..2dc6355ac 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -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 diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 9f1bb560c..5ea32dac4 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -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 diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 9da435172..ae2e65c2e 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -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 | diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 222ab4ee4..649f031a5 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -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: diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 85ee15044..88ada91ba 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -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 | diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 469570fb3..3d765ca97 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -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 { @@ -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; } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 6dac4def3..308bf8109 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -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; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 429c768a7..bce3828e3 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -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 { + private async handleCopyCommand( + options: { flashConfirmation?: boolean; preferSelection?: boolean } = {}, + ): Promise { + 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."); diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index a4a0408d8..cd94dfb61 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -87,6 +87,7 @@ describe("createInteractiveTui", () => { renderer.setFocus(component); type SwitchContext = { + runtimeHost: { session: { settingsManager: { getFullscreenCopyOnSelect: () => boolean } } }; renderer: ReturnType; ui: TUI; fullscreenLayoutRoot: Component; @@ -95,6 +96,7 @@ describe("createInteractiveTui", () => { extensionTerminalInputSubscriptions: Set; }; 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; @@ -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(); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 043e9039f..685243254 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -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", () => { diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index 9c88fd282..c93178936 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -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]); }); }); diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 5a18c9717..15ae98abc 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -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 diff --git a/packages/tui/README.md b/packages/tui/README.md index 58e829370..553148306 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -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. diff --git a/packages/tui/src/tui-alt-screen.ts b/packages/tui/src/tui-alt-screen.ts index 1ecc08162..c37689a99 100644 --- a/packages/tui/src/tui-alt-screen.ts +++ b/packages/tui/src/tui-alt-screen.ts @@ -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; 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 { + 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 { + 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 { + const text = this.getActiveSelectionText(); + if (!text) return false; + return this.copyTextToClipboard(text); + } + + private async copyTextToClipboard(text: string): Promise { // 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 { diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts index 621e7f6a8..0be8fc2fd 100644 --- a/packages/tui/test/tui-alt-screen.test.ts +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -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, {