From 1fdf21621d1f536a5991fccb0fd65b87ae27cf87 Mon Sep 17 00:00:00 2001 From: Armin Ronacher Date: Sun, 2 Aug 2026 09:21:17 +0200 Subject: [PATCH] feat(tui): add switchable terminal renderers (#7440) Allow coding-agent UI modes to switch at runtime while preserving terminal, focus, input, and renderer state. --- packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/settings.md | 2 +- packages/coding-agent/docs/usage.md | 2 +- .../components/settings-selector.ts | 2 +- .../src/modes/interactive/interactive-mode.ts | 100 +++++------ .../src/modes/interactive/interactive-tui.ts | 34 ++++ .../coding-agent/test/interactive-tui.test.ts | 163 +++++++++++++++--- packages/tui/CHANGELOG.md | 1 + packages/tui/README.md | 16 ++ packages/tui/src/index.ts | 1 + packages/tui/src/switchable-tui.ts | 83 +++++++++ packages/tui/src/terminal.ts | 59 ++++--- packages/tui/src/tui-alt-screen.ts | 76 +++++--- packages/tui/src/tui-main-screen.ts | 2 +- packages/tui/src/tui.ts | 51 ++++++ packages/tui/test/terminal.test.ts | 18 ++ 16 files changed, 476 insertions(+), 135 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/interactive-tui.ts create mode 100644 packages/tui/src/switchable-tui.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a5c10c055..d25bf1d89 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -6,6 +6,7 @@ - Added chainable `pi.registerMarkdownTransformer()` hooks for display-only transformation of user and assistant Markdown. - Added an experimental fullscreen UI mode, selectable through `--ui-mode fullscreen` or `/settings` ([#7304](https://github.com/earendil-works/pi/issues/7304)). +- Added runtime switching between regular and fullscreen UI modes through `/settings` without restarting pi. - Added a sticky editor, status, widget, and footer dock to fullscreen mode while keeping the transcript independently scrollable. - Added a draggable transcript scrollbar to fullscreen mode with configurable `auto`, `always`, and `hidden` modes through `/settings`; `always` reserves the rightmost column. - Added page scrolling and marked-message navigation shortcuts to fullscreen mode. diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index b0b4e985c..d2b629dfe 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -65,7 +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 | +| `uiMode` | string | `"regular"` | Interactive UI mode: `"regular"` or experimental `"fullscreen"`. Changes from `/settings` apply immediately; `--ui-mode` overrides this setting at startup | | `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 UI 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 33fcb45af..3b1da16f1 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -248,7 +248,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. -To use fullscreen mode by default, set **UI mode** to `fullscreen` in `/settings`. The change applies after restarting pi. +Set **UI mode** in `/settings` to switch between `regular` and `fullscreen` immediately and choose the default for future sessions. ### File Arguments 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 c1ee4f501..460c76d5f 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -618,7 +618,7 @@ export class SettingsSelectorComponent extends Container { { id: "ui-mode", label: "UI mode", - description: "Interface layout used after restart; fullscreen mode is experimental", + description: "Interface layout; fullscreen mode is experimental", currentValue: config.uiMode, values: ["regular", "fullscreen"], }, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index dd740010c..75f2ec339 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -20,7 +20,6 @@ import type { OverlayHandle, OverlayOptions, SlashCommand, - Terminal, } from "@earendil-works/pi-tui"; import * as TuiLayouts from "@earendil-works/pi-tui"; import { @@ -32,14 +31,11 @@ import { hyperlink, Markdown, matchesKey, - ProcessTerminal, Spacer, setKeybindings, Text, TruncatedText, type TUI, - TuiAltScreen, - TuiMainScreen, visibleWidth, } from "@earendil-works/pi-tui"; import chalk from "chalk"; @@ -102,7 +98,6 @@ import { getChangelogPath, getNewEntries, normalizeChangelogLinks, parseChangelo import { copyToClipboard, readClipboardText } from "../../utils/clipboard.ts"; import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts"; import { parseGitUrl } from "../../utils/git.ts"; -import { openBrowser } from "../../utils/open-browser.ts"; import { getCwdRelativePath } from "../../utils/paths.ts"; import { getPiUserAgent } from "../../utils/pi-user-agent.ts"; import { killTrackedDetachedChildren } from "../../utils/shell.ts"; @@ -150,6 +145,7 @@ import { TrustSelectorComponent } from "./components/trust-selector.ts"; import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; import { editInExternalEditor } from "./external-editor.ts"; +import { createInteractiveTui, type InteractiveTui } from "./interactive-tui.ts"; import { getModelSearchText } from "./model-search.ts"; import { getAvailableThemes, @@ -329,25 +325,11 @@ export interface InteractiveModeOptions { uiMode?: UiMode; } -interface InteractiveTuiOptions { - uiMode: UiMode; - showHardwareCursor: boolean; - logDirectory: string; - terminal?: Terminal; -} - -/** Composition root for selecting the interactive terminal renderer. */ -export function createInteractiveTui(options: InteractiveTuiOptions): TUI { - const terminal = options.terminal ?? new ProcessTerminal(); - if (options.uiMode === "fullscreen") { - return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { openUrl: openBrowser }); - } - return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory); -} +export { createInteractiveTui }; export class InteractiveMode { private runtimeHost: AgentSessionRuntime; - private ui: TUI; + private ui: InteractiveTui; private loadedResourcesContainer: Container; private chatContainer: Container; private documentContainer: Container; @@ -743,39 +725,36 @@ export class InteractiveMode { console.log(theme.fg("dim", `Model scope: ${modelList}${cycleHint}`)); } - // Populate stable regions before selecting the renderer-specific composition. + // Populate both compositions so the renderer can change without rebuilding component state. this.renderWidgets(); // Initialize with default spacer - if (TuiLayouts.isViewportTUI(this.ui)) { - this.transcriptScrollView = new TuiLayouts.ScrollView(this.documentContainer, { - follow: "end", - primary: true, - overscroll: "chain", - scrollbar: this.settingsManager.getFullscreenScrollbar(), - scrollbarStyle: (text) => theme.bg("scrollbarThumb", text), - }); - const dock = new TuiLayouts.VStack([ - { component: this.pendingMessagesContainer, shrink: 1, minSize: 0 }, - { component: this.statusContainer, shrink: 1, minSize: 0 }, - { component: this.widgetContainerAbove, shrink: 1, minSize: 0 }, - { component: this.editorContainer, shrink: 1, minSize: 3 }, - { component: this.widgetContainerBelow, shrink: 1, minSize: 0 }, - { component: this.footerContainer, shrink: 1, minSize: 1 }, - ]); - this.ui.setLayoutRoot( - new TuiLayouts.VStack([ - { component: this.transcriptScrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 }, - { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 }, - ]), - ); - } else { - this.ui.addChild(this.documentContainer); - this.ui.addChild(this.pendingMessagesContainer); - this.ui.addChild(this.statusContainer); - this.ui.addChild(this.widgetContainerAbove); - this.ui.addChild(this.editorContainer); - this.ui.addChild(this.widgetContainerBelow); - this.ui.addChild(this.footerContainer); - } + this.transcriptScrollView = new TuiLayouts.ScrollView(this.documentContainer, { + follow: "end", + primary: true, + overscroll: "chain", + scrollbar: this.settingsManager.getFullscreenScrollbar(), + scrollbarStyle: (text) => theme.bg("scrollbarThumb", text), + }); + const dock = new TuiLayouts.VStack([ + { component: this.pendingMessagesContainer, shrink: 1, minSize: 0 }, + { component: this.statusContainer, shrink: 1, minSize: 0 }, + { component: this.widgetContainerAbove, shrink: 1, minSize: 0 }, + { component: this.editorContainer, shrink: 1, minSize: 3 }, + { component: this.widgetContainerBelow, shrink: 1, minSize: 0 }, + { component: this.footerContainer, shrink: 1, minSize: 1 }, + ]); + this.ui.setLayoutRoot( + new TuiLayouts.VStack([ + { component: this.transcriptScrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 }, + { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 }, + ]), + ); + this.ui.addChild(this.documentContainer); + this.ui.addChild(this.pendingMessagesContainer); + this.ui.addChild(this.statusContainer); + this.ui.addChild(this.widgetContainerAbove); + this.ui.addChild(this.editorContainer); + this.ui.addChild(this.widgetContainerBelow); + this.ui.addChild(this.footerContainer); this.ui.setFocus(this.editor); this.setupKeyHandlers(); @@ -1937,7 +1916,7 @@ export class InteractiveMode { this.activeStatusIndicator?.dispose(); this.activeStatusIndicator = undefined; this.statusContainer.clear(); - if (hadActiveStatusIndicator && this.options.uiMode === "regular" && this.ui.getClearOnShrink()) { + if (hadActiveStatusIndicator && this.ui.uiMode === "regular" && this.ui.getClearOnShrink()) { this.statusContainer.addChild(this.idleStatus); } } @@ -4211,7 +4190,8 @@ export class InteractiveMode { private showSettingsSelector(): void { this.showSelector((done) => { - const selector = new SettingsSelectorComponent( + let selector: SettingsSelectorComponent | undefined; + selector = new SettingsSelectorComponent( { autoCompact: this.session.autoCompactionEnabled, showImages: this.settingsManager.getShowImages(), @@ -4242,7 +4222,7 @@ export class InteractiveMode { quietStartup: this.settingsManager.getQuietStartup(), clearOnShrink: this.settingsManager.getClearOnShrink(), showTerminalProgress: this.settingsManager.getShowTerminalProgress(), - uiMode: this.settingsManager.getUiMode(), + uiMode: this.ui.uiMode, fullscreenScrollbar: this.settingsManager.getFullscreenScrollbar(), warnings: this.settingsManager.getWarnings(), }, @@ -4385,8 +4365,14 @@ export class InteractiveMode { this.settingsManager.setShowTerminalProgress(enabled); }, onUiModeChange: (mode) => { + if (!this.ui.setUiMode(mode)) { + selector?.getSettingsList().updateValue("ui-mode", this.ui.uiMode); + this.showStatus("Close active overlays before changing UI mode"); + return; + } this.settingsManager.setUiMode(mode); - this.showStatus(`UI mode: ${mode} (restart required)`); + if (!this.activeStatusIndicator) this.statusContainer.clear(); + this.showStatus(`UI mode: ${mode}`); }, onFullscreenScrollbarChange: (mode) => { this.settingsManager.setFullscreenScrollbar(mode); diff --git a/packages/coding-agent/src/modes/interactive/interactive-tui.ts b/packages/coding-agent/src/modes/interactive/interactive-tui.ts new file mode 100644 index 000000000..643acae45 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/interactive-tui.ts @@ -0,0 +1,34 @@ +import { ProcessTerminal, SwitchableTui, type Terminal } from "@earendil-works/pi-tui"; +import type { UiMode } from "../../core/settings-manager.ts"; +import { openBrowser } from "../../utils/open-browser.ts"; + +export interface InteractiveTuiOptions { + uiMode: UiMode; + showHardwareCursor: boolean; + logDirectory: string; + terminal?: Terminal; +} + +/** Interactive TUI with coding-agent mode names and URL activation. */ +export class InteractiveTui extends SwitchableTui { + constructor(options: InteractiveTuiOptions) { + super(options.terminal ?? new ProcessTerminal(), { + mode: options.uiMode === "fullscreen" ? "alternate" : "main", + showHardwareCursor: options.showHardwareCursor, + logDirectory: options.logDirectory, + altScreen: { openUrl: openBrowser }, + }); + } + + get uiMode(): UiMode { + return this.mode === "alternate" ? "fullscreen" : "regular"; + } + + setUiMode(mode: UiMode): boolean { + return this.setMode(mode === "fullscreen" ? "alternate" : "main"); + } +} + +export function createInteractiveTui(options: InteractiveTuiOptions): InteractiveTui { + return new InteractiveTui(options); +} diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts index fec31b40e..c87594226 100644 --- a/packages/coding-agent/test/interactive-tui.test.ts +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -6,48 +6,156 @@ import { createInteractiveTui, InteractiveMode } from "../src/modes/interactive/ class RecordingTerminal extends VirtualTerminal implements Terminal { readonly writes: string[] = []; + readonly progressChanges: boolean[] = []; + readonly startScreens: Array<"main" | "alternate"> = []; + readonly suspendScreens: Array<"main" | "alternate"> = []; + readonly resumeScreens: Array<"main" | "alternate"> = []; + startCount = 0; + stopCount = 0; + private progressActive = false; + private alternateScreen = false; + + override start(onInput: (data: string) => void, onResize: () => void): void { + this.startCount += 1; + this.startScreens.push(this.alternateScreen ? "alternate" : "main"); + super.start(onInput, onResize); + } + + suspendRenderer(): void { + this.suspendScreens.push(this.alternateScreen ? "alternate" : "main"); + } + + resumeRenderer(): void { + this.resumeScreens.push(this.alternateScreen ? "alternate" : "main"); + } override write(data: string): void { this.writes.push(data); + if (data.includes("\x1b[?1049h")) this.alternateScreen = true; + if (data.includes("\x1b[?1049l")) this.alternateScreen = false; super.write(data); } + + override setProgress(active: boolean): void { + this.progressActive = active; + this.progressChanges.push(active); + super.setProgress(active); + } + + override stop(): void { + this.stopCount += 1; + if (this.progressActive) this.setProgress(false); + super.stop(); + } +} + +function createTui(terminal: Terminal, uiMode: "regular" | "fullscreen" = "regular") { + return createInteractiveTui({ uiMode, showHardwareCursor: false, logDirectory: "/tmp", terminal }); } describe("createInteractiveTui", () => { it("selects the alternate-screen renderer only when requested", async () => { - const mainTerminal = new RecordingTerminal(); - const mainTui = createInteractiveTui({ - uiMode: "regular", - showHardwareCursor: false, - logDirectory: "/tmp", - terminal: mainTerminal, - }); - expect(isViewportTUI(mainTui)).toBe(false); - mainTui.start(); - await mainTerminal.waitForRender(); - expect(mainTerminal.writes.some((write) => write.includes("\x1b[?1049h"))).toBe(false); - mainTui.stop(); + for (const [uiMode, screen] of [ + ["regular", "main"], + ["fullscreen", "alternate"], + ] as const) { + const terminal = new RecordingTerminal(); + const tui = createTui(terminal, uiMode); + expect(tui.uiMode).toBe(uiMode); + expect(isViewportTUI(tui)).toBe(uiMode === "fullscreen"); + tui.start(); + await terminal.waitForRender(); + expect(terminal.startScreens).toEqual([screen]); + expect(terminal.writes.some((write) => write.includes("\x1b[?1049h"))).toBe(uiMode === "fullscreen"); + tui.stop(); + } + }); - const altTerminal = new RecordingTerminal(); - const altTui = createInteractiveTui({ - uiMode: "fullscreen", - showHardwareCursor: false, - logDirectory: "/tmp", - terminal: altTerminal, - }); - expect(isViewportTUI(altTui)).toBe(true); - altTui.start(); - await altTerminal.waitForRender(); - expect(altTerminal.writes.some((write) => write.includes("\x1b[?1049h"))).toBe(true); - altTui.stop(); + it("switches renderers without restarting the terminal or losing focus", async () => { + const terminal = new RecordingTerminal(30, 6); + const tui = createTui(terminal); + const inputs: string[] = []; + let text = "regular content"; + const component: Component = { + render: () => [text], + handleInput: (data) => inputs.push(data), + invalidate: () => {}, + }; + tui.addChild(component); + tui.setFocus(component); + terminal.write("shell history\r\n"); + tui.start(); + await terminal.waitForRender(); + tui.terminal.setProgress(true); + + const overlay = tui.showOverlay(new Text("overlay", 0, 0)); + expect(tui.setUiMode("fullscreen")).toBe(false); + overlay.hide(); + expect(tui.setUiMode("fullscreen")).toBe(true); + await terminal.waitForRender(); + expect(terminal.suspendScreens).toEqual(["main"]); + expect(terminal.resumeScreens).toEqual(["alternate"]); + terminal.sendInput("b"); + + text = "updated in fullscreen"; + tui.requestRender(); + await terminal.waitForRender(); + expect(tui.setUiMode("regular")).toBe(true); + await terminal.waitForRender(); + expect([terminal.startCount, terminal.stopCount]).toEqual([1, 0]); + expect(terminal.progressChanges).toEqual([true]); + expect(terminal.suspendScreens).toEqual(["main", "alternate"]); + expect(terminal.resumeScreens).toEqual(["alternate", "main"]); + expect(terminal.getViewport().some((line) => line.includes(text))).toBe(true); + expect(terminal.getScrollBuffer().some((line) => line.includes("shell history"))).toBe(true); + terminal.sendInput("c"); + expect(inputs).toEqual(["b", "c"]); + tui.stop(); + expect(terminal.stopCount).toBe(1); + }); + + it("restores the main screen once when stopping from fullscreen", async () => { + const terminal = new RecordingTerminal(40, 10); + const tui = createTui(terminal); + let lines = ["header-once", "body-before", "editor"]; + const component: Component = { + render: () => lines, + invalidate: () => {}, + }; + tui.addChild(component); + tui.start(); + await terminal.waitForRender(); + expect(tui.setUiMode("fullscreen")).toBe(true); + lines = ["header-once", "body-after", "editor"]; + tui.requestRender(); + await terminal.waitForRender(); + + tui.stop(); + await terminal.flush(); + + const scrollBuffer = terminal.getScrollBuffer(); + expect(scrollBuffer.filter((line) => line.includes("header-once"))).toHaveLength(1); + expect(scrollBuffer.some((line) => line.includes("body-before"))).toBe(false); + expect(scrollBuffer.filter((line) => line.includes("body-after"))).toHaveLength(1); + expect(terminal.suspendScreens).toEqual(["main", "alternate"]); + expect(terminal.resumeScreens).toEqual(["alternate"]); + expect(tui.uiMode).toBe("fullscreen"); + + terminal.write("\x1b[2J\x1b[Hexternal application"); + tui.start(); + await terminal.waitForRender(); + tui.stop(); + await terminal.flush(); + const viewport = terminal.getViewport(); + expect(viewport.some((line) => line.includes("body-after"))).toBe(true); + expect(viewport.some((line) => line.includes("external application"))).toBe(false); }); }); type ClearStatusContext = { activeStatusIndicator: { kind: "working"; dispose: () => void } | undefined; statusContainer: Container; - options: { uiMode?: "regular" | "fullscreen" }; - ui: { getClearOnShrink: () => boolean }; + ui: { uiMode: "regular" | "fullscreen"; getClearOnShrink: () => boolean }; idleStatus: Component; }; @@ -67,8 +175,7 @@ describe("clear-on-shrink status spacing", () => { const context: ClearStatusContext = { activeStatusIndicator: { kind: "working", dispose }, statusContainer: new Container(), - options: { uiMode }, - ui: { getClearOnShrink: () => true }, + ui: { uiMode, getClearOnShrink: () => true }, idleStatus: new Text("", 0, 0), }; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 859ed738d..1efb4fdba 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -14,6 +14,7 @@ - Exported the bundled `Marked` parser and token types. - Added width-aware source transforms to the `Markdown` component. - Added interface-compatible main-screen and alternate-screen TUI renderers with application-owned scrolling ([#7304](https://github.com/earendil-works/pi/issues/7304)). +- Added `SwitchableTui` for switching between main-screen and alternate-screen renderers with stable TUI references, supported terminal lifecycle handoff, and no transcript replay. - Added alternate-screen `VStack`, `HStack`, and nested `ScrollView` layouts with constrained sizing, sticky regions, and pointer-targeted scrolling. - Added edge auto-scrolling for alternate-screen drag selection across off-screen scroll-view content. - Added proportional scrollbars with mouse dragging, Home/End document navigation, transient `auto` mode, and an `always` mode that reserves the rightmost column; scrollbar modes can be changed at runtime. diff --git a/packages/tui/README.md b/packages/tui/README.md index dfcfc9ea5..b7647c0d7 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -60,6 +60,7 @@ tui.start(); - `TuiMainScreen` renders into the main terminal buffer and preserves terminal scrollback. - `TuiAltScreen` renders a fixed-height viewport in the alternate terminal buffer with application-owned scrolling. When stopped, it restores the main buffer and prints the complete final document. +- `SwitchableTui` keeps one stable TUI while switching between main-screen and alternate-screen rendering. `ProcessTerminal` preserves its terminal lifecycle across switches. ```typescript import { type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui"; @@ -78,6 +79,19 @@ tui.requestRender(); // Request a re-render tui.onDebug = () => console.log("Debug triggered"); ``` +Use `SwitchableTui` when the renderer must change without replacing references retained by components. `setMode()` returns `false` while an overlay is mounted. Custom terminals can implement the optional `suspendRenderer()` and `resumeRenderer()` hooks to preserve their lifecycle while moving screen-local modes; otherwise `SwitchableTui` stops and restarts them during a switch. + +```typescript +import { SwitchableTui } from "@earendil-works/pi-tui"; + +const tui = new SwitchableTui(terminal, { mode: "main" }); +tui.setLayoutRoot(fullscreenLayout); +tui.start(); + +tui.setMode("alternate"); +tui.setMode("main"); +``` + ### Alternate-screen viewport layouts `TuiAltScreen` can render an explicit terminal-height layout. `VStack` and `HStack` allocate constrained regions, while `ScrollView` owns scrolling for one region. These semantics are intentionally unavailable on `TuiMainScreen`, where the terminal owns scrollback. @@ -669,6 +683,8 @@ The TUI works with any object implementing the `Terminal` interface: interface Terminal { start(onInput: (data: string) => void, onResize: () => void): void; stop(): void; + suspendRenderer?(): void; + resumeRenderer?(): void; write(data: string): void; get columns(): number; get rows(): number; diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 5c7d68e9c..6c2f3439b 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -69,6 +69,7 @@ export { } from "./keys.ts"; // Input buffering for batch splitting export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; +export { SwitchableTui, type SwitchableTuiOptions, type TuiScreenMode } from "./switchable-tui.ts"; // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.ts"; // Terminal colors diff --git a/packages/tui/src/switchable-tui.ts b/packages/tui/src/switchable-tui.ts new file mode 100644 index 000000000..5aa45f292 --- /dev/null +++ b/packages/tui/src/switchable-tui.ts @@ -0,0 +1,83 @@ +import type { Terminal } from "./terminal.ts"; +import type { TuiAltScreenOptions } from "./tui-alt-screen.ts"; +import { TuiAltScreen } from "./tui-alt-screen.ts"; + +export type TuiScreenMode = "main" | "alternate"; + +export interface SwitchableTuiOptions { + mode: TuiScreenMode; + showHardwareCursor?: boolean; + logDirectory?: string; + altScreen?: TuiAltScreenOptions; +} + +/** TUI that can switch between main-screen and alternate-screen rendering. */ +export class SwitchableTui extends TuiAltScreen { + private currentMode: TuiScreenMode; + private started = false; + private renderedMainScreen: boolean; + private mainNeedsFullRedraw = false; + + constructor(terminal: Terminal, options: SwitchableTuiOptions) { + super(terminal, options.showHardwareCursor, options.logDirectory, options.altScreen); + this.currentMode = options.mode; + this.renderedMainScreen = options.mode === "main"; + } + + protected override get alternateScreenEnabled(): boolean { + return this.currentMode === "alternate"; + } + + get mode(): TuiScreenMode { + return this.currentMode; + } + + setMode(mode: TuiScreenMode): boolean { + if (mode === this.currentMode) return true; + if (this.hasOverlayEntries) return false; + if (!this.started) { + this.currentMode = mode; + if (mode === "main") this.renderedMainScreen = true; + return true; + } + + const forceMainRedraw = mode === "main" && this.mainNeedsFullRedraw; + this.switchRenderer(() => { + this.currentMode = mode; + }); + if (mode === "main") { + this.renderedMainScreen = true; + if (forceMainRedraw) this.requestRender(true); + this.mainNeedsFullRedraw = false; + } + return true; + } + + override start(): void { + if (this.started) return; + super.start(); + this.started = true; + if (this.currentMode === "main" && this.mainNeedsFullRedraw) { + this.requestRender(true); + this.mainNeedsFullRedraw = false; + } + } + + override stop(): void { + if (!this.started) return; + const mode = this.currentMode; + try { + if (mode === "alternate" && this.renderedMainScreen) { + this.switchRenderer(() => { + this.currentMode = "main"; + }, false); + this.renderImmediately(this.mainNeedsFullRedraw); + } + super.stop(); + } finally { + this.currentMode = mode; + if (mode === "alternate" && this.renderedMainScreen) this.mainNeedsFullRedraw = true; + this.started = false; + } + } +} diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index 2542286ae..aec3a8ce9 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -56,6 +56,12 @@ export interface Terminal { // Stop the terminal and restore state stop(): void; + /** Temporarily leave renderer-local terminal modes before changing screen buffers. */ + suspendRenderer?(): void; + + /** Restore renderer-local terminal modes after changing screen buffers. */ + resumeRenderer?(): void; + /** * Drain stdin before exiting to prevent Kitty key release events from * leaking to the parent shell over slow SSH connections. @@ -220,11 +226,25 @@ export class ProcessTerminal implements Terminal { private queryAndEnableKittyProtocol(): void { this.setupStdinBuffer(); process.stdin.on("data", this.stdinDataHandler!); + this.pushKeyboardProtocol(); + } + + private pushKeyboardProtocol(): void { this.keyboardProtocolPushed = true; this.clearKeyboardProtocolNegotiationBuffer(); process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY); } + private popKeyboardProtocol(): void { + const shouldDisable = this.keyboardProtocolPushed || this._kittyProtocolActive; + this.clearKeyboardProtocolNegotiationBuffer(); + if (!shouldDisable) return; + process.stdout.write("\x1b[ { - const shouldDisableKittyProtocol = this.keyboardProtocolPushed || this._kittyProtocolActive; - this.clearKeyboardProtocolNegotiationBuffer(); - if (shouldDisableKittyProtocol) { - // Disable Kitty keyboard protocol first so any late key releases - // do not generate new Kitty escape sequences. - process.stdout.write("\x1b[ this.handleViewportInput(data)); + this.addInputListener((data) => (this.alternateScreenEnabled ? this.handleViewportInput(data) : undefined)); } get viewportTop(): number { @@ -147,16 +155,16 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } override render(width: number): string[] { - return this.layoutRoot?.render(width) ?? super.render(width); + return this.alternateScreenEnabled && this.layoutRoot ? this.layoutRoot.render(width) : super.render(width); } override invalidate(): void { super.invalidate(); - this.layoutRoot?.invalidate(); + if (this.alternateScreenEnabled) this.layoutRoot?.invalidate(); } protected override getMountedRoots(): readonly Component[] { - return this.layoutRoot ? [this.layoutRoot] : this.children; + return this.alternateScreenEnabled && this.layoutRoot ? [this.layoutRoot] : this.children; } private getPrimaryScrollView(): ScrollView { @@ -164,6 +172,10 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } protected override beforeTerminalStart(): void { + if (!this.alternateScreenEnabled) { + super.beforeTerminalStart(); + return; + } this.stopSelectionAutoScroll(); this.selectionPressActive = false; this.stopScrollbarHover(); @@ -189,6 +201,10 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } protected override beforeTerminalStop(): void { + if (!this.alternateScreenEnabled) { + super.beforeTerminalStop(); + return; + } this.stopSelectionAutoScroll(); this.selectionPressActive = false; this.stopScrollbarHover(); @@ -196,36 +212,48 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.flashes.dispose(); if (!this.altScreenActive) return; this.terminal.write( - `${BEGIN_SYNCHRONIZED_OUTPUT}${this.deleteKittyImages()}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${END_SYNCHRONIZED_OUTPUT}`, + `${BEGIN_SYNCHRONIZED_OUTPUT}${this.deleteAltKittyImages()}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${END_SYNCHRONIZED_OUTPUT}`, ); } protected override afterTerminalStop(): void { + if (!this.alternateScreenEnabled) { + super.afterTerminalStop(); + return; + } if (!this.altScreenActive) return; this.altScreenActive = false; - const width = Math.max(1, this.terminal.columns); - const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, "")); - this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map( - (line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)), - ); - let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`; - for (let row = 0; row < this.lastDocument.length; row++) { - if (row > 0) buffer += "\r\n"; - buffer += `\r\x1b[2K${this.lastDocument[row] ?? ""}`; + if (this.isRendererSwitchStop) { + this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`); + } else { + const width = Math.max(1, this.terminal.columns); + const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map( + (line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)), + ); + let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`; + for (let row = 0; row < this.lastDocument.length; row++) { + if (row > 0) buffer += "\r\n"; + buffer += `\r\x1b[2K${this.lastDocument[row] ?? ""}`; + } + buffer += `\x1b[0m${ENABLE_AUTOWRAP}\r\n\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`; + this.terminal.write(buffer); } - buffer += `\x1b[0m${ENABLE_AUTOWRAP}\r\n\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`; - this.terminal.write(buffer); if (this.savedCapabilities) { setCapabilities(this.savedCapabilities); this.savedCapabilities = undefined; } } - private deleteKittyImages(): string { + private deleteAltKittyImages(): string { return this.imageProtocol === "kitty" ? deleteAllKittyImages() : ""; } protected override resetRenderState(): void { + if (!this.alternateScreenEnabled) { + super.resetRenderState(); + return; + } this.previousScreen = []; this.previousScreenWidth = 0; this.previousScreenHeight = 0; @@ -751,6 +779,10 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } protected override doRender(): void { + if (!this.alternateScreenEnabled) { + super.doRender(); + return; + } if (this.stopped || !this.altScreenActive) return; const width = Math.max(1, this.terminal.columns); const height = Math.max(1, this.terminal.rows); @@ -778,9 +810,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { let buffer = BEGIN_SYNCHRONIZED_OUTPUT; if (fullRedraw) { this.fullRedrawCount += 1; - buffer += `${this.deleteKittyImages()}\x1b[2J`; + buffer += `${this.deleteAltKittyImages()}\x1b[2J`; } else if (imagesNeedRedraw) { - buffer += this.imageProtocol === "iterm2" ? "\x1b[2J" : this.deleteKittyImages(); + buffer += this.imageProtocol === "iterm2" ? "\x1b[2J" : this.deleteAltKittyImages(); } for (let row = 0; row < height; row++) { diff --git a/packages/tui/src/tui-main-screen.ts b/packages/tui/src/tui-main-screen.ts index a1cd09f55..adf33ffa0 100644 --- a/packages/tui/src/tui-main-screen.ts +++ b/packages/tui/src/tui-main-screen.ts @@ -65,7 +65,7 @@ export class TuiMainScreen extends TuiBase implements TUI { } protected override beforeTerminalStop(): void { - if (this.previousLines.length === 0) return; + if (this.isRendererSwitchStop || this.previousLines.length === 0) return; this.terminal.write(" "); const targetRow = this.previousLines.length; const lineDiff = targetRow - this.hardwareCursorRow; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index eb807000e..4423be4d4 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -334,6 +334,7 @@ export abstract class TuiBase extends Container implements TUI { private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; protected fullRedrawCount = 0; protected stopped = false; + private rendererSwitchStop = false; private pendingOsc11BackgroundReplies = 0; private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = []; private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>(); @@ -370,6 +371,10 @@ export abstract class TuiBase extends Container implements TUI { protected afterTerminalStop(): void {} + protected get isRendererSwitchStop(): boolean { + return this.rendererSwitchStop; + } + get fullRedraws(): number { return this.fullRedrawCount; } @@ -727,8 +732,54 @@ export abstract class TuiBase extends Container implements TUI { this.terminal.write("\x1b[16t"); } + /** Change renderer-specific screen modes while retaining this TUI instance. */ + protected switchRenderer(switchMode: () => void, resumeTerminal = true): void { + const canSuspend = this.terminal.suspendRenderer !== undefined && this.terminal.resumeRenderer !== undefined; + if (this.terminalColorSchemeNotificationsEnabled) this.terminal.write("\x1b[?2031l"); + this.rendererSwitchStop = true; + try { + this.beforeTerminalStop(); + this.terminal.showCursor(); + if (canSuspend) this.terminal.suspendRenderer?.(); + else this.terminal.stop(); + this.afterTerminalStop(); + } finally { + this.rendererSwitchStop = false; + } + + switchMode(); + if (!resumeTerminal && canSuspend) return; + + this.beforeTerminalStart(); + if (canSuspend) { + this.terminal.resumeRenderer?.(); + } else { + this.terminal.start( + (data) => this.handleTerminalInput(data), + () => this.requestRender(), + ); + } + this.afterTerminalStart(); + this.terminal.hideCursor(); + if (this.terminalColorSchemeNotificationsEnabled) this.terminal.write("\x1b[?2031h"); + this.queryCellSize(); + this.requestRender(); + } + + protected renderImmediately(force = false): void { + if (force) this.resetRenderState(); + this.renderRequested = false; + if (this.renderTimer) { + clearTimeout(this.renderTimer); + this.renderTimer = undefined; + } + this.lastRenderAt = performance.now(); + this.doRender(); + } + stop(): void { this.stopped = true; + this.renderRequested = false; if (this.renderTimer) { clearTimeout(this.renderTimer); this.renderTimer = undefined; diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index a356bff1d..fd9eadb33 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -111,6 +111,24 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { } }); + it("moves keyboard modes between renderer screen buffers", () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?7u"); + harness.terminal.suspendRenderer(); + + assert.equal(harness.writes.filter((write) => write === "\x1b[ write === "\x1b[>7u\x1b[?u\x1b[c").length, 2); + assert.equal(harness.writes.includes("\x1b[?2004h"), true); + } finally { + harness.cleanup(); + } + }); + it("falls back to modifyOtherKeys for zero Kitty flags", () => { const harness = setupNegotiation(); try {