mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
feat(tui): allow disable copy on fullscreen, ctrl + x copies selection (#8731)
This commit is contained in:
parent
1defa151e0
commit
4e49492999
15 changed files with 260 additions and 16 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue