feat(tui): add alternate-screen renderer

Closes #7304
This commit is contained in:
Mario Zechner 2026-07-30 12:58:58 +02:00
parent 96eda8b47f
commit c13ffe1877
43 changed files with 1929 additions and 803 deletions

View file

@ -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);

View file

@ -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)).

View file

@ -605,6 +605,7 @@ Combine `--no-*` with explicit flags to load exactly what you need, ignoring set
|--------|-------------|
| `--system-prompt <text>` | Replace default prompt (context files and skills still appended) |
| `--append-system-prompt <text>` | 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 |

View file

@ -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 |

View file

@ -239,12 +239,15 @@ pi --no-extensions -e ./my-extension.ts
|--------|-------------|
| `--system-prompt <text>` | Replace default prompt; context files and skills are still appended |
| `--append-system-prompt <text>` | 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:

View file

@ -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();

View file

@ -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 <file> 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)

View file

@ -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<void
initTheme(options.settingsManager.getTheme(), true);
return new Promise((resolve) => {
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(

View file

@ -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;
}

View file

@ -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();

View file

@ -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);
}
}

View file

@ -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"]);

View file

@ -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",

View file

@ -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");

View file

@ -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);
}
});
});

View file

@ -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<void> {
async function main() {
const terminal = new ProcessTerminal();
const tui = new TUI(terminal);
const tui: TUI = new TuiMainScreen(terminal);
// Start with empty message
const message = {

View file

@ -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

View file

@ -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

View file

@ -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;
}
}

View file

@ -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<number>();
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<number> {
const ids = new Set<number>();
for (const line of lines) {
for (const id of extractKittyImageIds(line)) {
ids.add(id);
}
}
return ids;
}
private deleteKittyImages(ids: Iterable<number>): 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<number>();
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();
}
}
}

View file

@ -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";

View file

@ -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 {

View file

@ -257,6 +257,42 @@ export interface ImageCellSize {
rows: number;
}
export interface KittyImageMetadata extends ImageCellSize {
imageId: number;
widthPx: number;
heightPx: number;
}
const kittyImageMetadata = new Map<number, KittyImageMetadata>();
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;

View file

@ -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<RgbColor | undefined>;
queryTerminalColorScheme(options: { timeoutMs: number }): Promise<TerminalColorScheme | undefined>;
}
export abstract class TuiBase extends Container implements TUI {
public terminal: Terminal;
private previousLines: string[] = [];
private previousKittyImageIds = new Set<number>();
private previousWidth = 0;
private previousHeight = 0;
private focusedComponent: Component | null = null;
private inputListeners = new Set<InputListener>();
private inputListeners = new Set<TuiInputListener>();
/** 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<number> {
const ids = new Set<number>();
for (const line of lines) {
for (const id of extractKittyImageIds(line)) {
ids.add(id);
}
}
return ids;
}
private deleteKittyImages(ids: Iterable<number>): 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<number>();
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.

View file

@ -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

View file

@ -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(

View file

@ -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 */

View file

@ -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));

View file

@ -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);

View file

@ -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();

View file

@ -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);

View file

@ -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());

View file

@ -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"]));

View file

@ -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);

View file

@ -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();

View file

@ -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);

View file

@ -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 });

View file

@ -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"));
}
});
});

View file

@ -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);

View file

@ -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 });

View file

@ -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);

View file

@ -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();

View file

@ -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<void> => new Promise((resolve) => setTimeout(resolve, ms));
@ -52,7 +54,7 @@ async function streamLines(buffer: Lines, label: string, count: number, delayMs:
}
async function main(): Promise<void> {
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<void> {
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.