From ac4ac9eaf69f2b01ca3af984a5c48f3b99b84278 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Fri, 7 Aug 2026 18:29:56 +0200 Subject: [PATCH] feat(coding-agent): configure fullscreen exit output --- packages/coding-agent/CHANGELOG.md | 4 ++ packages/coding-agent/docs/settings.md | 1 + packages/coding-agent/docs/usage.md | 2 +- .../coding-agent/src/core/settings-manager.ts | 12 ++++++ packages/coding-agent/src/index.ts | 1 + .../components/settings-selector.ts | 13 ++++++ .../src/modes/interactive/interactive-mode.ts | 18 +++++---- .../coding-agent/test/interactive-tui.test.ts | 12 +++--- .../test/settings-manager.test.ts | 17 ++++++-- .../test/settings-selector.test.ts | 40 +++++++++++-------- 10 files changed, 85 insertions(+), 35 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 18a373278..1bf3b77b0 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added a fullscreen exit output setting to choose between printing the final transcript and only a session resume hint. + ## [0.84.1] - 2026-08-07 ### New Features diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 761a1941e..ec8aa26cc 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 | `autocompleteMaxVisible` | number | `5` | Max visible items in autocomplete dropdown (3-20) | | `showHardwareCursor` | boolean | `false` | Show the terminal cursor while TUI positions it for IME support | | `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 | 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 d09e37f83..5104b8790 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -250,7 +250,7 @@ pi --no-extensions -e ./my-extension.ts In `fullscreen` mode, the transcript scrolls inside the terminal viewport while queued messages, working status, extension widgets, editor, and footer remain fixed at the bottom. Mouse/trackpad input scrolls the region under the pointer; keyboard viewport actions always remain available. Inline images work in terminals that support the Kitty graphics protocol, including Kitty and Ghostty. In iTerm2 they render as text placeholders because its inline-image protocol cannot delete or crop placements during application-owned scrolling. In `regular` mode, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally. -Set **TUI mode** in `/settings` to switch between `regular` and `fullscreen` immediately and choose the default for future sessions. +Set **TUI mode** in `/settings` to switch between `regular` and `fullscreen` immediately and choose the default for future sessions. **Fullscreen exit output** controls whether exiting fullscreen prints the final transcript or restores the previous screen and prints only the session resume hint. ### File Arguments diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 4a8c8bc47..d18916a5c 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -34,6 +34,7 @@ export interface RetrySettings { } export type TuiMode = RendererTuiMode; +export type FullscreenExitOutput = "transcript" | "resume-hint"; export interface TerminalSettings { showImages?: boolean; // default: true (only relevant if terminal supports images) @@ -133,6 +134,7 @@ export interface Settings { httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it tuiMode?: TuiMode; // default: "regular" + fullscreenExitOutput?: FullscreenExitOutput; // default: "transcript"; no effect in regular TUI mode fullscreenScrollbar?: ScrollViewScrollbar; // default: "auto"; no effect in regular TUI mode } @@ -1135,6 +1137,16 @@ export class SettingsManager { this.save(); } + getFullscreenExitOutput(): FullscreenExitOutput { + return this.settings.fullscreenExitOutput === "resume-hint" ? "resume-hint" : "transcript"; + } + + setFullscreenExitOutput(output: FullscreenExitOutput): void { + this.globalSettings.fullscreenExitOutput = output; + this.markModified("fullscreenExitOutput"); + this.save(); + } + getFullscreenScrollbar(): ScrollViewScrollbar { const mode = this.settings.fullscreenScrollbar; return mode === "always" || mode === "hidden" ? mode : "auto"; diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3d05c2af4..6d64d70c8 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -252,6 +252,7 @@ export { export { type CompactionSettings, type DefaultProjectTrust, + type FullscreenExitOutput, type ImageSettings, type PackageSource, type RetrySettings, 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 c0c49f544..20c6b7586 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -16,6 +16,7 @@ import { import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; import type { DefaultProjectTrust, + FullscreenExitOutput, MermaidRenderingMode, TuiMode, WarningSettings, @@ -87,6 +88,7 @@ export interface SettingsConfig { clearOnShrink: boolean; showTerminalProgress: boolean; tuiMode: TuiMode; + fullscreenExitOutput: FullscreenExitOutput; fullscreenScrollbar: ScrollViewScrollbar; warnings: WarningSettings; } @@ -121,6 +123,7 @@ export interface SettingsCallbacks { onClearOnShrinkChange: (enabled: boolean) => void; onShowTerminalProgressChange: (enabled: boolean) => void; onTuiModeChange: (mode: TuiMode) => void; + onFullscreenExitOutputChange: (output: FullscreenExitOutput) => void; onFullscreenScrollbarChange: (mode: ScrollViewScrollbar) => void; onWarningsChange: (warnings: WarningSettings) => void; onCancel: () => void; @@ -636,6 +639,13 @@ export class SettingsSelectorComponent extends Container { currentValue: config.tuiMode, values: ["regular", "fullscreen"], }, + { + id: "fullscreen-exit-output", + label: "Fullscreen exit output", + description: "Print the transcript or only a session resume hint when exiting fullscreen mode", + currentValue: config.fullscreenExitOutput, + values: ["transcript", "resume-hint"], + }, { id: "fullscreen-scrollbar", label: "Fullscreen scrollbar", @@ -858,6 +868,9 @@ export class SettingsSelectorComponent extends Container { case "tui-mode": callbacks.onTuiModeChange(newValue as TuiMode); break; + case "fullscreen-exit-output": + callbacks.onFullscreenExitOutputChange(newValue as FullscreenExitOutput); + break; case "fullscreen-scrollbar": callbacks.onFullscreenScrollbarChange(newValue as ScrollViewScrollbar); break; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 720f9b0f9..943b9edbd 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -92,7 +92,7 @@ import { DefaultPackageManager } from "../../core/package-manager.ts"; import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; import { type SessionEntry, SessionManager, sessionEntryToContextMessages } from "../../core/session-manager.ts"; -import type { TuiMode } from "../../core/settings-manager.ts"; +import type { FullscreenExitOutput, TuiMode } from "../../core/settings-manager.ts"; import { BUILTIN_SLASH_COMMANDS } from "../../core/slash-commands.ts"; import type { SourceInfo } from "../../core/source-info.ts"; import { isInstallTelemetryEnabled } from "../../core/telemetry.ts"; @@ -776,13 +776,13 @@ export class InteractiveMode { } } - private stopInteractiveTui(): void { - if (this.renderer.mode === "fullscreen") { + private stopInteractiveTui(fullscreenExitOutput: FullscreenExitOutput): void { + if (this.renderer.mode === "fullscreen" && fullscreenExitOutput === "transcript") { while (this.renderer.hasOverlayEntries) this.renderer.hideOverlay(); this.switchTuiMode("regular", false, false); this.renderer.renderNow(); } - this.ui.stop(); + this.ui.stop({ preserveScreen: this.renderer.mode === "fullscreen" }); } private switchTuiMode(mode: TuiMode, restoreProgress = true, startRenderer = true): boolean { @@ -1950,7 +1950,7 @@ export class InteractiveMode { const message = error instanceof Error ? error.message : String(error); this.showError(`${prefix}: ${message}`); stopThemeWatcher(); - this.stop(); + this.stop("transcript"); process.exit(1); } @@ -4409,6 +4409,7 @@ export class InteractiveMode { clearOnShrink: this.settingsManager.getClearOnShrink(), showTerminalProgress: this.settingsManager.getShowTerminalProgress(), tuiMode: this.ui.mode, + fullscreenExitOutput: this.settingsManager.getFullscreenExitOutput(), fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(), warnings: this.settingsManager.getWarnings(), }, @@ -4565,6 +4566,9 @@ export class InteractiveMode { if (!this.activeStatusIndicator) this.statusContainer.clear(); this.showStatus(`TUI mode: ${mode}`); }, + onFullscreenExitOutputChange: (output) => { + this.settingsManager.setFullscreenExitOutput(output); + }, onFullscreenScrollbarChange: (mode) => { this.settingsManager.setFullscreenScrollbar(mode); this.applyFullscreenScrollbarSetting(); @@ -6373,7 +6377,7 @@ export class InteractiveMode { } } - stop(): void { + stop(fullscreenExitOutput = this.settingsManager.getFullscreenExitOutput()): void { this.disposeActiveSelector(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(false); @@ -6387,7 +6391,7 @@ export class InteractiveMode { this.unsubscribe(); } if (this.isInitialized) { - this.stopInteractiveTui(); + this.stopInteractiveTui(fullscreenExitOutput); this.isInitialized = false; } this.unregisterSignalHandlers(); diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index 82a29edc6..a4a0408d8 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -2,7 +2,7 @@ import type { Component, Terminal, TUI } from "@earendil-works/pi-tui"; import { Container, isViewportTUI, Text } from "@earendil-works/pi-tui"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; -import type { TuiMode } from "../src/core/settings-manager.ts"; +import type { FullscreenExitOutput, TuiMode } from "../src/core/settings-manager.ts"; import { createInteractiveTui, createInteractiveTuiReference, @@ -68,7 +68,7 @@ describe("createInteractiveTui", () => { altTui.stop(); }); - it("replaces the renderer while preserving components and focus", async () => { + it("replaces the renderer and restores the previous screen for resume-hint exits", async () => { const terminal = new RecordingTerminal(40, 8); const renderer = createInteractiveTui({ tuiMode: "regular", @@ -105,7 +105,7 @@ describe("createInteractiveTui", () => { stableUi = createInteractiveTuiReference(() => context.renderer); context.ui = stableUi; const { stopInteractiveTui, switchTuiMode } = InteractiveMode.prototype as unknown as { - stopInteractiveTui(this: SwitchContext): void; + stopInteractiveTui(this: SwitchContext, fullscreenExitOutput: FullscreenExitOutput): void; switchTuiMode(this: SwitchContext, mode: TuiMode, restoreProgress?: boolean): boolean; }; @@ -121,10 +121,10 @@ describe("createInteractiveTui", () => { expect(invalidatedModes).toEqual(["fullscreen"]); expect([terminal.startCount, terminal.stopCount]).toEqual([2, 1]); - stopInteractiveTui.call(context); + stopInteractiveTui.call(context, "resume-hint"); - expect(stableUi.mode).toBe("regular"); - expect([terminal.startCount, terminal.stopCount]).toEqual([2, 3]); + expect(stableUi.mode).toBe("fullscreen"); + expect([terminal.startCount, terminal.stopCount]).toEqual([2, 2]); }); }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 799da2028..6a223fd00 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -428,16 +428,25 @@ describe("SettingsManager", () => { }); }); - it("validates and persists the fullscreen scrollbar mode", async () => { + it("validates and persists fullscreen settings", async () => { const manager = SettingsManager.create(projectDir, agentDir); + expect(manager.getFullscreenExitOutput()).toBe("transcript"); expect(manager.getFullscreenScrollbar()).toBe("auto"); + manager.setFullscreenExitOutput("resume-hint"); manager.setFullscreenScrollbar("hidden"); await manager.flush(); - expect(JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")).fullscreenScrollbar).toBe("hidden"); + const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")); + expect(savedSettings.fullscreenExitOutput).toBe("resume-hint"); + expect(savedSettings.fullscreenScrollbar).toBe("hidden"); - writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ fullscreenScrollbar: "sometimes" })); - expect(SettingsManager.create(projectDir, agentDir).getFullscreenScrollbar()).toBe("auto"); + writeFileSync( + join(agentDir, "settings.json"), + JSON.stringify({ fullscreenExitOutput: "nothing", fullscreenScrollbar: "sometimes" }), + ); + const reloadedManager = SettingsManager.create(projectDir, agentDir); + expect(reloadedManager.getFullscreenExitOutput()).toBe("transcript"); + expect(reloadedManager.getFullscreenScrollbar()).toBe("auto"); }); describe("outputPad", () => { diff --git a/packages/coding-agent/test/settings-selector.test.ts b/packages/coding-agent/test/settings-selector.test.ts index d8f3c1e22..e658223b9 100644 --- a/packages/coding-agent/test/settings-selector.test.ts +++ b/packages/coding-agent/test/settings-selector.test.ts @@ -14,24 +14,30 @@ describe("SettingsSelectorComponent", () => { setKeybindings(new KeybindingsManager()); }); - it("cycles through fullscreen scrollbar modes", () => { - const onChange = vi.fn(); - const selector = new SettingsSelectorComponent( - { - fullscreenScrollbar: "auto", - warnings: {}, - availableThinkingLevels: [], - availableThemes: [], - } as unknown as SettingsConfig, - { onFullscreenScrollbarChange: onChange } as unknown as SettingsCallbacks, - ); - const settingsList = selector.getSettingsList(); + it("cycles through fullscreen settings", () => { + const onExitOutputChange = vi.fn(); + const onScrollbarChange = vi.fn(); + const config = { + fullscreenExitOutput: "transcript", + fullscreenScrollbar: "auto", + warnings: {}, + availableThinkingLevels: [], + availableThemes: [], + } as unknown as SettingsConfig; + const callbacks = { + onFullscreenExitOutputChange: onExitOutputChange, + onFullscreenScrollbarChange: onScrollbarChange, + } as unknown as SettingsCallbacks; - for (const character of "Fullscreen scrollbar") settingsList.handleInput(character); - settingsList.handleInput("\r"); - settingsList.handleInput("\r"); - settingsList.handleInput("\r"); + const cycle = (label: string, count: number) => { + const list = new SettingsSelectorComponent(config, callbacks).getSettingsList(); + for (const character of label) list.handleInput(character); + for (let i = 0; i < count; i++) list.handleInput("\r"); + }; - expect(onChange.mock.calls.flat()).toEqual(["always", "hidden", "auto"]); + cycle("Fullscreen exit output", 2); + expect(onExitOutputChange.mock.calls.flat()).toEqual(["resume-hint", "transcript"]); + cycle("Fullscreen scrollbar", 3); + expect(onScrollbarChange.mock.calls.flat()).toEqual(["always", "hidden", "auto"]); }); });