From 511909131cfaa5caf90f8c8e2c1a92da7870d320 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 15 Oct 2026 19:01:32 -0400 Subject: [PATCH 1/2] chore(pi-tui): sync vendored pi-tui to upstream v1.3.0 Three-way merge from v1.0.1 (a7229ddc) to v1.3.0 (71023aec). Keeps the fork divergences; the set of files that differ from upstream is unchanged. Adds Terminal.setProgramStatus stubs to app test terminals. --- .changeset/pi-tui-upstream-v1-3-0.md | 5 + .../dialogs/agent-activity-viewer.test.ts | 1 + .../dialogs/approval-preview.test.ts | 1 + .../test/tui/controllers/notify.test.ts | 1 + .../test/tui/task-output-viewer.test.ts | 1 + .../test/tui/tasks-browser.test.ts | 1 + .../test/tui/tui-frame.bench.ts | 1 + .../test/tui/utils/screen-takeover.test.ts | 1 + packages/pi-tui/README.md | 8 +- packages/pi-tui/UPSTREAM.md | 4 +- packages/pi-tui/src/components/box.ts | 5 + packages/pi-tui/src/components/editor.ts | 5 +- packages/pi-tui/src/components/input.ts | 15 +- packages/pi-tui/src/components/text.ts | 5 + packages/pi-tui/src/index.ts | 3 + packages/pi-tui/src/keybindings.ts | 8 +- packages/pi-tui/src/keys.ts | 92 +++++++++--- packages/pi-tui/src/program-status.ts | 53 +++++++ packages/pi-tui/src/stdin-buffer.ts | 42 +++++- packages/pi-tui/src/terminal-image.ts | 6 + packages/pi-tui/src/terminal.ts | 59 +++++++- packages/pi-tui/src/tui-alt-screen.ts | 14 +- packages/pi-tui/src/tui-main-screen.ts | 3 +- packages/pi-tui/src/tui.ts | 26 +++- packages/pi-tui/src/utils.ts | 9 +- .../test/alt-screen-large-transcript-bench.ts | 1 + packages/pi-tui/test/editor.test.ts | 12 +- packages/pi-tui/test/keybindings.test.ts | 8 +- packages/pi-tui/test/keys.test.ts | 14 ++ packages/pi-tui/test/program-status.test.ts | 51 +++++++ packages/pi-tui/test/render-churn-bench.ts | 1 + packages/pi-tui/test/stdin-buffer.test.ts | 131 +++++++++++++++++- packages/pi-tui/test/terminal-colors.test.ts | 25 ++++ packages/pi-tui/test/terminal-image.test.ts | 13 ++ packages/pi-tui/test/terminal.test.ts | 125 ++++++++++++++++- packages/pi-tui/test/tui-alt-screen.test.ts | 77 ++++++++-- packages/pi-tui/test/tui-render.test.ts | 74 +++++++++- packages/pi-tui/test/virtual-terminal.ts | 1 + 38 files changed, 830 insertions(+), 72 deletions(-) create mode 100644 .changeset/pi-tui-upstream-v1-3-0.md create mode 100644 packages/pi-tui/src/program-status.ts create mode 100644 packages/pi-tui/test/program-status.test.ts diff --git a/.changeset/pi-tui-upstream-v1-3-0.md b/.changeset/pi-tui-upstream-v1-3-0.md new file mode 100644 index 000000000..9e15c06db --- /dev/null +++ b/.changeset/pi-tui-upstream-v1-3-0.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Update the terminal UI library to upstream v1.3.0: in fullscreen mode, Ctrl+Home and Ctrl+End now scroll the transcript to the top and bottom, and Home and End move the cursor in the input box. diff --git a/apps/pythinker-code/test/tui/components/dialogs/agent-activity-viewer.test.ts b/apps/pythinker-code/test/tui/components/dialogs/agent-activity-viewer.test.ts index 5daa027df..5d8bbe120 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/agent-activity-viewer.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/agent-activity-viewer.test.ts @@ -37,6 +37,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal { clearScreen: () => {}, setTitle: () => {}, setProgress: () => {}, + setProgramStatus: () => {}, }; } diff --git a/apps/pythinker-code/test/tui/components/dialogs/approval-preview.test.ts b/apps/pythinker-code/test/tui/components/dialogs/approval-preview.test.ts index a53538673..dccf605ca 100644 --- a/apps/pythinker-code/test/tui/components/dialogs/approval-preview.test.ts +++ b/apps/pythinker-code/test/tui/components/dialogs/approval-preview.test.ts @@ -33,6 +33,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal { clearScreen: () => {}, setTitle: () => {}, setProgress: () => {}, + setProgramStatus: () => {}, }; } diff --git a/apps/pythinker-code/test/tui/controllers/notify.test.ts b/apps/pythinker-code/test/tui/controllers/notify.test.ts index c981f3cb6..6fa24656a 100644 --- a/apps/pythinker-code/test/tui/controllers/notify.test.ts +++ b/apps/pythinker-code/test/tui/controllers/notify.test.ts @@ -34,6 +34,7 @@ function makeHarness(enabled = true, fullscreen = false) { clearScreen: () => {}, setTitle: () => {}, setProgress: () => {}, + setProgramStatus: () => {}, }; const ui = fullscreen ? new TuiAltScreen(terminal) : new TuiMainScreen(terminal); const editor = new CustomEditor(ui); diff --git a/apps/pythinker-code/test/tui/task-output-viewer.test.ts b/apps/pythinker-code/test/tui/task-output-viewer.test.ts index 733bfd00a..69fd3216c 100644 --- a/apps/pythinker-code/test/tui/task-output-viewer.test.ts +++ b/apps/pythinker-code/test/tui/task-output-viewer.test.ts @@ -33,6 +33,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal { clearScreen: () => {}, setTitle: () => {}, setProgress: () => {}, + setProgramStatus: () => {}, }; } diff --git a/apps/pythinker-code/test/tui/tasks-browser.test.ts b/apps/pythinker-code/test/tui/tasks-browser.test.ts index 5195c43e6..b569c3a61 100644 --- a/apps/pythinker-code/test/tui/tasks-browser.test.ts +++ b/apps/pythinker-code/test/tui/tasks-browser.test.ts @@ -42,6 +42,7 @@ function fakeTerminal(rows: number, columns = 120): Terminal { clearScreen: () => {}, setTitle: () => {}, setProgress: () => {}, + setProgramStatus: () => {}, }; } diff --git a/apps/pythinker-code/test/tui/tui-frame.bench.ts b/apps/pythinker-code/test/tui/tui-frame.bench.ts index 9e4db900c..5a6036280 100644 --- a/apps/pythinker-code/test/tui/tui-frame.bench.ts +++ b/apps/pythinker-code/test/tui/tui-frame.bench.ts @@ -51,6 +51,7 @@ class StubTerminal implements Terminal { clearScreen(): void {} setTitle(): void {} setProgress(): void {} + setProgramStatus(): void {} } /** Returns the same array reference every frame, mirroring the app's cached message components. */ diff --git a/apps/pythinker-code/test/tui/utils/screen-takeover.test.ts b/apps/pythinker-code/test/tui/utils/screen-takeover.test.ts index 2735f4e9e..fc2ae6037 100644 --- a/apps/pythinker-code/test/tui/utils/screen-takeover.test.ts +++ b/apps/pythinker-code/test/tui/utils/screen-takeover.test.ts @@ -29,6 +29,7 @@ function stubTerminal(): Terminal { clearScreen: () => {}, setTitle: () => {}, setProgress: () => {}, + setProgramStatus: () => {}, }; } diff --git a/packages/pi-tui/README.md b/packages/pi-tui/README.md index e3b4c41bb..f0dcee6cb 100644 --- a/packages/pi-tui/README.md +++ b/packages/pi-tui/README.md @@ -258,7 +258,7 @@ The TUI appends a full SGR reset and OSC 8 reset at the end of each rendered lin Components that display a text cursor and need IME (Input Method Editor) support should implement the `Focusable` interface: ```typescript -import { CURSOR_MARKER, type Component, type Focusable } from "@pymodel/pi-tui"; +import { CURSOR_MARKER, type Component, type Focusable, renderFakeCursor } from "@pymodel/pi-tui"; class MyInput implements Component, Focusable { focused: boolean = false; // Set by TUI when focus changes @@ -266,7 +266,7 @@ class MyInput implements Component, Focusable { render(width: number): string[] { const marker = this.focused ? CURSOR_MARKER : ""; // Emit marker right before the fake cursor - return [`> ${beforeCursor}${marker}\x1b[7m${atCursor}\x1b[27m${afterCursor}`]; + return [`> ${beforeCursor}${marker}${renderFakeCursor(atCursor)}${afterCursor}`]; } invalidate(): void {} @@ -279,7 +279,9 @@ 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. +`renderFakeCursor()` wraps the cursor cell in zero-width markers. TUI renders them as reverse video. When `showHardwareCursor` is enabled, TUI removes the fake cursor that directly follows `CURSOR_MARKER`, so the focused component shows only the terminal cursor. Fake cursors without a preceding `CURSOR_MARKER` stay visible. + +The hardware 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 or `setShowHardwareCursor(true)`. 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: diff --git a/packages/pi-tui/UPSTREAM.md b/packages/pi-tui/UPSTREAM.md index f5f65772b..79ac46f96 100644 --- a/packages/pi-tui/UPSTREAM.md +++ b/packages/pi-tui/UPSTREAM.md @@ -8,8 +8,8 @@ Update this section after every sync. Do not reuse the previous range. - **Repo:** `https://github.com/earendil-works/pi.git` - **Subtree:** `packages/tui` -- **Commit:** `a7229ddc21810d6245105978033b7df645ecc2f7` (2026-10-03; upstream tag v1.0.1) -- **Darwin prebuilds:** rebuilt locally from `native/darwin/src/darwin-platform.m` with `native/darwin/build.sh`, not copied from upstream. Exports and linked libraries match the upstream v1.0.1 prebuilds. +- **Commit:** `71023aec1546d6d677a65669c5665c0927ce0dd0` (2026-10-15; upstream tag v1.3.0) +- **Darwin prebuilds:** rebuilt locally from `native/darwin/src/darwin-platform.m` with `native/darwin/build.sh`, not copied from upstream. Exports and linked libraries match the upstream v1.0.1 prebuilds. Upstream did not change the native sources or prebuilds between v1.0.1 and v1.3.0. - **This commit is an upstream marker.** It may not exist in this repo's object database. ## Reconstruct the fork diff --git a/packages/pi-tui/src/components/box.ts b/packages/pi-tui/src/components/box.ts index c70bd6003..185c78c89 100644 --- a/packages/pi-tui/src/components/box.ts +++ b/packages/pi-tui/src/components/box.ts @@ -50,6 +50,11 @@ export class Box implements Component { // Don't invalidate here - we'll detect bgFn changes by sampling output } + setPaddingX(paddingX: number): void { + this.paddingX = paddingX; + this.invalidateCache(); + } + private invalidateCache(): void { this.cache = undefined; } diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index 4af2f2b12..65162a1d1 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -7,6 +7,7 @@ import { type Component, CURSOR_MARKER, type Focusable, + renderFakeCursor, type TUI, type TuiMouseEvent, type TuiMouseEventResult, @@ -682,12 +683,12 @@ export class Editor implements Component, Focusable { const afterGraphemes = [...this.segment(after, "grapheme")]; const firstGrapheme = afterGraphemes[0]?.segment || ""; const restAfter = after.slice(firstGrapheme.length); - const cursor = `\x1b[7m${firstGrapheme}\x1b[0m`; + const cursor = renderFakeCursor(firstGrapheme); displayText = before + marker + cursor + restAfter; // lineVisibleWidth stays the same - we're replacing, not adding } else { // Cursor is at the end - add highlighted space - const cursor = "\x1b[7m \x1b[0m"; + const cursor = renderFakeCursor(" "); displayText = before + marker + cursor; lineVisibleWidth = lineVisibleWidth + 1; // If cursor overflows content width into the padding, flag it diff --git a/packages/pi-tui/src/components/input.ts b/packages/pi-tui/src/components/input.ts index b33173807..d4872975b 100644 --- a/packages/pi-tui/src/components/input.ts +++ b/packages/pi-tui/src/components/input.ts @@ -1,7 +1,14 @@ import { getKeybindings } from "../keybindings.ts"; import { decodeKittyPrintable } from "../keys.ts"; import { KillRing } from "../kill-ring.ts"; -import { type Component, CURSOR_MARKER, type Focusable, type TuiMouseEvent, type TuiMouseEventResult } from "../tui.ts"; +import { + type Component, + CURSOR_MARKER, + type Focusable, + renderFakeCursor, + type TuiMouseEvent, + type TuiMouseEventResult, +} from "../tui.ts"; import { UndoStack } from "../undo-stack.ts"; import { getGraphemeSegmenter, isWhitespaceChar, sliceByColumn, truncateToWidth, visibleWidth } from "../utils.ts"; import { findWordBackward, findWordForward } from "../word-navigation.ts"; @@ -423,7 +430,7 @@ export class Input implements Component, Focusable { const atCursor = graphemes[0]?.segment ?? " "; const afterCursor = placeholder.slice(atCursor.length); const marker = this.focused ? CURSOR_MARKER : ""; - const cursorChar = `\x1b[7m${this.placeholderStyle(atCursor)}\x1b[27m`; + const cursorChar = renderFakeCursor(this.placeholderStyle(atCursor)); const textWithCursor = marker + cursorChar + this.placeholderStyle(afterCursor); const padding = " ".repeat(Math.max(0, availableWidth - visibleWidth(textWithCursor))); return [this.prompt + textWithCursor + padding]; @@ -487,8 +494,8 @@ export class Input implements Component, Focusable { // Hardware cursor marker (zero-width, emitted before fake cursor for IME positioning) const marker = this.focused ? CURSOR_MARKER : ""; - // Use inverse video to show cursor - const cursorChar = `\x1b[7m${atCursor}\x1b[27m`; // ESC[7m = reverse video, ESC[27m = normal + // TUI draws the fake cursor in reverse video, or omits it when the hardware cursor is shown + const cursorChar = renderFakeCursor(atCursor); const textWithCursor = beforeCursor + marker + cursorChar + afterCursor; // Calculate visual width diff --git a/packages/pi-tui/src/components/text.ts b/packages/pi-tui/src/components/text.ts index 6aaa36bb6..a397e1aa7 100644 --- a/packages/pi-tui/src/components/text.ts +++ b/packages/pi-tui/src/components/text.ts @@ -36,6 +36,11 @@ export class Text implements Component { this.cachedLines = undefined; } + setPaddingX(paddingX: number): void { + this.paddingX = paddingX; + this.invalidate(); + } + invalidate(): void { this.cachedText = undefined; this.cachedWidth = undefined; diff --git a/packages/pi-tui/src/index.ts b/packages/pi-tui/src/index.ts index 9372382d2..1372a7c1b 100644 --- a/packages/pi-tui/src/index.ts +++ b/packages/pi-tui/src/index.ts @@ -111,6 +111,8 @@ export { type RenderLatexOptions, renderLatex } from "./latex.ts"; // Native platform integration export { getNativeClipboard, type NativeClipboard } from "./native-platform.ts"; export { oklabToOkhslLightness } from "./oklab.ts"; +// Program status reporting (OSC 7501) +export { formatProgramStatus, type ProgramStatus } from "./program-status.ts"; // Input buffering for batch splitting export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations @@ -167,6 +169,7 @@ export { type OverlayMargin, type OverlayOptions, type OverlayUnfocusOptions, + renderFakeCursor, type SizeValue, type TUI, type TuiInputListener, diff --git a/packages/pi-tui/src/keybindings.ts b/packages/pi-tui/src/keybindings.ts index c46ac1304..6f10f0a0c 100644 --- a/packages/pi-tui/src/keybindings.ts +++ b/packages/pi-tui/src/keybindings.ts @@ -96,11 +96,11 @@ export const TUI_KEYBINDINGS = { description: "Move cursor word right", }, "tui.editor.cursorLineStart": { - defaultKeys: ["home", "ctrl+home", "ctrl+a"], + defaultKeys: ["home", "ctrl+a"], description: "Move to line start", }, "tui.editor.cursorLineEnd": { - defaultKeys: ["end", "ctrl+end", "ctrl+e"], + defaultKeys: ["end", "ctrl+e"], description: "Move to line end", }, "tui.editor.jumpForward": { @@ -205,8 +205,8 @@ export const TUI_KEYBINDINGS = { defaultKeys: "escape", description: "Close transcript search", }, - "tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" }, - "tui.altScreen.bottom": { defaultKeys: "end", description: "Scroll viewport to bottom" }, + "tui.altScreen.top": { defaultKeys: "ctrl+home", description: "Scroll viewport to top" }, + "tui.altScreen.bottom": { defaultKeys: "ctrl+end", description: "Scroll viewport to bottom" }, } as const satisfies KeybindingDefinitions; export interface KeybindingConflict { diff --git a/packages/pi-tui/src/keys.ts b/packages/pi-tui/src/keys.ts index 4d04cc801..0277cf9b9 100644 --- a/packages/pi-tui/src/keys.ts +++ b/packages/pi-tui/src/keys.ts @@ -289,6 +289,10 @@ const SYMBOL_KEYS = new Set([ "?", ]); +const SHIFTED_SYMBOL_KEYS = new Set("~!@#$%^&*()_+|{}:<>?"); + +const MODIFIER_NAMES = ["shift", "alt", "ctrl", "super"] as const; + const MODIFIERS = { shift: 1, alt: 2, @@ -365,6 +369,17 @@ function normalizeShiftedLetterIdentityCodepoint(codepoint: number, modifier: nu return codepoint; } +function getLogicalShiftedSymbolCandidate( + codepoint: number | undefined, + modifier: number, + allowedSymbols: ReadonlySet, +): { codepoint: number; modifier: number } | undefined { + if (codepoint === undefined || (modifier & MODIFIERS.shift) === 0) return undefined; + const normalizedCodepoint = normalizeKittyFunctionalCodepoint(codepoint); + if (!allowedSymbols.has(String.fromCharCode(normalizedCodepoint))) return undefined; + return { codepoint: normalizedCodepoint, modifier: modifier & ~MODIFIERS.shift }; +} + const LEGACY_KEY_SEQUENCES = { up: ["\x1b[A", "\x1bOA"], down: ["\x1b[B", "\x1bOB"], @@ -656,9 +671,6 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM const actualMod = parsed.modifier & ~LOCK_MASK; const expectedMod = expectedModifier & ~LOCK_MASK; - // Check if modifiers match - if (actualMod !== expectedMod) return false; - const normalizedCodepoint = normalizeShiftedLetterIdentityCodepoint( normalizeKittyFunctionalCodepoint(parsed.codepoint), parsed.modifier, @@ -668,8 +680,22 @@ function matchesKittySequence(data: string, expectedCodepoint: number, expectedM expectedModifier, ); - // Primary match: codepoint matches directly after normalizing functional keys - if (normalizedCodepoint === normalizedExpectedCodepoint) return true; + // Physical match: primary codepoint and every reported modifier match. + if (actualMod === expectedMod && normalizedCodepoint === normalizedExpectedCodepoint) return true; + + // Logical match: Kitty reports symbols such as "+" as Shift plus the physical "=" key. + // Bindings name the produced symbol, so consume that implicit Shift while preserving + // Ctrl, Alt, and Super. The physical "shift+=" spelling remains valid through the match above. + const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(parsed.shiftedKey, actualMod, SYMBOL_KEYS); + if ( + logicalShiftedSymbol && + (logicalShiftedSymbol.modifier & ~LOCK_MASK) === expectedMod && + logicalShiftedSymbol.codepoint === normalizeKittyFunctionalCodepoint(expectedCodepoint) + ) { + return true; + } + + if (actualMod !== expectedMod) return false; // Alternate match: use base layout key for non-Latin keyboard layouts. // This allows Ctrl+С (Cyrillic) to match Ctrl+c (Latin) when terminal reports @@ -764,13 +790,19 @@ function isDigitKey(key: string): boolean { } function matchesPrintableModifyOtherKeys(data: string, expectedKeycode: number, expectedModifier: number): boolean { - if (expectedModifier === 0) return false; const parsed = parseModifyOtherKeysSequence(data); - if (!parsed || parsed.modifier !== expectedModifier) return false; - return ( + if (!parsed) return false; + const physicalMatch = + parsed.modifier === expectedModifier && normalizeShiftedLetterIdentityCodepoint(parsed.codepoint, parsed.modifier) === - normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier) + normalizeShiftedLetterIdentityCodepoint(expectedKeycode, expectedModifier); + if (physicalMatch) return true; + const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate( + parsed.codepoint, + parsed.modifier, + SHIFTED_SYMBOL_KEYS, ); + return logicalShiftedSymbol?.modifier === expectedModifier && logicalShiftedSymbol.codepoint === expectedKeycode; } function formatKeyNameWithModifiers(keyName: string, modifier: number): string | undefined { @@ -788,15 +820,22 @@ function formatKeyNameWithModifiers(keyName: string, modifier: number): string | function parseKeyId( keyId: string, ): { key: string; ctrl: boolean; shift: boolean; alt: boolean; super: boolean } | null { - const parts = keyId.toLowerCase().split("+"); - const key = parts[parts.length - 1]; - if (!key) return null; + let key = keyId.toLowerCase(); + const modifiers = new Set<(typeof MODIFIER_NAMES)[number]>(); + while (true) { + const modifier = MODIFIER_NAMES.find((name) => key.startsWith(`${name}+`)); + if (!modifier) break; + if (modifiers.has(modifier)) return null; + modifiers.add(modifier); + key = key.slice(modifier.length + 1); + } + if (!key || (key.includes("+") && key !== "+")) return null; return { key, - ctrl: parts.includes("ctrl"), - shift: parts.includes("shift"), - alt: parts.includes("alt"), - super: parts.includes("super"), + ctrl: modifiers.has("ctrl"), + shift: modifiers.has("shift"), + alt: modifiers.has("alt"), + super: modifiers.has("super"), }; } @@ -1197,7 +1236,9 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } // Check both raw char and Kitty sequence (needed for release events) - return data === key || matchesKittySequence(data, codepoint, 0); + return ( + data === key || matchesKittySequence(data, codepoint, 0) || matchesPrintableModifyOtherKeys(data, codepoint, 0) + ); } return false; @@ -1251,12 +1292,25 @@ function formatParsedKey(codepoint: number, modifier: number, baseLayoutKey?: nu export function parseKey(data: string): string | undefined { const kitty = parseKittySequence(data); if (kitty) { - return formatParsedKey(kitty.codepoint, kitty.modifier, kitty.baseLayoutKey); + const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate(kitty.shiftedKey, kitty.modifier, SYMBOL_KEYS); + return formatParsedKey( + logicalShiftedSymbol?.codepoint ?? kitty.codepoint, + logicalShiftedSymbol?.modifier ?? kitty.modifier, + kitty.baseLayoutKey, + ); } const modifyOtherKeys = parseModifyOtherKeysSequence(data); if (modifyOtherKeys) { - return formatParsedKey(modifyOtherKeys.codepoint, modifyOtherKeys.modifier); + const logicalShiftedSymbol = getLogicalShiftedSymbolCandidate( + modifyOtherKeys.codepoint, + modifyOtherKeys.modifier, + SHIFTED_SYMBOL_KEYS, + ); + return formatParsedKey( + logicalShiftedSymbol?.codepoint ?? modifyOtherKeys.codepoint, + logicalShiftedSymbol?.modifier ?? modifyOtherKeys.modifier, + ); } // Mode-aware legacy sequences diff --git a/packages/pi-tui/src/program-status.ts b/packages/pi-tui/src/program-status.ts new file mode 100644 index 000000000..3b71e268d --- /dev/null +++ b/packages/pi-tui/src/program-status.ts @@ -0,0 +1,53 @@ +/** + * Program Status Protocol (OSC 7501): a program tells the terminal whether it is idle, working, + * blocked on the user, done, or failed. Only the root record is supported. + * + * Spec: https://www.superlogical.com/rex/docs/build/program-status + */ + +export interface ProgramStatus { + /** `clear` removes the status instead of reporting one. */ + state: "idle" | "working" | "blocked" | "done" | "error" | "clear"; + /** Stable program name, `[A-Za-z0-9_.+-]{1,32}`. Other values are omitted. */ + app?: string; + /** What a blocked program waits for. Omitted for other states. */ + kind?: "permission" | "question" | "auth"; + /** One human-readable line. Control characters become spaces; longer text is cut to the spec limit. */ + message?: string; +} + +/** Feature detection query. A supporting terminal replies with the same body. */ +export const PROGRAM_STATUS_QUERY = "\x1b]7501;?\x1b\\"; + +/** The reply to {@link PROGRAM_STATUS_QUERY}. Later spec revisions may add pairs after the `?`. */ +export function isProgramStatusReply(sequence: string): boolean { + return /^\x1b\]7501;\?[^\x07\x1b]*(?:\x07|\x1b\\)$/.test(sequence); +} + +const APP_PATTERN = /^[A-Za-z0-9_.+-]{1,32}$/; +const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]+/g; +/** Decoded `msg` limit. Its base64 encoding stays under the 2732-byte encoded limit. */ +const MAX_MESSAGE_BYTES = 2048; + +function truncateUtf8(text: string, maxBytes: number): string { + if (Buffer.byteLength(text, "utf8") <= maxBytes) return text; + let bytes = 0; + let end = 0; + for (const char of text) { + const size = Buffer.byteLength(char, "utf8"); + if (bytes + size > maxBytes) break; + bytes += size; + end += char.length; + } + return text.slice(0, end); +} + +/** Encode a status report. Terminals discard reports whose text contains control characters, so they are replaced. */ +export function formatProgramStatus(status: ProgramStatus): string { + const pairs = [`state=${status.state}`]; + if (status.app !== undefined && APP_PATTERN.test(status.app)) pairs.push(`app=${status.app}`); + if (status.state === "blocked" && status.kind) pairs.push(`kind=${status.kind}`); + const message = truncateUtf8((status.message ?? "").replace(CONTROL_CHARACTERS, " ").trim(), MAX_MESSAGE_BYTES); + if (message) pairs.push(`msg=${Buffer.from(message, "utf8").toString("base64")}`); + return `\x1b]7501;${pairs.join(":")}\x1b\\`; +} diff --git a/packages/pi-tui/src/stdin-buffer.ts b/packages/pi-tui/src/stdin-buffer.ts index a5b4b07e2..9e917f0b5 100644 --- a/packages/pi-tui/src/stdin-buffer.ts +++ b/packages/pi-tui/src/stdin-buffer.ts @@ -24,6 +24,13 @@ const DEFAULT_SEQUENCE_TIMEOUT_MS = 50; const DEFAULT_ESCAPE_TIMEOUT_MS = 10; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; +// Color and program-status OSC replies, XTVersion, and Kitty graphics replies. +// Until their header is complete, the input may be batched legacy Alt keystrokes. +const TERMINAL_REPLY_HEADER_PATTERN = /^\x1b(?:\](?:1[01];|4;\d{1,3};|7501;\?)|P>\||_G(?:[a-z]=\d+,)*[a-z]=\d+;)/; + +function isControlString(data: string): boolean { + return data.startsWith(`${ESC}]`) || data.startsWith(`${ESC}P`) || data.startsWith(`${ESC}_`); +} /** * Check if a string is a complete escape sequence or needs more data @@ -200,9 +207,24 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain // Try to extract a sequence starting at this position if (remaining.startsWith(ESC)) { + const terminalReply = isControlString(remaining) && TERMINAL_REPLY_HEADER_PATTERN.test(remaining); // Find the end of this escape sequence let seqEnd = 1; while (seqEnd <= remaining.length) { + if (terminalReply && seqEnd > 2) { + const char = remaining[seqEnd - 1]; + if (char === "\x18" || char === "\x1a") { + // CAN and SUB cancel the string, not subsequent keyboard input. + pos += seqEnd; + break; + } + if (char === ESC && seqEnd < remaining.length && remaining[seqEnd] !== "\\") { + // A new escape sequence aborts an unterminated reply. Keep a + // trailing ESC buffered until we can distinguish it from ST. + pos += seqEnd - 1; + break; + } + } const candidate = remaining.slice(0, seqEnd); const status = isCompleteSequence(candidate); @@ -258,8 +280,10 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain export type StdinBufferOptions = { /** - * Maximum time to wait for an incomplete sequence such as CSI or mouse - * (default: 50ms). + * Maximum time to wait for an incomplete keyboard or mouse sequence + * (default: 50ms). Strings with a recognized terminal reply header wait for + * their terminator instead, so delayed replies cannot become keypresses. + * Ambiguous legacy Alt-key input still uses this timeout. */ timeout?: number; /** @@ -385,12 +409,24 @@ export class StdinBuffer extends EventEmitter { } if (this.buffer.length > 0) { + // Only a recognizable reply header establishes a delimited string. + // Flushing it would turn a delayed tail into text and an OSC's BEL + // terminator into Ctrl+G (the external editor shortcut). Other input, + // including Alt+] followed by text, must retain bounded recovery. + if (TERMINAL_REPLY_HEADER_PATTERN.test(this.buffer)) return; const timeoutMs = this.buffer === ESC ? this.escapeTimeoutMs : this.timeoutMs; this.timeout = setTimeout(() => { const flushed = this.flush(); for (const sequence of flushed) { - this.emitDataSequence(sequence); + if (sequence.length > 2 && isControlString(sequence)) { + // Recover the Alt key and reparse its tail so batched text, + // Enter, and control keys are not swallowed as one event. + this.emitDataSequence(sequence.slice(0, 2)); + this.process(sequence.slice(2)); + } else { + this.emitDataSequence(sequence); + } } }, timeoutMs); } diff --git a/packages/pi-tui/src/terminal-image.ts b/packages/pi-tui/src/terminal-image.ts index 78da5f381..24cb11d9a 100644 --- a/packages/pi-tui/src/terminal-image.ts +++ b/packages/pi-tui/src/terminal-image.ts @@ -86,6 +86,12 @@ function detectCapabilitiesFromEnvironment(tmuxForwardsHyperlink: () => boolean) return { images: null, trueColor: hasTrueColorHint, hyperlinks: false }; } + // Herdr forwards OSC 8 hyperlinks and supports the Kitty graphics protocol. Check it before + // outer-terminal detection because variables like KITTY_WINDOW_ID can leak into its panes. + if (termProgram === "herdr") { + return { images: "kitty", trueColor: hasTrueColorHint, hyperlinks: true }; + } + if (process.env['KITTY_WINDOW_ID'] || termProgram === "kitty") { return { images: "kitty", trueColor: true, hyperlinks: true }; } diff --git a/packages/pi-tui/src/terminal.ts b/packages/pi-tui/src/terminal.ts index 5f83e4763..d921fd110 100644 --- a/packages/pi-tui/src/terminal.ts +++ b/packages/pi-tui/src/terminal.ts @@ -3,6 +3,12 @@ import * as path from "node:path"; import { setKittyProtocolActive } from "./keys.ts"; import { isNativeModifierPressed } from "./native-modifiers.ts"; import { getNativePlatformHelper } from "./native-platform.ts"; +import { + formatProgramStatus, + isProgramStatusReply, + PROGRAM_STATUS_QUERY, + type ProgramStatus, +} from "./program-status.ts"; import { StdinBuffer } from "./stdin-buffer.ts"; const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; @@ -11,7 +17,8 @@ const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0\x07"; const NATIVE_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; const DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS = 7; const KEYBOARD_PROTOCOL_RESPONSE_FRAGMENT_TIMEOUT_MS = 150; -const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u\x1b[c`; +const KITTY_KEYBOARD_PROTOCOL_QUERY = `\x1b[>${DESIRED_KITTY_KEYBOARD_PROTOCOL_FLAGS}u\x1b[?u`; +const DEVICE_ATTRIBUTES_QUERY = "\x1b[c"; export type KeyboardProtocolNegotiationSequence = | { type: "kitty-flags"; flags: number } @@ -110,6 +117,12 @@ export interface Terminal { // Progress indicator (OSC 9;4) setProgress(active: boolean): void; + + /** + * Report what the program is doing (OSC 7501). Sent only to terminals that support it; the latest + * status is re-sent when support is confirmed or the terminal restarts. + */ + setProgramStatus(status: ProgramStatus): void; } const DEFAULT_ESCAPE_TIMEOUT_MS = 10; @@ -148,6 +161,12 @@ export class ProcessTerminal implements Terminal { private stdinBuffer?: StdinBuffer; private stdinDataHandler?: (data: string) => void; private progressInterval?: ReturnType; + /** Latest program status, kept across stop() so start() can report it again. */ + private programStatus?: ProgramStatus; + /** Whether the terminal confirmed OSC 7501 support since the last start(), or `PI_PROGRAM_STATUS=1`. */ + private programStatusSupported = false; + /** The support query was sent and its DA1 sentinel has not arrived yet. */ + private programStatusQueryPending = false; private writeLogPath = (() => { const env = process.env['PI_TUI_WRITE_LOG'] || ""; if (!env) return ""; @@ -217,6 +236,14 @@ export class ProcessTerminal implements Terminal { // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { + if (isProgramStatusReply(sequence)) { + if (this.programStatusQueryPending) { + this.programStatusQueryPending = false; + this.programStatusSupported = true; + this.writeProgramStatus(); + } + return; + } const negotiation = this.readKeyboardProtocolNegotiationSequence(sequence); if (negotiation === "pending") { this.scheduleKeyboardProtocolNegotiationBufferFlush(); @@ -254,6 +281,9 @@ export class ProcessTerminal implements Terminal { * - 1 = disambiguate escape codes * - 2 = report event types (press/repeat/release) * - 4 = report alternate keys (shifted key, base layout key) + * + * The OSC 7501 program status query shares the DA sentinel: a terminal that supports it replies + * before DA. `PI_PROGRAM_STATUS=1` or `0` skips the query. */ private queryAndEnableKittyProtocol(): void { this.setupStdinBuffer(); @@ -261,7 +291,12 @@ export class ProcessTerminal implements Terminal { this.keyboardProtocolPushed = true; this.pendingKeyboardProtocolDeviceAttributes += 1; this.clearKeyboardProtocolNegotiationBuffer(); - process.stdout.write(KITTY_KEYBOARD_PROTOCOL_QUERY); + const programStatusOverride = process.env['PI_PROGRAM_STATUS']; + this.programStatusSupported = programStatusOverride === "1"; + this.programStatusQueryPending = programStatusOverride !== "1" && programStatusOverride !== "0"; + const programStatusQuery = this.programStatusQueryPending ? PROGRAM_STATUS_QUERY : ""; + process.stdout.write(`${KITTY_KEYBOARD_PROTOCOL_QUERY}${programStatusQuery}${DEVICE_ATTRIBUTES_QUERY}`); + this.writeProgramStatus(); } private handleKeyboardProtocolNegotiationSequence( @@ -271,6 +306,9 @@ export class ProcessTerminal implements Terminal { if (negotiationSequence.type === "device-attributes") { if (this.pendingKeyboardProtocolDeviceAttributes === 0) return false; this.pendingKeyboardProtocolDeviceAttributes -= 1; + // The last owed DA answers the latest query, which got no program status reply first. Earlier DA + // replies belong to queries from before a restart. + if (this.pendingKeyboardProtocolDeviceAttributes === 0) this.programStatusQueryPending = false; } if (negotiationSequence.type === "kitty-flags") { if (negotiationSequence.flags !== 0) { @@ -430,6 +468,12 @@ export class ProcessTerminal implements Terminal { if (this.clearProgressInterval()) { process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE); } + // Remove the status while stopped, for example after exit or while suspended. start() reports it again. + if (this.programStatusSupported && this.programStatus) { + process.stdout.write(formatProgramStatus({ state: "clear" })); + } + this.programStatusSupported = false; + this.programStatusQueryPending = false; // Disable bracketed paste mode process.stdout.write("\x1b[?2004l"); @@ -529,6 +573,17 @@ export class ProcessTerminal implements Terminal { process.stdout.write(`\x1b]0;${title}\x07`); } + setProgramStatus(status: ProgramStatus): void { + this.programStatus = status.state === "clear" ? undefined : status; + if (this.programStatusSupported) process.stdout.write(formatProgramStatus(status)); + } + + private writeProgramStatus(): void { + if (this.programStatusSupported && this.programStatus) { + process.stdout.write(formatProgramStatus(this.programStatus)); + } + } + setProgress(active: boolean): void { if (active) { // OSC 9;4;3 - indeterminate progress diff --git a/packages/pi-tui/src/tui-alt-screen.ts b/packages/pi-tui/src/tui-alt-screen.ts index 47eac04dc..86c13e746 100644 --- a/packages/pi-tui/src/tui-alt-screen.ts +++ b/packages/pi-tui/src/tui-alt-screen.ts @@ -319,6 +319,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { return this.copyTextToClipboard(text); } + /** Drop the text selection and multi-click history, e.g. before the host replaces the transcript. */ + resetTextSelection(): void { + this.clearTextSelection(); + this.lastClick = undefined; + } + /** The lines of the last rendered frame, one per terminal row, as written to the terminal. */ getScreenLines(): string[] { return [...this.previousScreen]; @@ -427,7 +433,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`); } else { const width = Math.max(1, this.terminal.columns); - const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + const documentLines = this.resolveFakeCursors(this.render(width)).map((line) => + line.replace(OSC133_ZONE_PREFIX, ""), + ); this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map( (line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)), ); @@ -741,7 +749,6 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (this.applyMouseDispatchResult(event, result)) this.requestRender(); return { consume: true }; } - if (this.shouldDeferViewportInputToOverlay()) return undefined; this.routeWheel(wheelEvent, wheelDelta); return { consume: true }; } @@ -1767,7 +1774,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { throw new Error(`Layout effects did not stabilize after ${MAX_LAYOUT_EFFECT_PASSES} passes`); } } - let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + // Resolve fake cursors before highlighting and compositing, which only track SGR codes + let screen = this.resolveFakeCursors(nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, ""))); screen = this.applySearchHighlights(screen, nextLayout); screen = this.compositeScrollToEndIndicator(screen, nextLayout, width); screen = this.compositeOverlays(screen, width, height); diff --git a/packages/pi-tui/src/tui-main-screen.ts b/packages/pi-tui/src/tui-main-screen.ts index d5bd928f6..5088833ef 100644 --- a/packages/pi-tui/src/tui-main-screen.ts +++ b/packages/pi-tui/src/tui-main-screen.ts @@ -279,7 +279,8 @@ export class TuiMainScreen extends TuiBase implements TUI { }; // Render all components to get new lines - let newLines = this.render(width); + // Resolve fake cursors before compositing so overlays and line slicing see plain SGR codes + let newLines = this.resolveFakeCursors(this.render(width)); // Composite overlays into the rendered lines (before differential compare) if (this.hasOverlayEntries) { diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index 7cd7f722e..2a969c0a6 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -197,6 +197,17 @@ export function isFocusable(component: Component | null): component is Component */ export const CURSOR_MARKER = "\x1b_pi:c\x07"; +/** Fake cursor markers - zero-width APC sequences around the text drawn as the cursor. See resolveFakeCursors(). */ +export const FAKE_CURSOR_START = "\x1b_pi:fc\x07"; +export const FAKE_CURSOR_END = "\x1b_pi:/fc\x07"; +// CURSOR_MARKER + FAKE_CURSOR_START + text + FAKE_CURSOR_END. Truncating the line right after the cursor cell drops the end. +const FOCUSED_FAKE_CURSOR = /(\x1b_pi:c\x07)\x1b_pi:fc\x07(.*?)(?:\x1b_pi:\/fc\x07|$)/s; + +/** Wrap text in fake cursor markers. */ +export function renderFakeCursor(text: string): string { + return `${FAKE_CURSOR_START}${text}${FAKE_CURSOR_END}`; +} + export { visibleWidth }; /** @@ -1369,7 +1380,7 @@ export abstract class TuiBase extends Container implements TUI { const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight); // Render component at calculated width - let overlayLines = component.render(width); + let overlayLines = this.resolveFakeCursors(component.render(width)); // Apply maxHeight if specified if (maxHeight !== undefined && overlayLines.length > maxHeight) { @@ -1469,6 +1480,19 @@ export abstract class TuiBase extends Container implements TUI { return null; } + /** + * Resolve fake cursor markers to reverse video. When the hardware cursor is shown, a fake cursor + * directly after CURSOR_MARKER is dropped because the terminal cursor is drawn there. Fake cursors + * of components without focus have no CURSOR_MARKER and stay visible. + */ + protected resolveFakeCursors(lines: string[]): string[] { + return lines.map((line) => { + if (!line.includes(FAKE_CURSOR_START)) return line; + const resolved = this.showHardwareCursor ? line.replace(FOCUSED_FAKE_CURSOR, "$1$2") : line; + return resolved.replaceAll(FAKE_CURSOR_START, "\x1b[7m").replaceAll(FAKE_CURSOR_END, "\x1b[27m"); + }); + } + /** * Query the terminal's theme colors: the default foreground (OSC 10), the default background * (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the diff --git a/packages/pi-tui/src/utils.ts b/packages/pi-tui/src/utils.ts index 245023bc7..345cc4bff 100644 --- a/packages/pi-tui/src/utils.ts +++ b/packages/pi-tui/src/utils.ts @@ -1377,8 +1377,13 @@ export function extractSegments( // Include ANSI codes in their respective segments if (currentCol < beforeEnd) { pendingAnsiBefore += ansi.code; - } else if (currentCol >= afterStart && currentCol < afterEnd && afterStarted) { - // Only include after we've started "after" (styling already prepended) + } else if ( + currentCol >= afterStart && + currentCol < afterEnd && + (afterStarted || ansi.code.startsWith("\x1b_")) + ) { + // SGR codes are only included after "after" started (styling already prepended). Zero-width APC + // markers like CURSOR_MARKER are not tracked styling, so keep them at the boundary too. after += ansi.code; } i += ansi.length; diff --git a/packages/pi-tui/test/alt-screen-large-transcript-bench.ts b/packages/pi-tui/test/alt-screen-large-transcript-bench.ts index 03df87543..de6a7958c 100644 --- a/packages/pi-tui/test/alt-screen-large-transcript-bench.ts +++ b/packages/pi-tui/test/alt-screen-large-transcript-bench.ts @@ -67,6 +67,7 @@ class NullTerminal implements Terminal { clearScreen(): void {} setTitle(_title: string): void {} setProgress(_active: boolean): void {} + setProgramStatus(): void {} } const userMarkdown = [ diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index 8691a8b24..3199338c6 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -7,9 +7,9 @@ import { stripVTControlCharacters } from "node:util"; import { type AutocompleteProvider, CombinedAutocompleteProvider } from "../src/autocomplete.ts"; import { Editor, wordWrapLine } from "../src/components/editor.ts"; import { PasteBurst } from "../src/paste-burst.ts"; -import type { TUI } from "../src/tui.ts"; +import { FAKE_CURSOR_START, renderFakeCursor, type TUI } from "../src/tui.ts"; import { TuiMainScreen } from "../src/tui-main-screen.ts"; -import { visibleWidth } from "../src/utils.ts"; +import { stripTerminalSequences, visibleWidth } from "../src/utils.ts"; import { defaultEditorTheme } from "./test-themes.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; @@ -1080,7 +1080,7 @@ describe("Editor component", () => { } // Verify content split correctly - const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim()); + const contentLines = lines.slice(1, -1).map((l) => stripTerminalSequences(l).trim()); assert.strictEqual(contentLines.length, 2); assert.strictEqual(contentLines[0], "\u65e5\u672c\u8a9eテス"); // 5 chars = 10 columns assert.strictEqual(contentLines[1], "ト"); // 1 char = 2 columns (+ padding) @@ -1112,7 +1112,7 @@ describe("Editor component", () => { // The cursor (reverse video space) should be visible const contentLine = lines[1]!; - assert.ok(contentLine.includes("\x1b[7m"), "Should have reverse video cursor"); + assert.ok(contentLine.includes(FAKE_CURSOR_START), "Should have fake cursor"); // Line should still be correct width assert.strictEqual(visibleWidth(contentLine), width); @@ -1143,7 +1143,7 @@ describe("Editor component", () => { let lines = editor.render(width + paddingX); let contentLines = lines.slice(1, -1); assert.strictEqual(contentLines.length, 1, "Should be 1 content line before wrap"); - assert.ok(contentLines[0]!.endsWith("\x1b[7m \x1b[0m"), "Cursor should be at end of line"); + assert.ok(contentLines[0]!.endsWith(renderFakeCursor(" ")), "Cursor should be at end of line"); // Type 1 more → text wraps to second line editor.handleInput("a"); @@ -1163,7 +1163,7 @@ describe("Editor component", () => { const lines = editor.render(width); // Get content lines (between borders) - const contentLines = lines.slice(1, -1).map((l) => stripVTControlCharacters(l).trim()); + const contentLines = lines.slice(1, -1).map((l) => stripTerminalSequences(l).trim()); // Should NOT break mid-word // Line 1 should end with a complete word diff --git a/packages/pi-tui/test/keybindings.test.ts b/packages/pi-tui/test/keybindings.test.ts index b0c827dde..a68644c54 100644 --- a/packages/pi-tui/test/keybindings.test.ts +++ b/packages/pi-tui/test/keybindings.test.ts @@ -14,8 +14,8 @@ describe("KeybindingsManager", () => { it("binds modified and unmodified editor viewport navigation", () => { const keybindings = new KeybindingsManager(TUI_KEYBINDINGS); - assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineStart"), ["home", "ctrl+home", "ctrl+a"]); - assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineEnd"), ["end", "ctrl+end", "ctrl+e"]); + assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineStart"), ["home", "ctrl+a"]); + assert.deepStrictEqual(keybindings.getKeys("tui.editor.cursorLineEnd"), ["end", "ctrl+e"]); assert.deepStrictEqual(keybindings.getKeys("tui.editor.pageUp"), ["pageUp", "ctrl+pageUp"]); assert.deepStrictEqual(keybindings.getKeys("tui.editor.pageDown"), ["pageDown", "ctrl+pageDown"]); }); @@ -42,8 +42,8 @@ describe("KeybindingsManager", () => { assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchNext"), ["enter", "ctrl+g"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchPrevious"), ["shift+enter", "ctrl+shift+g"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.searchClose"), ["escape"]); - assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]); - assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["ctrl+home"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["ctrl+end"]); }); it("does not evict selector confirm when input submit is rebound", () => { diff --git a/packages/pi-tui/test/keys.test.ts b/packages/pi-tui/test/keys.test.ts index 68f594359..0045610bc 100644 --- a/packages/pi-tui/test/keys.test.ts +++ b/packages/pi-tui/test/keys.test.ts @@ -134,6 +134,16 @@ describe("matchesKey", () => { setKittyProtocolActive(false); }); + it("should match shifted symbols by their produced character", () => { + setKittyProtocolActive(true); + assert.strictEqual(matchesKey("\x1b[61:43;2u", "+"), true); + assert.strictEqual(matchesKey("\x1b[61:43;2u", "shift+="), true); + assert.strictEqual(parseKey("\x1b[61:43;2u"), "+"); + assert.strictEqual(matchesKey("\x1b[61:43;6u", "ctrl++"), true); + assert.strictEqual(parseKey("\x1b[61:43;6u"), "ctrl++"); + setKittyProtocolActive(false); + }); + it("should handle shifted key in format", () => { setKittyProtocolActive(true); // Format with shifted key: CSI codepoint:shifted:base;modifier u @@ -265,6 +275,8 @@ describe("matchesKey", () => { setKittyProtocolActive(false); assert.strictEqual(matchesKey("\x1b[27;5;47~", "ctrl+/"), true); assert.strictEqual(parseKey("\x1b[27;5;47~"), "ctrl+/"); + assert.strictEqual(matchesKey("\x1b[27;6;43~", "ctrl++"), true); + assert.strictEqual(parseKey("\x1b[27;6;43~"), "ctrl++"); }); it("should match xterm modifyOtherKeys digit combos", () => { @@ -600,6 +612,8 @@ describe("parseKey", () => { assert.strictEqual(parseKey(" "), "space"); assert.strictEqual(parseKey("1"), "1"); assert.strictEqual(matchesKey("1", "1"), true); + assert.strictEqual(parseKey("+"), "+"); + assert.strictEqual(matchesKey("+", "+"), true); }); it("should parse arrow keys", () => { diff --git a/packages/pi-tui/test/program-status.test.ts b/packages/pi-tui/test/program-status.test.ts new file mode 100644 index 000000000..a2a164c00 --- /dev/null +++ b/packages/pi-tui/test/program-status.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { formatProgramStatus, isProgramStatusReply } from "../src/program-status.ts"; + +function decodeMessage(sequence: string): string | undefined { + const message = /:msg=([A-Za-z0-9+/=]*)/.exec(sequence)?.[1]; + return message === undefined ? undefined : Buffer.from(message, "base64").toString("utf8"); +} + +// #10607 +describe("formatProgramStatus", () => { + it("encodes state, app, kind, and a base64 message", () => { + assert.equal( + formatProgramStatus({ state: "blocked", app: "pi", kind: "permission", message: "Allow bash?" }), + `\x1b]7501;state=blocked:app=pi:kind=permission:msg=${Buffer.from("Allow bash?").toString("base64")}\x1b\\`, + ); + assert.equal(formatProgramStatus({ state: "clear" }), "\x1b]7501;state=clear\x1b\\"); + }); + + it("omits kind outside blocked, invalid app names, and empty messages", () => { + assert.equal( + formatProgramStatus({ state: "working", app: "my app", kind: "auth", message: " \n " }), + "\x1b]7501;state=working\x1b\\", + ); + assert.match(formatProgramStatus({ state: "idle", app: "a".repeat(32) }), /:app=a{32}\x1b/); + assert.doesNotMatch(formatProgramStatus({ state: "idle", app: "a".repeat(33) }), /app=/); + }); + + it("replaces control characters, which make terminals discard the report", () => { + const sequence = formatProgramStatus({ state: "error", message: "first\nsecond\x1b[31m\u009bthird\t" }); + assert.equal(decodeMessage(sequence), "first second [31m third"); + }); + + it("cuts long messages at a UTF-8 boundary within the spec limits", () => { + const sequence = formatProgramStatus({ state: "working", app: "pi", message: "é".repeat(2000) }); + const message = decodeMessage(sequence)!; + assert.equal(message, "é".repeat(1024)); + assert.ok(Buffer.byteLength(message) <= 2048); + assert.ok(sequence.length <= 4096); + }); +}); + +describe("isProgramStatusReply", () => { + it("accepts the query echo with either terminator and future pairs", () => { + assert.equal(isProgramStatusReply("\x1b]7501;?\x1b\\"), true); + assert.equal(isProgramStatusReply("\x1b]7501;?\x07"), true); + assert.equal(isProgramStatusReply("\x1b]7501;?version=2\x1b\\"), true); + assert.equal(isProgramStatusReply("\x1b]7501;state=idle\x1b\\"), false); + assert.equal(isProgramStatusReply("\x1b]11;rgb:0000/0000/0000\x07"), false); + }); +}); diff --git a/packages/pi-tui/test/render-churn-bench.ts b/packages/pi-tui/test/render-churn-bench.ts index 103c9561b..6bf712560 100644 --- a/packages/pi-tui/test/render-churn-bench.ts +++ b/packages/pi-tui/test/render-churn-bench.ts @@ -57,6 +57,7 @@ class NullTerminal implements Terminal { clearScreen(): void {} setTitle(_title: string): void {} setProgress(_active: boolean): void {} + setProgramStatus(): void {} } /** Editor stand-in: caches lines per (text, width), re-renders when text changes. */ diff --git a/packages/pi-tui/test/stdin-buffer.test.ts b/packages/pi-tui/test/stdin-buffer.test.ts index 9e423cb91..be3531cad 100644 --- a/packages/pi-tui/test/stdin-buffer.test.ts +++ b/packages/pi-tui/test/stdin-buffer.test.ts @@ -6,7 +6,7 @@ */ import assert from "node:assert"; -import { beforeEach, describe, it } from "node:test"; +import { afterEach, beforeEach, describe, it } from "node:test"; import { matchesKey } from "../src/keys.ts"; import { StdinBuffer } from "../src/stdin-buffer.ts"; @@ -24,6 +24,8 @@ describe("StdinBuffer", () => { }); }); + afterEach(() => buffer.destroy()); + // Helper to process data through the buffer function processInput(data: string | Buffer): void { buffer.process(data); @@ -192,6 +194,133 @@ describe("StdinBuffer", () => { }); }); + describe("Terminal Reply Strings", () => { + const replies = [ + { header: "\x1b]4;1;", reply: "\x1b]4;1;rgb:eaea/6c6c/6d6d\x07" }, // Ghostty's Ayu Light palette color 1. + { header: "\x1b]10;", reply: "\x1b]10;rgb:ffff/ffff/ffff\x1b\\" }, + { header: "\x1b]11;", reply: "\x1b]11;rgb:0000/0000/0000\x07" }, + { header: "\x1b]4;1;", reply: "\x1b]4;1;rgb:6464/6464/4d4d\x07" }, // Another reported color-reply tail. + { header: "\x1bP>|", reply: "\x1bP>|Ghostty 1.3.1\x1b\\" }, + { header: "\x1b_Gi=1;", reply: "\x1b_Gi=1;OK\x1b\\" }, + { header: "\x1b]7501;?", reply: "\x1b]7501;?\x1b\\" }, + ]; + + for (const { header, reply } of replies) { + it(`keeps delayed ${JSON.stringify(reply)} intact at every split after its reply header`, (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + for (let split = header.length; split < reply.length; split++) { + const delayedBuffer = new StdinBuffer(); + const sequences: string[] = []; + delayedBuffer.on("data", (sequence) => sequences.push(sequence)); + try { + delayedBuffer.process(reply.slice(0, split)); + t.mock.timers.tick(5000); + delayedBuffer.process(`${reply.slice(split)}x`); + assert.equal( + sequences.some((sequence) => matchesKey(sequence, "ctrl+g")), + false, + ); + assert.deepStrictEqual(sequences, [reply, "x"]); + } finally { + delayedBuffer.destroy(); + } + } + }); + } + + it("keeps an OSC reply buffered across multiple expired sequence timeouts", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + processInput("\x1b]4;1;rgb:eaea/6c"); + t.mock.timers.tick(100); + assert.deepStrictEqual(emittedSequences, []); + processInput("6c/6d6d"); + t.mock.timers.tick(100); + assert.deepStrictEqual(emittedSequences, []); + processInput("\x07"); + assert.deepStrictEqual(emittedSequences, [replies[0].reply]); + }); + + it("still times out bare string introducers as Alt keys", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + for (const prefix of ["\x1b]", "\x1bP", "\x1b_"]) { + processInput(prefix); + t.mock.timers.tick(10); + } + assert.deepStrictEqual(emittedSequences, ["\x1b]", "\x1bP", "\x1b_"]); + }); + + for (const ambiguous of [ + "\x1b]a", + "\x1b]1", + "\x1b]10", + "\x1b]0;title", + "\x1b]4;", + "\x1b]4;a;", + "\x1bPa", + "\x1bP>", + "\x1b_a", + "\x1b_G", + "\x1b_Gi=1", + ]) { + it(`recovers batched legacy Alt input ${JSON.stringify(ambiguous)} within the sequence timeout`, (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const legacyBuffer = new StdinBuffer(); + const sequences: string[] = []; + legacyBuffer.on("data", (sequence) => sequences.push(sequence)); + try { + legacyBuffer.process(ambiguous); + t.mock.timers.tick(20); + legacyBuffer.process("x\r\x03\x04"); + t.mock.timers.tick(49); + assert.deepStrictEqual(sequences, []); + t.mock.timers.tick(1); + assert.deepStrictEqual(sequences, [ + ambiguous.slice(0, 2), + ...ambiguous.slice(2), + "x", + "\r", + "\x03", + "\x04", + ]); + assert.strictEqual(legacyBuffer.getBuffer(), ""); + + legacyBuffer.process("y\r\x03\x04"); + assert.deepStrictEqual(sequences.slice(-4), ["y", "\r", "\x03", "\x04"]); + } finally { + legacyBuffer.destroy(); + } + }); + } + + it("recognizes a reply header assembled within the ambiguous-input timeout", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + processInput("\x1b]1"); + t.mock.timers.tick(5); + processInput("0;"); + t.mock.timers.tick(5000); + assert.deepStrictEqual(emittedSequences, []); + processInput("rgb:ffff/ffff/ffff\x1b\\"); + assert.deepStrictEqual(emittedSequences, [replies[1].reply]); + }); + + it("recovers from an unterminated reply when a new escape sequence arrives", (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + processInput("\x1b]4;1;rgb:eaea/6c"); + t.mock.timers.tick(100); + processInput("\x1b"); + t.mock.timers.tick(100); + processInput("[Ahello"); + assert.deepStrictEqual(emittedSequences, ["\x1b[A", "h", "e", "l", "l", "o"]); + }); + + it("discards a canceled reply without swallowing subsequent input", () => { + for (const cancel of ["\x18", "\x1a"]) { + processInput(`\x1b]11;rgb:0000${cancel}x`); + } + assert.deepStrictEqual(emittedSequences, ["x", "x"]); + }); + }); + describe("Mixed Content", () => { it("should handle characters followed by escape sequence", () => { processInput("abc\x1b[A"); diff --git a/packages/pi-tui/test/terminal-colors.test.ts b/packages/pi-tui/test/terminal-colors.test.ts index 1f2b7013f..40e8f9d3e 100644 --- a/packages/pi-tui/test/terminal-colors.test.ts +++ b/packages/pi-tui/test/terminal-colors.test.ts @@ -8,6 +8,7 @@ import { type TUI, TuiMainScreen, } from "../src/index.ts"; +import { StdinBuffer } from "../src/stdin-buffer.ts"; import { parseOscColorResponse } from "../src/terminal-colors.ts"; class TestTerminal implements Terminal { @@ -65,6 +66,7 @@ class TestTerminal implements Terminal { setTitle(_title: string): void {} setProgress(_active: boolean): void {} + setProgramStatus(): void {} sendInput(data: string): void { this.inputHandler?.(data); @@ -158,6 +160,29 @@ describe("TUI.queryTerminalColors", () => { } }); + it("consumes delayed Ghostty color fragments without typing their tail or opening the editor", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + const { terminal, tui, component } = setup(); + const buffer = new StdinBuffer(); + buffer.on("data", (data) => terminal.sendInput(data)); + try { + const query = tui.queryTerminalColors({ timeoutMs: 1000 }); + // Captured from Ghostty 1.3.1 with Ayu Light, split at the reported tail. + buffer.process("\x1b]4;1;rgb:eaea/6c"); + t.mock.timers.tick(100); + buffer.process(`6c/6d6d\x07${DA1}`); + assert.deepStrictEqual(await query, { foreground: undefined, background: undefined, palette: undefined }); + assert.deepStrictEqual(component.inputs, []); + + // A real Ctrl+G must still reach the editor after the reply. + buffer.process("\x07"); + assert.deepStrictEqual(component.inputs, ["\x07"]); + } finally { + buffer.destroy(); + tui.stop(); + } + }); + it("resolves on DA1 with the replies that arrived, in query order", async () => { const { terminal, tui } = setup(); try { diff --git a/packages/pi-tui/test/terminal-image.test.ts b/packages/pi-tui/test/terminal-image.test.ts index 8ca552cbc..aa28c7537 100644 --- a/packages/pi-tui/test/terminal-image.test.ts +++ b/packages/pi-tui/test/terminal-image.test.ts @@ -317,6 +317,19 @@ describe("detectCapabilities", () => { }); }); + // #10573 + it("enables hyperlinks and Kitty images for Herdr", () => { + withEnv({ TERM_PROGRAM: "herdr", TERM: "xterm-256color", COLORTERM: "truecolor", KITTY_WINDOW_ID: "1" }, () => { + const caps = detectCapabilities(); + assert.strictEqual(caps.hyperlinks, true); + assert.strictEqual(caps.images, "kitty"); + assert.strictEqual(caps.trueColor, true); + }); + withEnv({ TERM_PROGRAM: "herdr", PI_HYPERLINKS: "0" }, () => { + assert.strictEqual(detectCapabilities().hyperlinks, false); + }); + }); + it("enables hyperlinks for Ghostty", () => { withEnv({ TERM_PROGRAM: "ghostty" }, () => { const caps = detectCapabilities(); diff --git a/packages/pi-tui/test/terminal.test.ts b/packages/pi-tui/test/terminal.test.ts index 79f4410f3..437994a38 100644 --- a/packages/pi-tui/test/terminal.test.ts +++ b/packages/pi-tui/test/terminal.test.ts @@ -132,7 +132,7 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { it("queries Kitty mode before enabling modifyOtherKeys fallback", () => { const harness = setupNegotiation(); try { - assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b]7501;?\x1b\\\x1b[c"); assert.equal(harness.writes.includes("\x1b[>4;2m"), false); assert.equal(harness.terminal.kittyProtocolActive, false); } finally { @@ -233,6 +233,129 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { } }); + // #10607 + describe("program status (OSC 7501)", () => { + const working = "\x1b]7501;state=working:app=pi\x1b\\"; + const clear = "\x1b]7501;state=clear\x1b\\"; + + function withOverride(value: string | undefined, fn: () => void): void { + const previous = process.env.PI_PROGRAM_STATUS; + if (value === undefined) delete process.env.PI_PROGRAM_STATUS; + else process.env.PI_PROGRAM_STATUS = value; + try { + fn(); + } finally { + if (previous === undefined) delete process.env.PI_PROGRAM_STATUS; + else process.env.PI_PROGRAM_STATUS = previous; + } + } + + it("reports the latest status once the terminal answers the query before DA", () => { + withOverride(undefined, () => { + const harness = setupNegotiation(); + try { + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + assert.equal(harness.writes.includes(working), false); + + harness.send("\x1b]7501;?\x1b\\"); + assert.equal(harness.getInput(), undefined); + assert.equal(harness.writes.filter((write) => write === working).length, 1); + harness.send("\x1b[?62;4;52c"); + + harness.terminal.setProgramStatus({ state: "done" }); + assert.equal(harness.writes.at(-1), "\x1b]7501;state=done\x1b\\"); + } finally { + harness.cleanup(); + } + }); + }); + + it("reports nothing when DA arrives first and swallows late replies", () => { + withOverride(undefined, () => { + const harness = setupNegotiation(); + try { + harness.send("\x1b[?62;4;52c"); + harness.send("\x1b]7501;?\x07"); + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + + assert.equal(harness.getInput(), undefined); + assert.equal( + harness.writes.some((write) => write.startsWith("\x1b]7501;state=")), + false, + ); + } finally { + harness.cleanup(); + } + }); + }); + + it("skips the query when PI_PROGRAM_STATUS is set", () => { + withOverride("1", () => { + const harness = setupNegotiation(); + try { + assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + assert.equal(harness.writes.at(-1), working); + } finally { + harness.cleanup(); + } + }); + withOverride("0", () => { + const harness = setupNegotiation(); + try { + assert.equal(harness.writes[0], "\x1b[>7u\x1b[?u\x1b[c"); + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + assert.equal(harness.writes.includes(working), false); + } finally { + harness.cleanup(); + } + }); + }); + + it("does not let a DA reply from before a restart end the new query", () => { + withOverride(undefined, () => { + const harness = setupNegotiation(); + try { + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + harness.terminal.stop(); + (harness.terminal as unknown as { queryAndEnableKittyProtocol(): void }).queryAndEnableKittyProtocol(); + + // The first start's replies arrive late: its DA, then the second start's reply and DA. + harness.send("\x1b[?62;4;52c"); + harness.send("\x1b]7501;?\x1b\\"); + harness.send("\x1b[?62;4;52c"); + assert.equal(harness.writes.at(-1), working); + assert.equal(harness.getInput(), undefined); + } finally { + harness.cleanup(); + } + }); + }); + + it("clears the status on stop and reports it again after restart", () => { + withOverride(undefined, () => { + const harness = setupNegotiation(); + try { + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + harness.send("\x1b]7501;?\x1b\\"); + harness.terminal.stop(); + assert.ok(harness.writes.includes(clear)); + + // Stopped: nothing is written until the restarted terminal confirms support again. + const writesBeforeRestart = harness.writes.length; + harness.terminal.setProgramStatus({ state: "working", app: "pi" }); + assert.equal(harness.writes.length, writesBeforeRestart); + + (harness.terminal as unknown as { queryAndEnableKittyProtocol(): void }).queryAndEnableKittyProtocol(); + harness.send("\x1b]7501;?\x1b\\"); + assert.equal(harness.writes.at(-1), working); + } finally { + harness.cleanup(); + } + }); + }); + }); + it("replays buffered CSI-prefix input when it is not a Kitty response", () => { mock.timers.enable({ apis: ["setTimeout"] }); const harness = setupNegotiation(); diff --git a/packages/pi-tui/test/tui-alt-screen.test.ts b/packages/pi-tui/test/tui-alt-screen.test.ts index eab198581..147798001 100644 --- a/packages/pi-tui/test/tui-alt-screen.test.ts +++ b/packages/pi-tui/test/tui-alt-screen.test.ts @@ -20,7 +20,7 @@ import { resetCapabilitiesCache, setCapabilities, } from "../src/terminal-image.ts"; -import type { TuiMouseEvent } from "../src/tui.ts"; +import { renderFakeCursor, type TuiMouseEvent } from "../src/tui.ts"; import { TuiAltScreen } from "../src/tui-alt-screen.ts"; import { stripTerminalSequences, visibleWidth } from "../src/utils.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; @@ -742,14 +742,14 @@ describe("TuiAltScreen", () => { ["line 5", "line 6", "line 7", "line 8", "line 9", "line 10", "line 11", "line 12"], ); - terminal.sendInput("\x1bOH"); + terminal.sendInput("\x1b[7^"); await terminal.waitForRender(); assert.deepStrictEqual( terminal.getViewport().map((line) => line.trimEnd()), ["line 1", "line 2", "line 3", "line 4", "line 5", "line 6", "line 7", "line 8"], ); - terminal.sendInput("\x1bOF"); + terminal.sendInput("\x1b[8^"); await terminal.waitForRender(); assert.deepStrictEqual( terminal.getViewport().map((line) => line.trimEnd()), @@ -1120,7 +1120,7 @@ describe("TuiAltScreen", () => { } }); - it("routes Ctrl-modified viewport navigation to the focused component", async () => { + it("routes Home and End to the focused component and Ctrl+Home/End to the transcript", async () => { const terminal = new VirtualTerminal(20, 6); const tui = new TuiAltScreen(terminal); const transcript = new ScrollView( @@ -1144,22 +1144,34 @@ describe("TuiAltScreen", () => { tui.start(); await terminal.waitForRender(); - terminal.sendInput("\x1bOH"); + const bottom = transcript.scrollTop; + assert.ok(bottom > 0); + + // #10314: unmodified Home/End belong to the editor in every UI mode. + const editorKeys = ["\x1bOH", "\x1b[F", "\x1b[57423u", "\x1b[5;5~", "\x1b[6;5~"]; + for (const input of editorKeys) terminal.sendInput(input); + await terminal.waitForRender(); + assert.strictEqual(transcript.scrollTop, bottom); + assert.deepStrictEqual(editorInputs, editorKeys); + + terminal.sendInput("\x1b[1;5H"); await terminal.waitForRender(); assert.strictEqual(transcript.scrollTop, 0); - assert.deepStrictEqual(editorInputs, []); - const modifiedInputs = ["\x1b[1;5H", "\x1b[1;5F", "\x1b[5;5~", "\x1b[6;5~", "\x1b[57423;5u"]; - for (const input of modifiedInputs) terminal.sendInput(input); + terminal.sendInput("\x1b[1;5F"); + await terminal.waitForRender(); + assert.strictEqual(transcript.scrollTop, bottom); + assert.strictEqual(transcript.isFollowingEnd, true); + + terminal.sendInput("\x1b[57423;5u"); terminal.sendInput("\x1b[57423;5:3u"); await terminal.waitForRender(); assert.strictEqual(transcript.scrollTop, 0); - assert.deepStrictEqual(editorInputs, modifiedInputs); terminal.sendInput("\x1b[6~"); await terminal.waitForRender(); assert.strictEqual(transcript.scrollTop, 1); - assert.deepStrictEqual(editorInputs, modifiedInputs); + assert.deepStrictEqual(editorInputs, editorKeys); tui.stop(); }); @@ -1572,6 +1584,26 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("keeps the selection highlight after a fake cursor cell", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(`> ${renderFakeCursor("a")}bcd`, 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<32;6;1M"); + terminal.sendInput("\x1b[<3;6;1m"); + await terminal.waitForRender(); + + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[27m\x1b[7mbcd")), + "selection inverse must be reapplied after the fake cursor ends", + ); + + tui.stop(); + }); + it("uses an injected copySelection handler instead of OSC 52 and reports success", async () => { const terminal = new RecordingTerminal(20, 4); const copied: string[] = []; @@ -2052,6 +2084,26 @@ describe("TuiAltScreen", () => { tui.stop(); }); + // #10769 + it("scrolls the transcript on wheel input a focused overlay does not handle", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + const overlay = new InputOverlay(); + tui.start(); + await terminal.waitForRender(); + const topBefore = tui.viewportTop; + tui.showOverlay(overlay, { anchor: "top-left", width: 10 }); + await terminal.waitForRender(); + assert.strictEqual(overlay.focused, true); + + terminal.sendInput("\x1b[<64;3;1M"); + await terminal.waitForRender(); + assert.ok(tui.viewportTop < topBefore); + assert.deepStrictEqual(overlay.inputs, []); + tui.stop(); + }); + it("focuses and captures drag gestures for mouse-aware components", async () => { const terminal = new VirtualTerminal(20, 2); const tui = new TuiAltScreen(terminal); @@ -2191,7 +2243,7 @@ describe("TuiAltScreen", () => { } }); - it("gives wheel and viewport keys to a focused overlay", async () => { + it("gives viewport keys to a focused overlay", async () => { const terminal = new VirtualTerminal(20, 6); const tui = new TuiAltScreen(terminal); tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); @@ -2203,8 +2255,7 @@ describe("TuiAltScreen", () => { await terminal.waitForRender(); assert.strictEqual(overlay.focused, true); - const wheel = "\x1b[<64;10;3M"; - const keys = ["\x1b[5~", "\x1b[6~", "\x1bOH", "\x1bOF", wheel]; + const keys = ["\x1b[5~", "\x1b[6~", "\x1bOH", "\x1bOF"]; for (const key of keys) terminal.sendInput(key); await terminal.waitForRender(); diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts index 697bc1092..c84b79069 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -6,6 +6,7 @@ import { describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Image } from "../src/components/image.ts"; import { Text } from "../src/components/text.ts"; +import { Input } from "../src/components/input.ts"; import type { Terminal } from "../src/terminal.ts"; import { deleteKittyImage, @@ -14,8 +15,9 @@ import { setCapabilities, setCellDimensions, } from "../src/terminal-image.ts"; -import { type Component, Container, type TUI } from "../src/tui.ts"; +import { type Component, Container, CURSOR_MARKER, renderFakeCursor, type TUI } from "../src/tui.ts"; import { TuiMainScreen } from "../src/tui-main-screen.ts"; +import { sliceByColumn } from "../src/utils.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class TestComponent implements Component { @@ -61,6 +63,7 @@ class BoundedWriteTerminal implements Terminal { clearScreen(): void {} setTitle(_title: string): void {} setProgress(_active: boolean): void {} + setProgramStatus(): void {} } class LoggingVirtualTerminal extends VirtualTerminal { @@ -1125,3 +1128,72 @@ describe("TUI steady-frame processed-line reuse", () => { } }); }); + +describe("TUI fake cursor", () => { + it("draws the focused fake cursor in reverse video and omits it when the hardware cursor is shown", async () => { + const terminal = new LoggingVirtualTerminal(20, 5); + const tui: TUI = new TuiMainScreen(terminal); + const input = new Input(); + input.setValue("abc"); + tui.addChild(input); + tui.setFocus(input); + tui.start(); + await terminal.waitForRender(); + assert.ok(terminal.getWrites().includes("> \x1b[7ma\x1b[27mbc")); + + terminal.clearWrites(); + tui.setShowHardwareCursor(true); + await terminal.waitForRender(); + assert.ok(!terminal.getWrites().includes("\x1b[7m")); + assert.ok(!terminal.getWrites().includes("\x1b_pi:")); + + tui.stop(); + }); + + it("keeps the fake cursor of an unfocused input when the hardware cursor is shown", async () => { + const terminal = new LoggingVirtualTerminal(20, 5); + const tui: TUI = new TuiMainScreen(terminal, true); + const input = new Input(); + input.setValue("abc"); + tui.addChild(input); + tui.start(); + await terminal.waitForRender(); + assert.ok(terminal.getWrites().includes("> \x1b[7ma\x1b[27mbc")); + tui.stop(); + }); + + it("omits the focused fake cursor when truncation dropped its end marker", async () => { + const terminal = new LoggingVirtualTerminal(20, 5); + const tui: TUI = new TuiMainScreen(terminal, true); + const base = new TestComponent(); + base.lines = [sliceByColumn(`ab${CURSOR_MARKER}${renderFakeCursor("c")}def`, 0, 3, true)]; + tui.addChild(base); + tui.start(); + await terminal.waitForRender(); + assert.ok(!terminal.getWrites().includes("\x1b[7m")); + assert.ok(terminal.getWrites().endsWith("\x1b[3G")); + tui.stop(); + }); + + it("keeps the cursor when an overlay ends directly left of it", async () => { + for (const showHardwareCursor of [false, true]) { + const terminal = new LoggingVirtualTerminal(20, 5); + const tui: TUI = new TuiMainScreen(terminal, showHardwareCursor); + const base = new TestComponent(); + base.lines = [`abc${CURSOR_MARKER}${renderFakeCursor("d")}ef`]; + tui.addChild(base); + const overlay = new TestComponent(); + overlay.lines = ["XXX"]; + tui.showOverlay(overlay, { row: 0, col: 0, width: 3 }); + tui.start(); + await terminal.waitForRender(); + + const writes = terminal.getWrites(); + assert.ok(writes.includes("XXX")); + assert.strictEqual(writes.includes("\x1b[7md"), !showHardwareCursor); + // The hardware cursor is positioned at column 4 in both modes + assert.ok(writes.endsWith("\x1b[4G")); + tui.stop(); + } + }); +}); diff --git a/packages/pi-tui/test/virtual-terminal.ts b/packages/pi-tui/test/virtual-terminal.ts index 4e067f4e5..28865d18e 100644 --- a/packages/pi-tui/test/virtual-terminal.ts +++ b/packages/pi-tui/test/virtual-terminal.ts @@ -101,6 +101,7 @@ export class VirtualTerminal implements Terminal { } setProgress(_active: boolean): void {} + setProgramStatus(): void {} // Test-specific methods not in Terminal interface From 8302341b33ea9b36ab819df48a5104fd798d5b16 Mon Sep 17 00:00:00 2001 From: elkaix Date: Thu, 15 Oct 2026 19:20:08 -0400 Subject: [PATCH 2/2] docs: document Home/End and Ctrl-Home/Ctrl-End keys --- docs/reference/keyboard.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/reference/keyboard.md b/docs/reference/keyboard.md index 7a087dc55..712410b58 100644 --- a/docs/reference/keyboard.md +++ b/docs/reference/keyboard.md @@ -38,6 +38,8 @@ Type `!` in an empty input box to enter shell mode and run terminal commands dir | Shortcut | Function | | --- | --- | | `Ctrl-G` | Edit the current input in an external editor | +| `Home` / `End` | Move the cursor to the start or end of the current line | +| `Ctrl-Home` / `Ctrl-End` | Scroll the transcript to the top or bottom (fullscreen mode) | | `Ctrl-V` | Paste an image or video from the clipboard (Unix / macOS) | | `Alt-V` | Paste an image or video from the clipboard (Windows) | | `Ctrl--` | Undo |