diff --git a/docs/fork/upstream-integration.md b/docs/fork/upstream-integration.md index a7afef0bfc15..114fbb8c0da1 100644 --- a/docs/fork/upstream-integration.md +++ b/docs/fork/upstream-integration.md @@ -175,6 +175,7 @@ Removal set today: empty. The one future removal candidate (permissions legacy d | Date | Upstream range | Conflicts | Notes | | --- | --- | --- | --- | +| 2026-08-27 | e86823096..4e4949299 (3 commits) | coding-agent CHANGELOG only | Additive sync: `fullscreenCopyOnSelect` + Ctrl+X selection copy (#7720/#8731), `ui_prompt_start`/`ui_prompt_end` extension events (#5329/#8355), Google Vertex https-proxy-agent named-export fix (#8610/#8723). No telemetry or managed-install subsystem re-additions to re-drop. CHANGELOG conflict resolved by keeping the fork's [Unreleased] entries and adding upstream's three new ones marked inherited; upstream's re-listing of the already-released transcript-notices entry skipped. Fork surfaces verified intact post-merge (permissions settings, request-observer, ToolInfo capability + timeoutMs, telemetry removals). | | 2026-08-26 | 8fa7eebd2..e86823096 (11 commits) | compaction.ts, settings-manager.ts, 3 tests, CHANGELOG | Upstream removed `toolChoice: "none"` from summarizer calls (#8649/#8638); adopted in both `cacheOptOut` branches (persona instruction + rejects-tool-call handling already cover it). e2e cache test classification reworked to the persona marker. Upstream's session-tail newline repair (0b5ee5d8b) is complementary to the fork's read-only validator. Settings test needed both sides' imports. | | 2026-08-24 | 460191cfc..a470b121b (4 commits) | `agent-session.ts` (import trim vs fork's `join` usage; resolved by taking upstream imports + re-adding `join`) | README untouched upstream, no port needed; ledger assumptions re-verified; fork test set green | | 2026-08-24 | a470b121b..dcd461925 (8 commits, v0.84.3) | `tools/bash.ts` (upstream extracted a shared `createShellToolDefinition`; fork's `capability: "process.execute"` moved onto the shared factory, covering powershell too), `tools/index.ts` (fork permissions exports + upstream powershell exports, both kept), `package-manager-cli.ts` + `test/package-command-paths.test.ts` (managed-install subsystem dropped per the decision above; fork self-update flow kept), `main.ts` (`cleanupManagedInstall` wiring unwound) | Upstream's installer-managed update subsystem not ported (see Decisions); `docs/packages.md` managed sentence reverted to pre-upstream text; `minimal` profile deny+hide list gains `powershell` (registered by default via `createAllToolDefinitions`); powershell ships with `process.execute` capability; golden system-prompt and prompt-prefix tests green without updates; fresh worktrees must run `npm run generate-models` (in `packages/ai`) before `npm run check`/tests — `src/providers/data/.manifest.json` is a git-ignored artifact whose absence cascades `unknown`/`never` type errors | diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 92e35675a81c..2b203abc1b80 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -21,6 +21,8 @@ - Added opt-in permission policies (`tools.permissions`): `legacy` mode (default) is byte-identical to upstream; `policy` mode evaluates rules matching tool name, capability, path prefix, or command prefix with deny > ask > allow precedence, user rules overriding profile presets, and `hide: true` removing a tool from the model's tool list; profiles `code` (default), `review` (read-only), and `minimal` are plain rule presets ([#11](https://github.com/R-Dson/pi/issues/11)). - Added fork-aware self-update channels: the running install's package name picks the update source — the fork's standalone tarball, the fork's GitHub-Packages registry, or upstream npmjs with a channel notice — so no update path replaces the fork with upstream ([#29](https://github.com/R-Dson/pi/issues/29)). - Added a live preview of the newest thinking run while thinking is hidden: first ~120 display columns while streaming, collapsing to a one-line duration marker when the message finishes ([#30](https://github.com/R-Dson/pi/issues/30)). +- Added inherited `ui_prompt_start` and `ui_prompt_end` extension events so host integrations can distinguish active agent work from waiting on user-facing `ctx.ui` prompts ([#5329](https://github.com/earendil-works/pi/issues/5329)). +- Added inherited `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 @@ -30,6 +32,7 @@ - Fixed inherited Windows shell aborts crashing Pi when `taskkill.exe` is unavailable on `PATH` ([#6596](https://github.com/earendil-works/pi/issues/6596)). - Fixed inherited resumed sessions corrupting the next appended entry when their JSONL file lacks a trailing newline ([#8345](https://github.com/earendil-works/pi/issues/8345)). - Fixed inherited compaction and branch summaries forcing `toolChoice: "none"` ([#8649](https://github.com/earendil-works/pi/issues/8649), [#8638](https://github.com/earendil-works/pi/issues/8638)). +- Fixed inherited Google Vertex requests failing with `HttpsProxyAgent is not a constructor` when the bundled Node.js runtime uses an HTTP(S) proxy ([#8610](https://github.com/earendil-works/pi/issues/8610)). ### Removed @@ -52,7 +55,11 @@ - Added an optional `powershell` tool for Windows, configurable through `defaultTools` and the SDK. See [PowerShell Tool](docs/windows.md#powershell-tool). - Added a `/thinking` selector and searchable default choices to the model and thinking selectors; Ctrl+S saves the selected model as the global default. See [Models and Thinking](docs/keybindings.md#models-and-thinking). - Added optional routing session IDs to exported compaction summary helpers so callers can preserve provider routing without enabling prompt cache writes. -- Added transcript usage notices for compaction and branch summaries when cache miss notices are enabled. +- Added + + + +usage notices for compaction and branch summaries when cache miss notices are enabled. - Added `session_compact_failed` extension events so compaction failures and aborts expose their reason, retry state, source, and error message to handlers ([#8175](https://github.com/earendil-works/pi/issues/8175)). - Added inherited provider-neutral `toolChoice` support to simple stream requests. - Added inherited automatic Anthropic server-side refusal fallback for supported first-party models, including returned-model usage pricing ([#8017](https://github.com/earendil-works/pi/issues/8017)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index a00414013d31..b92f258e4648 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/extensions.md b/packages/coding-agent/docs/extensions.md index 7e8728227486..ffe57964f75f 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -580,6 +580,24 @@ pi.on("agent_settled", async (_event, ctx) => { }); ``` +#### ui_prompt_start / ui_prompt_end + +Notification-only lifecycle events for blocking user-facing extension UI prompts. They fire around `ctx.ui.select()`, `ctx.ui.confirm()`, `ctx.ui.input()`, `ctx.ui.editor()`, and `ctx.ui.custom()` so host/status integrations can report "waiting for user" instead of just "running". + +Nested or overlapping prompts are coalesced into one outer waiting span. Handlers are invoked best-effort and are not awaited before showing or closing the prompt. + +```typescript +pi.on("ui_prompt_start", async (event, ctx) => { + // event.reason === "ui_prompt" + // event.kind: "select" | "confirm" | "input" | "editor" | "custom" + // event.title: prompt title when available +}); + +pi.on("ui_prompt_end", async (event, ctx) => { + // Pi is no longer waiting on that UI prompt span. +}); +``` + #### turn_start / turn_end Fired for each turn (one LLM response + tool calls). diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index 9da435172f9d..ae2e65c2e02b 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 b9e22d5d7256..cc2a2e38dd66 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -66,6 +66,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 a80c113d6737..0f23d7e605b5 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/extensions/index.ts b/packages/coding-agent/src/core/extensions/index.ts index 90c7860412f0..f19816a9a634 100644 --- a/packages/coding-agent/src/core/extensions/index.ts +++ b/packages/coding-agent/src/core/extensions/index.ts @@ -166,6 +166,9 @@ export type { TreePreparation, TurnEndEvent, TurnStartEvent, + UIPromptEndEvent, + UIPromptKind, + UIPromptStartEvent, // Events - User Bash UserBashEvent, UserBashEventResult, diff --git a/packages/coding-agent/src/core/extensions/runner.ts b/packages/coding-agent/src/core/extensions/runner.ts index fc486e4cd4d1..3346645a1045 100644 --- a/packages/coding-agent/src/core/extensions/runner.ts +++ b/packages/coding-agent/src/core/extensions/runner.ts @@ -62,6 +62,7 @@ import type { ToolCallEventResult, ToolResultEvent, ToolResultEventResult, + UIPromptKind, UserBashEvent, UserBashEventResult, } from "./types.ts"; @@ -295,6 +296,8 @@ export class ExtensionRunner { private shortcutDiagnostics: ResourceDiagnostic[] = []; private commandDiagnostics: ResourceDiagnostic[] = []; private staleMessage: string | undefined; + private uiPromptDepth = 0; + private activeUIPrompt: { kind: UIPromptKind; title?: string } | undefined; constructor( extensions: Extension[], @@ -431,10 +434,57 @@ export class ExtensionRunner { } setUIContext(uiContext?: ExtensionUIContext, mode: ExtensionMode = "print"): void { - this.uiContext = uiContext ?? noOpUIContext; + this.uiContext = uiContext ? this.wrapUIPromptContext(uiContext) : noOpUIContext; this.mode = mode; } + private wrapUIPromptContext(ui: ExtensionUIContext): ExtensionUIContext { + return { + ...ui, + select: (title, options, opts) => this.withUIPrompt("select", title, () => ui.select(title, options, opts)), + confirm: (title, message, opts) => this.withUIPrompt("confirm", title, () => ui.confirm(title, message, opts)), + input: (title, placeholder, opts) => + this.withUIPrompt("input", title, () => ui.input(title, placeholder, opts)), + editor: (title, prefill) => this.withUIPrompt("editor", title, () => ui.editor(title, prefill)), + custom: (factory, options) => this.withUIPrompt("custom", undefined, () => ui.custom(factory, options)), + }; + } + + private withUIPrompt(kind: UIPromptKind, title: string | undefined, run: () => Promise): Promise { + const outerPrompt = this.uiPromptDepth++ === 0; + if (outerPrompt) { + this.activeUIPrompt = { kind, title }; + this.emitUIPromptEvent({ type: "ui_prompt_start", reason: "ui_prompt", kind, ...(title ? { title } : {}) }); + } + + const finish = () => { + if (--this.uiPromptDepth > 0) return; + this.uiPromptDepth = 0; + + const prompt = this.activeUIPrompt ?? { kind, title }; + this.activeUIPrompt = undefined; + this.emitUIPromptEvent({ + type: "ui_prompt_end", + reason: "ui_prompt", + kind: prompt.kind, + ...(prompt.title ? { title: prompt.title } : {}), + }); + }; + + try { + return run().finally(finish); + } catch (err) { + finish(); + throw err; + } + } + + private emitUIPromptEvent(event: Extract): void { + queueMicrotask(() => { + void this.emit(event); + }); + } + getUIContext(): ExtensionUIContext { return this.uiContext; } diff --git a/packages/coding-agent/src/core/extensions/types.ts b/packages/coding-agent/src/core/extensions/types.ts index 92fb88779dfc..425a7ce38ca3 100644 --- a/packages/coding-agent/src/core/extensions/types.ts +++ b/packages/coding-agent/src/core/extensions/types.ts @@ -759,6 +759,24 @@ export interface AgentSettledEvent { type: "agent_settled"; } +export type UIPromptKind = "select" | "confirm" | "input" | "editor" | "custom"; + +/** Fired when Pi starts waiting on a blocking user-facing extension UI prompt. */ +export interface UIPromptStartEvent { + type: "ui_prompt_start"; + reason: "ui_prompt"; + kind: UIPromptKind; + title?: string; +} + +/** Fired when Pi is no longer waiting on a blocking user-facing extension UI prompt. */ +export interface UIPromptEndEvent { + type: "ui_prompt_end"; + reason: "ui_prompt"; + kind: UIPromptKind; + title?: string; +} + /** Fired at the start of each turn */ export interface TurnStartEvent { type: "turn_start"; @@ -1094,6 +1112,8 @@ export type ExtensionEvent = | AgentStartEvent | AgentEndEvent | AgentSettledEvent + | UIPromptStartEvent + | UIPromptEndEvent | TurnStartEvent | TurnEndEvent | MessageStartEvent @@ -1280,6 +1300,8 @@ export interface ExtensionAPI { on(event: "agent_start", handler: ExtensionHandler): void; on(event: "agent_end", handler: ExtensionHandler): void; on(event: "agent_settled", handler: ExtensionHandler): void; + on(event: "ui_prompt_start", handler: ExtensionHandler): void; + on(event: "ui_prompt_end", handler: ExtensionHandler): void; on(event: "turn_start", handler: ExtensionHandler): void; on(event: "turn_end", handler: ExtensionHandler): void; on(event: "message_start", handler: ExtensionHandler): void; diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 7945c712aa92..2a46ad382a3d 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -175,6 +175,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 { @@ -1233,6 +1234,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/index.ts b/packages/coding-agent/src/index.ts index 40e29dd37920..bdbaef6419fe 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -145,6 +145,9 @@ export type { ToolResultEvent, TurnEndEvent, TurnStartEvent, + UIPromptEndEvent, + UIPromptKind, + UIPromptStartEvent, UserBashEvent, UserBashEventResult, WidgetPlacement, 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 49166ea5e089..70d895244631 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -83,6 +83,7 @@ export interface SettingsConfig { tuiMode: TuiMode; fullscreenExitOutput: FullscreenExitOutput; fullscreenScrollbar: ScrollViewScrollbar; + fullscreenCopyOnSelect: boolean; warnings: WarningSettings; } @@ -118,6 +119,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; } @@ -680,6 +682,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", @@ -898,6 +907,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 b6c37c9883a1..fe0a8a5b7ef4 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; @@ -1926,6 +1930,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()); @@ -2833,7 +2840,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()); @@ -4459,6 +4469,7 @@ export class InteractiveMode { tuiMode: this.ui.mode, fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(), fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(), + fullscreenCopyOnSelect: this.settingsManager.getFullscreenCopyOnSelect(), warnings: this.settingsManager.getWarnings(), }, { @@ -4628,6 +4639,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); }, @@ -5984,7 +5999,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 a4a0408d8c8c..cd94dfb61fc1 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 020f9f8c7069..dba422771bfb 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -456,13 +456,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"), @@ -471,6 +474,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 9c88fd2820ec..c931789363fb 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 5a18c9717aa3..15ae98abcaf0 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 58e829370815..5531483061af 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 1ecc081626d0..c37689a99b9f 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 621e7f6a8aa4..0be8fc2fd765 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, { diff --git a/scripts/build-coding-agent-bundle.mjs b/scripts/build-coding-agent-bundle.mjs index 1b6d233eb818..c27e8d56d7a7 100644 --- a/scripts/build-coding-agent-bundle.mjs +++ b/scripts/build-coding-agent-bundle.mjs @@ -49,6 +49,30 @@ export function createJiti(...args) { }, }; +const httpsProxyAgentNamedExportPlugin = { + name: "https-proxy-agent-named-export", + setup(build) { + build.onResolve({ filter: /^https-proxy-agent$/ }, (args) => { + if (args.kind !== "dynamic-import") return undefined; + return { + namespace: "https-proxy-agent-named-export", + path: args.path, + }; + }); + build.onLoad( + { + filter: /^https-proxy-agent$/, + namespace: "https-proxy-agent-named-export", + }, + () => ({ + contents: 'export { HttpsProxyAgent } from "https-proxy-agent";', + loader: "js", + resolveDir: repoRoot, + }), + ); + }, +}; + function commonBuildOptions() { return { absWorkingDir: repoRoot, @@ -67,7 +91,7 @@ function commonBuildOptions() { // package replaces it with a synchronous lazy require so jiti loads only // when importing an extension; Babel remains deferred until a cache miss // needs transformation. - plugins: [lazyJitiPlugin], + plugins: [lazyJitiPlugin, httpsProxyAgentNamedExportPlugin], sourcemap: false, target: "node22.19", // Do not apply the monorepo's source-oriented path aliases while bundling