From c13ffe1877c3a47ce9f2fc98d9880447d64a0e87 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Thu, 30 Jul 2026 12:58:58 +0200 Subject: [PATCH] feat(tui): add alternate-screen renderer Closes #7304 --- .pi/extensions/prompt-url-widget.ts | 6 +- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/README.md | 1 + packages/coding-agent/docs/keybindings.md | 11 + packages/coding-agent/docs/usage.md | 3 + .../coding-agent/examples/rpc-extension-ui.ts | 13 +- packages/coding-agent/src/cli/args.ts | 4 + .../coding-agent/src/cli/config-selector.ts | 4 +- packages/coding-agent/src/cli/startup-ui.ts | 4 +- packages/coding-agent/src/main.ts | 1 + .../src/modes/interactive/interactive-mode.ts | 32 +- packages/coding-agent/test/args.test.ts | 8 + .../test/edit-tool-no-full-redraw.test.ts | 8 +- .../test/interactive-mode-status.test.ts | 5 +- .../coding-agent/test/interactive-tui.test.ts | 79 ++ .../test/streaming-render-debug.ts | 4 +- packages/tui/CHANGELOG.md | 4 + packages/tui/README.md | 42 +- packages/tui/src/TuiAltScreen.ts | 431 +++++++++++ packages/tui/src/TuiMainScreen.ts | 552 ++++++++++++++ packages/tui/src/index.ts | 16 +- packages/tui/src/keybindings.ts | 9 + packages/tui/src/terminal-image.ts | 51 +- packages/tui/src/tui.ts | 713 +++--------------- packages/tui/src/utils.ts | 71 ++ packages/tui/test/chat-simple.ts | 5 +- packages/tui/test/editor.test.ts | 5 +- packages/tui/test/image-test.ts | 5 +- packages/tui/test/key-tester.ts | 5 +- packages/tui/test/markdown.test.ts | 7 +- .../tui/test/overlay-non-capturing.test.ts | 91 +-- packages/tui/test/overlay-options.test.ts | 52 +- .../tui/test/overlay-short-content.test.ts | 5 +- .../regression-overlay-cjk-boundary.test.ts | 28 +- packages/tui/test/tab-width.test.ts | 5 +- packages/tui/test/terminal-colors.test.ts | 13 +- packages/tui/test/terminal-image.test.ts | 26 + packages/tui/test/tui-alt-screen.test.ts | 331 ++++++++ packages/tui/test/tui-cell-size-input.test.ts | 7 +- .../tui/test/tui-overlay-style-leak.test.ts | 7 +- packages/tui/test/tui-render.test.ts | 51 +- packages/tui/test/tui-shrink.test.ts | 5 +- packages/tui/test/viewport-overwrite-repro.ts | 8 +- 43 files changed, 1929 insertions(+), 803 deletions(-) create mode 100644 packages/coding-agent/test/interactive-tui.test.ts create mode 100644 packages/tui/src/TuiAltScreen.ts create mode 100644 packages/tui/src/TuiMainScreen.ts create mode 100644 packages/tui/test/tui-alt-screen.test.ts diff --git a/.pi/extensions/prompt-url-widget.ts b/.pi/extensions/prompt-url-widget.ts index 85ef6537a..20a41a509 100644 --- a/.pi/extensions/prompt-url-widget.ts +++ b/.pi/extensions/prompt-url-widget.ts @@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises"; import { homedir } from "node:os"; import { resolve } from "node:path"; import { DynamicBorder, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent"; -import { Container, Text } from "@earendil-works/pi-tui"; +import { Container, hyperlink, Text } from "@earendil-works/pi-tui"; const PR_PROMPT_PATTERN = /^\s*You are given one or more GitHub PR URLs:\s*(\S+)/im; const ISSUE_PROMPT_PATTERN = /^\s*Analyze GitHub issue\(s\):\s*(\S+)/im; @@ -175,10 +175,10 @@ export default function promptUrlWidgetExtension(pi: ExtensionAPI) { const displayTarget = metadata?.displayUrl ?? match.target; const titleText = metadata?.title ? thm.fg("accent", metadata.title) - : thm.fg("accent", displayTarget); + : hyperlink(thm.fg("accent", displayTarget), displayTarget); const detailText = metadata?.detail ?? formatAuthor(metadata?.author); const detailLine = detailText ? thm.fg("muted", detailText) : undefined; - const urlLine = thm.fg("dim", displayTarget); + const urlLine = hyperlink(thm.fg("dim", displayTarget), displayTarget); const lines = [titleText]; if (detailLine) lines.push(detailLine); diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 09581db6c..6e3efb3fc 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `--alt` to run interactive mode with the alternate-screen TUI ([#7304](https://github.com/earendil-works/pi/issues/7304)). + ### Fixed - Fixed `setToolsExpanded(false)` to be a no-op when tool output is already collapsed, avoiding redundant `Tool output: collapsed` startup notices from extensions ([#7292](https://github.com/earendil-works/pi/issues/7292)). diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index ebb81ec0d..bdf88df73 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -605,6 +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 | | `--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 08a58150d..d48790708 100644 --- a/packages/coding-agent/docs/keybindings.md +++ b/packages/coding-agent/docs/keybindings.md @@ -78,6 +78,17 @@ 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 + +These actions apply when interactive mode is started with `--alt`. Two-finger trackpad and mouse-wheel input also scroll the viewport. Clicking an OSC 8 hyperlink opens it in the default handler. Dragging with the primary mouse button selects visible text and copies it to the clipboard. + +| Keybinding id | Default | Description | +|--------|---------|-------------| +| `tui.altScreen.pageUp` | `shift+pageUp` | Scroll the viewport up by one page | +| `tui.altScreen.pageDown` | `shift+pageDown` | Scroll the viewport down by one page | +| `tui.altScreen.top` | `ctrl+home` | Scroll to the beginning of the document | +| `tui.altScreen.bottom` | `ctrl+end` | Scroll to the end and follow new output | + ### Application | Keybinding id | Default | Description | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index 8c0aa5657..cac56387d 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -239,12 +239,15 @@ 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 application-owned scrolling in interactive mode | | `--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, 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`, iTerm2 inline images continue to render normally. + ### File Arguments Prefix files with `@` to include them in the message: diff --git a/packages/coding-agent/examples/rpc-extension-ui.ts b/packages/coding-agent/examples/rpc-extension-ui.ts index bcbb6ec89..ba055d6ae 100644 --- a/packages/coding-agent/examples/rpc-extension-ui.ts +++ b/packages/coding-agent/examples/rpc-extension-ui.ts @@ -18,7 +18,16 @@ import { spawn } from "node:child_process"; import { dirname, join } from "node:path"; import * as readline from "node:readline"; import { fileURLToPath } from "node:url"; -import { type Component, Container, Input, matchesKey, ProcessTerminal, SelectList, TUI } from "@earendil-works/pi-tui"; +import { + type Component, + Container, + Input, + matchesKey, + ProcessTerminal, + SelectList, + type TUI, + TuiMainScreen, +} from "@earendil-works/pi-tui"; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -267,7 +276,7 @@ async function main() { // -- TUI setup -- const terminal = new ProcessTerminal(); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const outputLog = new OutputLog(); const loadingIndicator = new LoadingIndicator(); diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index ae70dc409..381add4e9 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -45,6 +45,7 @@ export interface Args { noContextFiles?: boolean; listModels?: string | true; offline?: boolean; + alt?: boolean; verbose?: boolean; projectTrustOverride?: boolean; messages: string[]; @@ -175,6 +176,8 @@ export function parseArgs(args: string[]): Args { } else { result.listModels = true; } + } else if (arg === "--alt") { + result.alt = true; } else if (arg === "--verbose") { result.verbose = true; } else if (arg === "--approve" || arg === "-a") { @@ -272,6 +275,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 --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/cli/config-selector.ts b/packages/coding-agent/src/cli/config-selector.ts index cba05d860..08663b0b1 100644 --- a/packages/coding-agent/src/cli/config-selector.ts +++ b/packages/coding-agent/src/cli/config-selector.ts @@ -2,7 +2,7 @@ * TUI config selector for `pi config` command */ -import { ProcessTerminal, TUI } from "@earendil-works/pi-tui"; +import { ProcessTerminal, type TUI, TuiMainScreen } from "@earendil-works/pi-tui"; import type { SettingsManager } from "../core/settings-manager.ts"; import { ConfigSelectorComponent, type ScopedResolvedPaths } from "../modes/interactive/components/config-selector.ts"; import { initTheme, stopThemeWatcher } from "../modes/interactive/theme/theme.ts"; @@ -22,7 +22,7 @@ export async function selectConfig(options: ConfigSelectorOptions): Promise { - const ui = new TUI(new ProcessTerminal(), undefined, options.agentDir); + const ui: TUI = new TuiMainScreen(new ProcessTerminal(), undefined, options.agentDir); let resolved = false; const selector = new ConfigSelectorComponent( diff --git a/packages/coding-agent/src/cli/startup-ui.ts b/packages/coding-agent/src/cli/startup-ui.ts index 12669e791..37d6882ec 100644 --- a/packages/coding-agent/src/cli/startup-ui.ts +++ b/packages/coding-agent/src/cli/startup-ui.ts @@ -1,4 +1,4 @@ -import { ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; +import { ProcessTerminal, setKeybindings, type TUI, TuiMainScreen } from "@earendil-works/pi-tui"; import { existsSync } from "fs"; import { APP_NAME, CONFIG_DIR_NAME, ENV_AGENT_DIR, getAgentDir, getSettingsPath, PACKAGE_NAME } from "../config.ts"; import { areExperimentalFeaturesEnabled } from "../core/experimental.ts"; @@ -79,7 +79,7 @@ export async function createStartupTui(settingsManager: SettingsManager): Promis const terminalTheme = detectTerminalBackgroundFromEnv().theme; initTheme(resolveThemeSetting(settingsManager.getThemeSetting(), terminalTheme) ?? terminalTheme); setKeybindings(KeybindingsManager.create()); - const ui = new TUI(new ProcessTerminal(), settingsManager.getShowHardwareCursor(), getAgentDir()); + const ui: TUI = new TuiMainScreen(new ProcessTerminal(), settingsManager.getShowHardwareCursor(), getAgentDir()); ui.setClearOnShrink(settingsManager.getClearOnShrink()); return ui; } diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index a9c3e4680..e9a481fdb 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -877,6 +877,7 @@ export async function main(args: string[], options?: MainOptions) { initialImages, initialMessages: parsed.messages, verbose: parsed.verbose, + alt: parsed.alt, }); if (startupBenchmark) { await interactiveMode.init(); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index f0fdc9d4d..488c6b0ed 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -20,6 +20,7 @@ import type { OverlayHandle, OverlayOptions, SlashCommand, + Terminal, } from "@earendil-works/pi-tui"; import { CombinedAutocompleteProvider, @@ -35,7 +36,9 @@ import { setKeybindings, Text, TruncatedText, - TUI, + type TUI, + TuiAltScreen, + TuiMainScreen, visibleWidth, } from "@earendil-works/pi-tui"; import chalk from "chalk"; @@ -96,6 +99,7 @@ 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"; @@ -318,6 +322,24 @@ export interface InteractiveModeOptions { initialMessages?: string[]; /** Force verbose startup (overrides quietStartup setting) */ verbose?: boolean; + /** Use the alternate-screen TUI renderer. */ + alt?: boolean; +} + +interface InteractiveTuiOptions { + alt: boolean; + 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.alt) { + return new TuiAltScreen(terminal, options.showHardwareCursor, options.logDirectory, { openUrl: openBrowser }); + } + return new TuiMainScreen(terminal, options.showHardwareCursor, options.logDirectory); } export class InteractiveMode { @@ -456,7 +478,11 @@ export class InteractiveMode { await this.rebindCurrentSession({ renderBeforeBind: true }); }); this.version = VERSION; - this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor(), getAgentDir()); + this.ui = createInteractiveTui({ + alt: options.alt ?? false, + showHardwareCursor: this.settingsManager.getShowHardwareCursor(), + logDirectory: getAgentDir(), + }); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); this.headerContainer = new Container(); this.loadedResourcesContainer = new Container(); @@ -1869,7 +1895,7 @@ export class InteractiveMode { this.activeStatusIndicator?.dispose(); this.activeStatusIndicator = undefined; this.statusContainer.clear(); - if (hadActiveStatusIndicator && this.ui.getClearOnShrink()) { + if (hadActiveStatusIndicator && !this.options.alt && this.ui.getClearOnShrink()) { this.statusContainer.addChild(this.idleStatus); } } diff --git a/packages/coding-agent/test/args.test.ts b/packages/coding-agent/test/args.test.ts index 8591974d7..3ab937451 100644 --- a/packages/coding-agent/test/args.test.ts +++ b/packages/coding-agent/test/args.test.ts @@ -329,6 +329,14 @@ describe("parseArgs", () => { }); }); + describe("--alt flag", () => { + test("parses --alt flag", () => { + const result = parseArgs(["--alt"]); + expect(result.alt).toBe(true); + expect(result.unknownFlags.has("alt")).toBe(false); + }); + }); + describe("tool flags", () => { test("parses --no-tools flag", () => { const result = parseArgs(["--no-tools"]); diff --git a/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts b/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts index 1da9f0df7..8e6d66f56 100644 --- a/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts +++ b/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Container, type Terminal, Text, TUI } from "@earendil-works/pi-tui"; +import { Container, type Terminal, Text, type TUI, TuiMainScreen } from "@earendil-works/pi-tui"; import { afterEach, beforeAll, describe, expect, it } from "vitest"; import { createEditToolDefinition } from "../src/core/tools/edit.ts"; import { computeEditsDiff, type Edit } from "../src/core/tools/edit-diff.ts"; @@ -94,7 +94,7 @@ describe("edit tool TUI rendering", () => { } const terminal = new FakeTerminal(); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const root = new Container(); for (let i = 0; i < 200; i++) { root.addChild(new Text(`history ${i}`, 0, 0)); @@ -168,7 +168,7 @@ describe("edit tool TUI rendering", () => { await rm(filePath, { force: true }); const terminal = new FakeTerminal(); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new ToolExecutionComponent( "edit", "tool-call-replay", @@ -205,7 +205,7 @@ describe("edit tool TUI rendering", () => { await writeFile(filePath, "line 0\nline 1\n", "utf8"); const terminal = new FakeTerminal(); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new ToolExecutionComponent( "edit", "tool-call-2", diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 416858174..166cafc02 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -2,7 +2,8 @@ import { homedir } from "node:os"; import * as path from "node:path"; import { type AutocompleteProvider, CombinedAutocompleteProvider } from "@earendil-works/pi-tui"; import { beforeAll, describe, expect, test, vi } from "vitest"; -import { type Component, Container, type Focusable, TUI } from "../../tui/src/tui.ts"; +import { TuiMainScreen } from "../../tui/src/TuiMainScreen.ts"; +import { type Component, Container, type Focusable, type TUI } from "../../tui/src/tui.ts"; import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; import type { AutocompleteProviderFactory } from "../src/core/extensions/types.ts"; import type { SourceInfo } from "../src/core/source-info.ts"; @@ -210,7 +211,7 @@ describe("InteractiveMode.showExtensionCustom", () => { test("overlay custom UI reclaims input after non-overlay custom UI closes", async () => { const terminal = new VirtualTerminal(80, 24); - const ui = new TUI(terminal); + const ui: TUI = new TuiMainScreen(terminal); const editorContainer = new Container(); const editor = new TestFocusableComponent("EDITOR"); const palette = new TestFocusableComponent("PALETTE"); diff --git a/packages/coding-agent/test/interactive-tui.test.ts b/packages/coding-agent/test/interactive-tui.test.ts new file mode 100644 index 000000000..5e2e3f543 --- /dev/null +++ b/packages/coding-agent/test/interactive-tui.test.ts @@ -0,0 +1,79 @@ +import type { Component, Terminal } from "@earendil-works/pi-tui"; +import { Container, Text } from "@earendil-works/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import { VirtualTerminal } from "../../tui/test/virtual-terminal.ts"; +import { createInteractiveTui, InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; + +class RecordingTerminal extends VirtualTerminal implements Terminal { + readonly writes: string[] = []; + + override write(data: string): void { + this.writes.push(data); + super.write(data); + } +} + +describe("createInteractiveTui", () => { + it("selects the alternate-screen renderer only when requested", async () => { + const mainTerminal = new RecordingTerminal(); + const mainTui = createInteractiveTui({ + alt: false, + showHardwareCursor: false, + logDirectory: "/tmp", + terminal: mainTerminal, + }); + mainTui.start(); + await mainTerminal.waitForRender(); + expect(mainTerminal.writes.some((write) => write.includes("\x1b[?1049h"))).toBe(false); + mainTui.stop(); + + const altTerminal = new RecordingTerminal(); + const altTui = createInteractiveTui({ + alt: true, + showHardwareCursor: false, + logDirectory: "/tmp", + terminal: altTerminal, + }); + altTui.start(); + await altTerminal.waitForRender(); + expect(altTerminal.writes.some((write) => write.includes("\x1b[?1049h"))).toBe(true); + altTui.stop(); + }); +}); + +type ClearStatusContext = { + activeStatusIndicator: { kind: "working"; dispose: () => void } | undefined; + statusContainer: Container; + options: { alt?: boolean }; + ui: { getClearOnShrink: () => boolean }; + idleStatus: Component; +}; + +type InteractiveModePrototype = { + clearStatusIndicator(this: ClearStatusContext, kind?: "working"): void; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; + +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], + ] as const) { + const dispose = vi.fn(); + const context: ClearStatusContext = { + activeStatusIndicator: { kind: "working", dispose }, + statusContainer: new Container(), + options: { alt }, + ui: { getClearOnShrink: () => true }, + idleStatus: new Text("", 0, 0), + }; + + interactiveModePrototype.clearStatusIndicator.call(context); + + expect(dispose).toHaveBeenCalledOnce(); + expect(context.statusContainer.children).toHaveLength(expectedChildren); + } + }); +}); diff --git a/packages/coding-agent/test/streaming-render-debug.ts b/packages/coding-agent/test/streaming-render-debug.ts index 315c664dd..934782380 100644 --- a/packages/coding-agent/test/streaming-render-debug.ts +++ b/packages/coding-agent/test/streaming-render-debug.ts @@ -5,7 +5,7 @@ */ import type { AssistantMessage } from "@earendil-works/pi-ai"; -import { ProcessTerminal, TUI } from "@earendil-works/pi-tui"; +import { ProcessTerminal, type TUI, TuiMainScreen } from "@earendil-works/pi-tui"; import { readFileSync } from "fs"; import { dirname, join } from "path"; import { fileURLToPath } from "url"; @@ -42,7 +42,7 @@ async function sleep(ms: number): Promise { async function main() { const terminal = new ProcessTerminal(); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Start with empty message const message = { diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index d8534f556..408d9d9ee 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added interface-compatible main-screen and alternate-screen TUI renderers with application-owned scrolling ([#7304](https://github.com/earendil-works/pi/issues/7304)). + ## [0.83.0] - 2026-07-29 ### Fixed diff --git a/packages/tui/README.md b/packages/tui/README.md index d0aae4005..acec9fc20 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -4,7 +4,9 @@ Minimal terminal UI framework with differential rendering and synchronized outpu ## Features -- **Differential Rendering**: Three-strategy rendering system that only updates what changed +- **Interchangeable Renderers**: Shared `TUI` interface with main-screen and alternate-screen implementations +- **Differential Rendering**: Updates only changed lines or viewport rows +- **Application-owned Scrolling**: Alternate-screen viewport supports mouse, trackpad, and keyboard navigation - **Synchronized Output**: Uses CSI 2026 for atomic screen updates (no flicker) - **Bracketed Paste Mode**: Handles large pastes correctly with markers for >10 line pastes - **Component-based**: Simple Component interface with render() method @@ -16,13 +18,13 @@ Minimal terminal UI framework with differential rendering and synchronized outpu ## Quick Start ```typescript -import { TUI, Text, Editor, ProcessTerminal, matchesKey } from "@earendil-works/pi-tui"; +import { type TUI, Text, Editor, ProcessTerminal, TuiMainScreen, matchesKey } from "@earendil-works/pi-tui"; // Create terminal const terminal = new ProcessTerminal(); -// Create TUI -const tui = new TUI(terminal); +// Create the default main-screen renderer through the shared TUI interface +const tui: TUI = new TuiMainScreen(terminal); // Add components tui.addChild(new Text("Welcome to my app!")); @@ -52,12 +54,20 @@ tui.start(); ## Core API -### TUI +### TUI interface and renderers -Main container that manages components and rendering. +`TUI` is the shared interface for component management, focus, overlays, input, lifecycle, terminal queries, and rendering. Choose a concrete renderer only when constructing the application: + +- `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. ```typescript -const tui = new TUI(terminal); +import { type TUI, TuiAltScreen, TuiMainScreen } from "@earendil-works/pi-tui"; + +const tui: TUI = new TuiMainScreen(terminal); +// To use an application-owned viewport in the alternate terminal buffer instead: +// const tui: TUI = new TuiAltScreen(terminal); + tui.addChild(component); tui.removeChild(component); tui.start(); @@ -190,7 +200,7 @@ When a `Focusable` component has focus, TUI: 3. Positions the hardware terminal cursor at that location 4. Shows the hardware cursor only when `showHardwareCursor` is enabled -The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the `TUI` constructor option, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface. +The cursor remains hidden by default. This keeps the fake cursor rendering, while still positioning the hardware cursor for terminals that track IME candidate windows with hidden cursors. Some terminals require a visible hardware cursor for IME positioning; enable it with the renderer constructor's `showHardwareCursor` argument, `setShowHardwareCursor(true)`, or `PI_HARDWARE_CURSOR=1`. The `Editor` and `Input` built-in components already implement this interface. **Container components with embedded inputs:** When a container component (dialog, selector, etc.) contains an `Input` or `Editor` child, the container must implement `Focusable` and propagate the focus state to the child: @@ -535,6 +545,10 @@ tui.addChild(image); Supported formats: PNG, JPEG, GIF, WebP. Dimensions are parsed from the image headers automatically. +#### Alternate-screen image compatibility + +`TuiAltScreen` supports inline images and partial viewport cropping in terminals that implement the Kitty graphics protocol, including Kitty and Ghostty. iTerm2's inline-image protocol does not provide operations to delete an existing placement or crop its source while scrolling. To prevent stale images from remaining over repainted content, `TuiAltScreen` renders image components as text placeholders in iTerm2. `TuiMainScreen` continues to render iTerm2 inline images normally. + ## Autocomplete ### CombinedAutocompleteProvider @@ -588,15 +602,17 @@ if (matchesKey(data, Key.enter)) { - With modifiers: `Key.ctrl("c")`, `Key.shift("tab")`, `Key.alt("left")`, `Key.ctrlShift("p")` - String format also works: `"enter"`, `"ctrl+c"`, `"shift+tab"`, `"ctrl+shift+p"` -## Differential Rendering +## Rendering modes -The TUI uses three rendering strategies: +`TuiMainScreen` uses three rendering strategies: 1. **First Render**: Output all lines without clearing scrollback -2. **Width Changed or Change Above Viewport**: Clear screen and full re-render -3. **Normal Update**: Move cursor to first changed line, clear to end, render changed lines +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 -All updates are wrapped in **synchronized output** (`\x1b[?2026h` ... `\x1b[?2026l`) for atomic, flicker-free rendering. +`TuiAltScreen` owns a terminal-height viewport. 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 the document without modifying terminal scrollback. Clicking an OSC 8 hyperlink opens it with the configured URL handler. Dragging with the primary mouse button selects visible text and copies it to the clipboard with OSC 52. Kitty images support 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. ## Terminal Interface diff --git a/packages/tui/src/TuiAltScreen.ts b/packages/tui/src/TuiAltScreen.ts new file mode 100644 index 000000000..d284647ac --- /dev/null +++ b/packages/tui/src/TuiAltScreen.ts @@ -0,0 +1,431 @@ +import { getKeybindings } from "./keybindings.ts"; +import type { Terminal } from "./terminal.ts"; +import { + cropKittyImageLine, + deleteAllKittyImages, + getCapabilities, + getKittyImageMetadata, + type ImageProtocol, + isImageLine, + setCapabilities, + type TerminalCapabilities, +} from "./terminal-image.ts"; +import { CURSOR_MARKER, type TUI, TuiBase } from "./tui.ts"; +import { + getGraphemeCellRange, + getOsc8LinkAtColumn, + sliceByColumn, + stripTerminalSequences, + visibleWidth, +} from "./utils.ts"; + +const ENTER_ALT_SCREEN = "\x1b[?1049h"; +const EXIT_ALT_SCREEN = "\x1b[?1049l"; +const DISABLE_AUTOWRAP = "\x1b[?7l"; +const ENABLE_AUTOWRAP = "\x1b[?7h"; +const ENABLE_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1006h"; +const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1002l\x1b[?1000l"; +const BEGIN_SYNCHRONIZED_OUTPUT = "\x1b[?2026h"; +const END_SYNCHRONIZED_OUTPUT = "\x1b[?2026l"; +const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/; + +interface SelectionPoint { + row: number; + col: number; +} + +interface SgrMouseEvent { + button: number; + x: number; + y: number; + release: boolean; +} + +export interface TuiAltScreenOptions { + /** Number of logical lines moved for each mouse-wheel event. */ + wheelScrollLines?: number; + /** Capture mouse events for viewport scrolling and application-owned text selection. */ + mouse?: boolean; + /** Open an OSC 8 hyperlink activated with a primary-button click. */ + openUrl?: (url: string) => void; +} + +/** Alternate-screen TUI with a scrollable, application-owned viewport. */ +export class TuiAltScreen extends TuiBase implements TUI { + private previousScreen: string[] = []; + private lastDocument: string[] = []; + private previousScreenWidth = 0; + private previousScreenHeight = 0; + private scrollTop = 0; + private contentLineCount = 0; + private stickToBottom = true; + private altScreenActive = false; + private imageProtocol: ImageProtocol = null; + private savedCapabilities?: TerminalCapabilities; + private selectionAnchor?: SelectionPoint; + private selectionFocus?: SelectionPoint; + private pressedUrl?: string; + private selectionDragged = false; + private readonly wheelScrollLines: number; + private readonly mouseEnabled: boolean; + private readonly openUrl?: (url: string) => void; + + constructor( + terminal: Terminal, + showHardwareCursor?: boolean, + logDirectory?: string, + options: TuiAltScreenOptions = {}, + ) { + super(terminal, showHardwareCursor, logDirectory); + this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 3)); + this.mouseEnabled = options.mouse ?? true; + this.openUrl = options.openUrl; + this.addInputListener((data) => this.handleViewportInput(data)); + } + + get viewportTop(): number { + return this.scrollTop; + } + + get isFollowingOutput(): boolean { + return this.stickToBottom; + } + + protected override beforeTerminalStart(): void { + this.altScreenActive = true; + const capabilities = getCapabilities(); + this.imageProtocol = capabilities.images; + if (capabilities.images === "iterm2") { + this.savedCapabilities = capabilities; + setCapabilities({ ...capabilities, images: null }); + this.invalidate(); + } + this.lastDocument = []; + this.selectionAnchor = undefined; + this.selectionFocus = undefined; + this.pressedUrl = undefined; + this.selectionDragged = false; + this.resetRenderState(); + this.terminal.write( + `${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? ENABLE_MOUSE : ""}\x1b[2J\x1b[H\x1b[?25l`, + ); + } + + protected override beforeTerminalStop(): void { + if (!this.altScreenActive) return; + this.terminal.write( + `${BEGIN_SYNCHRONIZED_OUTPUT}${this.deleteKittyImages()}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${END_SYNCHRONIZED_OUTPUT}`, + ); + } + + protected override afterTerminalStop(): void { + if (!this.altScreenActive) return; + this.altScreenActive = false; + 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); + if (this.savedCapabilities) { + setCapabilities(this.savedCapabilities); + this.savedCapabilities = undefined; + } + } + + private deleteKittyImages(): string { + return this.imageProtocol === "kitty" ? deleteAllKittyImages() : ""; + } + + protected override resetRenderState(): void { + this.previousScreen = []; + this.previousScreenWidth = 0; + this.previousScreenHeight = 0; + } + + scrollBy(lines: number): void { + if (lines === 0) return; + const height = Math.max(1, this.terminal.rows); + const maxScrollTop = Math.max(0, this.contentLineCount - height); + const currentTop = this.stickToBottom ? maxScrollTop : this.scrollTop; + this.scrollTop = Math.max(0, Math.min(maxScrollTop, currentTop + lines)); + this.stickToBottom = this.scrollTop === maxScrollTop; + this.requestRender(); + } + + scrollToTop(): void { + this.scrollTop = 0; + this.stickToBottom = this.contentLineCount <= this.terminal.rows; + this.requestRender(); + } + + scrollToBottom(): void { + this.stickToBottom = true; + this.scrollTop = Math.max(0, this.contentLineCount - this.terminal.rows); + this.requestRender(); + } + + private handleViewportInput(data: string): { consume?: boolean } | undefined { + const wheelDirection = this.parseWheelDirection(data); + if (wheelDirection !== undefined) { + this.scrollBy(wheelDirection * this.wheelScrollLines); + return { consume: true }; + } + const mouseEvent = this.parseSgrMouseEvent(data); + if (mouseEvent) { + this.handleSelectionMouseEvent(mouseEvent); + return { consume: true }; + } + if (this.isMouseSequence(data)) return { consume: true }; + + const keybindings = getKeybindings(); + if (keybindings.matches(data, "tui.altScreen.pageUp")) { + this.scrollBy(-Math.max(1, this.terminal.rows - 1)); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.pageDown")) { + this.scrollBy(Math.max(1, this.terminal.rows - 1)); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.top")) { + this.scrollToTop(); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.bottom")) { + this.scrollToBottom(); + return { consume: true }; + } + return undefined; + } + + private parseWheelDirection(data: string): -1 | 1 | undefined { + const sgr = /^\x1b\[<(\d+);\d+;\d+[Mm]$/.exec(data); + if (sgr) { + const button = Number.parseInt(sgr[1], 10); + if ((button & 64) === 0) return undefined; + const direction = button & 3; + if (direction === 0) return -1; + if (direction === 1) return 1; + return undefined; + } + if (data.length === 6 && data.startsWith("\x1b[M")) { + const button = data.charCodeAt(3) - 32; + if ((button & 64) === 0) return undefined; + const direction = button & 3; + if (direction === 0) return -1; + if (direction === 1) return 1; + return undefined; + } + return undefined; + } + + private parseSgrMouseEvent(data: string): SgrMouseEvent | undefined { + const match = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/.exec(data); + if (!match) return undefined; + return { + button: Number.parseInt(match[1], 10), + x: Number.parseInt(match[2], 10) - 1, + y: Number.parseInt(match[3], 10) - 1, + release: match[4] === "m", + }; + } + + private handleSelectionMouseEvent(event: SgrMouseEvent): void { + if ((event.button & 3) !== 0) return; + const viewportRow = Math.max(0, Math.min(this.terminal.rows - 1, event.y)); + const point = { + row: this.scrollTop + viewportRow, + col: Math.max(0, Math.min(this.terminal.columns - 1, event.x)), + }; + if (event.release) { + if (!this.selectionAnchor) return; + this.selectionFocus = point; + const clickedUrl = + !this.selectionDragged && this.selectionAnchor.row === point.row && this.selectionAnchor.col === point.col + ? this.pressedUrl + : undefined; + this.pressedUrl = undefined; + if (clickedUrl && this.openUrl) { + this.selectionAnchor = undefined; + this.selectionFocus = undefined; + try { + this.openUrl(clickedUrl); + } catch { + // URL activation is best-effort. + } + this.requestRender(); + return; + } + this.copySelectionToClipboard(); + this.requestRender(); + return; + } + if ((event.button & 32) !== 0) { + if (!this.selectionAnchor) return; + this.selectionDragged = true; + this.pressedUrl = undefined; + this.selectionFocus = point; + this.requestRender(); + return; + } + this.selectionAnchor = point; + this.selectionFocus = point; + this.selectionDragged = false; + this.pressedUrl = getOsc8LinkAtColumn(this.previousScreen[viewportRow] ?? "", point.col); + this.requestRender(); + } + + private getSelectionBounds(): { start: SelectionPoint; end: SelectionPoint } | undefined { + if (!this.selectionAnchor || !this.selectionFocus) return undefined; + const anchorBeforeFocus = + this.selectionAnchor.row < this.selectionFocus.row || + (this.selectionAnchor.row === this.selectionFocus.row && this.selectionAnchor.col < this.selectionFocus.col); + if ( + this.selectionAnchor.row === this.selectionFocus.row && + this.selectionAnchor.col === this.selectionFocus.col + ) { + return undefined; + } + return anchorBeforeFocus + ? { start: this.selectionAnchor, end: this.selectionFocus } + : { start: this.selectionFocus, end: this.selectionAnchor }; + } + + private getSelectionColumns( + line: string, + row: number, + selection: { start: SelectionPoint; end: SelectionPoint }, + ): { start: number; end: number } { + const lineWidth = visibleWidth(line); + let start = 0; + let end = lineWidth; + if (row === selection.start.row) { + start = getGraphemeCellRange(line, selection.start.col)?.start ?? Math.min(selection.start.col, lineWidth); + } + if (row === selection.end.row) { + end = getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth); + } + return { start, end }; + } + + private copySelectionToClipboard(): void { + const selection = this.getSelectionBounds(); + if (!selection) return; + const lines: string[] = []; + for (let row = selection.start.row; row <= selection.end.row; row++) { + const line = this.lastDocument[row] ?? ""; + const columns = this.getSelectionColumns(line, row, selection); + lines.push( + stripTerminalSequences( + sliceByColumn(line, columns.start, Math.max(0, columns.end - columns.start), true), + ).trimEnd(), + ); + } + const text = lines.join("\n"); + if (text.length === 0) return; + this.terminal.write(`\x1b]52;c;${Buffer.from(text).toString("base64")}\x07`); + } + + private applySelection(screen: string[]): string[] { + const selection = this.getSelectionBounds(); + if (!selection) return screen; + return screen.map((line, viewportRow) => { + const row = this.scrollTop + viewportRow; + if (row < selection.start.row || row > selection.end.row || isImageLine(line)) return line; + const lineWidth = visibleWidth(line); + const columns = this.getSelectionColumns(line, row, selection); + if (columns.end <= columns.start) return line; + const before = sliceByColumn(line, 0, columns.start, true); + const selected = sliceByColumn(line, columns.start, columns.end - columns.start, true); + const after = sliceByColumn(line, columns.end, Math.max(0, lineWidth - columns.end), true); + return `${before}\x1b[7m${selected}\x1b[27m${after}`; + }); + } + + private isMouseSequence(data: string): boolean { + return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data) || (data.length === 6 && data.startsWith("\x1b[M")); + } + + protected override doRender(): void { + if (this.stopped || !this.altScreenActive) return; + const width = Math.max(1, this.terminal.columns); + const height = Math.max(1, this.terminal.rows); + const contentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + this.contentLineCount = contentLines.length; + this.lastDocument = this.applyLineResets(contentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map( + (line) => { + if (isImageLine(line) || visibleWidth(line) <= width) return line; + return sliceByColumn(line, 0, width, true); + }, + ); + + const maxScrollTop = Math.max(0, contentLines.length - height); + if (this.stickToBottom) { + this.scrollTop = maxScrollTop; + } else { + this.scrollTop = Math.max(0, Math.min(this.scrollTop, maxScrollTop)); + this.stickToBottom = this.scrollTop === maxScrollTop; + } + + let screen = contentLines.slice(this.scrollTop, this.scrollTop + height); + if (this.scrollTop > 0) { + for (let imageRow = this.scrollTop - 1; imageRow >= 0; imageRow--) { + const imageLine = contentLines[imageRow] ?? ""; + const metadata = getKittyImageMetadata(imageLine); + if (metadata) { + const hiddenRows = this.scrollTop - imageRow; + if (hiddenRows < metadata.rows) { + const visibleRows = Math.min(height, metadata.rows - hiddenRows); + screen[0] = cropKittyImageLine(imageLine, hiddenRows, visibleRows); + } + break; + } + if (imageLine !== "") break; + } + } + while (screen.length < height) screen.push(""); + screen = this.compositeOverlays(screen, width, height); + if (screen.length > height) screen = screen.slice(screen.length - height); + screen = this.applySelection(screen); + + const cursorPos = this.extractCursorPosition(screen, height); + screen = this.applyLineResets(screen).map((line) => { + if (isImageLine(line) || visibleWidth(line) <= width) return line; + return sliceByColumn(line, 0, width, true); + }); + + const fullRedraw = + this.previousScreen.length === 0 || this.previousScreenWidth !== width || this.previousScreenHeight !== height; + const imagesNeedRedraw = screen.some( + (line, row) => + line !== this.previousScreen[row] && (isImageLine(line) || isImageLine(this.previousScreen[row] ?? "")), + ); + + let buffer = BEGIN_SYNCHRONIZED_OUTPUT; + if (fullRedraw) { + this.fullRedrawCount += 1; + buffer += `${this.deleteKittyImages()}\x1b[2J`; + } else if (imagesNeedRedraw) { + buffer += this.imageProtocol === "iterm2" ? "\x1b[2J" : this.deleteKittyImages(); + } + + for (let row = 0; row < height; row++) { + if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; + buffer += `\x1b[${row + 1};1H\x1b[2K${screen[row] ?? ""}`; + } + + if (cursorPos) { + buffer += `\x1b[${cursorPos.row + 1};${Math.min(width, cursorPos.col) + 1}H`; + buffer += this.getShowHardwareCursor() ? "\x1b[?25h" : "\x1b[?25l"; + } else { + buffer += "\x1b[?25l"; + } + buffer += END_SYNCHRONIZED_OUTPUT; + this.terminal.write(buffer); + + this.previousScreen = screen; + this.previousScreenWidth = width; + this.previousScreenHeight = height; + } +} diff --git a/packages/tui/src/TuiMainScreen.ts b/packages/tui/src/TuiMainScreen.ts new file mode 100644 index 000000000..a1cd09f55 --- /dev/null +++ b/packages/tui/src/TuiMainScreen.ts @@ -0,0 +1,552 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { deleteKittyImage, isImageLine } from "./terminal-image.ts"; +import { type TUI, TuiBase } from "./tui.ts"; +import { visibleWidth } from "./utils.ts"; + +const KITTY_SEQUENCE_PREFIX = "\x1b_G"; + +interface KittyImageHeader { + ids: number[]; + rows: number; +} + +function parseKittyImageHeader(line: string): KittyImageHeader | undefined { + const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX); + if (sequenceStart === -1) return undefined; + const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length; + const paramsEnd = line.indexOf(";", paramsStart); + if (paramsEnd === -1) return undefined; + + const ids: number[] = []; + let rows = 1; + for (const param of line.slice(paramsStart, paramsEnd).split(",")) { + const [key, value] = param.split("=", 2); + if (value === undefined) continue; + const numberValue = Number(value); + if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff) continue; + if (key === "i") ids.push(numberValue); + else if (key === "r") rows = numberValue; + } + return { ids, rows }; +} + +function extractKittyImageIds(line: string): number[] { + return parseKittyImageHeader(line)?.ids ?? []; +} + +function extractKittyImageRows(line: string): number { + return parseKittyImageHeader(line)?.rows ?? 1; +} + +function isTermuxSession(): boolean { + return Boolean(process.env.TERMUX_VERSION); +} + +/** TUI implementation that renders into the terminal's main screen and scrollback. */ +export class TuiMainScreen extends TuiBase implements TUI { + private previousLines: string[] = []; + private previousKittyImageIds = new Set(); + private previousWidth = 0; + private previousHeight = 0; + private cursorRow = 0; + private hardwareCursorRow = 0; + private maxLinesRendered = 0; + private previousViewportTop = 0; + + protected override resetRenderState(): void { + this.previousLines = []; + this.previousWidth = -1; + this.previousHeight = -1; + this.cursorRow = 0; + this.hardwareCursorRow = 0; + this.maxLinesRendered = 0; + this.previousViewportTop = 0; + } + + protected override beforeTerminalStop(): void { + if (this.previousLines.length === 0) return; + this.terminal.write(" "); + const targetRow = this.previousLines.length; + const lineDiff = targetRow - this.hardwareCursorRow; + if (lineDiff > 0) this.terminal.write(`\x1b[${lineDiff}B`); + else if (lineDiff < 0) this.terminal.write(`\x1b[${-lineDiff}A`); + this.terminal.write("\r\n"); + } + + private collectKittyImageIds(lines: string[]): Set { + const ids = new Set(); + for (const line of lines) { + for (const id of extractKittyImageIds(line)) { + ids.add(id); + } + } + return ids; + } + + private deleteKittyImages(ids: Iterable): string { + let buffer = ""; + for (const id of ids) { + buffer += deleteKittyImage(id); + } + return buffer; + } + + private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number { + const rows = extractKittyImageRows(lines[index] ?? ""); + if (rows <= 1) return 1; + + const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index); + let reservedRows = 1; + while (reservedRows < maxRows) { + const line = lines[index + reservedRows] ?? ""; + if (isImageLine(line) || visibleWidth(line) > 0) break; + reservedRows++; + } + return reservedRows; + } + + private expandChangedRangeForKittyImages( + firstChanged: number, + lastChanged: number, + newLines: string[], + ): { firstChanged: number; lastChanged: number } { + let expandedFirstChanged = firstChanged; + let expandedLastChanged = lastChanged; + const expandForLines = (lines: string[]): void => { + for (let i = 0; i < lines.length; i++) { + if (extractKittyImageIds(lines[i]).length === 0) continue; + const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1; + if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) { + expandedFirstChanged = Math.min(expandedFirstChanged, i); + expandedLastChanged = Math.max(expandedLastChanged, blockEnd); + } + } + }; + + expandForLines(this.previousLines); + expandForLines(newLines); + return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged }; + } + + private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string { + if (firstChanged < 0 || lastChanged < firstChanged) return ""; + + const ids = new Set(); + const maxLine = Math.min(lastChanged, this.previousLines.length - 1); + for (let i = firstChanged; i <= maxLine; i++) { + for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) { + ids.add(id); + } + } + + return this.deleteKittyImages(ids); + } + + protected doRender(): void { + if (this.stopped) return; + const width = this.terminal.columns; + const height = this.terminal.rows; + const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; + const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height; + const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height; + let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop; + let viewportTop = prevViewportTop; + let hardwareCursorRow = this.hardwareCursorRow; + const computeLineDiff = (targetRow: number): number => { + const currentScreenRow = hardwareCursorRow - prevViewportTop; + const targetScreenRow = targetRow - viewportTop; + return targetScreenRow - currentScreenRow; + }; + + // Render all components to get new lines + let newLines = this.render(width); + + // Composite overlays into the rendered lines (before differential compare) + if (this.hasOverlayEntries) { + newLines = this.compositeOverlays(newLines, width, height); + } + + // Extract cursor position before applying line resets (marker must be found first) + const cursorPos = this.extractCursorPosition(newLines, height); + + newLines = this.applyLineResets(newLines); + + // Helper to clear scrollback and viewport and render all new lines + const fullRender = (clear: boolean): void => { + this.fullRedrawCount += 1; + let buffer = "\x1b[?2026h"; // Begin synchronized output + if (clear) { + buffer += this.deleteKittyImages(this.previousKittyImageIds); + buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback + } + for (let i = 0; i < newLines.length; i++) { + if (i > 0) buffer += "\r\n"; + const line = newLines[i]; + const isImage = isImageLine(line); + const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1; + if (imageReservedRows > 1 && imageReservedRows <= height) { + for (let row = 1; row < imageReservedRows; row++) { + buffer += "\r\n"; + } + buffer += `\x1b[${imageReservedRows - 1}A`; + buffer += line; + buffer += `\x1b[${imageReservedRows - 1}B`; + i += imageReservedRows - 1; + continue; + } + buffer += line; + } + buffer += "\x1b[?2026l"; // End synchronized output + this.terminal.write(buffer); + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = this.cursorRow; + // Reset max lines when clearing, otherwise track growth + if (clear) { + this.maxLinesRendered = newLines.length; + } else { + this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + } + const bufferLength = Math.max(height, newLines.length); + this.previousViewportTop = Math.max(0, bufferLength - height); + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + }; + + const debugRedraw = process.env.PI_DEBUG_REDRAW === "1"; + const logRedraw = (reason: string): void => { + if (!debugRedraw) return; + const logPath = path.join(this.logDirectory, "pi-debug.log"); + const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`; + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + fs.appendFileSync(logPath, msg); + }; + + // First render - just output everything without clearing (assumes clean screen) + if (this.previousLines.length === 0 && !widthChanged && !heightChanged) { + logRedraw("first render"); + fullRender(false); + return; + } + + // Width changes always need a full re-render because wrapping changes. + if (widthChanged) { + logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`); + fullRender(true); + return; + } + + // Height changes normally need a full re-render to keep the visible viewport aligned, + // but Termux changes height when the software keyboard shows or hides. + // In that environment, a full redraw causes the entire history to replay on every toggle. + if (heightChanged && !isTermuxSession()) { + logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`); + fullRender(true); + return; + } + + // Content shrunk below the working area and no overlays - re-render to clear empty rows + // (overlays need the padding, so only do this when no overlays are active) + // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var + if (this.getClearOnShrink() && newLines.length < this.maxLinesRendered && !this.hasOverlayEntries) { + logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`); + fullRender(true); + return; + } + + // Find first and last changed lines + let firstChanged = -1; + let lastChanged = -1; + const maxLines = Math.max(newLines.length, this.previousLines.length); + for (let i = 0; i < maxLines; i++) { + const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; + const newLine = i < newLines.length ? newLines[i] : ""; + + if (oldLine !== newLine) { + if (firstChanged === -1) { + firstChanged = i; + } + lastChanged = i; + } + } + const appendedLines = newLines.length > this.previousLines.length; + if (appendedLines) { + if (firstChanged === -1) { + firstChanged = this.previousLines.length; + } + lastChanged = newLines.length - 1; + } + if (firstChanged !== -1) { + const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines); + firstChanged = expandedRange.firstChanged; + lastChanged = expandedRange.lastChanged; + } + const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0; + + // No changes - but still need to update hardware cursor position if it moved + if (firstChanged === -1) { + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousViewportTop = prevViewportTop; + this.previousHeight = height; + return; + } + + // All changes are in deleted lines (nothing to render, just clear) + if (firstChanged >= newLines.length) { + if (this.previousLines.length > newLines.length) { + let buffer = "\x1b[?2026h"; + buffer += this.deleteChangedKittyImages(firstChanged, lastChanged); + // Move to end of new content (clamp to 0 for empty content) + const targetRow = Math.max(0, newLines.length - 1); + if (targetRow < prevViewportTop) { + logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`); + fullRender(true); + return; + } + const lineDiff = computeLineDiff(targetRow); + if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; + else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; + buffer += "\r"; + // Clear extra lines without scrolling + const extraLines = this.previousLines.length - newLines.length; + if (extraLines > height) { + logRedraw(`extraLines > height (${extraLines} > ${height})`); + fullRender(true); + return; + } + const clearStartOffset = newLines.length === 0 ? 0 : 1; + if (extraLines > 0 && clearStartOffset > 0) { + buffer += `\x1b[${clearStartOffset}B`; + } + for (let i = 0; i < extraLines; i++) { + buffer += "\r\x1b[2K"; + if (i < extraLines - 1) buffer += "\x1b[1B"; + } + const moveBack = Math.max(0, extraLines - 1 + clearStartOffset); + if (moveBack > 0) { + buffer += `\x1b[${moveBack}A`; + } + buffer += "\x1b[?2026l"; + this.terminal.write(buffer); + this.cursorRow = targetRow; + this.hardwareCursorRow = targetRow; + } + this.positionHardwareCursor(cursorPos, newLines.length); + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + this.previousViewportTop = prevViewportTop; + return; + } + + // Differential rendering can only touch what was actually visible. + // If the first changed line is above the previous viewport, we need a full redraw. + if (firstChanged < prevViewportTop) { + logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`); + fullRender(true); + return; + } + + // Render from first changed line to end + // Build buffer with all updates wrapped in synchronized output + let buffer = "\x1b[?2026h"; // Begin synchronized output + buffer += this.deleteChangedKittyImages(firstChanged, lastChanged); + const prevViewportBottom = prevViewportTop + height - 1; + const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged; + if (moveTargetRow > prevViewportBottom) { + const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop)); + const moveToBottom = height - 1 - currentScreenRow; + if (moveToBottom > 0) { + buffer += `\x1b[${moveToBottom}B`; + } + const scroll = moveTargetRow - prevViewportBottom; + buffer += "\r\n".repeat(scroll); + prevViewportTop += scroll; + viewportTop += scroll; + hardwareCursorRow = moveTargetRow; + } + + // Move cursor to first changed line (use hardwareCursorRow for actual position) + const lineDiff = computeLineDiff(moveTargetRow); + if (lineDiff > 0) { + buffer += `\x1b[${lineDiff}B`; // Move down + } else if (lineDiff < 0) { + buffer += `\x1b[${-lineDiff}A`; // Move up + } + + buffer += appendStart ? "\r\n" : "\r"; // Move to column 0 + + // Only render changed lines (firstChanged to lastChanged), not all lines to end + // This reduces flicker when only a single line changes (e.g., spinner animation) + const renderEnd = Math.min(lastChanged, newLines.length - 1); + for (let i = firstChanged; i <= renderEnd; i++) { + if (i > firstChanged) buffer += "\r\n"; + const line = newLines[i]; + const isImage = isImageLine(line); + const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1; + if (imageReservedRows > 1) { + const imageStartScreenRow = i - viewportTop; + if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) { + logRedraw( + `kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`, + ); + fullRender(true); + return; + } + + buffer += "\x1b[2K"; + for (let row = 1; row < imageReservedRows; row++) { + buffer += "\r\n\x1b[2K"; + } + buffer += `\x1b[${imageReservedRows - 1}A`; + buffer += line; + buffer += `\x1b[${imageReservedRows - 1}B`; + i += imageReservedRows - 1; + continue; + } + + buffer += "\x1b[2K"; // Clear current line + if (!isImage && visibleWidth(line) > width) { + // Log all lines to crash file for debugging + const crashLogPath = path.join(this.logDirectory, "pi-crash.log"); + const crashData = [ + `Crash at ${new Date().toISOString()}`, + `Terminal width: ${width}`, + `Line ${i} visible width: ${visibleWidth(line)}`, + "", + "=== All rendered lines ===", + ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`), + "", + ].join("\n"); + fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); + fs.writeFileSync(crashLogPath, crashData); + + // Clean up terminal state before throwing + this.stop(); + + const errorMsg = [ + `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, + "", + "This is likely caused by a custom TUI component not truncating its output.", + "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", + "", + `Debug log written to: ${crashLogPath}`, + ].join("\n"); + throw new Error(errorMsg); + } + buffer += line; + } + + // Track where cursor ended up after rendering + let finalCursorRow = renderEnd; + + // If we had more lines before, clear them and move cursor back + if (this.previousLines.length > newLines.length) { + // Move to end of new content first if we stopped before it + if (renderEnd < newLines.length - 1) { + const moveDown = newLines.length - 1 - renderEnd; + buffer += `\x1b[${moveDown}B`; + finalCursorRow = newLines.length - 1; + } + const extraLines = this.previousLines.length - newLines.length; + for (let i = newLines.length; i < this.previousLines.length; i++) { + buffer += "\r\n\x1b[2K"; + } + // Move cursor back to end of new content + buffer += `\x1b[${extraLines}A`; + } + + buffer += "\x1b[?2026l"; // End synchronized output + + if (process.env.PI_TUI_DEBUG === "1") { + const debugDir = "/tmp/tui"; + fs.mkdirSync(debugDir, { recursive: true }); + const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`); + const debugData = [ + `firstChanged: ${firstChanged}`, + `viewportTop: ${viewportTop}`, + `cursorRow: ${this.cursorRow}`, + `height: ${height}`, + `lineDiff: ${lineDiff}`, + `hardwareCursorRow: ${hardwareCursorRow}`, + `renderEnd: ${renderEnd}`, + `finalCursorRow: ${finalCursorRow}`, + `cursorPos: ${JSON.stringify(cursorPos)}`, + `newLines.length: ${newLines.length}`, + `previousLines.length: ${this.previousLines.length}`, + "", + "=== newLines ===", + JSON.stringify(newLines, null, 2), + "", + "=== previousLines ===", + JSON.stringify(this.previousLines, null, 2), + "", + "=== buffer ===", + JSON.stringify(buffer), + ].join("\n"); + fs.writeFileSync(debugPath, debugData); + } + + // Write entire buffer at once + this.terminal.write(buffer); + + // Track cursor position for next render + // cursorRow tracks end of content (for viewport calculation) + // hardwareCursorRow tracks actual terminal cursor position (for movement) + this.cursorRow = Math.max(0, newLines.length - 1); + this.hardwareCursorRow = finalCursorRow; + // Track terminal's working area (grows but doesn't shrink unless cleared) + this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); + this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1); + + // Position hardware cursor for IME + this.positionHardwareCursor(cursorPos, newLines.length); + + this.previousLines = newLines; + this.previousKittyImageIds = this.collectKittyImageIds(newLines); + this.previousWidth = width; + this.previousHeight = height; + } + + /** + * Position the hardware cursor for IME candidate window. + * @param cursorPos The cursor position extracted from rendered output, or null + * @param totalLines Total number of rendered lines + */ + private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void { + if (!cursorPos || totalLines <= 0) { + this.terminal.hideCursor(); + return; + } + + // Clamp cursor position to valid range + const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1)); + const targetCol = Math.max(0, cursorPos.col); + + // Move cursor from current position to target + const rowDelta = targetRow - this.hardwareCursorRow; + let buffer = ""; + if (rowDelta > 0) { + buffer += `\x1b[${rowDelta}B`; // Move down + } else if (rowDelta < 0) { + buffer += `\x1b[${-rowDelta}A`; // Move up + } + // Move to absolute column (1-indexed) + buffer += `\x1b[${targetCol + 1}G`; + + if (buffer) { + this.terminal.write(buffer); + } + + this.hardwareCursorRow = targetRow; + if (this.getShowHardwareCursor()) { + this.terminal.showCursor(); + } else { + this.terminal.hideCursor(); + } + } +} diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index 4e76b1079..1ad1b62f5 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -59,6 +59,8 @@ export { } from "./keys.ts"; // Input buffering for batch splitting export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; +export { TuiAltScreen, type TuiAltScreenOptions } from "./TuiAltScreen.ts"; +export { TuiMainScreen } from "./TuiMainScreen.ts"; // Terminal interface and implementations export { ProcessTerminal, type Terminal } from "./terminal.ts"; // Terminal colors @@ -100,6 +102,7 @@ export { type Component, Container, CURSOR_MARKER, + compositeTuiLine, type Focusable, isFocusable, type OverlayAnchor, @@ -108,7 +111,16 @@ export { type OverlayOptions, type OverlayUnfocusOptions, type SizeValue, - TUI, + type TUI, + type TuiInputListener, + type TuiInputListenerResult, } from "./tui.ts"; // Utilities -export { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts"; +export { + getOsc8LinkAtColumn, + sliceByColumn, + stripTerminalSequences, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "./utils.ts"; diff --git a/packages/tui/src/keybindings.ts b/packages/tui/src/keybindings.ts index eaf0838b4..317837561 100644 --- a/packages/tui/src/keybindings.ts +++ b/packages/tui/src/keybindings.ts @@ -39,6 +39,11 @@ export interface Keybindings { "tui.select.pageDown": true; "tui.select.confirm": true; "tui.select.cancel": true; + // Alternate-screen viewport navigation + "tui.altScreen.pageUp": true; + "tui.altScreen.pageDown": true; + "tui.altScreen.top": true; + "tui.altScreen.bottom": true; } export type Keybinding = keyof Keybindings; @@ -131,6 +136,10 @@ export const TUI_KEYBINDINGS = { defaultKeys: ["escape", "ctrl+c"], description: "Cancel selection", }, + "tui.altScreen.pageUp": { defaultKeys: "shift+pageUp", description: "Scroll viewport up one page" }, + "tui.altScreen.pageDown": { defaultKeys: "shift+pageDown", description: "Scroll viewport down one page" }, + "tui.altScreen.top": { defaultKeys: "ctrl+home", description: "Scroll viewport to top" }, + "tui.altScreen.bottom": { defaultKeys: "ctrl+end", description: "Scroll viewport to bottom" }, } as const satisfies KeybindingDefinitions; export interface KeybindingConflict { diff --git a/packages/tui/src/terminal-image.ts b/packages/tui/src/terminal-image.ts index d59eef5b6..9841c508d 100644 --- a/packages/tui/src/terminal-image.ts +++ b/packages/tui/src/terminal-image.ts @@ -257,6 +257,42 @@ export interface ImageCellSize { rows: number; } +export interface KittyImageMetadata extends ImageCellSize { + imageId: number; + widthPx: number; + heightPx: number; +} + +const kittyImageMetadata = new Map(); + +export function registerKittyImageMetadata(metadata: KittyImageMetadata): void { + kittyImageMetadata.delete(metadata.imageId); + kittyImageMetadata.set(metadata.imageId, metadata); + if (kittyImageMetadata.size > 1000) { + const oldestImageId = kittyImageMetadata.keys().next().value; + if (oldestImageId !== undefined) kittyImageMetadata.delete(oldestImageId); + } +} + +export function getKittyImageMetadata(line: string): KittyImageMetadata | undefined { + const controls = /\x1b_G([^;]*);/.exec(line)?.[1]; + if (!controls) return undefined; + const imageId = /(?:^|,)i=(\d+)(?:,|$)/.exec(controls)?.[1]; + return imageId === undefined ? undefined : kittyImageMetadata.get(Number.parseInt(imageId, 10)); +} + +export function cropKittyImageLine(line: string, hiddenRows: number, visibleRows: number): string { + const metadata = getKittyImageMetadata(line); + const match = /\x1b_G([^;]*);/.exec(line); + if (!metadata || !match || hiddenRows <= 0 || visibleRows <= 0) return line; + const sourceY = Math.floor((metadata.heightPx * hiddenRows) / metadata.rows); + const sourceEnd = Math.ceil((metadata.heightPx * (hiddenRows + visibleRows)) / metadata.rows); + const sourceHeight = Math.max(1, Math.min(metadata.heightPx, sourceEnd) - sourceY); + const controls = match[1].split(",").filter((control) => !/^[yhr]=/.test(control)); + controls.push(`y=${sourceY}`, `h=${sourceHeight}`, `r=${visibleRows}`); + return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`; +} + export function calculateImageCellSize( imageDimensions: ImageDimensions, maxWidthCells: number, @@ -436,7 +472,7 @@ export function renderImage( base64Data: string, imageDimensions: ImageDimensions, options: ImageRenderOptions = {}, -): { sequence: string; rows: number; imageId?: number } | null { +): { sequence: string; columns: number; rows: number; imageId?: number } | null { const caps = getCapabilities(); if (!caps.images) { @@ -447,13 +483,22 @@ export function renderImage( const size = calculateImageCellSize(imageDimensions, maxWidth, options.maxHeightCells, getCellDimensions()); if (caps.images === "kitty") { + if (options.imageId !== undefined) { + registerKittyImageMetadata({ + imageId: options.imageId, + columns: size.columns, + rows: size.rows, + widthPx: imageDimensions.widthPx, + heightPx: imageDimensions.heightPx, + }); + } const sequence = encodeKitty(base64Data, { columns: size.columns, rows: size.rows, imageId: options.imageId, moveCursor: options.moveCursor, }); - return { sequence, rows: size.rows, imageId: options.imageId }; + return { sequence, columns: size.columns, rows: size.rows, imageId: options.imageId }; } if (caps.images === "iterm2") { @@ -462,7 +507,7 @@ export function renderImage( height: "auto", preserveAspectRatio: options.preserveAspectRatio ?? true, }); - return { sequence, rows: size.rows }; + return { sequence, columns: size.columns, rows: size.rows }; } return null; diff --git a/packages/tui/src/tui.ts b/packages/tui/src/tui.ts index 954256a1d..50cde5ce3 100644 --- a/packages/tui/src/tui.ts +++ b/packages/tui/src/tui.ts @@ -2,7 +2,6 @@ * Minimal TUI implementation with differential rendering */ -import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { performance } from "node:perf_hooks"; @@ -15,49 +14,9 @@ import { type RgbColor, type TerminalColorScheme, } from "./terminal-colors.ts"; -import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; +import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; -const KITTY_SEQUENCE_PREFIX = "\x1b_G"; - -interface KittyImageHeader { - ids: number[]; - rows: number; -} - -function parseKittyImageHeader(line: string): KittyImageHeader | undefined { - const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX); - if (sequenceStart === -1) return undefined; - - const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length; - const paramsEnd = line.indexOf(";", paramsStart); - if (paramsEnd === -1) return undefined; - - const ids: number[] = []; - let rows = 1; - const params = line.slice(paramsStart, paramsEnd); - for (const param of params.split(",")) { - const [key, value] = param.split("=", 2); - if (value === undefined) continue; - const numberValue = Number(value); - if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff) continue; - if (key === "i") { - ids.push(numberValue); - } else if (key === "r") { - rows = numberValue; - } - } - return { ids, rows }; -} - -function extractKittyImageIds(line: string): number[] { - return parseKittyImageHeader(line)?.ids ?? []; -} - -function extractKittyImageRows(line: string): number { - return parseKittyImageHeader(line)?.rows ?? 1; -} - /** * Component interface - all components must implement this */ @@ -87,8 +46,8 @@ export interface Component { invalidate(): void; } -type InputListenerResult = { consume?: boolean; data?: string } | undefined; -type InputListener = (data: string) => InputListenerResult; +export type TuiInputListenerResult = { consume?: boolean; data?: string } | undefined; +export type TuiInputListener = (data: string) => TuiInputListenerResult; type PendingOsc11BackgroundQuery = { settled: boolean; resolve: ((rgb: RgbColor | undefined) => void) | undefined; @@ -160,10 +119,6 @@ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): nu return undefined; } -function isTermuxSession(): boolean { - return Boolean(process.env.TERMUX_VERSION); -} - /** * Options for overlay positioning and sizing. * Values can be absolute numbers or percentage strings (e.g., "50%"). @@ -292,14 +247,71 @@ export class Container implements Component { /** * TUI - Main class for managing terminal UI with differential rendering */ -export class TUI extends Container { +const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; + +/** Composite overlay content into a terminal line at a fixed column. */ +export function compositeTuiLine( + baseLine: string, + overlayLine: string, + startCol: number, + overlayWidth: number, + totalWidth: number, +): string { + if (isImageLine(baseLine)) return baseLine; + + const afterStart = startCol + overlayWidth; + const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true); + const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true); + const beforePad = Math.max(0, startCol - base.beforeWidth); + const overlayPad = Math.max(0, overlayWidth - overlay.width); + const actualBeforeWidth = Math.max(startCol, base.beforeWidth); + const actualOverlayWidth = Math.max(overlayWidth, overlay.width); + const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth); + const afterPad = Math.max(0, afterTarget - base.afterWidth); + const result = + base.before + + " ".repeat(beforePad) + + SEGMENT_RESET + + overlay.text + + " ".repeat(overlayPad) + + SEGMENT_RESET + + base.after + + " ".repeat(afterPad); + + return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true); +} + +export interface TUI extends Component { + children: Component[]; + terminal: Terminal; + onDebug?: () => void; + readonly fullRedraws: number; + addChild(component: Component): void; + removeChild(component: Component): void; + clear(): void; + getShowHardwareCursor(): boolean; + setShowHardwareCursor(enabled: boolean): void; + getClearOnShrink(): boolean; + setClearOnShrink(enabled: boolean): void; + setFocus(component: Component | null): void; + showOverlay(component: Component, options?: OverlayOptions): OverlayHandle; + hideOverlay(): void; + hasOverlay(): boolean; + start(): void; + stop(): void; + requestRender(force?: boolean): void; + addInputListener(listener: TuiInputListener): () => void; + removeInputListener(listener: TuiInputListener): void; + onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; + setTerminalColorSchemeNotifications(enabled: boolean): void; + queryTerminalBackgroundColor(options: { timeoutMs: number }): Promise; + queryTerminalColorScheme(options: { timeoutMs: number }): Promise; +} + +export abstract class TuiBase extends Container implements TUI { public terminal: Terminal; - private previousLines: string[] = []; - private previousKittyImageIds = new Set(); - private previousWidth = 0; - private previousHeight = 0; private focusedComponent: Component | null = null; - private inputListeners = new Set(); + private inputListeners = new Set(); /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ public onDebug?: () => void; @@ -307,23 +319,23 @@ export class TUI extends Container { private renderTimer: NodeJS.Timeout | undefined; private lastRenderAt = 0; private static readonly MIN_RENDER_INTERVAL_MS = 16; - private cursorRow = 0; // Logical cursor row (end of rendered content) - private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning) private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1"; - private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off) - private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered) - private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves - private fullRedrawCount = 0; - private stopped = false; + private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; + protected fullRedrawCount = 0; + protected stopped = false; private pendingOsc11BackgroundReplies = 0; private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = []; private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>(); private terminalColorSchemeNotificationsEnabled = false; - private readonly logDirectory: string; + protected readonly logDirectory: string; // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; private overlayStack: OverlayStackEntry[] = []; + + protected get hasOverlayEntries(): boolean { + return this.overlayStack.length > 0; + } private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" }; constructor(terminal: Terminal, showHardwareCursor?: boolean, logDirectory?: string) { @@ -335,6 +347,18 @@ export class TUI extends Container { } } + protected abstract doRender(): void; + + protected resetRenderState(): void {} + + protected beforeTerminalStart(): void {} + + protected afterTerminalStart(): void {} + + protected beforeTerminalStop(): void {} + + protected afterTerminalStop(): void {} + get fullRedraws(): number { return this.fullRedrawCount; } @@ -636,10 +660,12 @@ export class TUI extends Container { start(): void { this.stopped = false; + this.beforeTerminalStart(); this.terminal.start( - (data) => this.handleInput(data), + (data) => this.handleTerminalInput(data), () => this.requestRender(), ); + this.afterTerminalStart(); this.terminal.hideCursor(); if (this.terminalColorSchemeNotificationsEnabled) { this.terminal.write("\x1b[?2031h"); @@ -648,14 +674,14 @@ export class TUI extends Container { this.requestRender(); } - addInputListener(listener: InputListener): () => void { + addInputListener(listener: TuiInputListener): () => void { this.inputListeners.add(listener); return () => { this.inputListeners.delete(listener); }; } - removeInputListener(listener: InputListener): void { + removeInputListener(listener: TuiInputListener): void { this.inputListeners.delete(listener); } @@ -695,33 +721,15 @@ export class TUI extends Container { if (this.terminalColorSchemeNotificationsEnabled) { this.terminal.write("\x1b[?2031l"); } - // Move cursor to the end of the content to prevent overwriting/artifacts on exit - if (this.previousLines.length > 0) { - // Overwrite the inverted cursor with a normal space to clear the artifact - this.terminal.write(" "); - const targetRow = this.previousLines.length; // Line after the last content - const lineDiff = targetRow - this.hardwareCursorRow; - if (lineDiff > 0) { - this.terminal.write(`\x1b[${lineDiff}B`); - } else if (lineDiff < 0) { - this.terminal.write(`\x1b[${-lineDiff}A`); - } - this.terminal.write("\r\n"); - } - + this.beforeTerminalStop(); this.terminal.showCursor(); this.terminal.stop(); + this.afterTerminalStop(); } requestRender(force = false): void { if (force) { - this.previousLines = []; - this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear - this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear - this.cursorRow = 0; - this.hardwareCursorRow = 0; - this.maxLinesRendered = 0; - this.previousViewportTop = 0; + this.resetRenderState(); if (this.renderTimer) { clearTimeout(this.renderTimer); this.renderTimer = undefined; @@ -747,7 +755,7 @@ export class TUI extends Container { return; } const elapsed = performance.now() - this.lastRenderAt; - const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed); + const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed); this.renderTimer = setTimeout(() => { this.renderTimer = undefined; if (this.stopped || !this.renderRequested) { @@ -762,7 +770,7 @@ export class TUI extends Container { }, delay); } - private handleInput(data: string): void { + private handleTerminalInput(data: string): void { if (this.consumeOsc11BackgroundResponse(data)) { return; } @@ -1033,7 +1041,7 @@ export class TUI extends Container { } /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */ - private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { + protected compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { if (this.overlayStack.length === 0) return lines; const result = [...lines]; @@ -1094,10 +1102,8 @@ export class TUI extends Container { return result; } - private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; - - private applyLineResets(lines: string[]): string[] { - const reset = TUI.SEGMENT_RESET; + protected applyLineResets(lines: string[]): string[] { + const reset = SEGMENT_RESET; for (let i = 0; i < lines.length; i++) { const line = lines[i]; if (!isImageLine(line)) { @@ -1107,76 +1113,6 @@ export class TUI extends Container { return lines; } - private collectKittyImageIds(lines: string[]): Set { - const ids = new Set(); - for (const line of lines) { - for (const id of extractKittyImageIds(line)) { - ids.add(id); - } - } - return ids; - } - - private deleteKittyImages(ids: Iterable): string { - let buffer = ""; - for (const id of ids) { - buffer += deleteKittyImage(id); - } - return buffer; - } - - private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number { - const rows = extractKittyImageRows(lines[index] ?? ""); - if (rows <= 1) return 1; - - const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index); - let reservedRows = 1; - while (reservedRows < maxRows) { - const line = lines[index + reservedRows] ?? ""; - if (isImageLine(line) || visibleWidth(line) > 0) break; - reservedRows++; - } - return reservedRows; - } - - private expandChangedRangeForKittyImages( - firstChanged: number, - lastChanged: number, - newLines: string[], - ): { firstChanged: number; lastChanged: number } { - let expandedFirstChanged = firstChanged; - let expandedLastChanged = lastChanged; - const expandForLines = (lines: string[]): void => { - for (let i = 0; i < lines.length; i++) { - if (extractKittyImageIds(lines[i]).length === 0) continue; - const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1; - if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) { - expandedFirstChanged = Math.min(expandedFirstChanged, i); - expandedLastChanged = Math.max(expandedLastChanged, blockEnd); - } - } - }; - - expandForLines(this.previousLines); - expandForLines(newLines); - return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged }; - } - - private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string { - if (firstChanged < 0 || lastChanged < firstChanged) return ""; - - const ids = new Set(); - const maxLine = Math.min(lastChanged, this.previousLines.length - 1); - for (let i = firstChanged; i <= maxLine; i++) { - for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) { - ids.add(id); - } - } - - return this.deleteKittyImages(ids); - } - - /** Splice overlay content into a base line at a specific column. Single-pass optimized. */ private compositeLineAt( baseLine: string, overlayLine: string, @@ -1184,47 +1120,7 @@ export class TUI extends Container { overlayWidth: number, totalWidth: number, ): string { - if (isImageLine(baseLine)) return baseLine; - - // Single pass through baseLine extracts both before and after segments - const afterStart = startCol + overlayWidth; - const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true); - - // Extract overlay with width tracking (strict=true to exclude wide chars at boundary) - const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true); - - // Pad segments to target widths - const beforePad = Math.max(0, startCol - base.beforeWidth); - const overlayPad = Math.max(0, overlayWidth - overlay.width); - const actualBeforeWidth = Math.max(startCol, base.beforeWidth); - const actualOverlayWidth = Math.max(overlayWidth, overlay.width); - const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth); - const afterPad = Math.max(0, afterTarget - base.afterWidth); - - // Compose result - const r = TUI.SEGMENT_RESET; - const result = - base.before + - " ".repeat(beforePad) + - r + - overlay.text + - " ".repeat(overlayPad) + - r + - base.after + - " ".repeat(afterPad); - - // CRITICAL: Always verify and truncate to terminal width. - // This is the final safeguard against width overflow which would crash the TUI. - // Width tracking can drift from actual visible width due to: - // - Complex ANSI/OSC sequences (hyperlinks, colors) - // - Wide characters at segment boundaries - // - Edge cases in segment extraction - const resultWidth = visibleWidth(result); - if (resultWidth <= totalWidth) { - return result; - } - // Truncate with strict=true to ensure we don't exceed totalWidth - return sliceByColumn(result, 0, totalWidth, true); + return compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth); } /** @@ -1235,7 +1131,7 @@ export class TUI extends Container { * @param height - Terminal height (visible viewport size) * @returns Cursor position { row, col } or null if no marker found */ - private extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null { + protected extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null { // Only scan the bottom `height` lines (visible viewport) const viewportTop = Math.max(0, lines.length - height); for (let row = lines.length - 1; row >= viewportTop; row--) { @@ -1255,413 +1151,6 @@ export class TUI extends Container { return null; } - private doRender(): void { - if (this.stopped) return; - const width = this.terminal.columns; - const height = this.terminal.rows; - const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; - const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height; - const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height; - let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop; - let viewportTop = prevViewportTop; - let hardwareCursorRow = this.hardwareCursorRow; - const computeLineDiff = (targetRow: number): number => { - const currentScreenRow = hardwareCursorRow - prevViewportTop; - const targetScreenRow = targetRow - viewportTop; - return targetScreenRow - currentScreenRow; - }; - - // Render all components to get new lines - let newLines = this.render(width); - - // Composite overlays into the rendered lines (before differential compare) - if (this.overlayStack.length > 0) { - newLines = this.compositeOverlays(newLines, width, height); - } - - // Extract cursor position before applying line resets (marker must be found first) - const cursorPos = this.extractCursorPosition(newLines, height); - - newLines = this.applyLineResets(newLines); - - // Helper to clear scrollback and viewport and render all new lines - const fullRender = (clear: boolean): void => { - this.fullRedrawCount += 1; - let buffer = "\x1b[?2026h"; // Begin synchronized output - if (clear) { - buffer += this.deleteKittyImages(this.previousKittyImageIds); - buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback - } - for (let i = 0; i < newLines.length; i++) { - if (i > 0) buffer += "\r\n"; - const line = newLines[i]; - const isImage = isImageLine(line); - const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1; - if (imageReservedRows > 1 && imageReservedRows <= height) { - for (let row = 1; row < imageReservedRows; row++) { - buffer += "\r\n"; - } - buffer += `\x1b[${imageReservedRows - 1}A`; - buffer += line; - buffer += `\x1b[${imageReservedRows - 1}B`; - i += imageReservedRows - 1; - continue; - } - buffer += line; - } - buffer += "\x1b[?2026l"; // End synchronized output - this.terminal.write(buffer); - this.cursorRow = Math.max(0, newLines.length - 1); - this.hardwareCursorRow = this.cursorRow; - // Reset max lines when clearing, otherwise track growth - if (clear) { - this.maxLinesRendered = newLines.length; - } else { - this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); - } - const bufferLength = Math.max(height, newLines.length); - this.previousViewportTop = Math.max(0, bufferLength - height); - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); - this.previousWidth = width; - this.previousHeight = height; - }; - - const debugRedraw = process.env.PI_DEBUG_REDRAW === "1"; - const logRedraw = (reason: string): void => { - if (!debugRedraw) return; - const logPath = path.join(this.logDirectory, "pi-debug.log"); - const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`; - fs.mkdirSync(path.dirname(logPath), { recursive: true }); - fs.appendFileSync(logPath, msg); - }; - - // First render - just output everything without clearing (assumes clean screen) - if (this.previousLines.length === 0 && !widthChanged && !heightChanged) { - logRedraw("first render"); - fullRender(false); - return; - } - - // Width changes always need a full re-render because wrapping changes. - if (widthChanged) { - logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`); - fullRender(true); - return; - } - - // Height changes normally need a full re-render to keep the visible viewport aligned, - // but Termux changes height when the software keyboard shows or hides. - // In that environment, a full redraw causes the entire history to replay on every toggle. - if (heightChanged && !isTermuxSession()) { - logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`); - fullRender(true); - return; - } - - // Content shrunk below the working area and no overlays - re-render to clear empty rows - // (overlays need the padding, so only do this when no overlays are active) - // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var - if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) { - logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`); - fullRender(true); - return; - } - - // Find first and last changed lines - let firstChanged = -1; - let lastChanged = -1; - const maxLines = Math.max(newLines.length, this.previousLines.length); - for (let i = 0; i < maxLines; i++) { - const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; - const newLine = i < newLines.length ? newLines[i] : ""; - - if (oldLine !== newLine) { - if (firstChanged === -1) { - firstChanged = i; - } - lastChanged = i; - } - } - const appendedLines = newLines.length > this.previousLines.length; - if (appendedLines) { - if (firstChanged === -1) { - firstChanged = this.previousLines.length; - } - lastChanged = newLines.length - 1; - } - if (firstChanged !== -1) { - const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines); - firstChanged = expandedRange.firstChanged; - lastChanged = expandedRange.lastChanged; - } - const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0; - - // No changes - but still need to update hardware cursor position if it moved - if (firstChanged === -1) { - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousViewportTop = prevViewportTop; - this.previousHeight = height; - return; - } - - // All changes are in deleted lines (nothing to render, just clear) - if (firstChanged >= newLines.length) { - if (this.previousLines.length > newLines.length) { - let buffer = "\x1b[?2026h"; - buffer += this.deleteChangedKittyImages(firstChanged, lastChanged); - // Move to end of new content (clamp to 0 for empty content) - const targetRow = Math.max(0, newLines.length - 1); - if (targetRow < prevViewportTop) { - logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`); - fullRender(true); - return; - } - const lineDiff = computeLineDiff(targetRow); - if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; - else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; - buffer += "\r"; - // Clear extra lines without scrolling - const extraLines = this.previousLines.length - newLines.length; - if (extraLines > height) { - logRedraw(`extraLines > height (${extraLines} > ${height})`); - fullRender(true); - return; - } - const clearStartOffset = newLines.length === 0 ? 0 : 1; - if (extraLines > 0 && clearStartOffset > 0) { - buffer += `\x1b[${clearStartOffset}B`; - } - for (let i = 0; i < extraLines; i++) { - buffer += "\r\x1b[2K"; - if (i < extraLines - 1) buffer += "\x1b[1B"; - } - const moveBack = Math.max(0, extraLines - 1 + clearStartOffset); - if (moveBack > 0) { - buffer += `\x1b[${moveBack}A`; - } - buffer += "\x1b[?2026l"; - this.terminal.write(buffer); - this.cursorRow = targetRow; - this.hardwareCursorRow = targetRow; - } - this.positionHardwareCursor(cursorPos, newLines.length); - this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); - this.previousWidth = width; - this.previousHeight = height; - this.previousViewportTop = prevViewportTop; - return; - } - - // Differential rendering can only touch what was actually visible. - // If the first changed line is above the previous viewport, we need a full redraw. - if (firstChanged < prevViewportTop) { - logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`); - fullRender(true); - return; - } - - // Render from first changed line to end - // Build buffer with all updates wrapped in synchronized output - let buffer = "\x1b[?2026h"; // Begin synchronized output - buffer += this.deleteChangedKittyImages(firstChanged, lastChanged); - const prevViewportBottom = prevViewportTop + height - 1; - const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged; - if (moveTargetRow > prevViewportBottom) { - const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop)); - const moveToBottom = height - 1 - currentScreenRow; - if (moveToBottom > 0) { - buffer += `\x1b[${moveToBottom}B`; - } - const scroll = moveTargetRow - prevViewportBottom; - buffer += "\r\n".repeat(scroll); - prevViewportTop += scroll; - viewportTop += scroll; - hardwareCursorRow = moveTargetRow; - } - - // Move cursor to first changed line (use hardwareCursorRow for actual position) - const lineDiff = computeLineDiff(moveTargetRow); - if (lineDiff > 0) { - buffer += `\x1b[${lineDiff}B`; // Move down - } else if (lineDiff < 0) { - buffer += `\x1b[${-lineDiff}A`; // Move up - } - - buffer += appendStart ? "\r\n" : "\r"; // Move to column 0 - - // Only render changed lines (firstChanged to lastChanged), not all lines to end - // This reduces flicker when only a single line changes (e.g., spinner animation) - const renderEnd = Math.min(lastChanged, newLines.length - 1); - for (let i = firstChanged; i <= renderEnd; i++) { - if (i > firstChanged) buffer += "\r\n"; - const line = newLines[i]; - const isImage = isImageLine(line); - const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1; - if (imageReservedRows > 1) { - const imageStartScreenRow = i - viewportTop; - if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) { - logRedraw( - `kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`, - ); - fullRender(true); - return; - } - - buffer += "\x1b[2K"; - for (let row = 1; row < imageReservedRows; row++) { - buffer += "\r\n\x1b[2K"; - } - buffer += `\x1b[${imageReservedRows - 1}A`; - buffer += line; - buffer += `\x1b[${imageReservedRows - 1}B`; - i += imageReservedRows - 1; - continue; - } - - buffer += "\x1b[2K"; // Clear current line - if (!isImage && visibleWidth(line) > width) { - // Log all lines to crash file for debugging - const crashLogPath = path.join(this.logDirectory, "pi-crash.log"); - const crashData = [ - `Crash at ${new Date().toISOString()}`, - `Terminal width: ${width}`, - `Line ${i} visible width: ${visibleWidth(line)}`, - "", - "=== All rendered lines ===", - ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`), - "", - ].join("\n"); - fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); - fs.writeFileSync(crashLogPath, crashData); - - // Clean up terminal state before throwing - this.stop(); - - const errorMsg = [ - `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, - "", - "This is likely caused by a custom TUI component not truncating its output.", - "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", - "", - `Debug log written to: ${crashLogPath}`, - ].join("\n"); - throw new Error(errorMsg); - } - buffer += line; - } - - // Track where cursor ended up after rendering - let finalCursorRow = renderEnd; - - // If we had more lines before, clear them and move cursor back - if (this.previousLines.length > newLines.length) { - // Move to end of new content first if we stopped before it - if (renderEnd < newLines.length - 1) { - const moveDown = newLines.length - 1 - renderEnd; - buffer += `\x1b[${moveDown}B`; - finalCursorRow = newLines.length - 1; - } - const extraLines = this.previousLines.length - newLines.length; - for (let i = newLines.length; i < this.previousLines.length; i++) { - buffer += "\r\n\x1b[2K"; - } - // Move cursor back to end of new content - buffer += `\x1b[${extraLines}A`; - } - - buffer += "\x1b[?2026l"; // End synchronized output - - if (process.env.PI_TUI_DEBUG === "1") { - const debugDir = "/tmp/tui"; - fs.mkdirSync(debugDir, { recursive: true }); - const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`); - const debugData = [ - `firstChanged: ${firstChanged}`, - `viewportTop: ${viewportTop}`, - `cursorRow: ${this.cursorRow}`, - `height: ${height}`, - `lineDiff: ${lineDiff}`, - `hardwareCursorRow: ${hardwareCursorRow}`, - `renderEnd: ${renderEnd}`, - `finalCursorRow: ${finalCursorRow}`, - `cursorPos: ${JSON.stringify(cursorPos)}`, - `newLines.length: ${newLines.length}`, - `previousLines.length: ${this.previousLines.length}`, - "", - "=== newLines ===", - JSON.stringify(newLines, null, 2), - "", - "=== previousLines ===", - JSON.stringify(this.previousLines, null, 2), - "", - "=== buffer ===", - JSON.stringify(buffer), - ].join("\n"); - fs.writeFileSync(debugPath, debugData); - } - - // Write entire buffer at once - this.terminal.write(buffer); - - // Track cursor position for next render - // cursorRow tracks end of content (for viewport calculation) - // hardwareCursorRow tracks actual terminal cursor position (for movement) - this.cursorRow = Math.max(0, newLines.length - 1); - this.hardwareCursorRow = finalCursorRow; - // Track terminal's working area (grows but doesn't shrink unless cleared) - this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); - this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1); - - // Position hardware cursor for IME - this.positionHardwareCursor(cursorPos, newLines.length); - - this.previousLines = newLines; - this.previousKittyImageIds = this.collectKittyImageIds(newLines); - this.previousWidth = width; - this.previousHeight = height; - } - - /** - * Position the hardware cursor for IME candidate window. - * @param cursorPos The cursor position extracted from rendered output, or null - * @param totalLines Total number of rendered lines - */ - private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void { - if (!cursorPos || totalLines <= 0) { - this.terminal.hideCursor(); - return; - } - - // Clamp cursor position to valid range - const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1)); - const targetCol = Math.max(0, cursorPos.col); - - // Move cursor from current position to target - const rowDelta = targetRow - this.hardwareCursorRow; - let buffer = ""; - if (rowDelta > 0) { - buffer += `\x1b[${rowDelta}B`; // Move down - } else if (rowDelta < 0) { - buffer += `\x1b[${-rowDelta}A`; // Move up - } - // Move to absolute column (1-indexed) - buffer += `\x1b[${targetCol + 1}G`; - - if (buffer) { - this.terminal.write(buffer); - } - - this.hardwareCursorRow = targetRow; - if (this.showHardwareCursor) { - this.terminal.showCursor(); - } else { - this.terminal.hideCursor(); - } - } - /** * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`). * @param timeoutMs Query timeout in milliseconds. diff --git a/packages/tui/src/utils.ts b/packages/tui/src/utils.ts index ceadfc69d..29b339632 100644 --- a/packages/tui/src/utils.ts +++ b/packages/tui/src/utils.ts @@ -270,6 +270,77 @@ export function visibleWidth(str: string): number { return width; } +/** Remove ANSI, OSC, and APC control sequences while preserving visible text. */ +export function stripTerminalSequences(str: string): string { + if (!str.includes("\x1b")) return str; + let result = ""; + let i = 0; + while (i < str.length) { + const ansi = extractAnsiCode(str, i); + if (ansi) { + i += ansi.length; + continue; + } + result += str[i]; + i++; + } + return result; +} + +interface GraphemeCellRange { + start: number; + end: number; +} + +/** Return the terminal-cell range occupied by the grapheme at a visible column. */ +export function getGraphemeCellRange(line: string, column: number): GraphemeCellRange | undefined { + let currentCol = 0; + let i = 0; + while (i < line.length) { + const ansi = extractAnsiCode(line, i); + if (ansi) { + i += ansi.length; + continue; + } + let textEnd = i; + while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; + for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { + const width = graphemeWidth(segment); + if (width > 0 && column >= currentCol && column < currentCol + width) { + return { start: currentCol, end: currentCol + width }; + } + currentCol += width; + } + i = textEnd; + } + return undefined; +} + +/** Return the OSC 8 hyperlink covering a visible terminal column. */ +export function getOsc8LinkAtColumn(line: string, column: number): string | undefined { + let activeUrl: string | undefined; + let currentCol = 0; + let i = 0; + while (i < line.length) { + const ansi = extractAnsiCode(line, i); + if (ansi) { + const hyperlink = /^\x1b\]8;[^;]*;([^\x07\x1b]*)(?:\x07|\x1b\\)$/.exec(ansi.code); + if (hyperlink) activeUrl = hyperlink[1] || undefined; + i += ansi.length; + continue; + } + let textEnd = i; + while (textEnd < line.length && !extractAnsiCode(line, textEnd)) textEnd++; + for (const { segment } of graphemeSegmenter.segment(line.slice(i, textEnd))) { + const width = segment === "\t" ? 3 : graphemeWidth(segment); + if (column >= currentCol && column < currentCol + width) return activeUrl; + currentCol += width; + } + i = textEnd; + } + return undefined; +} + /** * Normalize text for terminal output without changing logical editor content. * Some terminals render precomposed Thai/Lao AM vowels inconsistently during diff --git a/packages/tui/test/chat-simple.ts b/packages/tui/test/chat-simple.ts index b6ccd1a85..03851e98b 100644 --- a/packages/tui/test/chat-simple.ts +++ b/packages/tui/test/chat-simple.ts @@ -8,15 +8,16 @@ import { Editor } from "../src/components/editor.ts"; import { Loader } from "../src/components/loader.ts"; import { Markdown } from "../src/components/markdown.ts"; import { Text } from "../src/components/text.ts"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; import { ProcessTerminal } from "../src/terminal.ts"; -import { TUI } from "../src/tui.ts"; +import type { TUI } from "../src/tui.ts"; import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.ts"; // Create terminal const terminal = new ProcessTerminal(); // Create TUI -const tui = new TUI(terminal); +const tui: TUI = new TuiMainScreen(terminal); // Create chat container with some initial messages tui.addChild( diff --git a/packages/tui/test/editor.test.ts b/packages/tui/test/editor.test.ts index 950f488f5..317c76ef9 100644 --- a/packages/tui/test/editor.test.ts +++ b/packages/tui/test/editor.test.ts @@ -3,14 +3,15 @@ import { describe, it } from "node:test"; import { stripVTControlCharacters } from "node:util"; import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts"; import { Editor, wordWrapLine } from "../src/components/editor.ts"; -import { TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; +import type { TUI } from "../src/tui.ts"; import { visibleWidth } from "../src/utils.ts"; import { defaultEditorTheme } from "./test-themes.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; /** Create a TUI with a virtual terminal for testing */ function createTestTUI(cols = 80, rows = 24): TUI { - return new TUI(new VirtualTerminal(cols, rows)); + return new TuiMainScreen(new VirtualTerminal(cols, rows)); } /** Standard applyCompletion that replaces prefix with item.value */ diff --git a/packages/tui/test/image-test.ts b/packages/tui/test/image-test.ts index 116c97d02..4b20c2ec2 100644 --- a/packages/tui/test/image-test.ts +++ b/packages/tui/test/image-test.ts @@ -2,9 +2,10 @@ import { readFileSync } from "fs"; import { Image } from "../src/components/image.ts"; import { Spacer } from "../src/components/spacer.ts"; import { Text } from "../src/components/text.ts"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; import { ProcessTerminal } from "../src/terminal.ts"; import { getCapabilities, getImageDimensions } from "../src/terminal-image.ts"; -import { TUI } from "../src/tui.ts"; +import type { TUI } from "../src/tui.ts"; const testImagePath = process.argv[2] || "/tmp/test-image.png"; @@ -27,7 +28,7 @@ console.log("Image dimensions:", dims); console.log(""); const terminal = new ProcessTerminal(); -const tui = new TUI(terminal); +const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new Text("Image Rendering Test", 1, 1)); tui.addChild(new Spacer(1)); diff --git a/packages/tui/test/key-tester.ts b/packages/tui/test/key-tester.ts index dce060d4f..b28a689c6 100755 --- a/packages/tui/test/key-tester.ts +++ b/packages/tui/test/key-tester.ts @@ -1,7 +1,8 @@ #!/usr/bin/env node import { matchesKey } from "../src/keys.ts"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; import { ProcessTerminal } from "../src/terminal.ts"; -import { type Component, TUI } from "../src/tui.ts"; +import type { Component, TUI } from "../src/tui.ts"; import { truncateToWidth } from "../src/utils.ts"; /** @@ -102,7 +103,7 @@ class KeyLogger implements Component { // Set up TUI const terminal = new ProcessTerminal(); -const tui = new TUI(terminal); +const tui: TUI = new TuiMainScreen(terminal); const logger = new KeyLogger(tui, terminal); tui.addChild(logger); diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index c166a582a..a3b1c682c 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -3,8 +3,9 @@ import { afterEach, describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Chalk } from "chalk"; import { Markdown } from "../src/components/markdown.ts"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; import { resetCapabilitiesCache, setCapabilities } from "../src/terminal-image.ts"; -import { type Component, TUI } from "../src/tui.ts"; +import type { Component, TUI } from "../src/tui.ts"; import { defaultMarkdownTheme } from "./test-themes.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; @@ -755,7 +756,7 @@ describe("Markdown component", () => { }); const terminal = new VirtualTerminal(80, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new MarkdownWithInput(markdown); tui.addChild(component); tui.start(); @@ -1196,7 +1197,7 @@ bar`, it("should not leak h1 underline into padding when inline code is the last token", async () => { const markdown = new Markdown("# Important distinction from `open()`", 0, 0, defaultMarkdownTheme); const terminal = new VirtualTerminal(80, 4); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(markdown); tui.start(); await terminal.waitForRender(); diff --git a/packages/tui/test/overlay-non-capturing.test.ts b/packages/tui/test/overlay-non-capturing.test.ts index 64ce62039..76d5368c3 100644 --- a/packages/tui/test/overlay-non-capturing.test.ts +++ b/packages/tui/test/overlay-non-capturing.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert"; import { describe, it } from "node:test"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; import type { Component, Focusable } from "../src/tui.ts"; -import { Container, TUI } from "../src/tui.ts"; +import { Container, type TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticOverlay implements Component { @@ -55,7 +56,7 @@ describe("TUI overlay non-capturing", () => { describe("focus management", () => { it("non-capturing overlay preserves focus on creation", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -73,7 +74,7 @@ describe("TUI overlay non-capturing", () => { it("focus() transfers focus to the overlay", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -93,7 +94,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus() restores previous focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -114,7 +115,7 @@ describe("TUI overlay non-capturing", () => { it("setHidden(false) on non-capturing overlay does not auto-focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -134,7 +135,7 @@ describe("TUI overlay non-capturing", () => { it("hide() when overlay is not focused does not change focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -152,7 +153,7 @@ describe("TUI overlay non-capturing", () => { it("hide() when focused restores focus correctly", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -172,7 +173,7 @@ describe("TUI overlay non-capturing", () => { it("capturing overlay removed with non-capturing below restores focus to editor", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const nonCapturing = new FocusableOverlay(["NC"]); const capturing = new FocusableOverlay(["CAP"]); @@ -194,7 +195,7 @@ describe("TUI overlay non-capturing", () => { it("sub-overlay cleanup then hideOverlay restores focus and input to editor", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const timer = new FocusableOverlay(["TIMER"]); const controller = new FocusableOverlay(["CTRL"]); @@ -224,7 +225,7 @@ describe("TUI overlay non-capturing", () => { it("removed focused child overlay does not become parent overlay fallback", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const child = new FocusableOverlay(["CHILD"]); const parent = new FocusableOverlay(["PARENT"]); @@ -253,7 +254,7 @@ describe("TUI overlay non-capturing", () => { it("microtask-deferred sub-overlay pattern (showExtensionCustom simulation) restores focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const timer = new FocusableOverlay(["TIMER"]); const controller = new FocusableOverlay(["CTRL"]); @@ -309,7 +310,7 @@ describe("TUI overlay non-capturing", () => { it("handleInput redirection skips non-capturing overlays when focused overlay becomes invisible", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const fallbackCapturing = new FocusableOverlay(["FALLBACK"]); const nonCapturing = new FocusableOverlay(["NC"]); @@ -337,7 +338,7 @@ describe("TUI overlay non-capturing", () => { it("active base focus replacement receives close input before overlay restore", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const replacement = new FocusableOverlay(["REPLACEMENT"]); const overlay = new FocusableOverlay(["OVERLAY"]); @@ -379,7 +380,7 @@ describe("TUI overlay non-capturing", () => { it("active replacement still receives input when it is another overlay preFocus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const replacement = new FocusableOverlay(["REPLACEMENT"]); const passive = new FocusableOverlay(["PASSIVE"]); @@ -421,7 +422,7 @@ describe("TUI overlay non-capturing", () => { it("blocked replacement can move focus internally before overlay restore", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const base = new Container(); const editor = new FocusableOverlay(["EDITOR"]); const firstReplacement = new FocusableOverlay(["FIRST"]); @@ -470,7 +471,7 @@ describe("TUI overlay non-capturing", () => { it("removed replacement restores overlay even when overlay preFocus differs from next focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const base = new Container(); const editor = new FocusableOverlay(["EDITOR"]); const palette = new FocusableOverlay(["PALETTE"]); @@ -513,7 +514,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus target releases a blocked overlay while replacement remains focused", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const fallback = new FocusableOverlay(["FALLBACK"]); const target = new FocusableOverlay(["TARGET"]); const replacement = new FocusableOverlay(["REPLACEMENT"]); @@ -552,7 +553,7 @@ describe("TUI overlay non-capturing", () => { it("handleInput restores focus to a visible focused overlay after base focus steal", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const replacement = new FocusableOverlay(["REPLACEMENT"]); const overlay = new FocusableOverlay(["OVERLAY"]); @@ -576,7 +577,7 @@ describe("TUI overlay non-capturing", () => { it("handleInput restores focus to explicitly focused raw sub-overlay after base focus steal", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const controller = new FocusableOverlay(["CONTROLLER"]); const subOverlay = new FocusableOverlay(["SUB"]); @@ -600,7 +601,7 @@ describe("TUI overlay non-capturing", () => { it("passive non-capturing overlay does not regain input after base focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const passive = new FocusableOverlay(["PASSIVE"]); tui.addChild(new EmptyContent()); @@ -620,7 +621,7 @@ describe("TUI overlay non-capturing", () => { it("explicitly focused non-capturing overlay regains input after base focus steal", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["NC"]); tui.addChild(new EmptyContent()); @@ -641,7 +642,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus() prevents visible overlay from regaining input", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -662,7 +663,7 @@ describe("TUI overlay non-capturing", () => { it("setFocus(null) explicitly clears visible overlay restore", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); tui.start(); @@ -680,7 +681,7 @@ describe("TUI overlay non-capturing", () => { it("blocked replacement setFocus(null) resumes the visible overlay", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const replacement = new FocusableOverlay(["REPLACEMENT"]); const overlay = new FocusableOverlay(["OVERLAY"]); replacement.handleInput = (data: string) => { @@ -710,7 +711,7 @@ describe("TUI overlay non-capturing", () => { it("temporarily invisible focused overlay falls back without losing restore eligibility", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); let visible = true; @@ -737,7 +738,7 @@ describe("TUI overlay non-capturing", () => { it("temporarily invisible focused overlay with null preFocus restores when visible again", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new FocusableOverlay(["OVERLAY"]); let visible = true; tui.addChild(new EmptyContent()); @@ -759,7 +760,7 @@ describe("TUI overlay non-capturing", () => { it("cyclic overlay preFocus ancestry does not hang focus changes", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -780,7 +781,7 @@ describe("TUI overlay non-capturing", () => { it("handleInput restores the focus-order top overlay after base focus steal", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const lower = new FocusableOverlay(["LOWER"]); const upper = new FocusableOverlay(["UPPER"]); @@ -804,7 +805,7 @@ describe("TUI overlay non-capturing", () => { it("hideOverlay() does not reassign focus when topmost overlay is non-capturing", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const capturing = new FocusableOverlay(["CAP"]); const nonCapturing = new FocusableOverlay(["NC"]); @@ -825,7 +826,7 @@ describe("TUI overlay non-capturing", () => { it("multiple capturing and non-capturing overlays restore focus through removals", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const c1 = new FocusableOverlay(["C1"]); const n1 = new FocusableOverlay(["N1"]); @@ -853,7 +854,7 @@ describe("TUI overlay non-capturing", () => { it("capturing overlay unfocus() on topmost capturing overlay falls back to preFocus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const capturing = new FocusableOverlay(["CAP"]); tui.addChild(new EmptyContent()); @@ -875,7 +876,7 @@ describe("TUI overlay non-capturing", () => { describe("no-op guards", () => { it("focus() on hidden overlay is a no-op", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -895,7 +896,7 @@ describe("TUI overlay non-capturing", () => { it("focus() after hide() is a no-op", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -915,7 +916,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus() when overlay does not have focus is a no-op", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -934,7 +935,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus() with null preFocus clears focus and does not route input back to overlay", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); tui.start(); @@ -956,7 +957,7 @@ describe("TUI overlay non-capturing", () => { describe("focus cycle prevention", () => { it("toggle focus between non-capturing overlays then unfocus returns to editor", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const a = new FocusableOverlay(["A"]); const b = new FocusableOverlay(["B"]); @@ -981,7 +982,7 @@ describe("TUI overlay non-capturing", () => { it("explicit unfocus target supports cycling between three overlays and editor", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const a = new FocusableOverlay(["A"]); const b = new FocusableOverlay(["B"]); @@ -1025,7 +1026,7 @@ describe("TUI overlay non-capturing", () => { it("explicit null unfocus target clears focus without restoring overlays", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); tui.start(); @@ -1043,7 +1044,7 @@ describe("TUI overlay non-capturing", () => { it("hiding focused overlay falls back to next visual-frontmost overlay", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const a = new FocusableOverlay(["A"]); const b = new FocusableOverlay(["B"]); @@ -1072,7 +1073,7 @@ describe("TUI overlay non-capturing", () => { describe("rendering order", () => { it("focus() on already-focused overlay bumps visual order", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); tui.addChild(new EmptyContent()); tui.setFocus(editor); @@ -1095,7 +1096,7 @@ describe("TUI overlay non-capturing", () => { it("default rendering order for overlapping overlays follows creation order", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); tui.start(); try { @@ -1110,7 +1111,7 @@ describe("TUI overlay non-capturing", () => { it("focus() on lower overlay renders it on top", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); tui.start(); try { @@ -1128,7 +1129,7 @@ describe("TUI overlay non-capturing", () => { it("focusing middle overlay places it on top while preserving others relative order", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); tui.start(); try { @@ -1153,7 +1154,7 @@ describe("TUI overlay non-capturing", () => { it("capturing overlay hidden and shown again renders on top after unhide", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); tui.start(); try { @@ -1175,7 +1176,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus() does not change visual order until another overlay is focused", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); tui.addChild(new EmptyContent()); tui.setFocus(editor); diff --git a/packages/tui/test/overlay-options.test.ts b/packages/tui/test/overlay-options.test.ts index c93f26cbd..45ec8dd5a 100644 --- a/packages/tui/test/overlay-options.test.ts +++ b/packages/tui/test/overlay-options.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import type { Component } from "../src/tui.ts"; -import { TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; +import type { Component, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticOverlay implements Component { @@ -39,7 +39,7 @@ describe("TUI overlay options", () => { describe("width overflow protection", () => { it("should truncate overlay lines that exceed declared width", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Overlay declares width 20 but renders lines much wider const overlay = new StaticOverlay(["X".repeat(100)]); @@ -60,7 +60,7 @@ describe("TUI overlay options", () => { it("should handle overlay with complex ANSI sequences without crashing", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Simulate complex ANSI content like the crash log showed const complexLine = "\x1b[48;2;40;50;40m \x1b[38;2;128;128;128mSome styled content\x1b[39m\x1b[49m" + @@ -81,7 +81,7 @@ describe("TUI overlay options", () => { it("should handle overlay composited on styled base content", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Base content with styling class StyledContent implements Component { @@ -108,7 +108,7 @@ describe("TUI overlay options", () => { it("should handle wide characters at overlay boundary", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Wide chars (each takes 2 columns) at the edge of declared width const wideCharLine = "中文日本語한글テスト漢字"; // Mix of CJK chars const overlay = new StaticOverlay([wideCharLine]); @@ -126,7 +126,7 @@ describe("TUI overlay options", () => { it("should handle overlay positioned at terminal edge", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Overlay positioned at right edge with content that exceeds declared width const overlay = new StaticOverlay(["X".repeat(50)]); @@ -144,7 +144,7 @@ describe("TUI overlay options", () => { it("should handle overlay on base content with OSC sequences", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Base content with OSC 8 hyperlinks (like file paths in agent output) class HyperlinkContent implements Component { @@ -173,7 +173,7 @@ describe("TUI overlay options", () => { describe("width percentage", () => { it("should render overlay at percentage of terminal width", async () => { const terminal = new VirtualTerminal(100, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["test"]); tui.addChild(new EmptyContent()); @@ -187,7 +187,7 @@ describe("TUI overlay options", () => { it("should respect minWidth when widthPercent results in smaller width", async () => { const terminal = new VirtualTerminal(100, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["test"]); tui.addChild(new EmptyContent()); @@ -203,7 +203,7 @@ describe("TUI overlay options", () => { describe("anchor positioning", () => { it("should position overlay at top-left", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["TOP-LEFT"]); tui.addChild(new EmptyContent()); @@ -218,7 +218,7 @@ describe("TUI overlay options", () => { it("should position overlay at bottom-right", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["BTM-RIGHT"]); tui.addChild(new EmptyContent()); @@ -236,7 +236,7 @@ describe("TUI overlay options", () => { it("should position overlay at top-center", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["CENTERED"]); tui.addChild(new EmptyContent()); @@ -258,7 +258,7 @@ describe("TUI overlay options", () => { describe("margin", () => { it("should clamp negative margins to zero", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["NEG-MARGIN"]); tui.addChild(new EmptyContent()); @@ -279,7 +279,7 @@ describe("TUI overlay options", () => { it("should respect margin as number", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["MARGIN"]); tui.addChild(new EmptyContent()); @@ -300,7 +300,7 @@ describe("TUI overlay options", () => { it("should respect margin object", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["MARGIN"]); tui.addChild(new EmptyContent()); @@ -323,7 +323,7 @@ describe("TUI overlay options", () => { describe("offset", () => { it("should apply offsetX and offsetY from anchor position", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["OFFSET"]); tui.addChild(new EmptyContent()); @@ -342,7 +342,7 @@ describe("TUI overlay options", () => { describe("percentage positioning", () => { it("should position with rowPercent and colPercent", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["PCT"]); tui.addChild(new EmptyContent()); @@ -367,7 +367,7 @@ describe("TUI overlay options", () => { it("rowPercent 0 should position at top", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["TOP"]); tui.addChild(new EmptyContent()); @@ -382,7 +382,7 @@ describe("TUI overlay options", () => { it("rowPercent 100 should position at bottom", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["BOTTOM"]); tui.addChild(new EmptyContent()); @@ -399,7 +399,7 @@ describe("TUI overlay options", () => { describe("maxHeight", () => { it("should truncate overlay to maxHeight", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"]); tui.addChild(new EmptyContent()); @@ -419,7 +419,7 @@ describe("TUI overlay options", () => { it("should truncate overlay to maxHeightPercent", async () => { const terminal = new VirtualTerminal(80, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // 10 lines in a 10 row terminal with 50% maxHeight should show 5 lines const overlay = new StaticOverlay(["L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8", "L9", "L10"]); @@ -440,7 +440,7 @@ describe("TUI overlay options", () => { describe("absolute positioning", () => { it("row and col should override anchor", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["ABSOLUTE"]); tui.addChild(new EmptyContent()); @@ -460,7 +460,7 @@ describe("TUI overlay options", () => { describe("stacked overlays", () => { it("should render multiple overlays with later ones on top", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); @@ -485,7 +485,7 @@ describe("TUI overlay options", () => { it("should handle overlays at different positions without interference", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); @@ -509,7 +509,7 @@ describe("TUI overlay options", () => { it("should properly hide overlays in stack order", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); diff --git a/packages/tui/test/overlay-short-content.test.ts b/packages/tui/test/overlay-short-content.test.ts index 135d8cb9e..e7fd0ccf5 100644 --- a/packages/tui/test/overlay-short-content.test.ts +++ b/packages/tui/test/overlay-short-content.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { type Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; +import type { Component, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class SimpleContent implements Component { @@ -27,7 +28,7 @@ describe("TUI overlay with short content", () => { it("should render overlay when content is shorter than terminal height", async () => { // Terminal has 24 rows, but content only has 3 lines const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Only 3 lines of content tui.addChild(new SimpleContent(["Line 1", "Line 2", "Line 3"])); diff --git a/packages/tui/test/regression-overlay-cjk-boundary.test.ts b/packages/tui/test/regression-overlay-cjk-boundary.test.ts index 2ae1f5ed8..5aa7f5c16 100644 --- a/packages/tui/test/regression-overlay-cjk-boundary.test.ts +++ b/packages/tui/test/regression-overlay-cjk-boundary.test.ts @@ -1,29 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { TUI } from "../src/tui.ts"; +import { compositeTuiLine } from "../src/tui.ts"; import { extractSegments, sliceByColumn, visibleWidth } from "../src/utils.ts"; -import { VirtualTerminal } from "./virtual-terminal.ts"; - -type TuiComposite = { - compositeLineAt( - baseLine: string, - overlayLine: string, - startCol: number, - overlayWidth: number, - totalWidth: number, - ): string; -}; - -function compositeLineAt( - baseLine: string, - overlayLine: string, - startCol: number, - overlayWidth: number, - totalWidth: number, -): string { - const tui = new TUI(new VirtualTerminal(totalWidth, 10)) as unknown as TuiComposite; - return tui.compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth); -} describe("overlay CJK boundary regression", () => { it("excludes a wide grapheme from before when overlay starts inside it", () => { @@ -45,7 +23,7 @@ describe("overlay CJK boundary regression", () => { }); it("composites an overlay at the requested column when it starts inside a wide grapheme", () => { - const out = compositeLineAt("abcd让EFGH", "│XX│", 5, 4, 20); + const out = compositeTuiLine("abcd让EFGH", "│XX│", 5, 4, 20); const prefix = sliceByColumn(out, 0, 5, true); const overlay = sliceByColumn(out, 5, 4, true); @@ -57,7 +35,7 @@ describe("overlay CJK boundary regression", () => { }); it("composites an overlay when it starts at a wide grapheme boundary", () => { - const out = compositeLineAt("abcd让EFGH", "│XX│", 4, 4, 20); + const out = compositeTuiLine("abcd让EFGH", "│XX│", 4, 4, 20); const overlay = sliceByColumn(out, 4, 4, true); assert.strictEqual(out.includes("让"), false); diff --git a/packages/tui/test/tab-width.test.ts b/packages/tui/test/tab-width.test.ts index 883fa335a..090d17e5b 100644 --- a/packages/tui/test/tab-width.test.ts +++ b/packages/tui/test/tab-width.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { type Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; +import type { Component, TUI } from "../src/tui.ts"; import { extractSegments, normalizeTerminalOutput, sliceWithWidth, visibleWidth } from "../src/utils.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; @@ -71,7 +72,7 @@ describe("tab width accounting", () => { it("keeps tab-containing overlays on one physical terminal row", async () => { const terminal = new CapturingVirtualTerminal(16, 3); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new FullViewportContent()); tui.showOverlay(new TabStatusOverlay(), { width: 4, row: 1, col: 4 }); tui.start(); diff --git a/packages/tui/test/terminal-colors.test.ts b/packages/tui/test/terminal-colors.test.ts index d777e0617..9fe67cd66 100644 --- a/packages/tui/test/terminal-colors.test.ts +++ b/packages/tui/test/terminal-colors.test.ts @@ -5,7 +5,8 @@ import { parseOsc11BackgroundColor, parseTerminalColorSchemeReport, type Terminal, - TUI, + type TUI, + TuiMainScreen, } from "../src/index.ts"; class TestTerminal implements Terminal { @@ -123,7 +124,7 @@ describe("parseTerminalColorSchemeReport", () => { describe("TUI.queryTerminalBackgroundColor", () => { it("writes OSC 11 query and resolves with the parsed RGB reply", async () => { const terminal = new TestTerminal(); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.start(); try { const query = tui.queryTerminalBackgroundColor({ timeoutMs: 1000 }); @@ -139,7 +140,7 @@ describe("TUI.queryTerminalBackgroundColor", () => { it("consumes OSC 11 replies before input listeners and focused component dispatch", async () => { const terminal = new TestTerminal(); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new InputRecorder(); const listenerInputs: string[] = []; tui.addChild(component); @@ -164,7 +165,7 @@ describe("TUI.queryTerminalBackgroundColor", () => { it("consumes unparseable strict OSC 11 replies and resolves undefined", async () => { const terminal = new TestTerminal(); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new InputRecorder(); const listenerInputs: string[] = []; tui.addChild(component); @@ -189,7 +190,7 @@ describe("TUI.queryTerminalBackgroundColor", () => { it("dispatches non-matching input normally while waiting for an OSC 11 reply", async () => { const terminal = new TestTerminal(); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new InputRecorder(); const listenerInputs: string[] = []; tui.addChild(component); @@ -222,7 +223,7 @@ describe("TUI.queryTerminalBackgroundColor", () => { it("keeps consuming a late OSC 11 reply after timeout", async () => { const terminal = new TestTerminal(); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new InputRecorder(); const listenerInputs: string[] = []; tui.addChild(component); diff --git a/packages/tui/test/terminal-image.test.ts b/packages/tui/test/terminal-image.test.ts index 115eb0c25..fc740b836 100644 --- a/packages/tui/test/terminal-image.test.ts +++ b/packages/tui/test/terminal-image.test.ts @@ -8,10 +8,12 @@ import { join } from "node:path"; import { describe, it } from "node:test"; import { Image } from "../src/components/image.ts"; import { + cropKittyImageLine, deleteAllKittyImages, deleteKittyImage, detectCapabilities, encodeKitty, + getKittyImageMetadata, hyperlink, imageFallback, isImageLine, @@ -409,6 +411,30 @@ describe("Kitty image cursor movement", () => { } }); + it("registers metadata and crops a partially visible placement", () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + setCellDimensions({ widthPx: 10, heightPx: 10 }); + try { + const result = renderImage( + "AAAA", + { widthPx: 100, heightPx: 100 }, + { maxWidthCells: 3, imageId: 42, moveCursor: false }, + ); + assert.ok(result); + assert.deepStrictEqual(getKittyImageMetadata(result.sequence), { + imageId: 42, + columns: 3, + rows: 3, + widthPx: 100, + heightPx: 100, + }); + assert.ok(cropKittyImageLine(result.sequence, 2, 1).includes("y=66,h=34,r=1")); + } finally { + resetCapabilitiesCache(); + setCellDimensions({ widthPx: 9, heightPx: 18 }); + } + }); + it("honors maxHeightCells by reducing rendered width", () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); setCellDimensions({ widthPx: 10, heightPx: 10 }); diff --git a/packages/tui/test/tui-alt-screen.test.ts b/packages/tui/test/tui-alt-screen.test.ts new file mode 100644 index 000000000..8ca07bb34 --- /dev/null +++ b/packages/tui/test/tui-alt-screen.test.ts @@ -0,0 +1,331 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { Image } from "../src/components/image.ts"; +import { Text } from "../src/components/text.ts"; +import { TuiAltScreen } from "../src/TuiAltScreen.ts"; +import { + encodeKitty, + hyperlink, + registerKittyImageMetadata, + resetCapabilitiesCache, + setCapabilities, +} from "../src/terminal-image.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class RecordingTerminal extends VirtualTerminal { + readonly events: Array<{ type: "write"; data: string } | { type: "start" } | { type: "stop" }> = []; + + override start(onInput: (data: string) => void, onResize: () => void): void { + this.events.push({ type: "start" }); + super.start(onInput, onResize); + } + + override write(data: string): void { + this.events.push({ type: "write", data }); + super.write(data); + } + + override stop(): void { + this.events.push({ type: "stop" }); + super.stop(); + } +} + +describe("TuiAltScreen", () => { + it("renders a terminal-height viewport and preserves manual scroll position", async () => { + const terminal = new VirtualTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + const text = new Text(Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0); + tui.addChild(text); + tui.start(); + await terminal.waitForRender(); + + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 7", "line 8", "line 9", "line 10"], + ); + assert.strictEqual(tui.isFollowingOutput, true); + + terminal.sendInput("\x1b[<64;1;1M"); + await terminal.waitForRender(); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 4", "line 5", "line 6", "line 7"], + ); + assert.strictEqual(tui.viewportTop, 3); + assert.strictEqual(tui.isFollowingOutput, false); + + text.setText(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n")); + tui.requestRender(); + await terminal.waitForRender(); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 4", "line 5", "line 6", "line 7"], + ); + + tui.stop(); + }); + + it("supports configurable keyboard viewport navigation", async () => { + const terminal = new VirtualTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[5$"); + await terminal.waitForRender(); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 2", "line 3", "line 4", "line 5"], + ); + + terminal.sendInput("\x1b[1;5F"); + await terminal.waitForRender(); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 5", "line 6", "line 7", "line 8"], + ); + + tui.stop(); + }); + + it("does not emit Kitty graphics commands or OSC 133 zones in iTerm2", async () => { + setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true }); + try { + const terminal = new RecordingTerminal(20, 3); + const tui = new TuiAltScreen(terminal); + tui.addChild({ + render: () => ["\x1b]133;B\x07\x1b]133;C\x07\x1b]133;A\x07content"], + invalidate: () => {}, + }); + tui.addChild( + new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => value }, + { filename: "example.png" }, + { widthPx: 10, heightPx: 10 }, + ), + ); + tui.start(); + await terminal.waitForRender(); + tui.stop(); + assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b_G"))); + assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]133;"))); + assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]1337;File="))); + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("[Image:"))); + } finally { + resetCapabilitiesCache(); + } + }); + + it("clears stale iTerm2 image placements when they leave the viewport", async () => { + setCapabilities({ images: "iterm2", trueColor: true, hyperlinks: true }); + try { + const terminal = new RecordingTerminal(20, 3); + const tui = new TuiAltScreen(terminal); + const imageLine = "\x1b]1337;File=inline=1;width=2;height=auto:AAAA\x07"; + tui.addChild({ + render: () => [imageLine, "", "", "after", "more", "end"], + invalidate: () => {}, + }); + tui.start(); + await terminal.waitForRender(); + tui.scrollToTop(); + await terminal.waitForRender(); + const eventCount = terminal.events.length; + + tui.scrollBy(1); + await terminal.waitForRender(); + assert.ok( + terminal.events.slice(eventCount).some((event) => event.type === "write" && event.data.includes("\x1b[2J")), + ); + tui.stop(); + } finally { + resetCapabilitiesCache(); + } + }); + + it("crops a Kitty image whose first line is above the viewport", async () => { + const terminal = new RecordingTerminal(20, 3); + const tui = new TuiAltScreen(terminal); + const imageId = 123; + const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false }); + registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 }); + tui.addChild({ + render: () => ["before", imageLine, "", "", "after", "end"], + invalidate: () => {}, + }); + tui.start(); + await terminal.waitForRender(); + + assert.strictEqual(tui.viewportTop, 3); + assert.ok( + terminal.events.some( + (event) => event.type === "write" && event.data.includes("i=123") && event.data.includes("y=66,h=34,r=1"), + ), + ); + + tui.stop(); + }); + + it("opens an OSC 8 hyperlink on click but not on drag", async () => { + const terminal = new RecordingTerminal(20, 3); + const openedUrls: string[] = []; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + openUrl: (url) => openedUrls.push(url), + }); + const url = "https://example.com/path?q=1"; + const belUrl = "https://example.com/bel"; + const emojiUrl = "https://example.com/emoji"; + tui.addChild( + new Text( + `${hyperlink("link", url)}\n\x1b]8;;${belUrl}\x07link\x1b]8;;\x07\n${hyperlink("🙂", emojiUrl)}`, + 0, + 0, + ), + ); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;2;1M"); + terminal.sendInput("\x1b[<0;2;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(openedUrls, [url]); + + terminal.sendInput("\x1b[<0;2;2M"); + terminal.sendInput("\x1b[<0;2;2m"); + await terminal.waitForRender(); + assert.deepStrictEqual(openedUrls, [url, belUrl]); + + terminal.sendInput("\x1b[<0;2;3M"); + terminal.sendInput("\x1b[<0;2;3m"); + await terminal.waitForRender(); + assert.deepStrictEqual(openedUrls, [url, belUrl, emojiUrl]); + + terminal.sendInput("\x1b[<0;2;1M"); + terminal.sendInput("\x1b[<32;4;1M"); + terminal.sendInput("\x1b[<0;4;1m"); + await terminal.waitForRender(); + assert.deepStrictEqual(openedUrls, [url, belUrl, emojiUrl]); + + tui.stop(); + }); + + it("selects visible text with the mouse and copies it with OSC 52", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + 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(); + + const expectedClipboardSequence = `\x1b]52;c;${Buffer.from("alpha\nbeta").toString("base64")}\x07`; + const clipboardWrites = terminal.events.filter( + (event) => event.type === "write" && event.data.includes("\x1b]52;c;"), + ); + assert.ok( + clipboardWrites.some((event) => event.type === "write" && event.data.includes(expectedClipboardSequence)), + JSON.stringify(clipboardWrites), + ); + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m"))); + + tui.stop(); + }); + + it("snaps mouse selection to CJK, emoji, and combining grapheme boundaries", async () => { + const terminal = new RecordingTerminal(20, 2); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("A界🙂éZ", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + const wideSelection = `\x1b]52;c;${Buffer.from("界🙂").toString("base64")}\x07`; + terminal.sendInput("\x1b[<0;3;1M"); + terminal.sendInput("\x1b[<32;4;1M"); + terminal.sendInput("\x1b[<0;4;1m"); + await terminal.waitForRender(); + assert.strictEqual( + terminal.events.filter((event) => event.type === "write" && event.data.includes(wideSelection)).length, + 1, + ); + + terminal.sendInput("\x1b[<0;5;1M"); + terminal.sendInput("\x1b[<32;2;1M"); + terminal.sendInput("\x1b[<0;2;1m"); + await terminal.waitForRender(); + assert.strictEqual( + terminal.events.filter((event) => event.type === "write" && event.data.includes(wideSelection)).length, + 2, + ); + + const combiningSelection = `\x1b]52;c;${Buffer.from("éZ").toString("base64")}\x07`; + terminal.sendInput("\x1b[<0;6;1M"); + terminal.sendInput("\x1b[<32;7;1M"); + terminal.sendInput("\x1b[<0;7;1m"); + await terminal.waitForRender(); + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(combiningSelection))); + + tui.stop(); + }); + + it("ignores horizontal trackpad wheel events", async () => { + const terminal = new VirtualTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<66;1;1M"); + terminal.sendInput("\x1b[<67;1;1M"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 4); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 5", "line 6", "line 7", "line 8"], + ); + + tui.stop(); + }); + + it("restores keyboard state before leaving alt mode and prints the full document", async () => { + const terminal = new RecordingTerminal(20, 3); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("first\nsecond\nthird\nfourth\nfifth\nsixth", 0, 0)); + tui.start(); + await terminal.waitForRender(); + tui.stop(); + + const startIndex = terminal.events.findIndex((event) => event.type === "start"); + const altScreenEnterIndex = terminal.events.findIndex( + (event) => event.type === "write" && event.data.includes("\x1b[?1049h"), + ); + const stopIndex = terminal.events.findIndex((event) => event.type === "stop"); + const mouseDisableIndex = terminal.events.findIndex( + (event) => event.type === "write" && event.data.includes("\x1b[?1006l"), + ); + const mainScreenRestoreIndex = terminal.events.findIndex( + (event) => event.type === "write" && event.data.includes("\x1b[?1049l"), + ); + assert.ok(altScreenEnterIndex >= 0 && altScreenEnterIndex < startIndex); + assert.ok(mouseDisableIndex >= 0 && mouseDisableIndex < stopIndex); + assert.ok(mainScreenRestoreIndex > stopIndex); + + const restoreEvent = terminal.events[mainScreenRestoreIndex]; + assert.strictEqual(restoreEvent?.type, "write"); + if (restoreEvent?.type === "write") { + assert.ok(restoreEvent.data.includes("first")); + assert.ok(restoreEvent.data.includes("second")); + assert.ok(restoreEvent.data.includes("third")); + assert.ok(restoreEvent.data.includes("fourth")); + assert.ok(restoreEvent.data.includes("fifth")); + assert.ok(restoreEvent.data.includes("sixth")); + assert.ok(restoreEvent.data.indexOf("first") < restoreEvent.data.indexOf("sixth")); + } + }); +}); diff --git a/packages/tui/test/tui-cell-size-input.test.ts b/packages/tui/test/tui-cell-size-input.test.ts index fab915cac..9b6f9cac1 100644 --- a/packages/tui/test/tui-cell-size-input.test.ts +++ b/packages/tui/test/tui-cell-size-input.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert"; import { describe, it } from "node:test"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; import { getCellDimensions, resetCapabilitiesCache, setCellDimensions } from "../src/terminal-image.ts"; -import { type Component, TUI } from "../src/tui.ts"; +import type { Component, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class InputRecorder implements Component { @@ -45,7 +46,7 @@ describe("TUI cell size responses", () => { it("forwards bare escape even when a cell size query was sent at startup", () => { withImageTerminal(() => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const recorder = new InputRecorder(); tui.setFocus(recorder); @@ -63,7 +64,7 @@ describe("TUI cell size responses", () => { setCellDimensions({ widthPx: 9, heightPx: 18 }); const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const recorder = new InputRecorder(); tui.setFocus(recorder); diff --git a/packages/tui/test/tui-overlay-style-leak.test.ts b/packages/tui/test/tui-overlay-style-leak.test.ts index a44b699ee..a1215ffdb 100644 --- a/packages/tui/test/tui-overlay-style-leak.test.ts +++ b/packages/tui/test/tui-overlay-style-leak.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; -import { type Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; +import type { Component, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticLines implements Component { @@ -54,7 +55,7 @@ describe("TUI overlay compositing", () => { const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`; const terminal = new VirtualTerminal(width, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new StaticLines([baseLine, "INPUT"])); tui.start(); await renderAndFlush(tui, terminal); @@ -67,7 +68,7 @@ describe("TUI overlay compositing", () => { const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`; const terminal = new VirtualTerminal(width, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new StaticLines([baseLine, "INPUT"])); tui.showOverlay(new StaticOverlay("OVR"), { row: 0, col: 5, width: 3 }); diff --git a/packages/tui/test/tui-render.test.ts b/packages/tui/test/tui-render.test.ts index 484639ba8..6c5af86f4 100644 --- a/packages/tui/test/tui-render.test.ts +++ b/packages/tui/test/tui-render.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Image } from "../src/components/image.ts"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; import { deleteKittyImage, encodeKitty, @@ -12,7 +13,7 @@ import { setCapabilities, setCellDimensions, } from "../src/terminal-image.ts"; -import { type Component, TUI } from "../src/tui.ts"; +import type { Component, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class TestComponent implements Component { @@ -80,7 +81,7 @@ describe("TUI debug logging", () => { try { await withEnv({ PI_DEBUG_REDRAW: "1" }, async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal, undefined, logDir); + const tui: TUI = new TuiMainScreen(terminal, undefined, logDir); const component = new TestComponent(); tui.addChild(component); component.lines = ["test"]; @@ -102,7 +103,7 @@ describe("TUI Kitty image cleanup", () => { setCellDimensions({ widthPx: 10, heightPx: 10 }); try { const terminal = new LoggingVirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -146,7 +147,7 @@ describe("TUI Kitty image cleanup", () => { setCellDimensions({ widthPx: 10, heightPx: 10 }); try { const terminal = new LoggingVirtualTerminal(40, 2); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -182,7 +183,7 @@ describe("TUI Kitty image cleanup", () => { setCellDimensions({ widthPx: 10, heightPx: 10 }); try { const terminal = new LoggingVirtualTerminal(40, 5); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -228,7 +229,7 @@ describe("TUI Kitty image cleanup", () => { setCellDimensions({ widthPx: 10, heightPx: 10 }); try { const terminal = new LoggingVirtualTerminal(40, 5); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -268,7 +269,7 @@ describe("TUI Kitty image cleanup", () => { it("deletes changed image ids before drawing moved placements", async () => { const terminal = new LoggingVirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -295,7 +296,7 @@ describe("TUI Kitty image cleanup", () => { it("redraws image lines when an earlier reserved image row changes", async () => { const terminal = new LoggingVirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -322,7 +323,7 @@ describe("TUI Kitty image cleanup", () => { it("deletes previously rendered image ids during full redraws", async () => { const terminal = new LoggingVirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -350,7 +351,7 @@ describe("TUI resize handling", () => { it("triggers full re-render when terminal height changes", async () => { await withEnv({ TERMUX_VERSION: undefined }, async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -377,7 +378,7 @@ describe("TUI resize handling", () => { it("skips full re-render on height changes in Termux", async () => { await withEnv({ TERMUX_VERSION: "1" }, async () => { const terminal = new LoggingVirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -405,7 +406,7 @@ describe("TUI resize handling", () => { it("triggers full re-render when terminal width changes", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -429,7 +430,7 @@ describe("TUI resize handling", () => { describe("TUI content shrinkage", () => { it("clears empty rows when content shrinks significantly", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var) const component = new TestComponent(); tui.addChild(component); @@ -461,7 +462,7 @@ describe("TUI content shrinkage", () => { it("handles shrink to single line", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var) const component = new TestComponent(); tui.addChild(component); @@ -484,7 +485,7 @@ describe("TUI content shrinkage", () => { it("handles shrink to empty", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.setClearOnShrink(true); // Explicitly enable (may be disabled via env var) const component = new TestComponent(); tui.addChild(component); @@ -510,7 +511,7 @@ describe("TUI content shrinkage", () => { describe("TUI differential rendering", () => { it("tracks cursor correctly when content shrinks with unchanged remaining lines", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -539,7 +540,7 @@ describe("TUI differential rendering", () => { it("renders correctly when only a middle line changes (spinner case)", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -566,7 +567,7 @@ describe("TUI differential rendering", () => { it("resets styles after each rendered line", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -580,7 +581,7 @@ describe("TUI differential rendering", () => { it("renders correctly when first line changes but rest stays same", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -604,7 +605,7 @@ describe("TUI differential rendering", () => { it("renders correctly when last line changes but rest stays same", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -628,7 +629,7 @@ describe("TUI differential rendering", () => { it("renders correctly when multiple non-adjacent lines change", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -653,7 +654,7 @@ describe("TUI differential rendering", () => { it("handles transition from content to empty and back to content", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -684,7 +685,7 @@ describe("TUI differential rendering", () => { it("full re-renders when deleted lines move the viewport upward", async () => { const terminal = new VirtualTerminal(20, 5); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -706,7 +707,7 @@ describe("TUI differential rendering", () => { it("appends after a shrink without another full redraw once the viewport is reset", async () => { const terminal = new VirtualTerminal(20, 5); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); @@ -735,7 +736,7 @@ describe("TUI differential rendering", () => { it("clears stale content when maxLinesRendered was inflated by a transient component", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const chat = new TestComponent(); const editor = new TestComponent(); tui.addChild(chat); diff --git a/packages/tui/test/tui-shrink.test.ts b/packages/tui/test/tui-shrink.test.ts index 13fa5c4bb..62ac3760e 100644 --- a/packages/tui/test/tui-shrink.test.ts +++ b/packages/tui/test/tui-shrink.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { type Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; +import type { Component, TUI } from "../src/tui.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class Lines implements Component { @@ -20,7 +21,7 @@ class Lines implements Component { describe("TUI shrinking content", () => { it("clears all rendered lines when content shrinks to zero", async () => { const terminal = new VirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const content = new Lines(["first", "second", "third"]); tui.addChild(content); tui.start(); diff --git a/packages/tui/test/viewport-overwrite-repro.ts b/packages/tui/test/viewport-overwrite-repro.ts index 1ef432a03..369264c87 100644 --- a/packages/tui/test/viewport-overwrite-repro.ts +++ b/packages/tui/test/viewport-overwrite-repro.ts @@ -17,8 +17,10 @@ * - When content exceeds the viewport and new lines arrive after a tool-call pause, * some earlier PRE-TOOL lines near the bottom are overwritten by POST-TOOL lines. */ + +import { TuiMainScreen } from "../src/TuiMainScreen.ts"; import { ProcessTerminal } from "../src/terminal.ts"; -import { type Component, TUI } from "../src/tui.ts"; +import type { Component, TUI } from "../src/tui.ts"; const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -52,7 +54,7 @@ async function streamLines(buffer: Lines, label: string, count: number, delayMs: } async function main(): Promise { - const ui = new TUI(new ProcessTerminal()); + const ui: TUI = new TuiMainScreen(new ProcessTerminal()); const buffer = new Lines(); ui.addChild(buffer); ui.start(); @@ -96,7 +98,7 @@ async function main(): Promise { main().catch((error) => { // Ensure terminal is restored if something goes wrong. try { - const ui = new TUI(new ProcessTerminal()); + const ui: TUI = new TuiMainScreen(new ProcessTerminal()); ui.stop(); } catch { // Ignore restore errors.