From f074efd921848bf5d858154e852d47ea82c60bf2 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 31 Jul 2026 14:33:51 +0200 Subject: [PATCH] feat(coding-agent): add ui mode setting --- packages/coding-agent/CHANGELOG.md | 6 ++--- packages/coding-agent/README.md | 2 +- packages/coding-agent/docs/keybindings.md | 4 ++-- packages/coding-agent/docs/settings.md | 1 + packages/coding-agent/docs/usage.md | 6 +++-- packages/coding-agent/src/cli/args.ts | 21 ++++++++++++++--- .../coding-agent/src/core/settings-manager.ts | 13 +++++++++++ packages/coding-agent/src/index.ts | 1 + packages/coding-agent/src/main.ts | 2 +- .../components/settings-selector.ts | 14 ++++++++++- .../src/modes/interactive/interactive-mode.ts | 21 +++++++++++------ packages/coding-agent/test/args.test.ts | 23 ++++++++++++++++--- .../coding-agent/test/interactive-tui.test.ts | 14 +++++------ .../test/settings-manager.test.ts | 23 +++++++++++++++++++ 14 files changed, 121 insertions(+), 30 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 98d66666b..17b6d9f0a 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,9 +5,9 @@ ### Added - Added chainable `pi.registerMarkdownTransformer()` hooks for display-only transformation of user and assistant Markdown. -- Added `--alt` to run interactive mode with the alternate-screen TUI ([#7304](https://github.com/earendil-works/pi/issues/7304)). -- Added a sticky editor, status, widget, and footer dock to `--alt` mode while keeping the transcript independently scrollable. -- Added a transient draggable transcript scrollbar to `--alt` mode. +- Added an experimental fullscreen UI mode, selectable through `--ui-mode fullscreen` or `/settings` ([#7304](https://github.com/earendil-works/pi/issues/7304)). +- Added a sticky editor, status, widget, and footer dock to fullscreen mode while keeping the transcript independently scrollable. +- Added a transient draggable transcript scrollbar to fullscreen mode. ### Fixed diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index bdf88df73..b1d02ae1b 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -605,7 +605,7 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set |--------|-------------| | `--system-prompt ` | Replace default prompt (context files and skills still appended) | | `--append-system-prompt ` | Append to system prompt | -| `--alt` | Use the alternate-screen TUI with application-owned scrolling in interactive mode | +| `--ui-mode ` | UI mode: `regular` (default) or experimental `fullscreen` | | `--verbose` | Force verbose startup | | `-a`, `--approve` | Trust project-local files for this run | | `-na`, `--no-approve` | Ignore project-local files for this run | diff --git a/packages/coding-agent/docs/keybindings.md b/packages/coding-agent/docs/keybindings.md index bb675120b..64e173580 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -78,9 +78,9 @@ Modifier combinations: `ctrl+shift+x`, `alt+ctrl+x`, `ctrl+shift+alt+x`, `ctrl+1 | `tui.select.confirm` | `enter` | Confirm selection | | `tui.select.cancel` | `escape`, `ctrl+c` | Cancel selection | -### TUI Alternate-Screen Viewport +### TUI Fullscreen Viewport -These actions apply when interactive mode is started with `--alt` and target the primary transcript scroll region. Two-finger trackpad and mouse-wheel input scroll the region under the pointer, falling back to the transcript over the fixed editor/status/footer dock. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects text and copies it to the clipboard; holding at the transcript's top or bottom edge auto-scrolls into off-screen content. +These actions apply when interactive mode uses `--ui-mode fullscreen` and target the primary transcript scroll region. Two-finger trackpad and mouse-wheel input scroll the region under the pointer, falling back to the transcript over the fixed editor/status/footer dock. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects text and copies it to the clipboard; holding at the transcript's top or bottom edge auto-scrolls into off-screen content. | Keybinding id | Default | Description | |--------|---------|-------------| diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index c8fc37134..eea08f1b6 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -65,6 +65,7 @@ Use `/trust` in interactive mode to save a project trust decision for future ses | `outputPad` | number | `1` | Horizontal padding for user messages, assistant messages, and thinking (0 or 1) | | `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 | +| `uiMode` | string | `"regular"` | Interactive UI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply after restart; `--ui-mode` overrides this setting for one run | 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 168090187..33fcb45af 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -239,14 +239,16 @@ pi --no-extensions -e ./my-extension.ts |--------|-------------| | `--system-prompt ` | Replace default prompt; context files and skills are still appended | | `--append-system-prompt ` | Append to system prompt | -| `--alt` | Use the alternate-screen TUI with a scrollable transcript and fixed editor/status/footer dock | +| `--ui-mode ` | UI mode: `regular` (default) or experimental `fullscreen` | | `--verbose` | Force verbose startup | | `-a`, `--approve` | Trust project-local files for this run | | `-na`, `--no-approve` | Ignore project-local files for this run | | `-h`, `--help` | Show help | | `-v`, `--version` | Show version | -When `--alt` is active, 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. Without `--alt`, pi uses the main screen and terminal-owned scrollback, and iTerm2 inline images continue to render normally. +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. + +To use fullscreen mode by default, set **UI mode** to `fullscreen` in `/settings`. The change applies after restarting pi. ### File Arguments diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 381add4e9..3a1954213 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -6,6 +6,7 @@ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import chalk from "chalk"; import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, ENV_SESSION_DIR } from "../config.ts"; import type { ExtensionFlag } from "../core/extensions/types.ts"; +import type { UiMode } from "../core/settings-manager.ts"; export type Mode = "text" | "json" | "rpc"; @@ -45,7 +46,7 @@ export interface Args { noContextFiles?: boolean; listModels?: string | true; offline?: boolean; - alt?: boolean; + uiMode?: UiMode; verbose?: boolean; projectTrustOverride?: boolean; messages: string[]; @@ -176,8 +177,22 @@ export function parseArgs(args: string[]): Args { } else { result.listModels = true; } + } else if (arg === "--ui-mode") { + const mode = args[i + 1]; + if (mode === "regular" || mode === "fullscreen") { + result.uiMode = mode; + i++; + } else if (mode === undefined || mode.startsWith("-")) { + result.diagnostics.push({ type: "error", message: "--ui-mode requires regular or fullscreen" }); + } else { + i++; + result.diagnostics.push({ + type: "error", + message: `Invalid UI mode "${mode}". Valid values: regular, fullscreen`, + }); + } } else if (arg === "--alt") { - result.alt = true; + result.uiMode = "fullscreen"; } else if (arg === "--verbose") { result.verbose = true; } else if (arg === "--approve" || arg === "-a") { @@ -275,7 +290,7 @@ ${chalk.bold("Options:")} --export Export session file to HTML and exit --list-models [search] List available models (with optional fuzzy search) --verbose Force verbose startup (overrides quietStartup setting) - --alt Use the alternate-screen TUI in interactive mode + --ui-mode UI mode: regular (default) or fullscreen --approve, -a Trust project-local files for this run --no-approve, -na Ignore project-local files for this run --offline Disable startup network operations (same as PI_OFFLINE=1) diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 3306d71bc..dd26c7175 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -32,6 +32,8 @@ export interface RetrySettings { provider?: ProviderRetrySettings; } +export type UiMode = "regular" | "fullscreen"; + export interface TerminalSettings { showImages?: boolean; // default: true (only relevant if terminal supports images) imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells) @@ -126,6 +128,7 @@ export interface Settings { httpProxy?: string; // Proxy URL applied as HTTP_PROXY and HTTPS_PROXY for Pi-managed HTTP clients httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it + uiMode?: UiMode; // default: "regular" } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ @@ -1120,6 +1123,16 @@ export class SettingsManager { this.save(); } + getUiMode(): UiMode { + return this.settings.uiMode === "fullscreen" ? "fullscreen" : "regular"; + } + + setUiMode(mode: UiMode): void { + this.globalSettings.uiMode = mode; + this.markModified("uiMode"); + 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 171b601ef..3615bccad 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -255,6 +255,7 @@ export { type RetrySettings, SettingsManager, type SettingsManagerCreateOptions, + type UiMode, } from "./core/settings-manager.ts"; // Skills export { diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index e9a481fdb..ca515d7a0 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -877,7 +877,7 @@ export async function main(args: string[], options?: MainOptions) { initialImages, initialMessages: parsed.messages, verbose: parsed.verbose, - alt: parsed.alt, + uiMode: parsed.uiMode, }); if (startupBenchmark) { await interactiveMode.init(); 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 9cfe184cb..90ea81d75 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -13,7 +13,7 @@ import { Text, } from "@earendil-works/pi-tui"; import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts"; -import type { DefaultProjectTrust, WarningSettings } from "../../../core/settings-manager.ts"; +import type { DefaultProjectTrust, UiMode, WarningSettings } from "../../../core/settings-manager.ts"; import { getSelectListTheme, getSettingsListTheme, @@ -79,6 +79,7 @@ export interface SettingsConfig { defaultProjectTrust: DefaultProjectTrust; clearOnShrink: boolean; showTerminalProgress: boolean; + uiMode: UiMode; warnings: WarningSettings; } @@ -110,6 +111,7 @@ export interface SettingsCallbacks { onDefaultProjectTrustChange: (defaultProjectTrust: DefaultProjectTrust) => void; onClearOnShrinkChange: (enabled: boolean) => void; onShowTerminalProgressChange: (enabled: boolean) => void; + onUiModeChange: (mode: UiMode) => void; onWarningsChange: (warnings: WarningSettings) => void; onCancel: () => void; } @@ -610,6 +612,13 @@ export class SettingsSelectorComponent extends Container { () => done(), ), }, + { + id: "ui-mode", + label: "UI mode", + description: "Interface layout used after restart; fullscreen mode is experimental", + currentValue: config.uiMode, + values: ["regular", "fullscreen"], + }, { id: "theme", label: "Theme", @@ -819,6 +828,9 @@ export class SettingsSelectorComponent extends Container { case "terminal-progress": callbacks.onShowTerminalProgressChange(newValue === "true"); break; + case "ui-mode": + callbacks.onUiModeChange(newValue as UiMode); + 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 cd182dead..94c7fed95 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -91,6 +91,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 { UiMode } 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"; @@ -324,12 +325,12 @@ export interface InteractiveModeOptions { initialMessages?: string[]; /** Force verbose startup (overrides quietStartup setting) */ verbose?: boolean; - /** Use the alternate-screen TUI renderer. */ - alt?: boolean; + /** UI layout mode. */ + uiMode?: UiMode; } interface InteractiveTuiOptions { - alt: boolean; + uiMode: UiMode; showHardwareCursor: boolean; logDirectory: string; terminal?: Terminal; @@ -338,7 +339,7 @@ interface InteractiveTuiOptions { /** Composition root for selecting the interactive terminal renderer. */ export function createInteractiveTui(options: InteractiveTuiOptions): TUI { const terminal = options.terminal ?? new ProcessTerminal(); - if (options.alt) { + if (options.uiMode === "fullscreen") { return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { openUrl: openBrowser }); } return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory); @@ -473,7 +474,8 @@ export class InteractiveMode { constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) { this.runtimeHost = runtimeHost; - this.options = options; + const uiMode = options.uiMode ?? this.settingsManager.getUiMode(); + this.options = { ...options, uiMode }; this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd; this.runtimeHost.setBeforeSessionInvalidate(() => { this.resetExtensionUI(); @@ -483,7 +485,7 @@ export class InteractiveMode { }); this.version = VERSION; this.ui = createInteractiveTui({ - alt: options.alt ?? false, + uiMode, showHardwareCursor: this.settingsManager.getShowHardwareCursor(), logDirectory: getAgentDir(), }); @@ -1929,7 +1931,7 @@ export class InteractiveMode { this.activeStatusIndicator?.dispose(); this.activeStatusIndicator = undefined; this.statusContainer.clear(); - if (hadActiveStatusIndicator && !this.options.alt && this.ui.getClearOnShrink()) { + if (hadActiveStatusIndicator && this.options.uiMode === "regular" && this.ui.getClearOnShrink()) { this.statusContainer.addChild(this.idleStatus); } } @@ -4234,6 +4236,7 @@ export class InteractiveMode { quietStartup: this.settingsManager.getQuietStartup(), clearOnShrink: this.settingsManager.getClearOnShrink(), showTerminalProgress: this.settingsManager.getShowTerminalProgress(), + uiMode: this.settingsManager.getUiMode(), warnings: this.settingsManager.getWarnings(), }, { @@ -4374,6 +4377,10 @@ export class InteractiveMode { onShowTerminalProgressChange: (enabled) => { this.settingsManager.setShowTerminalProgress(enabled); }, + onUiModeChange: (mode) => { + this.settingsManager.setUiMode(mode); + this.showStatus(`UI mode: ${mode} (restart required)`); + }, onWarningsChange: (warnings) => { this.settingsManager.setWarnings(warnings); }, diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 3ab937451..4998d90ff 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -329,10 +329,27 @@ describe("parseArgs", () => { }); }); - describe("--alt flag", () => { - test("parses --alt flag", () => { + describe("--ui-mode flag", () => { + test.each(["regular", "fullscreen"] as const)("parses %s mode", (mode) => { + const result = parseArgs(["--ui-mode", mode]); + expect(result.uiMode).toBe(mode); + }); + + test("rejects invalid modes", () => { + const result = parseArgs(["--ui-mode", "other"]); + expect(result.diagnostics).toEqual([ + { type: "error", message: 'Invalid UI mode "other". Valid values: regular, fullscreen' }, + ]); + }); + + test("requires a mode", () => { + const result = parseArgs(["--ui-mode"]); + expect(result.diagnostics).toEqual([{ type: "error", message: "--ui-mode requires regular or fullscreen" }]); + }); + + test("accepts --alt as a hidden fullscreen shortcut", () => { const result = parseArgs(["--alt"]); - expect(result.alt).toBe(true); + expect(result.uiMode).toBe("fullscreen"); expect(result.unknownFlags.has("alt")).toBe(false); }); }); diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index e191cbc4e..fec31b40e 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -17,7 +17,7 @@ describe("createInteractiveTui", () => { it("selects the alternate-screen renderer only when requested", async () => { const mainTerminal = new RecordingTerminal(); const mainTui = createInteractiveTui({ - alt: false, + uiMode: "regular", showHardwareCursor: false, logDirectory: "/tmp", terminal: mainTerminal, @@ -30,7 +30,7 @@ describe("createInteractiveTui", () => { const altTerminal = new RecordingTerminal(); const altTui = createInteractiveTui({ - alt: true, + uiMode: "fullscreen", showHardwareCursor: false, logDirectory: "/tmp", terminal: altTerminal, @@ -46,7 +46,7 @@ describe("createInteractiveTui", () => { type ClearStatusContext = { activeStatusIndicator: { kind: "working"; dispose: () => void } | undefined; statusContainer: Container; - options: { alt?: boolean }; + options: { uiMode?: "regular" | "fullscreen" }; ui: { getClearOnShrink: () => boolean }; idleStatus: Component; }; @@ -59,15 +59,15 @@ const interactiveModePrototype = InteractiveMode.prototype as unknown as Interac describe("clear-on-shrink status spacing", () => { it("reserves status height only on the main-screen renderer", () => { - for (const [alt, expectedChildren] of [ - [false, 1], - [true, 0], + for (const [uiMode, expectedChildren] of [ + ["regular", 1], + ["fullscreen", 0], ] as const) { const dispose = vi.fn(); const context: ClearStatusContext = { activeStatusIndicator: { kind: "working", dispose }, statusContainer: new Container(), - options: { alt }, + options: { uiMode }, ui: { getClearOnShrink: () => true }, idleStatus: new Text("", 0, 0), }; diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 46387997d..58c952054 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -397,6 +397,29 @@ describe("SettingsManager", () => { }); }); + describe("UI mode", () => { + it("defaults to regular and persists fullscreen mode", async () => { + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getUiMode()).toBe("regular"); + + manager.setUiMode("fullscreen"); + await manager.flush(); + + expect(manager.getUiMode()).toBe("fullscreen"); + const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8")); + expect(savedSettings.uiMode).toBe("fullscreen"); + }); + + it("falls back to regular for unsupported values", () => { + writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ uiMode: "other" })); + + const manager = SettingsManager.create(projectDir, agentDir); + + expect(manager.getUiMode()).toBe("regular"); + }); + }); + describe("outputPad", () => { it("should default to 1 and persist binary values", async () => { const manager = SettingsManager.create(projectDir, agentDir);