From c2259514ccf1aed966e8b98193867e0f1dc2468d Mon Sep 17 00:00:00 2001 From: liruifengv Date: Tue, 11 Aug 2026 23:43:50 +0800 Subject: [PATCH 01/17] feat(kimi-code): re-baseline pi-tui on upstream v0.84.1 and add fullscreen tui_mode Re-baseline the vendored pi-tui fork on upstream @earendil-works/pi-tui v0.84.1, keeping all local patches: narrow-terminal hardening, processed-line render caching (re-implemented into TuiMainScreen), editor history hooks, the paste-burst fallback, and multi-root @ completion. Upstream highlights absorbed: the renderer splits into TuiMainScreen and TuiAltScreen behind a TUI interface, the Markdown component gains opt-out LaTeX rendering (disabled on the kimi-code side), paste-registry repair on delete/undo, Windows input-latency and Shift+Enter fixes, and Kitty image layout fixes. Editor.setText gains a preservePasteRegistry option so paste-marker expansion survives wholesale text replacement. New tui_mode = "fullscreen" preference mounts TuiAltScreen: the transcript lives in a primary ScrollView with follow-end, the chrome docks at the bottom, mouse selection and scrollbar come from the renderer, and full-screen viewers (tasks browser, output viewer, approval preview) swap the layout root via screen-takeover. Viewport navigation keys fall through to the focused component when the primary scroll view cannot scroll. --- .changeset/cli-tui-engine-rebaseline.md | 5 + .changeset/cli-tui-mode-fullscreen.md | 5 + .changeset/pi-tui-alt-screen-groundwork.md | 5 + .changeset/pi-tui-upstream-rebaseline.md | 5 + apps/kimi-code/src/tui/commands/plugins.ts | 4 +- .../tui/components/editor/custom-editor.ts | 4 +- .../components/messages/assistant-message.ts | 6 +- .../src/tui/components/messages/plan-box.ts | 2 +- .../src/tui/components/panes/btw-panel.ts | 6 +- apps/kimi-code/src/tui/config.ts | 10 + .../src/tui/controllers/editor-keyboard.ts | 5 +- .../tui/controllers/subagent-event-handler.ts | 5 +- .../src/tui/controllers/tasks-browser.ts | 39 +- apps/kimi-code/src/tui/kimi-tui.ts | 34 +- apps/kimi-code/src/tui/tui-state.ts | 39 +- apps/kimi-code/src/tui/types.ts | 4 +- .../src/tui/utils/screen-takeover.ts | 38 + .../components/editor/custom-editor.test.ts | 6 +- apps/kimi-code/test/tui/config.test.ts | 13 + .../test/tui/create-tui-state.test.ts | 48 + .../test/tui/kimi-tui-startup.test.ts | 19 + apps/kimi-code/test/tui/tui-frame.bench.ts | 4 +- .../test/tui/utils/screen-takeover.test.ts | 86 ++ packages/pi-tui/CHANGELOG.md | 6 + packages/pi-tui/native/darwin/README.md | 20 + packages/pi-tui/native/darwin/build.sh | 62 + packages/pi-tui/native/win32/README.md | 22 + packages/pi-tui/native/win32/build.mjs | 229 +++ .../win32-arm64/win32-console-mode.node | Bin 3072 -> 4096 bytes .../win32-x64/win32-console-mode.node | Bin 3072 -> 4608 bytes .../native/win32/src/win32-console-mode.c | 72 +- packages/pi-tui/package.json | 2 +- .../pi-tui/src/components/alt-screen-flash.ts | 51 + packages/pi-tui/src/components/editor.ts | 90 +- packages/pi-tui/src/components/h-stack.ts | 44 + packages/pi-tui/src/components/image.ts | 5 +- packages/pi-tui/src/components/markdown.ts | 158 +- packages/pi-tui/src/components/scroll-view.ts | 200 +++ .../pi-tui/src/components/settings-list.ts | 11 +- packages/pi-tui/src/components/stack.ts | 154 ++ packages/pi-tui/src/components/v-stack.ts | 33 + packages/pi-tui/src/index.ts | 32 +- packages/pi-tui/src/keybindings.ts | 54 +- packages/pi-tui/src/keys.ts | 11 +- packages/pi-tui/src/latex.ts | 1373 +++++++++++++++++ packages/pi-tui/src/layout-node.ts | 51 + packages/pi-tui/src/layout.ts | 402 +++++ packages/pi-tui/src/native-modifiers.ts | 11 +- packages/pi-tui/src/terminal-colors.ts | 2 +- packages/pi-tui/src/terminal-image.ts | 179 ++- packages/pi-tui/src/terminal.ts | 25 +- packages/pi-tui/src/tui-alt-screen.ts | 1055 +++++++++++++ packages/pi-tui/src/tui-main-screen.ts | 629 ++++++++ packages/pi-tui/src/tui.ts | 851 +++------- packages/pi-tui/src/utils.ts | 158 +- packages/pi-tui/test/chat-simple.ts | 5 +- .../test/editor-history-keybindings.test.ts | 43 + packages/pi-tui/test/editor.test.ts | 123 +- packages/pi-tui/test/image-test.ts | 5 +- packages/pi-tui/test/key-tester.ts | 5 +- packages/pi-tui/test/keybindings.test.ts | 29 + packages/pi-tui/test/keys.test.ts | 19 + packages/pi-tui/test/latex.test.ts | 483 ++++++ packages/pi-tui/test/layout.test.ts | 306 ++++ packages/pi-tui/test/markdown.test.ts | 251 ++- .../pi-tui/test/overlay-non-capturing.test.ts | 91 +- packages/pi-tui/test/overlay-options.test.ts | 52 +- .../pi-tui/test/overlay-short-content.test.ts | 5 +- .../regression-overlay-cjk-boundary.test.ts | 28 +- packages/pi-tui/test/settings-list.test.ts | 58 + packages/pi-tui/test/tab-width.test.ts | 62 +- packages/pi-tui/test/terminal-colors.test.ts | 15 +- packages/pi-tui/test/terminal-image.test.ts | 140 ++ packages/pi-tui/test/terminal.test.ts | 41 +- .../pi-tui/test/truncate-to-width.test.ts | 51 + packages/pi-tui/test/tui-alt-screen.test.ts | 1100 +++++++++++++ .../pi-tui/test/tui-cell-size-input.test.ts | 7 +- .../test/tui-overlay-style-leak.test.ts | 7 +- packages/pi-tui/test/tui-render.test.ts | 127 +- packages/pi-tui/test/tui-shrink.test.ts | 45 + .../pi-tui/test/viewport-overwrite-repro.ts | 8 +- packages/pi-tui/test/wrap-ansi.test.ts | 20 + 82 files changed, 8561 insertions(+), 949 deletions(-) create mode 100644 .changeset/cli-tui-engine-rebaseline.md create mode 100644 .changeset/cli-tui-mode-fullscreen.md create mode 100644 .changeset/pi-tui-alt-screen-groundwork.md create mode 100644 .changeset/pi-tui-upstream-rebaseline.md create mode 100644 apps/kimi-code/src/tui/utils/screen-takeover.ts create mode 100644 apps/kimi-code/test/tui/utils/screen-takeover.test.ts create mode 100644 packages/pi-tui/native/darwin/README.md create mode 100755 packages/pi-tui/native/darwin/build.sh create mode 100644 packages/pi-tui/native/win32/README.md create mode 100644 packages/pi-tui/native/win32/build.mjs create mode 100644 packages/pi-tui/src/components/alt-screen-flash.ts create mode 100644 packages/pi-tui/src/components/h-stack.ts create mode 100644 packages/pi-tui/src/components/scroll-view.ts create mode 100644 packages/pi-tui/src/components/stack.ts create mode 100644 packages/pi-tui/src/components/v-stack.ts create mode 100644 packages/pi-tui/src/latex.ts create mode 100644 packages/pi-tui/src/layout-node.ts create mode 100644 packages/pi-tui/src/layout.ts create mode 100644 packages/pi-tui/src/tui-alt-screen.ts create mode 100644 packages/pi-tui/src/tui-main-screen.ts create mode 100644 packages/pi-tui/test/editor-history-keybindings.test.ts create mode 100644 packages/pi-tui/test/latex.test.ts create mode 100644 packages/pi-tui/test/layout.test.ts create mode 100644 packages/pi-tui/test/settings-list.test.ts create mode 100644 packages/pi-tui/test/tui-alt-screen.test.ts create mode 100644 packages/pi-tui/test/tui-shrink.test.ts diff --git a/.changeset/cli-tui-engine-rebaseline.md b/.changeset/cli-tui-engine-rebaseline.md new file mode 100644 index 00000000000..73bee5cb598 --- /dev/null +++ b/.changeset/cli-tui-engine-rebaseline.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Upgrade the terminal UI engine to the latest upstream baseline: fixes paste-marker corruption when deleting or undoing pasted blocks, reduces keyboard input latency on Windows, keeps wide grapheme wrapping from crashing very narrow terminals, and normalizes tab characters in rendered output. diff --git a/.changeset/cli-tui-mode-fullscreen.md b/.changeset/cli-tui-mode-fullscreen.md new file mode 100644 index 00000000000..c8c06b77edf --- /dev/null +++ b/.changeset/cli-tui-mode-fullscreen.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a `tui_mode` preference for a fullscreen alternate-screen UI: the transcript becomes a scrollable viewport with mouse wheel scrolling, text selection, and a scrollbar, while the editor and chrome stay docked at the bottom. Set `tui_mode = "fullscreen"` in ~/.kimi-code/tui.toml and restart. diff --git a/.changeset/pi-tui-alt-screen-groundwork.md b/.changeset/pi-tui-alt-screen-groundwork.md new file mode 100644 index 00000000000..7619f5df01e --- /dev/null +++ b/.changeset/pi-tui-alt-screen-groundwork.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Add fullscreen groundwork to the alternate-screen renderer: `ScrollView.canScroll`, `TuiAltScreen.getLayoutRoot()`, and viewport navigation keys (PageUp/PageDown/Home/End and friends) now fall through to the focused component when the primary scroll view has nothing to scroll. diff --git a/.changeset/pi-tui-upstream-rebaseline.md b/.changeset/pi-tui-upstream-rebaseline.md new file mode 100644 index 00000000000..f2071fc3a90 --- /dev/null +++ b/.changeset/pi-tui-upstream-rebaseline.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/pi-tui": patch +--- + +Re-baseline the fork on upstream pi-tui v0.84.1. The upstream renderer is now split into main-screen and alternate-screen implementations (the `TUI` class is now an interface implemented by `TuiMainScreen` and `TuiAltScreen`), and the Markdown component gained opt-out LaTeX math rendering. All local patches are retained: narrow-terminal hardening, processed-line render caching, editor history hooks, the paste-burst fallback, and multi-root `@` completion. `Editor.setText` accepts a `preservePasteRegistry` option so subclasses can replace text without orphaning live paste markers (upstream resets the registry on every `setText`). diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 9244c7d2527..e9be7ec9c27 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -566,7 +566,9 @@ async function installCapabilityFromPanel( host.showNotice(`${label} is installed.`); host.state.transcriptContainer.addChild(new Spacer(1)); host.state.transcriptContainer.addChild( - new Markdown(WEBBRIDGE_POST_INSTALL_MARKDOWN, 2, 0, createMarkdownTheme()), + new Markdown(WEBBRIDGE_POST_INSTALL_MARKDOWN, 2, 0, createMarkdownTheme(), undefined, { + renderLatex: false, + }), ); host.state.ui.requestRender(); return; diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 51958dbe966..a8b87ae0c5c 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -235,7 +235,9 @@ export class CustomEditor extends Editor { const text = this.getText(); const offset = lines.slice(0, line).reduce((sum, l) => sum + l.length + 1, 0) + start; const newText = text.slice(0, offset) + content + text.slice(offset + match[0].length); - this.setText(newText); + // Keep the paste registry intact: the text still holds other live markers + // whose entries a plain setText would drop (upstream resets the registry). + this.setText(newText, { preservePasteRegistry: true }); return true; } return false; diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index c1b39537d4c..fa7e109a768 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -61,7 +61,9 @@ export class AssistantMessageComponent implements Component { if (this.markdown === undefined || this.markdownTransient !== transient) { this.contentContainer.clear(); - this.markdown = new Markdown(displayText, 0, 0, createMarkdownTheme({ transient })); + this.markdown = new Markdown(displayText, 0, 0, createMarkdownTheme({ transient }), undefined, { + renderLatex: false, + }); this.markdownTransient = transient; this.contentContainer.addChild(this.markdown); return; @@ -84,6 +86,8 @@ export class AssistantMessageComponent implements Component { 0, 0, createMarkdownTheme({ transient: this.lastTransient }), + undefined, + { renderLatex: false }, ); this.markdownTransient = this.lastTransient; this.contentContainer.addChild(this.markdown); diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts index d1eeec03cba..0f25bd3a91c 100644 --- a/apps/kimi-code/src/tui/components/messages/plan-box.ts +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -41,7 +41,7 @@ export class PlanBoxComponent implements Component { // parse + wrap output keyed on (text, width), so reusing the same // instance means repeated render() calls from the parent Container // hit the cache instead of re-parsing on every frame. - this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme); + this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme, undefined, { renderLatex: false }); this.status = opts?.status; } diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index f32aa9321db..d829efea5af 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -195,7 +195,11 @@ export class BtwPanelComponent implements Component { const answer = turn.answer.trim(); const thinking = turn.thinking.trim(); if (answer.length > 0) { - lines.push(...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width)); + lines.push( + ...new Markdown(answer, 0, 0, this.options.markdownTheme, undefined, { + renderLatex: false, + }).render(width), + ); } else if (thinking.length > 0) { const thinkingLines = new Text(chalk.hex(currentTheme.palette.textDim)(thinking), 0, 0).render( width, diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 95f40d6bbb8..0dc7591ac96 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -21,6 +21,9 @@ export const TuiThemeSchema = z.string(); export const NotificationConditionSchema = z.enum(['unfocused', 'always']); +export const TuiModeSchema = z.enum(['regular', 'fullscreen']); +export type TuiMode = z.infer; + export const NotificationsConfigSchema = z.object({ enabled: z.boolean(), condition: NotificationConditionSchema, @@ -53,6 +56,7 @@ export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), + tui_mode: TuiModeSchema.optional(), disable_paste_burst: z.boolean().optional(), cache_expiry_hint: z.boolean().optional(), editor: z @@ -76,6 +80,9 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, + /** Present in every normalized config; optional only so hand-built test + * fixtures from before this field existed still typecheck. */ + tuiMode: TuiModeSchema.optional(), disablePasteBurst: z.boolean(), /** Present in every normalized config; optional only so hand-built test * fixtures from before this field existed still typecheck. */ @@ -104,6 +111,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', + tuiMode: 'regular', disablePasteBurst: false, cacheExpiryHint: true, editorCommand: null, @@ -190,6 +198,7 @@ export function normalizeTuiConfig( .map((item) => item as StatusLineItem) ?? null; return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, + tuiMode: config.tui_mode ?? DEFAULT_TUI_CONFIG.tuiMode, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, editorCommand: command === undefined || command.length === 0 ? null : command, @@ -239,6 +248,7 @@ export function renderTuiConfig(config: TuiConfig): string { # Agent/runtime settings stay in ~/.kimi-code/config.toml. theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name +tui_mode = "${config.tuiMode ?? 'regular'}" # "regular" | "fullscreen" (alternate-screen UI; applies on next start) disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 55df80609b9..bc7f3890920 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -525,7 +525,10 @@ export class EditorKeyboardController { } this.host.setExternalEditorRunning(true); const seed = state.editor.getExpandedText?.() ?? state.editor.getText(); - state.ui.stop(); + // Fullscreen: a plain stop() would replay the whole transcript into the + // main screen on exit; the external editor only needs the alternate + // screen released, so preserve the screen instead. + state.ui.stop({ preserveScreen: state.ui.mode === 'fullscreen' ? true : undefined }); await new Promise((resolve) => { setImmediate(resolve); }); diff --git a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts index 6bbf7b3bef3..18dd573b980 100644 --- a/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/subagent-event-handler.ts @@ -662,8 +662,11 @@ export class SubAgentEventHandler { } const width = Math.floor(terminalColumns); + const dock = state.dockContainer; + // Fullscreen: the root children are empty (layout root holds a ScrollView + + // dock); the chrome below the transcript is the dock's children instead. const rowsAfterSwarm = renderedRowsAfterChild( - state.ui.children, + dock !== undefined ? [state.transcriptContainer, ...dock.children] : state.ui.children, state.transcriptContainer, width, ); diff --git a/apps/kimi-code/src/tui/controllers/tasks-browser.ts b/apps/kimi-code/src/tui/controllers/tasks-browser.ts index 187d4619e81..7db2f0a82fc 100644 --- a/apps/kimi-code/src/tui/controllers/tasks-browser.ts +++ b/apps/kimi-code/src/tui/controllers/tasks-browser.ts @@ -1,11 +1,16 @@ import type { BackgroundTaskInfo, Session } from '@moonshot-ai/kimi-code-sdk'; -import type { Component, ProcessTerminal, TUI } from '@moonshot-ai/pi-tui'; +import type { ProcessTerminal, TUI } from '@moonshot-ai/pi-tui'; import { AgentActivityViewer, formatSubagentActivityPreview } from '../components/dialogs/agent-activity-viewer'; import { TaskOutputViewer } from '../components/dialogs/task-output-viewer'; import { TasksBrowserApp, type TasksFilter } from '../components/dialogs/tasks-browser'; import type { Theme } from '#/tui/theme'; import type { CustomEditor } from '../components/editor/custom-editor'; +import { + beginScreenTakeover, + endScreenTakeover, + type ScreenTakeover, +} from '../utils/screen-takeover'; import type { SessionEventHandler } from './session-event-handler'; import type { SubagentActivityRecord } from './subagent-activity-store'; @@ -26,7 +31,7 @@ export interface TasksBrowserHost { export type TasksBrowserState = { component: TasksBrowserApp; - savedChildren: readonly Component[]; + takeover: ScreenTakeover; filter: TasksFilter; selectedTaskId: string | undefined; tailOutput: string | undefined; @@ -38,7 +43,7 @@ export type TasksBrowserState = { viewer: | { component: TaskOutputViewer | AgentActivityViewer; - savedChildren: readonly Component[]; + takeover: ScreenTakeover; taskId: string; output: string; refreshId: number; @@ -86,9 +91,7 @@ export class TasksBrowserController { state.terminal, ); - const savedChildren = [...state.ui.children]; - state.ui.clear(); - state.ui.addChild(component); + const takeover = beginScreenTakeover(state.ui, component); state.ui.setFocus(component); state.ui.requestRender(true); @@ -98,7 +101,7 @@ export class TasksBrowserController { this.host.setTasksBrowser({ component, - savedChildren, + takeover, filter, selectedTaskId, tailOutput: undefined, @@ -123,10 +126,7 @@ export class TasksBrowserController { if (browser.pollTimer !== undefined) clearInterval(browser.pollTimer); if (browser.flashTimer !== undefined) clearTimeout(browser.flashTimer); - state.ui.clear(); - for (const child of browser.savedChildren) { - state.ui.addChild(child); - } + endScreenTakeover(state.ui, browser.takeover); this.host.setTasksBrowser(undefined); state.ui.setFocus(state.editor); state.ui.requestRender(true); @@ -383,9 +383,7 @@ export class TasksBrowserController { state.terminal, ); - const savedBrowserChildren = [...state.ui.children]; - state.ui.clear(); - state.ui.addChild(viewer); + const takeover = beginScreenTakeover(state.ui, viewer); state.ui.setFocus(viewer); state.ui.requestRender(true); @@ -395,7 +393,7 @@ export class TasksBrowserController { browser.viewer = { component: viewer, - savedChildren: savedBrowserChildren, + takeover, taskId, output, refreshId: 0, @@ -424,9 +422,7 @@ export class TasksBrowserController { state.terminal, ); - const savedBrowserChildren = [...state.ui.children]; - state.ui.clear(); - state.ui.addChild(viewer); + const takeover = beginScreenTakeover(state.ui, viewer); state.ui.setFocus(viewer); state.ui.requestRender(true); @@ -437,7 +433,7 @@ export class TasksBrowserController { browser.viewer = { component: viewer, - savedChildren: savedBrowserChildren, + takeover, taskId, output: '', refreshId: 0, @@ -538,10 +534,7 @@ export class TasksBrowserController { const viewer = browser.viewer; clearInterval(viewer.pollTimer); browser.viewer = undefined; - this.host.state.ui.clear(); - for (const child of viewer.savedChildren) { - this.host.state.ui.addChild(child); - } + endScreenTakeover(this.host.state.ui, viewer.takeover); this.host.state.ui.setFocus(browser.component); this.host.state.ui.requestRender(true); } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index c1254bce89c..a14f32aea9e 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -24,6 +24,7 @@ import { type Focusable, getCapabilities, Spacer, + TuiAltScreen, } from '@moonshot-ai/pi-tui'; import { resolve } from 'pathe'; @@ -151,6 +152,7 @@ import { installInputLatencyProbe } from './utils/input-latency'; import { startupTrace } from '#/utils/startup-trace'; import { REPLAY_TURN_LIMIT } from './utils/message-replay'; import { hasPatchChanges } from './utils/object-patch'; +import { beginScreenTakeover, endScreenTakeover, type ScreenTakeover } from './utils/screen-takeover'; import { sessionRowsForPicker } from './utils/session-picker-rows'; import { formatBashOutputForDisplay } from './utils/shell-output'; import { thinkingEffortFromConfig } from './utils/thinking-config'; @@ -245,6 +247,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { version: input.version, editorCommand: input.tuiConfig.editorCommand, disablePasteBurst: input.tuiConfig.disablePasteBurst, + tuiMode: input.tuiConfig.tuiMode, cacheExpiryHint: input.tuiConfig.cacheExpiryHint, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, @@ -375,12 +378,13 @@ export class KimiTUI { // preview viewer can restore focus to the exact same instance (and its // selection / feedback state) when it closes. private activeApprovalPanel: ApprovalPanelComponent | undefined; - // Active full-screen approval preview. While set, the root UI's normal - // children are stashed in `savedChildren`; closing restores them. + // Active full-screen approval preview. While set, the previous screen is + // stashed in `takeover` (root children in regular mode, the layout root in + // fullscreen); closing restores it. private approvalPreview: | { component: ApprovalPreviewViewer; - savedChildren: readonly Component[]; + takeover: ScreenTakeover; panel: ApprovalPanelComponent; } | undefined; @@ -1069,6 +1073,9 @@ export class KimiTUI { private buildLayout(): void { const { ui } = this.state; + // Fullscreen mounts its layout root (transcript ScrollView + bottom dock) + // in createTUIState; the root children list stays empty there. + if (ui instanceof TuiAltScreen) return; ui.clear(); ui.addChild(this.state.transcriptContainer); ui.addChild(this.state.activityContainer); @@ -1087,6 +1094,11 @@ export class KimiTUI { private mountFooter(): void { const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); footerWrap.addChild(this.state.footer); + const dock = this.state.dockContainer; + if (dock !== undefined) { + dock.addChild(footerWrap); + return; + } this.state.ui.addChild(footerWrap); } @@ -3401,12 +3413,12 @@ export class KimiTUI { // Mounts the full-screen approval preview viewer on top of the current // approval panel. Uses the same nested-takeover pattern as - // openTaskOutputViewer: we snapshot the root container's children, swap - // in the viewer, and restore on close. The approval panel instance is + // openTaskOutputViewer: beginScreenTakeover swaps the viewer in (root + // children in regular mode, layout root in fullscreen) and closing restores + // it. The approval panel instance is // kept around in `activeApprovalPanel` so its selection state survives. private openApprovalPreview(panel: ApprovalPanelComponent, block: ApprovalPreviewBlock): void { if (this.approvalPreview !== undefined) return; - const savedChildren = [...this.state.ui.children]; const viewer = new ApprovalPreviewViewer( { block, @@ -3416,21 +3428,17 @@ export class KimiTUI { }, this.state.terminal, ); - this.state.ui.clear(); - this.state.ui.addChild(viewer); + const takeover = beginScreenTakeover(this.state.ui, viewer); this.state.ui.setFocus(viewer); this.state.ui.requestRender(true); - this.approvalPreview = { component: viewer, savedChildren, panel }; + this.approvalPreview = { component: viewer, takeover, panel }; } private closeApprovalPreview(): void { const preview = this.approvalPreview; if (preview === undefined) return; this.approvalPreview = undefined; - this.state.ui.clear(); - for (const child of preview.savedChildren) { - this.state.ui.addChild(child); - } + endScreenTakeover(this.state.ui, preview.takeover); this.state.ui.setFocus(preview.panel); this.state.ui.requestRender(true); } diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 349ecbdea8a..f85a8e365ff 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -1,7 +1,11 @@ import { Container, ProcessTerminal, - TUI, + ScrollView, + TuiAltScreen, + TuiMainScreen, + VStack, + type TUI, } from '@moonshot-ai/pi-tui'; import { FooterComponent } from './components/chrome/footer'; @@ -35,6 +39,12 @@ export interface TUIState { queueContainer: Container; btwPanelContainer: Container; editorContainer: Container; + /** + * Fullscreen mode only: the bottom dock (activity/todo/queue/btw/editor + + * footer) stacked under the transcript ScrollView. Undefined in regular + * mode, where all chrome is a direct child of the root container. + */ + dockContainer: VStack | undefined; footer: FooterComponent; editor: CustomEditor; theme: Theme; @@ -67,7 +77,8 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const theme = currentTheme; const terminal = new ProcessTerminal(); - const ui = new TUI(terminal); + const tuiMode = initialAppState.tuiMode ?? DEFAULT_TUI_CONFIG.tuiMode ?? 'regular'; + const ui = tuiMode === 'fullscreen' ? new TuiAltScreen(terminal) : new TuiMainScreen(terminal); const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); @@ -83,6 +94,29 @@ export function createTUIState(options: KimiTUIOptions): TUIState { ui.requestRender(); }); + let dockContainer: VStack | undefined; + if (ui instanceof TuiAltScreen) { + // Fullscreen (alternate screen): the transcript scrolls inside the primary + // ScrollView while the rest of the chrome stays docked at the bottom. The + // footer joins the dock later via mountFooter(). + const scrollView = new ScrollView(transcriptContainer, { + follow: 'end', + primary: true, + overscroll: 'chain', + scrollbar: 'auto', + }); + dockContainer = new VStack(); + dockContainer.addChild(activityContainer); + dockContainer.addChild(todoPanelContainer); + dockContainer.addChild(queueContainer); + dockContainer.addChild(btwPanelContainer); + dockContainer.addChild(editorContainer); + const root = new VStack(); + root.addChild(scrollView, { grow: 1, shrink: 1 }); + root.addChild(dockContainer, { basis: 'auto' }); + ui.setLayoutRoot(root); + } + return { ui, terminal, @@ -93,6 +127,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { queueContainer, btwPanelContainer, editorContainer, + dockContainer, editor, footer, theme, diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index d423aec7053..f50a6807101 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -9,7 +9,7 @@ import type { ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; -import type { NotificationsConfig, StatusLineConfig, UpgradePreferences } from './config'; +import type { NotificationsConfig, StatusLineConfig, TuiMode, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; @@ -69,6 +69,8 @@ export interface AppState { editorCommand: string | null; /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ disablePasteBurst?: boolean; + /** Renderer selection from tui.toml; defaults to 'regular' when absent from older fixtures. */ + tuiMode?: TuiMode; /** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */ cacheExpiryHint?: boolean; notifications: NotificationsConfig; diff --git a/apps/kimi-code/src/tui/utils/screen-takeover.ts b/apps/kimi-code/src/tui/utils/screen-takeover.ts new file mode 100644 index 00000000000..05107a84e73 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/screen-takeover.ts @@ -0,0 +1,38 @@ +/** + * Mode-aware full-screen viewer takeover. + * + * In regular mode a viewer is mounted by snapshotting the root container's + * children and swapping the viewer in. In fullscreen (alternate screen) the + * root children are not painted at all — the layout root is — so the viewer + * must become the layout root instead. Both shapes restore cleanly and nest + * (a viewer opened from another viewer). + */ + +import type { Component, TUI } from '@moonshot-ai/pi-tui'; +import { TuiAltScreen } from '@moonshot-ai/pi-tui'; + +/** Restore data for a screen takeover; opaque to callers. */ +export type ScreenTakeover = + | { readonly kind: 'children'; readonly children: readonly Component[] } + | { readonly kind: 'root'; readonly root: Component | undefined }; + +export function beginScreenTakeover(ui: TUI, viewer: Component): ScreenTakeover { + if (ui instanceof TuiAltScreen) { + const root = ui.getLayoutRoot(); + ui.setLayoutRoot(viewer); + return { kind: 'root', root }; + } + const children = [...ui.children]; + ui.clear(); + ui.addChild(viewer); + return { kind: 'children', children }; +} + +export function endScreenTakeover(ui: TUI, takeover: ScreenTakeover): void { + if (takeover.kind === 'root') { + if (ui instanceof TuiAltScreen) ui.setLayoutRoot(takeover.root); + return; + } + ui.clear(); + for (const child of takeover.children) ui.addChild(child); +} diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index f66c92e7d62..a2755d2ea0e 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -516,8 +516,6 @@ describe('CustomEditor paste marker expansion', () => { expect(editor.getText()).toContain('[paste #1'); expect(editor.getText()).toContain('[paste #2'); - editor.setText('[paste #1 +15 lines] [paste #2 +15 lines]'); - simulateLargePaste(editor, 'anything'); expect(editor.getText()).toContain('[paste #1'); @@ -550,7 +548,9 @@ describe('CustomEditor paste marker expansion', () => { simulateLargePaste(editor, 'anything'); expect(editor.getText()).toContain(longText); - editor.setText(markerText); + // Undo (Ctrl+-) restores both the marker text and its paste-registry entry. + editor.handleInput('\x1b[45;5u'); + expect(editor.getText()).toContain('[paste #1'); simulateLargePaste(editor, 'anything'); expect(editor.getText()).not.toContain('[paste #'); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 9ae144a2b4e..764fce42302 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -60,6 +60,7 @@ auto_install = false expect(config).toEqual({ theme: 'light', + tuiMode: 'regular', disablePasteBurst: false, cacheExpiryHint: true, editorCommand: 'code --wait', @@ -78,6 +79,16 @@ disable_paste_burst = true expect(config.disablePasteBurst).toBe(true); }); + it('defaults tui_mode to regular and parses fullscreen', () => { + expect(parseTuiConfig('').tuiMode).toBe('regular'); + + const config = parseTuiConfig(` +tui_mode = "fullscreen" +`); + + expect(config.tuiMode).toBe('fullscreen'); + }); + it('parses cache_expiry_hint', () => { const config = parseTuiConfig(` theme = "dark" @@ -95,6 +106,7 @@ command = " " expect(config).toEqual({ theme: 'auto', + tuiMode: 'regular', disablePasteBurst: false, cacheExpiryHint: true, editorCommand: null, @@ -141,6 +153,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', + tuiMode: 'regular', disablePasteBurst: false, cacheExpiryHint: true, editorCommand: 'vim', diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 0899cf07023..63b4216b990 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from 'vitest'; +import { TuiAltScreen, TuiMainScreen } from '@moonshot-ai/pi-tui'; + import { createTUIState, type KimiTUIOptions } from '#/tui/kimi-tui'; import type { AppState } from '#/tui/types'; @@ -84,4 +86,50 @@ describe('createTUIState', () => { expect(state.sessionsScope).toBe('cwd'); expect(state.activitySpinner).toBeNull(); }); + + it('uses the main-screen renderer by default', () => { + const state = createTUIState({ + initialAppState: fakeInitialAppState(), + startup: { + continueLast: false, + yolo: false, + auto: false, + plan: false, + }, + }); + + expect(state.ui).toBeInstanceOf(TuiMainScreen); + expect(state.ui.mode).toBe('regular'); + expect(state.dockContainer).toBeUndefined(); + }); + + it('builds an alternate-screen renderer with a docked layout in fullscreen mode', () => { + const state = createTUIState({ + initialAppState: { ...fakeInitialAppState(), tuiMode: 'fullscreen' }, + startup: { + continueLast: false, + yolo: false, + auto: false, + plan: false, + }, + }); + + expect(state.ui).toBeInstanceOf(TuiAltScreen); + expect(state.ui.mode).toBe('fullscreen'); + + // The chrome docks below the transcript ScrollView, in z-order. + const dock = state.dockContainer; + expect(dock).toBeDefined(); + expect(dock?.children).toEqual([ + state.activityContainer, + state.todoPanelContainer, + state.queueContainer, + state.btwPanelContainer, + state.editorContainer, + ]); + + // The layout root is mounted and the root children list stays empty. + expect((state.ui as TuiAltScreen).getLayoutRoot()).toBeDefined(); + expect(state.ui.children).toHaveLength(0); + }); }); diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 2e76c044029..2bbdc75508c 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -309,6 +309,25 @@ describe('KimiTUI startup', () => { }); }); + it('mounts the docked fullscreen layout when tui_mode is fullscreen', async () => { + const harness = makeHarness(makeSession()); + const driver = makeDriver(harness, { + ...makeStartupInput({}, { tuiMode: 'fullscreen' }), + engineV2: true, + }); + + // buildLayout() runs in the constructor: fullscreen keeps the root + // children list empty and mounts the layout root instead. + expect(driver.state.ui.mode).toBe('fullscreen'); + expect(driver.state.ui.children).toHaveLength(0); + + await expect(driver.init()).resolves.toBe(false); + (driver as unknown as { mountFooter(): void }).mountFooter(); + + // Dock = 5 chrome containers + footer wrap, below the transcript viewport. + expect(driver.state.dockContainer?.children).toHaveLength(6); + }); + it('shows a session-less notice on v2 startup', async () => { const harness = makeHarness(makeSession()); const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); diff --git a/apps/kimi-code/test/tui/tui-frame.bench.ts b/apps/kimi-code/test/tui/tui-frame.bench.ts index 2fc06ec0275..0ada071af35 100644 --- a/apps/kimi-code/test/tui/tui-frame.bench.ts +++ b/apps/kimi-code/test/tui/tui-frame.bench.ts @@ -14,7 +14,7 @@ */ import type { Component, Terminal } from '@moonshot-ai/pi-tui'; -import { TUI } from '@moonshot-ai/pi-tui'; +import { TuiMainScreen } from '@moonshot-ai/pi-tui'; import { bench, describe } from 'vitest'; const WIDTH = 120; @@ -72,7 +72,7 @@ class SpinnerComponent implements Component { describe('TUI steady-state frame', () => { const terminal = new StubTerminal(); - const tui = new TUI(terminal); + const tui = new TuiMainScreen(terminal); const spinner = new SpinnerComponent(); tui.addChild( new StaticTranscript( diff --git a/apps/kimi-code/test/tui/utils/screen-takeover.test.ts b/apps/kimi-code/test/tui/utils/screen-takeover.test.ts new file mode 100644 index 00000000000..c3132bc30dc --- /dev/null +++ b/apps/kimi-code/test/tui/utils/screen-takeover.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest'; + +import type { Component, Terminal } from '@moonshot-ai/pi-tui'; +import { Text, TuiAltScreen, TuiMainScreen } from '@moonshot-ai/pi-tui'; + +import { beginScreenTakeover, endScreenTakeover } from '#/tui/utils/screen-takeover'; + +/** Minimal Terminal stub: takeover logic never starts the terminal. */ +function stubTerminal(): Terminal { + return { + start: () => {}, + stop: () => {}, + drainInput: async () => {}, + write: () => {}, + get columns() { + return 80; + }, + get rows() { + return 24; + }, + get kittyProtocolActive() { + return false; + }, + moveBy: () => {}, + hideCursor: () => {}, + showCursor: () => {}, + clearLine: () => {}, + clearFromCursor: () => {}, + clearScreen: () => {}, + setTitle: () => {}, + setProgress: () => {}, + }; +} + +function line(text: string): Component { + return new Text(text, 0, 0); +} + +describe('screen-takeover', () => { + it('swaps and restores root children in regular mode', () => { + const ui = new TuiMainScreen(stubTerminal()); + const transcript = line('transcript'); + const editor = line('editor'); + ui.addChild(transcript); + ui.addChild(editor); + + const viewer = line('viewer'); + const takeover = beginScreenTakeover(ui, viewer); + expect(ui.children).toEqual([viewer]); + + endScreenTakeover(ui, takeover); + expect(ui.children).toEqual([transcript, editor]); + }); + + it('swaps and restores the layout root in fullscreen mode', () => { + const ui = new TuiAltScreen(stubTerminal()); + const mainRoot = line('main-layout'); + ui.setLayoutRoot(mainRoot); + // The root children list is unused in fullscreen and stays empty. + expect(ui.children).toHaveLength(0); + + const viewer = line('viewer'); + const takeover = beginScreenTakeover(ui, viewer); + expect(ui.getLayoutRoot()).toBe(viewer); + + endScreenTakeover(ui, takeover); + expect(ui.getLayoutRoot()).toBe(mainRoot); + }); + + it('nests takeovers (viewer opened from a viewer)', () => { + const ui = new TuiAltScreen(stubTerminal()); + const mainRoot = line('main-layout'); + ui.setLayoutRoot(mainRoot); + + const browser = line('browser'); + const first = beginScreenTakeover(ui, browser); + const detail = line('detail'); + const second = beginScreenTakeover(ui, detail); + expect(ui.getLayoutRoot()).toBe(detail); + + endScreenTakeover(ui, second); + expect(ui.getLayoutRoot()).toBe(browser); + endScreenTakeover(ui, first); + expect(ui.getLayoutRoot()).toBe(mainRoot); + }); +}); diff --git a/packages/pi-tui/CHANGELOG.md b/packages/pi-tui/CHANGELOG.md index 595fabd73db..d909a022531 100644 --- a/packages/pi-tui/CHANGELOG.md +++ b/packages/pi-tui/CHANGELOG.md @@ -1,5 +1,11 @@ # @moonshot-ai/pi-tui +## 0.84.1 + +### Patch Changes + +- Re-baseline the fork on upstream `@earendil-works/pi-tui` v0.84.1, keeping all local patches (narrow-terminal hardening, processed-line render caching, editor history hooks, paste-burst fallback, multi-root `@` completion). The upstream renderer is now split into main-screen and alternate-screen implementations, the `TUI` class is now an interface implemented by `TuiMainScreen` and `TuiAltScreen`, and the Markdown component gained opt-out LaTeX math rendering (`renderLatex`). The version line now tracks the upstream baseline it forks from. + ## 0.80.8 ### Patch Changes diff --git a/packages/pi-tui/native/darwin/README.md b/packages/pi-tui/native/darwin/README.md new file mode 100644 index 00000000000..d8f98985362 --- /dev/null +++ b/packages/pi-tui/native/darwin/README.md @@ -0,0 +1,20 @@ +# Darwin native prebuilds + +Build both macOS architectures from the repository root: + +```sh +npm --prefix packages/tui run build:native:darwin +``` + +The build uses macOS 11.0 as the arm64 deployment target and macOS 10.15 as the x86_64 deployment target. On macOS, `build.sh` finds Apple clang and the active macOS SDK through `xcrun`. Either an Intel or Apple Silicon host can build both outputs. + +A non-macOS host needs a complete Darwin cross-toolchain, including a macOS SDK and a Mach-O linker. For example, an osxcross installation can be selected with `CC` and `SDKROOT`: + +```sh +CC=/path/to/osxcross/clang SDKROOT=/path/to/MacOSX.sdk \ + npm --prefix packages/tui run build:native:darwin +``` + +The SDK must be obtained and used in accordance with Apple's license. Plain Linux or Windows clang is not enough because the addon includes and links CoreGraphics. + +Zig is not used here because it does not provide the Apple SDK or CoreGraphics framework stubs. It therefore does not make this build SDK-independent, and its clang driver does not currently handle this Mach-O bundle recipe as a drop-in replacement for Apple clang. diff --git a/packages/pi-tui/native/darwin/build.sh b/packages/pi-tui/native/darwin/build.sh new file mode 100755 index 00000000000..874d02e2598 --- /dev/null +++ b/packages/pi-tui/native/darwin/build.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +set -euo pipefail + +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source_file="$script_dir/src/darwin-modifiers.c" + +if [[ -n "${CC:-}" ]]; then + compiler="$CC" +elif [[ "$(uname -s)" == "Darwin" ]] && command -v xcrun >/dev/null 2>&1; then + compiler="$(xcrun --find clang)" +else + compiler="clang" +fi + +if ! command -v "$compiler" >/dev/null 2>&1; then + echo "Darwin C compiler not found: $compiler" >&2 + exit 1 +fi + +sdk_flags=() +if [[ -n "${SDKROOT:-}" ]]; then + if [[ ! -d "$SDKROOT" ]]; then + echo "SDKROOT does not exist: $SDKROOT" >&2 + exit 1 + fi + sdk_flags=(-isysroot "$SDKROOT" "-F$SDKROOT/System/Library/Frameworks") +elif [[ "$(uname -s)" == "Darwin" ]]; then + sdkroot="$(xcrun --sdk macosx --show-sdk-path)" + sdk_flags=(-isysroot "$sdkroot" "-F$sdkroot/System/Library/Frameworks") +fi + +temporary_dir="$(mktemp -d "${TMPDIR:-/tmp}/pi-tui-darwin.XXXXXX")" +trap 'rm -rf "$temporary_dir"' EXIT + +build() { + local arch="$1" + local target="$2" + local output_dir="$script_dir/prebuilds/darwin-$arch" + local temporary_output="$temporary_dir/darwin-$arch/darwin-modifiers.node" + + mkdir -p "$(dirname "$temporary_output")" + "$compiler" \ + -std=c11 \ + -Wall \ + -Wextra \ + -O2 \ + "--target=$target" \ + "${sdk_flags[@]}" \ + -bundle \ + -undefined dynamic_lookup \ + -framework CoreGraphics \ + "$source_file" \ + -o "$temporary_output" + + mkdir -p "$output_dir" + install -m 755 "$temporary_output" "$output_dir/darwin-modifiers.node" + echo "Built $output_dir/darwin-modifiers.node" +} + +build arm64 arm64-apple-macos11.0 +build x64 x86_64-apple-macos10.15 diff --git a/packages/pi-tui/native/win32/README.md b/packages/pi-tui/native/win32/README.md new file mode 100644 index 00000000000..48099c7e1bc --- /dev/null +++ b/packages/pi-tui/native/win32/README.md @@ -0,0 +1,22 @@ +# Windows native prebuilds + +Build both Windows architectures from the repository root: + +```sh +npm --prefix packages/tui run build:native:win32 +``` + +On Windows, the build uses the Microsoft C++ Build Tools from Visual Studio. `build.mjs` locates `VsDevCmd.bat`, initializes a developer environment for `amd64` and `arm64`, and builds the addon with `cl.exe`/`link.exe`. + +Install the "Desktop development with C++" workload, or at minimum the MSVC toolset and Windows SDK components. No Node headers are required; the addon resolves N-API symbols from the host process. + +For non-Windows cross-builds, or for custom Windows toolchains, set `PI_TUI_WIN32_TOOLCHAIN=mingw` and provide MinGW-compatible compilers: + +```sh +PI_TUI_WIN32_TOOLCHAIN=mingw \ +CC_X64=/path/to/x86_64-w64-mingw32-gcc \ +CC_ARM64=/path/to/aarch64-w64-mingw32-gcc \ +npm --prefix packages/tui run build:native:win32 +``` + +The addon intentionally avoids the C runtime and links only against `kernel32`. diff --git a/packages/pi-tui/native/win32/build.mjs b/packages/pi-tui/native/win32/build.mjs new file mode 100644 index 00000000000..eee23fdcad3 --- /dev/null +++ b/packages/pi-tui/native/win32/build.mjs @@ -0,0 +1,229 @@ +import { spawnSync } from "node:child_process"; +import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import * as path from "node:path"; +import { fileURLToPath } from "node:url"; + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)); +const sourceFile = path.join(scriptDir, "src", "win32-console-mode.c"); +const temporaryDir = mkdtempSync(path.join(tmpdir(), "pi-tui-win32-")); + +const targets = [ + { + arch: "x64", + msvcArch: "x64", + mingwTarget: "x86_64-w64-windows-gnu", + prefixedCompilers: ["x86_64-w64-mingw32-gcc", "x86_64-w64-mingw32-clang"], + envCompiler: "CC_X64", + }, + { + arch: "arm64", + msvcArch: "arm64", + mingwTarget: "aarch64-w64-windows-gnu", + prefixedCompilers: ["aarch64-w64-mingw32-gcc", "aarch64-w64-mingw32-clang"], + envCompiler: "CC_ARM64", + }, +]; + +function removeTemporaryDir() { + rmSync(temporaryDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); +} + +process.on("exit", removeTemporaryDir); +process.on("SIGINT", () => { + removeTemporaryDir(); + process.exit(130); +}); + +function commandExists(command) { + const result = spawnSync(command, ["--version"], { stdio: "ignore" }); + return !result.error || result.error.code !== "ENOENT"; +} + +function run(command, args, options = {}) { + const result = spawnSync(command, args, { stdio: "inherit", ...options }); + if (result.error) throw result.error; + if (result.status !== 0) throw new Error(`${command} failed with exit code ${result.status ?? 1}`); +} + +function quoteBatch(value) { + return `"${value.replaceAll('"', '""')}"`; +} + +function msvcHostArch() { + return process.arch === "arm64" ? "arm64" : "x64"; +} + +function findVisualStudioDevCmd() { + const candidates = []; + const programFilesX86 = process.env["ProgramFiles(x86)"]; + if (programFilesX86) { + const vswhere = path.join(programFilesX86, "Microsoft Visual Studio", "Installer", "vswhere.exe"); + if (existsSync(vswhere)) { + const result = spawnSync( + vswhere, + [ + "-latest", + "-products", + "*", + "-requires", + "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-property", + "installationPath", + ], + { encoding: "utf8" }, + ); + for (const line of result.stdout.split(/\r?\n/)) { + const installationPath = line.trim(); + if (installationPath) candidates.push(path.join(installationPath, "Common7", "Tools", "VsDevCmd.bat")); + } + } + } + + for (const base of [process.env.ProgramFiles, programFilesX86]) { + if (!base) continue; + for (const edition of ["BuildTools", "Community", "Professional", "Enterprise"]) { + candidates.push(path.join(base, "Microsoft Visual Studio", "2022", edition, "Common7", "Tools", "VsDevCmd.bat")); + } + } + + return candidates.find((candidate) => existsSync(candidate)); +} + +function canUseMsvc(vsDevCmd) { + const probeDir = path.join(temporaryDir, "msvc-probe"); + mkdirSync(probeDir, { recursive: true }); + const batchFile = path.join(probeDir, "probe.cmd"); + writeFileSync( + batchFile, + [ + "@echo off", + `call ${quoteBatch(vsDevCmd)} -no_logo -arch=x64 -host_arch=${msvcHostArch()} >nul`, + "if errorlevel 1 exit /b %errorlevel%", + "where cl.exe >nul 2>nul", + ].join("\r\n"), + ); + const result = spawnSync("cmd.exe", ["/d", "/s", "/c", batchFile], { cwd: probeDir, stdio: "ignore" }); + return !result.error && result.status === 0; +} + +function resolveMingwCompiler(target) { + const envCompiler = process.env[target.envCompiler]; + if (envCompiler) return { command: envCompiler, useTargetFlag: false }; + if (process.env.CC) return { command: process.env.CC, useTargetFlag: true }; + for (const command of target.prefixedCompilers) { + if (commandExists(command)) return { command, useTargetFlag: false }; + } + if (commandExists("clang")) return { command: "clang", useTargetFlag: true }; + return undefined; +} + +function installOutput(temporaryOutput, target) { + const outputDir = path.join(scriptDir, "prebuilds", `win32-${target.arch}`); + mkdirSync(outputDir, { recursive: true }); + const output = path.join(outputDir, "win32-console-mode.node"); + copyFileSync(temporaryOutput, output); + chmodSync(output, 0o755); + console.log(`Built ${path.relative(process.cwd(), output)}`); +} + +function buildWithMsvc(target, vsDevCmd) { + const buildDir = path.join(temporaryDir, `msvc-${target.arch}`); + const temporaryOutput = path.join(buildDir, "win32-console-mode.node"); + mkdirSync(buildDir, { recursive: true }); + + const batchFile = path.join(buildDir, "build.cmd"); + writeFileSync( + batchFile, + [ + "@echo off", + `call ${quoteBatch(vsDevCmd)} -no_logo -arch=${target.msvcArch} -host_arch=${msvcHostArch()}`, + "if errorlevel 1 exit /b %errorlevel%", + [ + "cl", + "/nologo", + "/TC", + "/std:c11", + "/W4", + "/O1", + "/GS-", + "/LD", + `/Fe:${quoteBatch(temporaryOutput)}`, + quoteBatch(sourceFile), + "/link", + "/NOENTRY", + "/NODEFAULTLIB", + "kernel32.lib", + "/OPT:REF", + "/OPT:ICF", + ].join(" "), + "if errorlevel 1 exit /b %errorlevel%", + ].join("\r\n"), + ); + + run("cmd.exe", ["/d", "/s", "/c", batchFile], { cwd: buildDir }); + installOutput(temporaryOutput, target); +} + +function buildWithMingw(target) { + const compiler = resolveMingwCompiler(target); + if (!compiler) { + throw new Error( + `No Windows cross-compiler found for ${target.arch}. Install Microsoft C++ Build Tools on Windows, or set ${target.envCompiler} to a MinGW-compatible compiler.`, + ); + } + + const buildDir = path.join(temporaryDir, `mingw-${target.arch}`); + const temporaryOutput = path.join(buildDir, "win32-console-mode.node"); + mkdirSync(buildDir, { recursive: true }); + + const args = [ + ...(compiler.useTargetFlag ? [`--target=${target.mingwTarget}`] : []), + "-std=c11", + "-Wall", + "-Wextra", + "-Oz", + "-shared", + "-nostdlib", + "-Wl,--no-entry", + "-Wl,--strip-all", + sourceFile, + "-lkernel32", + "-o", + temporaryOutput, + ]; + + run(compiler.command, args, { cwd: buildDir }); + installOutput(temporaryOutput, target); +} + +if (process.argv.includes("--help") || process.argv.includes("-h")) { + console.log(`Usage: npm --prefix packages/tui run build:native:win32 + +Builds win32-x64 and win32-arm64 native prebuilds. + +Environment: + PI_TUI_WIN32_TOOLCHAIN=msvc|mingw + CC_X64=/path/to/x86_64-w64-mingw32-gcc + CC_ARM64=/path/to/aarch64-w64-mingw32-gcc + CC=/path/to/clang`); + process.exit(0); +} + +const requestedToolchain = process.env.PI_TUI_WIN32_TOOLCHAIN; +if (requestedToolchain && requestedToolchain !== "msvc" && requestedToolchain !== "mingw") { + throw new Error("PI_TUI_WIN32_TOOLCHAIN must be either 'msvc' or 'mingw'"); +} + +const vsDevCmd = findVisualStudioDevCmd(); +const hasMsvc = vsDevCmd ? canUseMsvc(vsDevCmd) : false; +const toolchain = requestedToolchain ?? (process.platform === "win32" && vsDevCmd ? "msvc" : "mingw"); + +if (toolchain === "msvc") { + if (!vsDevCmd || !hasMsvc) { + throw new Error("Microsoft C++ Build Tools not found. Install the Visual Studio C++ workload or set PI_TUI_WIN32_TOOLCHAIN=mingw."); + } + for (const target of targets) buildWithMsvc(target, vsDevCmd); +} else { + for (const target of targets) buildWithMingw(target); +} diff --git a/packages/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node b/packages/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node index 42b2c77ccadb03f7ad745fda39583b2e6711d10f..3b408f0ea294061d3eedaa931ecbc95107536a14 100644 GIT binary patch literal 4096 zcmeHJZ-^9S7=LEoJ5N2%)LZ{Rg>!nrDS4RQi9z9PCMlWPtwdC`H@7=?H?cQ+&d!!+ zAKL0m^~s5&gCNp~a3>253+?Y>1rbSUDHH^|YH;*PIRD)GVYlD&&J3<*eF%!6UiO{m zd4KQoJoEf}XWs`N*+YVenlS5iq9dR@bou=A$9LGCv*g1$ba>YBjw8yJ;~hJOt(-RM zWQQDML`xaAoprSVQ*-jRX4%@k+Y;JHHf?sdwaxAfO`lr)#h2cg|4K9`r{A79Taq|gCBpmSs?QJ2&XA3D*x#&yU)2KifVfd3(+ z-Plef-|d>aTr6VEc(BVHdl&~eIW(gFZYOQH2GJgF0svBGHfY1p(GJ~#SP=HAhRp^R za;5IkKv2dAK&Ep}gAiE2`29cr4H=k9d|IrvQnkK;PT*)9oVh@+P6_fgr7)kA^{4BC zj$Xj}^%-=itk4cA)23G|f;`^W73!H3`q8q8`HHe=wcMYlS9!0WsHcYteK=9E7%1=2 zKGpl4XnYbj>n&6zDxCa^=cvOn#i_*S(%0He zOQxmmLyGq+?EQq8HROuaLf^Ob`b43HdMQ@u`v$xQUIX77`BPE6vY7234|O!?9D`iB znR<1|ea8CW`@wfbI*KZFO3*2YAP-7MfPDn49Og!e2iOO|_9~&A5nzXaQG`7kU9E*TMK=!^ZOxJ0FM}BI{bT;{exTqK27jG_`TrA!E3d859g}Z5}&?W$B$(l zFQ?RE?Pm1f=XBx{^29kKMR?Of@i*h$mm|y&9zN~ksC6K-Pxazhb8oi8_Jp9rdCV^K zQ#0n|GgNE>J&V3NgI=wO#eP{V^vmk{(~~WFaS72*niap&*`;4W9(K$-939}nqhNEe4R%8AYQxO65H zt@An4S+n{oa-ZqOb7OYuese708m`H5xnXP24HZ&Z+jX)T%8t5L)+RDC!A>eWGGf?i z%H>B*2eMCD*u=1nQ7buQy2*iTHe(tA*odbFl9oM~mBNkqlSU?QCUdT1*+a>^I~ZQR zq`j15!nNdJ-cAKxgzw4WJ+?7orjxkK!VhR8$OlQQhjm&0yG#>_J zTM|2Nu1OEf&IUM41t-Nz})G>JV9`+Q_Xzs`Q0Omo=QT)Mu z4JtFTX7x(sFNZ{|l=o>aMvx)L9I|q*=_G;YGiLJ1J6MGWf4p%Ql=s5UksyCsaEI)+ z_|DdeZ#>q(aDFJd7e@EkZZo*ovHu)Lu;00R_wMw-5Y~LpjWHhBS`C)IxfWZ)SQ!|X zAe8H2jC*U>RctL|v?{o^b`Ruoa;=AY;r){BMB_Q`O)XkoOaE$r3xN(!xam!3#tgdm z4HavY7)cGFIML6HZFjP%csk8RC%PcEWR3I|Yrrv_F-Y9T5*vb2K^b=LZcbc>+`qAJ z>&7jOXXn}o{^k|>;vITJIS5*$H delta 956 zcmah{!Alfj6o210qvo2j>Y`S<&2Afr$R_3PDcad2T8K#8W0zKEO3QXj*Aj)C=prkG z;}=SvGCJsjAlM=3;7aue)WK5+qXLNzX6mw0Z12s^fSvl_H^1-wzW3fY-+RwcjHYQL zvU=ppyZmTo*1@B~)ZDIRPtEQiKlt%@Zr|dJ&65`Q4D|J5jJej>EWxejnwwy+ZSX5q zyrvyE3gza`DW(cP3%BGH01c%rA3&?^Yf%S4lj*B3dw~wk;vsLBanoV?j@1Rz`e+(` zUNQW*5nOy@-tZ!2RuD3x9C2W&0BZ1#mPjWsE9scs`V-gcbw1C;G;f`eYgw(RlTWZp z5tffD)oKA)WaAn@XD1jvnB3z*J_N7=jF}-ZB6m&X$N5sR3*P4?$XX`}X6{QY>jikS z=VQpAr3D+2=t$p7?|~#G2!RtR|!lRj1L@)nN6uN`;136asZ*vl3M7+ z^G{MkmUIL4P2A(Xht2%>`K7(9(WRg9H{x~0Ikql-RfL77GUo60;}dadHs7;((q`2! z+FV|-=xPyvT$e65y{g8Lw}=4tBTzvJ0E)PXcN74sxq~gVzazF0E7%B^SgwjpP+P1P z1#TzN+3E?EJ2*h~4}?hSQ1J1j4V8M5Lz|``sF!TIo&KV99vB-N(|T{9O;h3_y_D-T zZsGU8+LbdTy783PBakCa8R7H=4r2(zJ`ha*V2h8F_$jt>3CX; zC6d~p4zR${zMRrV2KAB@1^7r>_fV=Ys%b;|@GzE}l&(~AI5nuFGsc}Qi<2VmS7&fU dw;J34%kZ30t)Ie49;*eo3k$2!nl&2Xw!f-PQ&x7=lt%u=bm%!J=ZtM&m3kx#@HHsw$0cAD7C2U|8uv5;jTMg>S8}! zf4XPEqn+*KK_fZ z%s&5Q>uapMaP)|jUwHB;XlL{LM-NN-yi30)=_5vVissztVJg8`?n`S}|FZen)>xDE zD(&8`dzc?cpoM~VOa73D2=QJfRY)XN*;42!IcQ$CAF9rz7P(n=kY+FZz?_jg8QUsn zyImC6YT4z-dKlaC9{Br03S)!sJIGkX(q}A;pCjMNWeUiR1DtFMWBVg!E@NdF>!UFM zH{}RW6n_y%%?Y~?;Cr+vWA#YMQIs(PpqyUI6?n9${FCgh?*E(tK0l_egKgjg=F%03o-L;Ay}Ee zeJ%P+Yq@CigCWP(i?jC9M72KS6&IvS*Lf5jmZ_N@1EL@?nV;n zs@8+mt8i}dX>^STT;wl=JcI7d*U-ToKMV|sUp4N60Zfl9ZXopzSDo(O#b*h5`(4N6 zcM176x;MXy4hBwlIR^fWvO?f#KtFe(R~<+L^dlGgsRP{w=tUQL(SbGrdd7vmE+H$v z_$V#?MY(k4qcuKusb_H`L3sz-viKZH`y8oj@jE1?q-3?ptwGkXg^ywjoju&Odo(r@ z+ZEd#8|C6Ml9(-&UlN2LqDhLiPoQc`z;A*!fG__AdoY*_fw4^98Oau>r!$2dD_2Uo3BAu77(^>%N=AB8 zx6ToSRQ7L2{FWhy? zSf`Mg)^lmRW#NZ4%k)BKJg@IIOskT~kLc#KQOM+n3Z;r0kWo$+bH;?Bn<-N-m-U?M zNb4PnGH37L`&tjacE-ZseTRI9Kx8`%d>wK4(UfntRlI!5snbr(iu3M^81&9k?^>n%3}TYy|yJy6VB)8r4WO+Gk!>cN8tbK{ecpXg<5jPM!f?bdp`Ms)WrhrPdz z#+^0$!Llh}6oZQm5OxR{rPsx_5k}``<{I`f!WeE2gvlb~<+7Bc&YQUPcJu_+u)sDy z3-tR-o}ixvb8A@;awAS`~4|TIKaZW zE*2i?WZ`WcENtG^+_>CblIyq~?k}6zUigNz{rd|;-hxlTm)gYGc2Ic&2m0^Dc`f7M z-7Eipp~LT-NK>CQ%9d`XfmiZ+`oMjdL3wDkUaB9ZCEZN_*ZTAMP+`MXj-y!esIF(d zkZ}azgagGG`vYMqvzU$La&#ye`@2soW^$S_Zf49`Xnce;KJA2t$*}Wa+wcv@j>IDm zC$!bIeRCAIkPmDIP>4tHJ&ily2~ZU;QsRm3!t3t`;P-&;L_I8kr@HtCKDslW0$p)= QqTj?Nu>|~9``~2YUws%dm;e9( literal 3072 zcmeHIQD_`h6urCK#&qNErmzc93Qp>L1ZkIbH;RHtstJA7t%fA1h`62XjLFFC>@quB zV+4sV5tCshh_w9```-`!sQn7GVhd>vh#J%ng?isadnBG@Sa3 zqt%qEX4`dFnKl%sWh{5L8_C0mjNq4V1aXo(9~{R>Vsy!f4DAALoB#R> zh@}_QUa{n={m+W_6-9VgKi={oxdbjv=^No)L%@h=U-RAjL}v=7cizu^nIT$Sbq72N ze3m)$!!AE}0bu8~*y3s{?)@e@sqf)$ZlD5uh7gF~UG9vk!uv{im;80%G3vSML&7`D zPC>NKDV4KpYau5~9bjS#6Dxq2@V>^bAMmbk@~)lKDcHi>+-$P@DYoWvgR)rs(LKlq zJ|_6UL+$gvSQ=G_%AM4m$W3%|9bSnQO0eRR{{?7g%U)R(<<8E!-1l>)39gnp9x_Do zuKFBk7MNzhGc4Qn(giE{A$6)V=ox_-g0}9+A!cTeqwmzSb}+GlpB4uGcL?vv0zyj-k0m<)xNgb= 1 && args[0]) { + char name[16] = {0}; + unsigned long long copied = 0; + if (napi_get_value_string_utf8(env, args[0], name, sizeof(name), &copied) == 0) { + pressed = is_modifier_name_pressed(name); + } + } + } + + napi_value result = 0; + if (napi_get_boolean) napi_get_boolean(env, pressed, &result); + return result; +} + +static void set_function_export(napi_env env, napi_value exports, const char* name, napi_callback callback) { napi_create_function_fn napi_create_function = (napi_create_function_fn)node_symbol("napi_create_function"); napi_set_named_property_fn napi_set_named_property = (napi_set_named_property_fn)node_symbol("napi_set_named_property"); napi_value fn = 0; if (napi_create_function && napi_set_named_property && - napi_create_function(env, "enableVirtualTerminalInput", NAPI_AUTO_LENGTH, enable_virtual_terminal_input, 0, &fn) == 0) { - napi_set_named_property(env, exports, "enableVirtualTerminalInput", fn); + napi_create_function(env, name, NAPI_AUTO_LENGTH, callback, 0, &fn) == 0) { + napi_set_named_property(env, exports, name, fn); } +} +__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) { + set_function_export(env, exports, "enableVirtualTerminalInput", enable_virtual_terminal_input); + set_function_export(env, exports, "isModifierPressed", is_modifier_pressed); return exports; } diff --git a/packages/pi-tui/package.json b/packages/pi-tui/package.json index af8c1157e0e..485117d9413 100644 --- a/packages/pi-tui/package.json +++ b/packages/pi-tui/package.json @@ -1,6 +1,6 @@ { "name": "@moonshot-ai/pi-tui", - "version": "0.80.8", + "version": "0.84.1", "private": true, "description": "Terminal User Interface library with differential rendering for efficient text-based applications", "license": "MIT", diff --git a/packages/pi-tui/src/components/alt-screen-flash.ts b/packages/pi-tui/src/components/alt-screen-flash.ts new file mode 100644 index 00000000000..e4b823cd2bd --- /dev/null +++ b/packages/pi-tui/src/components/alt-screen-flash.ts @@ -0,0 +1,51 @@ +import type { Component } from "../tui.ts"; +import { truncateToWidth } from "../utils.ts"; + +const DEFAULT_DURATION_MS = 1000; + +interface FlashEntry { + id: number; + message: string; + timer: NodeJS.Timeout; +} + +/** Transient messages composited by the alternate-screen renderer. */ +export class AltScreenFlashContainer implements Component { + private readonly entries: FlashEntry[] = []; + private nextId = 0; + private readonly requestRender: () => void; + + constructor(requestRender: () => void) { + this.requestRender = requestRender; + } + + flash(message: string, durationMs = DEFAULT_DURATION_MS): void { + const id = this.nextId++; + const timer = setTimeout( + () => { + const index = this.entries.findIndex((entry) => entry.id === id); + if (index === -1) return; + this.entries.splice(index, 1); + this.requestRender(); + }, + Math.max(0, durationMs), + ); + timer.unref(); + this.entries.push({ id, message, timer }); + this.requestRender(); + } + + dispose(): void { + for (const entry of this.entries) clearTimeout(entry.timer); + this.entries.length = 0; + } + + invalidate(): void {} + + render(width: number): string[] { + return this.entries.map((entry) => { + const message = truncateToWidth(` ${entry.message} `, width, ""); + return `\x1b[7m${message}\x1b[27m`; + }); + } +} diff --git a/packages/pi-tui/src/components/editor.ts b/packages/pi-tui/src/components/editor.ts index dfaa3a59e4b..af50b13768e 100644 --- a/packages/pi-tui/src/components/editor.ts +++ b/packages/pi-tui/src/components/editor.ts @@ -10,7 +10,7 @@ import { getGraphemeSegmenter, getWordSegmenter, isWhitespaceChar, - truncateToWidth, + sliceByColumn, visibleWidth, } from "../utils.ts"; import { findWordBackward, findWordForward } from "../word-navigation.ts"; @@ -224,6 +224,13 @@ interface EditorState { cursorCol: number; } +/** Undo snapshot: editor text state plus the paste registry. */ +interface EditorSnapshot { + state: EditorState; + pastes: Map; + pasteCounter: number; +} + interface LayoutLine { text: string; hasCursor: boolean; @@ -262,6 +269,17 @@ function buildDebouncePattern(triggerCharacters: string[]): RegExp { return new RegExp(`(?:^|[ \\t])(?:@(?:"[^"]*|[^\\s]*)|[${escapedWithoutAt.join("")}][^\\s]*)$`); } +function createScrollBorder(direction: "↑" | "↓", hiddenLineCount: number, width: number): string { + const availableWidth = Math.max(0, width); + const indicator = `─── ${direction} ${hiddenLineCount} more `; + const remaining = availableWidth - visibleWidth(indicator); + if (remaining >= 0) return indicator + "─".repeat(remaining); + + const ellipsis = "...".slice(0, availableWidth); + const indicatorWidth = availableWidth - visibleWidth(ellipsis); + return sliceByColumn(indicator, 0, indicatorWidth, true) + ellipsis; +} + export class Editor implements Component, Focusable { private state: EditorState = { lines: [""], @@ -337,7 +355,7 @@ export class Editor implements Component, Focusable { private snappedFromCursorCol: number | null = null; // Undo support - private undoStack = new UndoStack(); + private undoStack = new UndoStack(); public onSubmit?: (text: string) => void; public onChange?: (text: string) => void; @@ -592,13 +610,8 @@ export class Editor implements Component, Focusable { // Render top border (with scroll indicator if scrolled down) if (this.scrollOffset > 0) { - const indicator = `─── ↑ ${this.scrollOffset} more `; - const remaining = width - visibleWidth(indicator); - if (remaining >= 0) { - result.push(this.borderColor(indicator + "─".repeat(remaining))); - } else { - result.push(this.borderColor(truncateToWidth(indicator, width))); - } + const border = createScrollBorder("↑", this.scrollOffset, width); + result.push(this.borderColor(border)); } else { result.push(horizontal.repeat(Math.max(0, width))); } @@ -654,9 +667,8 @@ export class Editor implements Component, Focusable { // Render bottom border (with scroll indicator if more content below) const linesBelow = layoutLines.length - (this.scrollOffset + visibleLines.length); if (linesBelow > 0) { - const indicator = `─── ↓ ${linesBelow} more `; - const remaining = width - visibleWidth(indicator); - result.push(this.borderColor(indicator + "─".repeat(Math.max(0, remaining)))); + const border = createScrollBorder("↓", linesBelow, width); + result.push(this.borderColor(border)); } else { result.push(horizontal.repeat(Math.max(0, width))); } @@ -848,6 +860,18 @@ export class Editor implements Component, Focusable { return; } + // Dedicated history actions always browse entries instead of moving the cursor. + if (kb.matches(data, "tui.editor.historyPrevious")) { + this.cancelAutocomplete(); + this.navigateHistory(-1); + return; + } + if (kb.matches(data, "tui.editor.historyNext")) { + this.cancelAutocomplete(); + this.navigateHistory(1); + return; + } + // Cursor movement actions if (kb.matches(data, "tui.editor.cursorLineStart")) { this.moveToLineStart(); @@ -1103,7 +1127,7 @@ export class Editor implements Component, Focusable { return { line: this.state.cursorLine, col: this.state.cursorCol }; } - setText(text: string): void { + setText(text: string, options?: { preservePasteRegistry?: boolean }): void { this.cancelAutocomplete(); this.lastAction = null; this.exitHistoryBrowsing(); @@ -1112,6 +1136,10 @@ export class Editor implements Component, Focusable { if (this.getText() !== normalized) { this.pushUndoSnapshot(); } + if (!options?.preservePasteRegistry) { + this.pastes.clear(); + this.pasteCounter = 0; + } this.setTextInternal(normalized); } @@ -1375,13 +1403,41 @@ export class Editor implements Component, Focusable { this.pushUndoSnapshot(); // Delete grapheme before cursor (handles emojis, combining characters, etc.) - const line = this.state.lines[this.state.cursorLine] || ""; + let line = this.state.lines[this.state.cursorLine] || ""; const beforeCursor = line.slice(0, this.state.cursorCol); // Find the last grapheme in the text before cursor const graphemes = [...this.segment(beforeCursor, "grapheme")]; const lastGrapheme = graphemes[graphemes.length - 1]; const graphemeLength = lastGrapheme ? lastGrapheme.segment.length : 1; + const isPastedSegmented = PASTE_MARKER_SINGLE.exec(lastGrapheme!.segment); + + if (isPastedSegmented) { + // This contains the id part e.g 4 from [paste #4 +123 lines] + const targetId = Number(isPastedSegmented[1]); + this.pastes.delete(targetId); + this.pasteCounter--; + + // Shift registry entries down in ascending id order, independent + // of marker order in the text ([paste #3] becomes [paste #2] when + // [paste #1] is removed). + const higherIds = [...this.pastes.keys()].filter((id) => id > targetId).sort((a, b) => a - b); + for (const id of higherIds) { + this.pastes.set(id - 1, this.pastes.get(id)!); + this.pastes.delete(id); + } + + // Renumber markers with ids greater than the removed one. + this.state.lines = this.state.lines.map((line) => + line.replace(PASTE_MARKER_REGEX, (fullMatch, idGroup, suffixGroup) => { + const x = Number(idGroup); + if (x <= targetId) return fullMatch; + return `[paste #${x - 1}${suffixGroup}]`; + }), + ); + } + + line = this.state.lines[this.state.cursorLine] || ""; const before = line.slice(0, this.state.cursorCol - graphemeLength); const after = line.slice(this.state.cursorCol); @@ -2076,14 +2132,16 @@ export class Editor implements Component, Focusable { } private pushUndoSnapshot(): void { - this.undoStack.push(this.state); + this.undoStack.push({ state: this.state, pastes: this.pastes, pasteCounter: this.pasteCounter }); } private undo(): void { this.exitHistoryBrowsing(); const snapshot = this.undoStack.pop(); if (!snapshot) return; - Object.assign(this.state, snapshot); + Object.assign(this.state, snapshot.state); + this.pastes = snapshot.pastes; + this.pasteCounter = snapshot.pasteCounter; this.lastAction = null; this.preferredVisualCol = null; if (this.onChange) { diff --git a/packages/pi-tui/src/components/h-stack.ts b/packages/pi-tui/src/components/h-stack.ts new file mode 100644 index 00000000000..07f8f8ffd05 --- /dev/null +++ b/packages/pi-tui/src/components/h-stack.ts @@ -0,0 +1,44 @@ +import { compositeTuiLine } from "../tui.ts"; +import { visibleWidth } from "../utils.ts"; +import { allocateStackSizes, Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts"; + +export class HStack extends Stack { + protected readonly layoutType = "hstack" as const; + + constructor(children: StackChild[] = [], options: StackOptions = {}) { + super(children, options); + } + + override render(width: number): string[] { + const safeWidth = Math.max(1, width); + const viewport = { width: safeWidth, height: Number.MAX_SAFE_INTEGER }; + const entries = visibleStackEntries(this.entries, viewport); + if (entries.length === 0) return []; + + const intrinsicWidths = entries.map((entry) => { + const lines = entry.component.render(safeWidth); + return lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0); + }); + const widths = allocateStackSizes(entries, intrinsicWidths, safeWidth, this.gap); + const rendered = entries.map((entry, index) => + widths[index] === 0 ? [] : entry.component.render(widths[index]!), + ); + const height = rendered.reduce((max, lines) => Math.max(max, lines.length), 0); + const result = Array.from({ length: height }, () => ""); + let x = 0; + for (let index = 0; index < rendered.length; index++) { + const lines = rendered[index]!; + const childWidth = widths[index]!; + let offset = 0; + if (this.align === "center") offset = Math.floor((height - lines.length) / 2); + else if (this.align === "end") offset = height - lines.length; + for (let row = 0; row < lines.length; row++) { + const target = row + offset; + if (target < 0 || target >= result.length) continue; + result[target] = compositeTuiLine(result[target]!, lines[row]!, x, childWidth, safeWidth); + } + x += childWidth + this.gap; + } + return result; + } +} diff --git a/packages/pi-tui/src/components/image.ts b/packages/pi-tui/src/components/image.ts index 50c1e0f7866..91d68016aae 100644 --- a/packages/pi-tui/src/components/image.ts +++ b/packages/pi-tui/src/components/image.ts @@ -8,6 +8,7 @@ import { renderImage, } from "../terminal-image.ts"; import type { Component } from "../tui.ts"; +import { truncateToWidth } from "../utils.ts"; export interface ImageTheme { fallbackColor: (str: string) => string; @@ -111,11 +112,11 @@ export class Image implements Component { } } else { const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); - lines = [this.theme.fallbackColor(fallback)]; + lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)]; } } else { const fallback = imageFallback(this.mimeType, this.dimensions, this.options.filename); - lines = [this.theme.fallbackColor(fallback)]; + lines = [truncateToWidth(this.theme.fallbackColor(fallback), width)]; } this.cachedLines = lines; diff --git a/packages/pi-tui/src/components/markdown.ts b/packages/pi-tui/src/components/markdown.ts index 34a0df9223c..9b47a31c287 100644 --- a/packages/pi-tui/src/components/markdown.ts +++ b/packages/pi-tui/src/components/markdown.ts @@ -1,4 +1,5 @@ -import { Marked, type Token, Tokenizer, type Tokens } from "marked"; +import { Marked, type Token, Tokenizer, type TokenizerExtension, type Tokens } from "marked"; +import { renderLatex } from "../latex.ts"; import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.ts"; import type { Component } from "../tui.ts"; import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.ts"; @@ -22,6 +23,126 @@ class StrictStrikethroughTokenizer extends Tokenizer { } } +interface LatexToken extends Tokens.Generic { + type: "latex" | "latexBlock"; + text: string; + pending?: boolean; +} + +function isEscaped(source: string, index: number): boolean { + let backslashes = 0; + for (let position = index - 1; position >= 0 && source[position] === "\\"; position--) { + backslashes++; + } + return backslashes % 2 === 1; +} + +function findClosingDelimiter(source: string, closing: string, start: number): number { + let index = source.indexOf(closing, start); + while (index >= 0 && isEscaped(source, index)) { + index = source.indexOf(closing, index + closing.length); + } + return index; +} + +function looksLikePendingDollarMath(source: string): boolean { + return /\\[A-Za-z]+|[_^=+*/<>()[\]|±≤≥≠≈∈→⇒∞∫∑√-]/.test(source); +} + +function tokenizeInlineLatex(source: string): LatexToken | undefined { + let opening = ""; + let closing = ""; + if (source.startsWith("$$")) { + opening = "$$"; + closing = "$$"; + } else if (source.startsWith("\\(")) { + opening = "\\("; + closing = "\\)"; + } else if (source.startsWith("\\[")) { + opening = "\\["; + closing = "\\]"; + } else if (source.startsWith("$") && !/^\$\s/.test(source)) { + opening = "$"; + closing = "$"; + } else { + return undefined; + } + + const closingIndex = findClosingDelimiter(source, closing, opening.length); + if ( + closingIndex >= 0 && + opening === "$" && + (/\s$/.test(source.slice(opening.length, closingIndex)) || + /^\d/.test(source.slice(closingIndex + 1)) || + (/^[A-Z_][A-Z0-9_]*(?:[^A-Za-z0-9_\s])?$/.test(source.slice(opening.length, closingIndex)) && + /^[A-Za-z_][A-Za-z0-9_]*/.test(source.slice(closingIndex + 1))) || + source.slice(opening.length, closingIndex).includes("`")) + ) { + return undefined; + } + + if (closingIndex < 0) { + const pendingSource = source.slice(opening.length); + if (opening.startsWith("\\") || looksLikePendingDollarMath(pendingSource)) { + return { type: "latex", raw: source, text: pendingSource, pending: true }; + } + return undefined; + } + + const text = source.slice(opening.length, closingIndex); + if (!text || text.includes("\n")) { + return undefined; + } + + const raw = source.slice(0, closingIndex + closing.length); + return { type: "latex", raw, text }; +} + +function tokenizeBlockLatex(source: string): LatexToken | undefined { + const dollarMatch = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*?)\$\$[ \t]*(?:\n|$)/.exec(source); + if (dollarMatch?.[1]) { + return { type: "latexBlock", raw: dollarMatch[0], text: dollarMatch[1].trim() }; + } + + const bracketMatch = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*?)\\\][ \t]*(?:\n|$)/.exec(source); + if (bracketMatch?.[1]) { + return { type: "latexBlock", raw: bracketMatch[0], text: bracketMatch[1].trim() }; + } + + const pendingBracket = /^ {0,3}\\\[[ \t]*(?:\n)?([\s\S]*)$/.exec(source); + if (pendingBracket) { + return { type: "latexBlock", raw: pendingBracket[0], text: pendingBracket[1]!, pending: true }; + } + const pendingDollar = /^ {0,3}\$\$[ \t]*(?:\n)?([\s\S]*)$/.exec(source); + if (pendingDollar?.[1] && looksLikePendingDollarMath(pendingDollar[1])) { + return { type: "latexBlock", raw: pendingDollar[0], text: pendingDollar[1], pending: true }; + } + return undefined; +} + +const LATEX_MARKDOWN_EXTENSIONS: readonly TokenizerExtension[] = [ + { + name: "latexBlock", + level: "block", + start(source) { + const match = /(?:^|\n) {0,3}(?:\$\$|\\\[)/.exec(source); + return match ? match.index + (match[0].startsWith("\n") ? 1 : 0) : undefined; + }, + tokenizer: tokenizeBlockLatex, + }, + { + name: "latex", + level: "inline", + start(source) { + const indices = [source.indexOf("$"), source.indexOf("\\("), source.indexOf("\\[")].filter( + (index) => index >= 0, + ); + return indices.length > 0 ? Math.min(...indices) : undefined; + }, + tokenizer: tokenizeInlineLatex, + }, +]; + function trimPartialClosingFences(tokens: readonly Token[]): void { const token = tokens[tokens.length - 1]; if (token?.type === "list") { @@ -51,6 +172,7 @@ const markdownParser = new Marked(); markdownParser.setOptions({ tokenizer: new StrictStrikethroughTokenizer(), }); +markdownParser.use({ extensions: [...LATEX_MARKDOWN_EXTENSIONS] }); /** * Default text styling for markdown content. @@ -100,6 +222,10 @@ export interface MarkdownOptions { preserveOrderedListMarkers?: boolean; /** Preserve source backslash escapes instead of normalizing escaped punctuation. */ preserveBackslashEscapes?: boolean; + /** Transform source Markdown before parsing, with the exact width available for content. */ + transform?: (markdown: string, availableWidth: number) => string; + /** Render supported LaTeX math expressions as Unicode text (default: true). */ + renderLatex?: boolean; } interface InlineStyleContext { @@ -156,9 +282,10 @@ export class Markdown implements Component { // Calculate available width for content (subtract horizontal padding) const contentWidth = Math.max(1, width - this.paddingX * 2); + const text = this.options.transform?.(this.text, contentWidth) ?? this.text; // Don't render anything if there's no actual text - if (!this.text || this.text.trim() === "") { + if (!text || text.trim() === "") { const result: string[] = []; // Update cache this.cachedText = this.text; @@ -168,7 +295,7 @@ export class Markdown implements Component { } // Replace tabs with 3 spaces for consistent rendering - const normalizedText = this.text.replace(/\t/g, " "); + const normalizedText = text.replace(/\t/g, " "); // Parse markdown to HTML-like tokens const tokens = markdownParser.lexer(normalizedText); @@ -375,6 +502,21 @@ export class Markdown implements Component { lines.push(this.renderInlineTokens([token], styleContext)); break; + case "latexBlock": { + const latexToken = token as LatexToken; + const rendered = + !latexToken.pending && this.options.renderLatex !== false + ? (renderLatex(latexToken.text, { display: true }) ?? latexToken.raw.trim()) + : latexToken.raw.trim(); + for (const line of rendered.split("\n")) { + lines.push(this.applyDefaultStyle(line)); + } + if (nextTokenType && nextTokenType !== "space") { + lines.push(""); + } + break; + } + case "code": { const indent = this.theme.codeBlockIndent ?? " "; lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); @@ -500,6 +642,16 @@ export class Markdown implements Component { for (const token of tokens) { switch (token.type) { + case "latex": { + const latexToken = token as LatexToken; + const rendered = + !latexToken.pending && this.options.renderLatex !== false + ? (renderLatex(latexToken.text) ?? latexToken.raw) + : latexToken.raw; + result += applyTextWithNewlines(rendered); + break; + } + case "escape": result += applyTextWithNewlines(this.options.preserveBackslashEscapes ? token.raw : token.text); break; diff --git a/packages/pi-tui/src/components/scroll-view.ts b/packages/pi-tui/src/components/scroll-view.ts new file mode 100644 index 00000000000..92bce6465ca --- /dev/null +++ b/packages/pi-tui/src/components/scroll-view.ts @@ -0,0 +1,200 @@ +import { LAYOUT_NODE, type ScrollLayoutNode } from "../layout-node.ts"; +import { type Component, Container } from "../tui.ts"; + +export type ScrollViewScrollbar = "hidden" | "auto" | "always"; + +export interface ScrollViewOptions { + axis?: "vertical"; + follow?: "none" | "end"; + primary?: boolean; + overscroll?: "chain" | "contain"; + scrollbar?: ScrollViewScrollbar; + scrollbarStyle?: (text: string) => string; + scrollbarHideDelayMs?: number; +} + +export class ScrollView extends Container { + private readonly child: Component; + private readonly followEnd: boolean; + readonly primary: boolean; + readonly overscroll: "chain" | "contain"; + readonly scrollbarStyle: (text: string) => string; + private currentScrollbar: ScrollViewScrollbar; + private readonly scrollbarHideDelayMs: number; + private currentScrollTop = 0; + private contentHeight = 0; + private currentViewportHeight = 0; + private followingEnd: boolean; + private requestRenderCallback: (() => void) | undefined; + private transientScrollbarVisible = false; + private scrollbarActive = false; + private scrollbarHideTimer: NodeJS.Timeout | undefined; + + constructor(component: Component, options: ScrollViewOptions = {}) { + super(); + if (options.axis !== undefined && options.axis !== "vertical") { + throw new Error(`Unsupported ScrollView axis: ${options.axis}`); + } + this.child = component; + this.children.push(component); + this.followEnd = (options.follow ?? "none") === "end"; + this.followingEnd = this.followEnd; + this.primary = options.primary ?? false; + this.overscroll = options.overscroll ?? "chain"; + this.currentScrollbar = options.scrollbar ?? "hidden"; + this.scrollbarStyle = options.scrollbarStyle ?? ((text) => `\x1b[100m${text}\x1b[49m`); + this.scrollbarHideDelayMs = Math.max(0, Math.floor(options.scrollbarHideDelayMs ?? 1000)); + } + + get scrollTop(): number { + return this.currentScrollTop; + } + + /** Whether the content currently exceeds the viewport (scrolling is possible). */ + get canScroll(): boolean { + return this.contentHeight > this.currentViewportHeight; + } + + get isFollowingEnd(): boolean { + return this.followingEnd; + } + + get viewportHeight(): number { + return this.currentViewportHeight; + } + + get scrollbar(): ScrollViewScrollbar { + return this.currentScrollbar; + } + + get isScrollbarVisible(): boolean { + if (this.scrollbar === "always") return this.currentViewportHeight > 0; + return ( + this.scrollbar === "auto" && this.contentHeight > this.currentViewportHeight && this.transientScrollbarVisible + ); + } + + setScrollbar(scrollbar: ScrollViewScrollbar): void { + if (scrollbar === this.currentScrollbar) return; + this.currentScrollbar = scrollbar; + if (scrollbar !== "auto") this.hideTransientScrollbar(); + else if (this.scrollbarActive) this.markScrollbarActivity(); + this.requestRenderCallback?.(); + } + + getContentWidth(width: number): number { + return this.scrollbar === "always" && width > 1 ? width - 1 : width; + } + + private markScrollbarActivity(): void { + if (this.scrollbar !== "auto" || this.contentHeight <= this.currentViewportHeight) return; + this.transientScrollbarVisible = true; + if (this.scrollbarHideTimer) { + clearTimeout(this.scrollbarHideTimer); + this.scrollbarHideTimer = undefined; + } + if (this.scrollbarActive) return; + this.scrollbarHideTimer = setTimeout(() => { + this.scrollbarHideTimer = undefined; + this.transientScrollbarVisible = false; + this.requestRenderCallback?.(); + }, this.scrollbarHideDelayMs); + this.scrollbarHideTimer.unref(); + } + + private hideTransientScrollbar(): void { + this.transientScrollbarVisible = false; + if (!this.scrollbarHideTimer) return; + clearTimeout(this.scrollbarHideTimer); + this.scrollbarHideTimer = undefined; + } + + setScrollbarActive(active: boolean): void { + if (active === this.scrollbarActive) return; + this.scrollbarActive = active; + this.markScrollbarActivity(); + } + + scrollTo(scrollTop: number): void { + const requested = Number.isFinite(scrollTop) ? Math.trunc(scrollTop) : this.currentScrollTop; + const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); + const next = Math.max(0, Math.min(maxScrollTop, requested)); + if (next === this.currentScrollTop) return; + this.currentScrollTop = next; + this.followingEnd = this.followEnd && next === maxScrollTop; + this.markScrollbarActivity(); + this.requestRenderCallback?.(); + } + + scrollBy(lines: number): number { + const requested = Number.isFinite(lines) ? Math.trunc(lines) : 0; + if (requested === 0) return 0; + const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); + const start = this.followingEnd ? maxScrollTop : this.currentScrollTop; + const next = Math.max(0, Math.min(maxScrollTop, start + requested)); + const moved = next - start; + this.currentScrollTop = next; + this.followingEnd = this.followEnd && next === maxScrollTop; + if (moved !== 0) { + this.markScrollbarActivity(); + this.requestRenderCallback?.(); + } + return requested - moved; + } + + scrollToStart(): void { + const changed = + this.currentScrollTop !== 0 || + this.followingEnd !== (this.followEnd && this.contentHeight <= this.currentViewportHeight); + this.currentScrollTop = 0; + this.followingEnd = this.followEnd && this.contentHeight <= this.currentViewportHeight; + if (changed) { + this.markScrollbarActivity(); + this.requestRenderCallback?.(); + } + } + + scrollToEnd(): void { + const next = Math.max(0, this.contentHeight - this.currentViewportHeight); + const changed = this.currentScrollTop !== next || this.followingEnd !== this.followEnd; + this.currentScrollTop = next; + this.followingEnd = this.followEnd; + if (changed) { + this.markScrollbarActivity(); + this.requestRenderCallback?.(); + } + } + + updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void { + this.contentHeight = Math.max(0, Math.floor(contentHeight)); + this.currentViewportHeight = Math.max(0, Math.floor(viewportHeight)); + this.requestRenderCallback = requestRender; + const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); + if (this.followingEnd) this.currentScrollTop = maxScrollTop; + else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop)); + if (this.followEnd && this.currentScrollTop === maxScrollTop) this.followingEnd = true; + if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar(); + } + + override addChild(_component: Component): void { + throw new Error("ScrollView has exactly one child"); + } + + override removeChild(_component: Component): void { + throw new Error("ScrollView child cannot be removed"); + } + + override clear(): void { + throw new Error("ScrollView child cannot be cleared"); + } + + override render(width: number): string[] { + const contentWidth = this.getContentWidth(width); + const lines = this.child.render(contentWidth); + return contentWidth === width ? lines : lines.map((line) => `${line} `); + } + + [LAYOUT_NODE](): ScrollLayoutNode { + return { type: "scroll", component: this.child, state: this }; + } +} diff --git a/packages/pi-tui/src/components/settings-list.ts b/packages/pi-tui/src/components/settings-list.ts index 8711923a1e7..b31bf0167cf 100644 --- a/packages/pi-tui/src/components/settings-list.ts +++ b/packages/pi-tui/src/components/settings-list.ts @@ -182,16 +182,15 @@ export class SettingsList implements Component { } else if (kb.matches(data, "tui.select.down")) { if (displayItems.length === 0) return; this.selectedIndex = this.selectedIndex === displayItems.length - 1 ? 0 : this.selectedIndex + 1; - } else if (kb.matches(data, "tui.select.confirm") || data === " ") { + } else if ( + kb.matches(data, "tui.select.confirm") || + (data === " " && (!this.searchEnabled || this.searchInput?.getValue().length === 0)) + ) { this.activateItem(); } else if (kb.matches(data, "tui.select.cancel")) { this.onCancel(); } else if (this.searchEnabled && this.searchInput) { - const sanitized = data.replace(/ /g, ""); - if (!sanitized) { - return; - } - this.searchInput.handleInput(sanitized); + this.searchInput.handleInput(data); this.applyFilter(this.searchInput.getValue()); } } diff --git a/packages/pi-tui/src/components/stack.ts b/packages/pi-tui/src/components/stack.ts new file mode 100644 index 00000000000..68bde036845 --- /dev/null +++ b/packages/pi-tui/src/components/stack.ts @@ -0,0 +1,154 @@ +import { LAYOUT_NODE, type LayoutViewport, type StackLayoutEntry, type StackLayoutNode } from "../layout-node.ts"; +import { type Component, Container } from "../tui.ts"; + +export interface StackEntryOptions { + basis?: number | "auto"; + grow?: number; + shrink?: number; + minSize?: number; + maxSize?: number; + visible?: (viewport: LayoutViewport) => boolean; +} + +export interface StackEntry extends StackEntryOptions { + component: Component; +} + +export type StackChild = Component | StackEntry; + +export interface StackOptions { + gap?: number; + align?: "stretch" | "start" | "center" | "end"; +} + +function isStackEntry(child: StackChild): child is StackEntry { + return !("render" in child); +} + +function normalizeSize(value: number | undefined, fallback: number): number { + return value === undefined || !Number.isFinite(value) ? fallback : Math.max(0, Math.floor(value)); +} + +export abstract class Stack extends Container { + protected readonly entries: StackLayoutEntry[] = []; + protected readonly gap: number; + protected readonly align: "stretch" | "start" | "center" | "end"; + protected abstract readonly layoutType: "vstack" | "hstack"; + + constructor(children: StackChild[] = [], options: StackOptions = {}) { + super(); + this.gap = normalizeSize(options.gap, 0); + this.align = options.align ?? "stretch"; + for (const child of children) { + if (isStackEntry(child)) this.addChild(child.component, child); + else this.addChild(child); + } + } + + override addChild(component: Component, options: StackEntryOptions = {}): void { + super.addChild(component); + this.entries.push({ + component, + ...(options.basis === undefined ? {} : { basis: options.basis }), + ...(options.grow === undefined ? {} : { grow: normalizeSize(options.grow, 0) }), + ...(options.shrink === undefined ? {} : { shrink: normalizeSize(options.shrink, 1) }), + ...(options.minSize === undefined ? {} : { minSize: normalizeSize(options.minSize, 0) }), + ...(options.maxSize === undefined ? {} : { maxSize: normalizeSize(options.maxSize, Number.MAX_SAFE_INTEGER) }), + ...(options.visible === undefined ? {} : { visible: options.visible }), + }); + } + + override removeChild(component: Component): void { + super.removeChild(component); + const index = this.entries.findIndex((entry) => entry.component === component); + if (index !== -1) this.entries.splice(index, 1); + } + + override clear(): void { + super.clear(); + this.entries.length = 0; + } + + [LAYOUT_NODE](): StackLayoutNode { + return { + type: this.layoutType, + entries: this.entries, + gap: this.gap, + align: this.align, + }; + } +} + +export function visibleStackEntries( + entries: readonly StackLayoutEntry[], + viewport: LayoutViewport, +): StackLayoutEntry[] { + return entries.filter((entry) => entry.visible?.(viewport) ?? true); +} + +function clampSize(size: number, entry: StackLayoutEntry): number { + const min = Math.max(0, Math.floor(entry.minSize ?? 0)); + const max = Math.max(min, Math.floor(entry.maxSize ?? Number.MAX_SAFE_INTEGER)); + return Math.max(min, Math.min(max, Math.max(0, Math.floor(size)))); +} + +function distribute( + sizes: number[], + entries: readonly StackLayoutEntry[], + amount: number, + mode: "grow" | "shrink", +): void { + let remaining = amount; + while (remaining > 0) { + const candidates = entries + .map((entry, index) => ({ entry, index })) + .filter(({ entry, index }) => { + if (mode === "grow") { + return (entry.grow ?? 0) > 0 && sizes[index]! < (entry.maxSize ?? Number.MAX_SAFE_INTEGER); + } + return (entry.shrink ?? 1) > 0 && sizes[index]! > (entry.minSize ?? 0); + }); + if (candidates.length === 0) return; + + const totalWeight = candidates.reduce((sum, { entry, index }) => { + return sum + (mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!)); + }, 0); + let distributed = 0; + for (const { entry, index } of candidates) { + if (remaining <= 0) break; + const weight = mode === "grow" ? (entry.grow ?? 0) : (entry.shrink ?? 1) * Math.max(1, sizes[index]!); + const proposed = Math.max(1, Math.floor((remaining * weight) / totalWeight)); + const capacity = + mode === "grow" + ? (entry.maxSize ?? Number.MAX_SAFE_INTEGER) - sizes[index]! + : sizes[index]! - (entry.minSize ?? 0); + const delta = Math.min(remaining, proposed, capacity); + if (delta <= 0) continue; + sizes[index] = sizes[index]! + (mode === "grow" ? delta : -delta); + remaining -= delta; + distributed += delta; + } + if (distributed === 0) return; + } +} + +export function allocateStackSizes( + entries: readonly StackLayoutEntry[], + intrinsicSizes: readonly number[], + availableSize: number | undefined, + gap: number, +): number[] { + const sizes = entries.map((entry, index) => + clampSize( + entry.basis === undefined || entry.basis === "auto" ? (intrinsicSizes[index] ?? 0) : entry.basis, + entry, + ), + ); + if (availableSize === undefined) return sizes; + + const contentSize = Math.max(0, Math.floor(availableSize) - Math.max(0, entries.length - 1) * gap); + const total = sizes.reduce((sum, size) => sum + size, 0); + if (total < contentSize) distribute(sizes, entries, contentSize - total, "grow"); + else if (total > contentSize) distribute(sizes, entries, total - contentSize, "shrink"); + return sizes; +} diff --git a/packages/pi-tui/src/components/v-stack.ts b/packages/pi-tui/src/components/v-stack.ts new file mode 100644 index 00000000000..ce7b9292d6d --- /dev/null +++ b/packages/pi-tui/src/components/v-stack.ts @@ -0,0 +1,33 @@ +import { allocateStackSizes, Stack, type StackChild, type StackOptions, visibleStackEntries } from "./stack.ts"; + +export class VStack extends Stack { + protected readonly layoutType = "vstack" as const; + + constructor(children: StackChild[] = [], options: StackOptions = {}) { + super(children, options); + } + + override render(width: number): string[] { + const viewport = { width: Math.max(1, width), height: Number.MAX_SAFE_INTEGER }; + const entries = visibleStackEntries(this.entries, viewport); + const rendered = entries.map((entry) => entry.component.render(viewport.width)); + const sizes = allocateStackSizes( + entries, + rendered.map((lines) => lines.length), + undefined, + this.gap, + ); + const lines: string[] = []; + for (let index = 0; index < entries.length; index++) { + if (index > 0) { + for (let gap = 0; gap < this.gap; gap++) lines.push(""); + } + const childLines = rendered[index]!.slice(0, sizes[index]); + lines.push(...childLines); + for (let padding = childLines.length; padding < sizes[index]!; padding++) lines.push(""); + } + return lines; + } +} + +export type { StackChild, StackEntry, StackEntryOptions, StackOptions } from "./stack.ts"; diff --git a/packages/pi-tui/src/index.ts b/packages/pi-tui/src/index.ts index 4e76b1079b2..00e9d414f44 100644 --- a/packages/pi-tui/src/index.ts +++ b/packages/pi-tui/src/index.ts @@ -1,5 +1,6 @@ // Core TUI interfaces and classes +export { Marked, type Token, type Tokens } from "marked"; // Autocomplete support export { type AutocompleteItem, @@ -12,10 +13,12 @@ export { export { Box } from "./components/box.ts"; export { CancellableLoader } from "./components/cancellable-loader.ts"; export { Editor, type EditorOptions, type EditorTheme } from "./components/editor.ts"; +export { HStack } from "./components/h-stack.ts"; export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts"; export { Input } from "./components/input.ts"; export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts"; +export { ScrollView, type ScrollViewOptions, type ScrollViewScrollbar } from "./components/scroll-view.ts"; export { type SelectItem, SelectList, @@ -27,6 +30,13 @@ export { type SettingItem, SettingsList, type SettingsListTheme } from "./compon export { Spacer } from "./components/spacer.ts"; export { Text } from "./components/text.ts"; export { TruncatedText } from "./components/truncated-text.ts"; +export { + type StackChild, + type StackEntry, + type StackEntryOptions, + type StackOptions, + VStack, +} from "./components/v-stack.ts"; // Editor component interface (for custom editors) export type { EditorComponent } from "./editor-component.ts"; // Fuzzy matching @@ -57,6 +67,8 @@ export { parseKey, setKittyProtocolActive, } from "./keys.ts"; +// LaTeX rendering +export { type RenderLatexOptions, renderLatex } from "./latex.ts"; // Input buffering for batch splitting export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations @@ -100,15 +112,31 @@ export { type Component, Container, CURSOR_MARKER, + compositeTuiLine, type Focusable, isFocusable, + isViewportTUI, type OverlayAnchor, type OverlayHandle, type OverlayMargin, type OverlayOptions, type OverlayUnfocusOptions, type SizeValue, - TUI, + type TUI, + type TuiInputListener, + type TuiInputListenerResult, + type TuiMode, + type TuiStopOptions, + type ViewportTUI, } from "./tui.ts"; +export { TuiAltScreen, type TuiAltScreenOptions } from "./tui-alt-screen.ts"; +export { TuiMainScreen, type TuiMainScreenRenderState } from "./tui-main-screen.ts"; // Utilities -export { sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "./utils.ts"; +export { + getOsc8LinkAtColumn, + sliceByColumn, + stripTerminalSequences, + truncateToWidth, + visibleWidth, + wrapTextWithAnsi, +} from "./utils.ts"; diff --git a/packages/pi-tui/src/keybindings.ts b/packages/pi-tui/src/keybindings.ts index 3aea1fed493..8a08ca10dc0 100644 --- a/packages/pi-tui/src/keybindings.ts +++ b/packages/pi-tui/src/keybindings.ts @@ -8,6 +8,8 @@ export interface Keybindings { // Editor navigation and editing "tui.editor.cursorUp": true; "tui.editor.cursorDown": true; + "tui.editor.historyPrevious": true; + "tui.editor.historyNext": true; "tui.editor.cursorLeft": true; "tui.editor.cursorRight": true; "tui.editor.cursorWordLeft": true; @@ -39,6 +41,15 @@ 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.halfPageUp": true; + "tui.altScreen.halfPageDown": true; + "tui.altScreen.previousPrompt": true; + "tui.altScreen.nextPrompt": true; + "tui.altScreen.top": true; + "tui.altScreen.bottom": true; } export type Keybinding = keyof Keybindings; @@ -54,6 +65,14 @@ export type KeybindingsConfig = Record; export const TUI_KEYBINDINGS = { "tui.editor.cursorUp": { defaultKeys: "up", description: "Move cursor up" }, "tui.editor.cursorDown": { defaultKeys: "down", description: "Move cursor down" }, + "tui.editor.historyPrevious": { + defaultKeys: [], + description: "Select previous prompt history entry", + }, + "tui.editor.historyNext": { + defaultKeys: [], + description: "Select next prompt history entry", + }, "tui.editor.cursorLeft": { defaultKeys: ["left", "ctrl+b"], description: "Move cursor left", @@ -71,11 +90,11 @@ export const TUI_KEYBINDINGS = { description: "Move cursor word right", }, "tui.editor.cursorLineStart": { - defaultKeys: ["home", "ctrl+a"], + defaultKeys: ["home", "ctrl+home", "ctrl+a"], description: "Move to line start", }, "tui.editor.cursorLineEnd": { - defaultKeys: ["end", "ctrl+e"], + defaultKeys: ["end", "ctrl+end", "ctrl+e"], description: "Move to line end", }, "tui.editor.jumpForward": { @@ -86,8 +105,8 @@ export const TUI_KEYBINDINGS = { defaultKeys: "ctrl+alt+]", description: "Jump backward to character", }, - "tui.editor.pageUp": { defaultKeys: "pageUp", description: "Page up" }, - "tui.editor.pageDown": { defaultKeys: "pageDown", description: "Page down" }, + "tui.editor.pageUp": { defaultKeys: ["pageUp", "ctrl+pageUp"], description: "Page up" }, + "tui.editor.pageDown": { defaultKeys: ["pageDown", "ctrl+pageDown"], description: "Page down" }, "tui.editor.deleteCharBackward": { defaultKeys: "backspace", description: "Delete character backward", @@ -131,6 +150,33 @@ export const TUI_KEYBINDINGS = { defaultKeys: ["escape", "ctrl+c"], description: "Cancel selection", }, + // These intentionally shadow the unmodified editor bindings in fullscreen mode. + "tui.altScreen.pageUp": { + defaultKeys: "pageUp", + description: "Scroll viewport up one page", + }, + "tui.altScreen.pageDown": { + defaultKeys: "pageDown", + description: "Scroll viewport down one page", + }, + "tui.altScreen.halfPageUp": { + defaultKeys: [], + description: "Scroll viewport up half a page", + }, + "tui.altScreen.halfPageDown": { + defaultKeys: [], + description: "Scroll viewport down half a page", + }, + "tui.altScreen.previousPrompt": { + defaultKeys: "ctrl+shift+up", + description: "Jump to previous semantic prompt", + }, + "tui.altScreen.nextPrompt": { + defaultKeys: "ctrl+shift+down", + description: "Jump to next semantic prompt", + }, + "tui.altScreen.top": { defaultKeys: "home", description: "Scroll viewport to top" }, + "tui.altScreen.bottom": { defaultKeys: "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 a543db6d5e2..4d04cc801ec 100644 --- a/packages/pi-tui/src/keys.ts +++ b/packages/pi-tui/src/keys.ts @@ -1159,8 +1159,8 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (data === `\x1b${rawCtrl}`) return true; } - if (modifier === MODIFIERS.alt && !_kittyProtocolActive && (isLetter || isDigit)) { - // Legacy: alt+letter/digit is ESC followed by the key + if (modifier === MODIFIERS.alt && !_kittyProtocolActive && (isLetter || isDigit || SYMBOL_KEYS.has(key))) { + // Legacy: alt+printable key is ESC followed by the key if (data === `\x1b${key}`) return true; } @@ -1296,9 +1296,10 @@ export function parseKey(data: string): string | undefined { if (code >= 1 && code <= 26) { return `ctrl+alt+${String.fromCharCode(code + 96)}`; } - // Legacy alt+letter/digit (ESC followed by the key) - if ((code >= 97 && code <= 122) || (code >= 48 && code <= 57)) { - return `alt+${String.fromCharCode(code)}`; + // Legacy alt+letter/digit/symbol (ESC followed by the key) + const key = String.fromCharCode(code); + if ((code >= 97 && code <= 122) || (code >= 48 && code <= 57) || SYMBOL_KEYS.has(key)) { + return `alt+${key}`; } } if (data === "\x1b[A") return "up"; diff --git a/packages/pi-tui/src/latex.ts b/packages/pi-tui/src/latex.ts new file mode 100644 index 00000000000..2b2e16deb48 --- /dev/null +++ b/packages/pi-tui/src/latex.ts @@ -0,0 +1,1373 @@ +import { visibleWidth } from "./utils.ts"; + +const SYMBOLS: Readonly> = { + alpha: "α", + beta: "β", + gamma: "γ", + delta: "δ", + epsilon: "ϵ", + varepsilon: "ε", + zeta: "ζ", + eta: "η", + theta: "θ", + vartheta: "ϑ", + iota: "ι", + kappa: "κ", + varkappa: "ϰ", + lambda: "λ", + mu: "μ", + nu: "ν", + xi: "ξ", + pi: "π", + varpi: "ϖ", + rho: "ρ", + varrho: "ϱ", + sigma: "σ", + varsigma: "ς", + tau: "τ", + upsilon: "υ", + phi: "ϕ", + varphi: "φ", + chi: "χ", + psi: "ψ", + omega: "ω", + Gamma: "Γ", + Delta: "Δ", + Theta: "Θ", + Lambda: "Λ", + Xi: "Ξ", + Pi: "Π", + Sigma: "Σ", + Upsilon: "Υ", + Phi: "Φ", + Psi: "Ψ", + Omega: "Ω", + pm: "±", + mp: "∓", + times: "×", + div: "÷", + cdot: "·", + ast: "∗", + star: "⋆", + circ: "∘", + bullet: "•", + oplus: "⊕", + ominus: "⊖", + otimes: "⊗", + oslash: "⊘", + odot: "⊙", + bigcirc: "○", + dagger: "†", + ddagger: "‡", + amalg: "⨿", + uplus: "⊎", + sqcap: "⊓", + sqcup: "⊔", + triangleleft: "◁", + triangleright: "▷", + wr: "≀", + cap: "∩", + cup: "∪", + bigcap: "⋂", + bigcup: "⋃", + bigwedge: "⋀", + bigvee: "⋁", + bigsqcup: "⨆", + biguplus: "⨄", + bigoplus: "⨁", + bigotimes: "⨂", + bigodot: "⨀", + setminus: "∖", + in: "∈", + notin: "∉", + ni: "∋", + subset: "⊂", + supset: "⊃", + subseteq: "⊆", + supseteq: "⊇", + sqsubset: "⊏", + sqsupset: "⊐", + sqsubseteq: "⊑", + sqsupseteq: "⊒", + prec: "≺", + preceq: "≼", + succ: "≻", + succeq: "≽", + ll: "≪", + gg: "≫", + le: "≤", + leq: "≤", + leqslant: "≤", + ge: "≥", + geq: "≥", + geqslant: "≥", + ne: "≠", + neq: "≠", + equiv: "≡", + approx: "≈", + sim: "∼", + simeq: "≃", + cong: "≅", + asymp: "≍", + doteq: "≐", + propto: "∝", + parallel: "∥", + perp: "⊥", + mid: "∣", + vdash: "⊢", + dashv: "⊣", + models: "⊨", + Vdash: "⊩", + Vvdash: "⊪", + nvdash: "⊬", + nvDash: "⊭", + forall: "∀", + exists: "∃", + nexists: "∄", + neg: "¬", + land: "∧", + wedge: "∧", + lor: "∨", + vee: "∨", + to: "→", + rightarrow: "→", + longrightarrow: "→", + leftarrow: "←", + longleftarrow: "←", + gets: "←", + leftrightarrow: "↔", + longleftrightarrow: "↔", + hookleftarrow: "↩", + hookrightarrow: "↪", + twoheadleftarrow: "↞", + twoheadrightarrow: "↠", + leftharpoonup: "↼", + leftharpoondown: "↽", + rightharpoonup: "⇀", + rightharpoondown: "⇁", + rightleftharpoons: "⇌", + leftrightharpoons: "⇋", + nearrow: "↗", + searrow: "↘", + swarrow: "↙", + nwarrow: "↖", + rightsquigarrow: "⇝", + leadsto: "⇝", + Rightarrow: "⇒", + Longrightarrow: "⇒", + Leftarrow: "⇐", + Longleftarrow: "⇐", + Leftrightarrow: "⇔", + Longleftrightarrow: "⇔", + implies: "⇒", + iff: "⇔", + mapsto: "↦", + longmapsto: "↦", + uparrow: "↑", + downarrow: "↓", + partial: "∂", + nabla: "∇", + int: "∫", + iint: "∬", + iiint: "∭", + oint: "∮", + sum: "∑", + prod: "∏", + coprod: "∐", + infty: "∞", + emptyset: "∅", + varnothing: "∅", + angle: "∠", + therefore: "∴", + because: "∵", + aleph: "ℵ", + beth: "ℶ", + gimel: "ℷ", + daleth: "ℸ", + top: "⊤", + bot: "⊥", + triangle: "△", + square: "□", + lozenge: "◊", + checkmark: "✓", + complement: "∁", + wp: "℘", + prime: "′", + ldots: "…", + dots: "…", + cdots: "⋯", + vdots: "⋮", + ddots: "⋱", + ell: "ℓ", + hbar: "ℏ", + Im: "ℑ", + Re: "ℜ", + langle: "⟨", + rangle: "⟩", + vert: "|", + lvert: "|", + rvert: "|", + Vert: "‖", + lVert: "‖", + rVert: "‖", + lbrace: "{", + rbrace: "}", + backslash: "\\", + lfloor: "⌊", + rfloor: "⌋", + lceil: "⌈", + rceil: "⌉", + colon: ":", +}; + +const NAMED_OPERATORS = new Set([ + "arccos", + "arcsin", + "arctan", + "arg", + "cos", + "cosh", + "cot", + "coth", + "csc", + "deg", + "det", + "dim", + "exp", + "gcd", + "hom", + "inf", + "ker", + "lg", + "lim", + "liminf", + "limsup", + "ln", + "log", + "max", + "min", + "Pr", + "sec", + "sin", + "sinh", + "sup", + "tan", + "tanh", +]); + +const LIMIT_OPERATORS = new Set([ + "argmax", + "argmin", + "inf", + "injlim", + "lim", + "liminf", + "limsup", + "max", + "min", + "projlim", + "sup", +]); + +const DISPLAY_LIMIT_SYMBOLS = new Set([ + "bigcap", + "bigcup", + "bigodot", + "bigoplus", + "bigotimes", + "bigsqcup", + "biguplus", + "bigvee", + "bigwedge", + "coprod", + "int", + "iint", + "iiint", + "oint", + "prod", + "sum", +]); + +const RELATION_COMMANDS = new Set([ + "Leftarrow", + "Leftrightarrow", + "Longleftarrow", + "Longleftrightarrow", + "Longrightarrow", + "Rightarrow", + "Vdash", + "Vvdash", + "approx", + "asymp", + "cong", + "dashv", + "doteq", + "downarrow", + "equiv", + "ge", + "geq", + "geqslant", + "gets", + "gg", + "hookleftarrow", + "hookrightarrow", + "iff", + "implies", + "in", + "leadsto", + "le", + "leftarrow", + "leftharpoondown", + "leftharpoonup", + "leftrightarrow", + "leftrightharpoons", + "leq", + "leqslant", + "ll", + "longleftarrow", + "longleftrightarrow", + "longmapsto", + "longrightarrow", + "mapsto", + "mid", + "models", + "ne", + "nearrow", + "neq", + "ni", + "notin", + "nvdash", + "nvDash", + "nwarrow", + "parallel", + "perp", + "prec", + "preceq", + "propto", + "rightharpoondown", + "rightharpoonup", + "rightleftharpoons", + "rightarrow", + "rightsquigarrow", + "searrow", + "sim", + "simeq", + "sqsubset", + "sqsubseteq", + "sqsupset", + "sqsupseteq", + "subset", + "subseteq", + "succ", + "succeq", + "supset", + "supseteq", + "swarrow", + "to", + "triangleleft", + "triangleright", + "twoheadleftarrow", + "twoheadrightarrow", + "uparrow", + "vdash", +]); + +const NEGATED_SYMBOLS: Readonly> = { + "<": "≮", + ">": "≯", + "=": "≠", + "∈": "∉", + "∋": "∌", + "∣": "∤", + "∥": "∦", + "∼": "≁", + "≃": "≄", + "≅": "≇", + "≈": "≉", + "≡": "≢", + "≤": "≰", + "≥": "≱", + "≺": "⊀", + "≻": "⊁", + "⊂": "⊄", + "⊃": "⊅", + "⊆": "⊈", + "⊇": "⊉", + "⊢": "⊬", + "⊨": "⊭", + "↔": "↮", + "←": "↚", + "→": "↛", + "⇒": "⇏", + "⇐": "⇍", + "⇔": "⇎", + "≼": "⋠", + "≽": "⋡", +}; + +const BLACKBOARD: Readonly> = { + C: "ℂ", + H: "ℍ", + N: "ℕ", + P: "ℙ", + Q: "ℚ", + R: "ℝ", + Z: "ℤ", +}; + +const SUPERSCRIPTS: Readonly> = { + "0": "⁰", + "1": "¹", + "2": "²", + "3": "³", + "4": "⁴", + "5": "⁵", + "6": "⁶", + "7": "⁷", + "8": "⁸", + "9": "⁹", + "+": "⁺", + "-": "⁻", + "=": "⁼", + "(": "⁽", + ")": "⁾", + a: "ᵃ", + b: "ᵇ", + c: "ᶜ", + d: "ᵈ", + e: "ᵉ", + f: "ᶠ", + g: "ᵍ", + h: "ʰ", + i: "ⁱ", + j: "ʲ", + k: "ᵏ", + l: "ˡ", + m: "ᵐ", + n: "ⁿ", + o: "ᵒ", + p: "ᵖ", + r: "ʳ", + s: "ˢ", + t: "ᵗ", + u: "ᵘ", + v: "ᵛ", + w: "ʷ", + x: "ˣ", + y: "ʸ", + z: "ᶻ", +}; + +const SUBSCRIPTS: Readonly> = { + "0": "₀", + "1": "₁", + "2": "₂", + "3": "₃", + "4": "₄", + "5": "₅", + "6": "₆", + "7": "₇", + "8": "₈", + "9": "₉", + "+": "₊", + "-": "₋", + "=": "₌", + "(": "₍", + ")": "₎", + a: "ₐ", + e: "ₑ", + h: "ₕ", + i: "ᵢ", + j: "ⱼ", + k: "ₖ", + l: "ₗ", + m: "ₘ", + n: "ₙ", + o: "ₒ", + p: "ₚ", + r: "ᵣ", + s: "ₛ", + t: "ₜ", + u: "ᵤ", + v: "ᵥ", + x: "ₓ", +}; + +const SPACING_COMMANDS = new Set([ + ",", + ":", + ";", + " ", + ">", + "enspace", + "enskip", + "medspace", + "quad", + "qquad", + "thickspace", + "thinspace", +]); +const NEGATIVE_SPACING_COMMANDS = new Set(["!", "negmedspace", "negthickspace", "negthinspace"]); +const NEGATIVE_SPACE = "\u0000"; +const IGNORED_COMMANDS = new Set([ + "displaystyle", + "limits", + "nolimits", + "scriptstyle", + "scriptscriptstyle", + "textstyle", +]); +const SIZE_COMMANDS = new Set([ + "big", + "Big", + "bigg", + "Bigg", + "bigl", + "Bigl", + "biggl", + "Biggl", + "bigr", + "Bigr", + "biggr", + "Biggr", +]); +const PLAIN_WRAPPERS = new Set([ + "emph", + "mathcal", + "mathbf", + "mathfrak", + "mathit", + "mathrm", + "mathnormal", + "mathscr", + "mathsf", + "mathtt", + "mathup", + "mbox", + "overbrace", + "pmb", + "smash", + "substack", + "text", + "textbf", + "textit", + "textmd", + "textnormal", + "textrm", + "textsc", + "textsf", + "textsl", + "texttt", + "textup", + "underbrace", + "bm", + "boldsymbol", +]); +const ACCENTS: Readonly> = { + acute: "\u0301", + bar: "\u0305", + breve: "\u0306", + check: "\u030c", + ddot: "\u0308", + dot: "\u0307", + grave: "\u0300", + hat: "\u0302", + mathring: "\u030a", + overleftarrow: "\u20d6", + overleftrightarrow: "\u20e1", + overline: "\u0305", + overrightarrow: "\u20d7", + tilde: "\u0303", + underline: "\u0332", + vec: "\u20d7", + widehat: "\u0302", + widetilde: "\u0303", +}; + +function replaceCharacters(value: string, replacements: Readonly>): string | undefined { + let result = ""; + for (const character of value) { + const replacement = replacements[character]; + if (replacement === undefined) { + return undefined; + } + result += replacement; + } + return result; +} + +function formatScript(value: string, kind: "sub" | "sup"): string { + value = value.trim(); + const replacements = kind === "sub" ? SUBSCRIPTS : SUPERSCRIPTS; + const unicode = replaceCharacters(value.replace(/\s*([=+-])\s*/g, "$1"), replacements); + if (unicode !== undefined) { + return unicode; + } + + const prefix = kind === "sub" ? "_" : "^"; + if (Array.from(value).length === 1 || (kind === "sub" && /^[A-Za-z]+$/.test(value))) { + return `${prefix}${value}`; + } + return `${prefix}(${value})`; +} + +function formatFraction(numerator: string, denominator: string): string { + numerator = numerator.trim(); + denominator = denominator.trim(); + const simpleNumerator = /^[\p{L}\p{N}.]+$/u.test(numerator); + const simpleDenominator = /^[\p{N}.]+$/u.test(denominator) || Array.from(denominator).length === 1; + return `${simpleNumerator ? numerator : `(${numerator})`}/${simpleDenominator ? denominator : `(${denominator})`}`; +} + +function formatRoot(value: string, symbol = "√"): string { + value = value.trim(); + return /^[\p{L}\p{N}.]+$/u.test(value) ? `${symbol}${value}` : `${symbol}(${value})`; +} + +const NAMED_OPERATOR_START = "\u{f0004}"; +const NAMED_OPERATOR_END = "\u{f0005}"; +const NAMED_OPERATOR_LEFT_SPACING_PATTERN = /(?<=[\p{L}\p{N})\]}\u{f0001}])\u{f0004}/gu; +const NAMED_OPERATOR_RIGHT_SPACING_PATTERN = /\u{f0005}(?=[\p{L}\p{N}√\u{f0000}])/gu; + +function normalizeOutput(value: string): string { + return value + .replace(NAMED_OPERATOR_LEFT_SPACING_PATTERN, " ") + .replaceAll(NAMED_OPERATOR_START, "") + .replace(NAMED_OPERATOR_RIGHT_SPACING_PATTERN, " ") + .replaceAll(NAMED_OPERATOR_END, "") + .split("\n") + .map((line) => line.replace(/[ \t]+/g, " ").trim()) + .filter((line, index, lines) => line.length > 0 || (index > 0 && index < lines.length - 1)) + .join("\n") + .trim(); +} + +interface FractionNode { + type: "fraction"; + numerator: string; + denominator: string; +} + +interface OperatorNode { + type: "operator"; + operator: string; + lower?: string; + upper?: string; +} + +interface MatrixNode { + type: "matrix"; + lines: string[]; + baseline: number; +} + +type LayoutNode = FractionNode | OperatorNode | MatrixNode; + +interface Layout { + lines: string[]; + width: number; + baseline: number; +} + +const LAYOUT_MARKER_START = "\u{f0000}"; +const LAYOUT_MARKER_END = "\u{f0001}"; +const LAYOUT_MARKER_PATTERN = /\u{f0000}(\d+)\u{f0001}/gu; +const TRAILING_LAYOUT_MARKER_PATTERN = /\u{f0000}(\d+)\u{f0001}$/u; +const PROTECTED_SPACE = "\u{f0002}"; + +function padLayoutLine(line: string, width: number, centered = false): string { + const padding = Math.max(0, width - visibleWidth(line)); + const left = centered ? Math.floor(padding / 2) : 0; + return `${" ".repeat(left)}${line}${" ".repeat(padding - left)}`; +} + +function joinLayouts(layouts: readonly Layout[]): Layout { + if (layouts.length === 0) { + return { lines: [""], width: 0, baseline: 0 }; + } + const baseline = Math.max(...layouts.map((layout) => layout.baseline)); + const below = Math.max(...layouts.map((layout) => layout.lines.length - layout.baseline - 1)); + const lines: string[] = []; + for (let row = 0; row <= baseline + below; row++) { + let line = ""; + for (const layout of layouts) { + const sourceRow = row - baseline + layout.baseline; + line += + sourceRow >= 0 && sourceRow < layout.lines.length + ? padLayoutLine(layout.lines[sourceRow] ?? "", layout.width) + : " ".repeat(layout.width); + } + lines.push(line.trimEnd()); + } + return { + lines, + width: layouts.reduce((width, layout) => width + layout.width, 0), + baseline, + }; +} + +function renderLayout(source: string, nodes: readonly LayoutNode[]): Layout { + const renderedLines: string[] = []; + let firstBaseline = 0; + for (const sourceLine of source.split("\n")) { + const layouts: Layout[] = []; + let position = 0; + let previousNode: LayoutNode | undefined; + for (const match of sourceLine.matchAll(LAYOUT_MARKER_PATTERN)) { + const index = match.index; + const node = nodes[Number(match[1])]; + if (!node) { + continue; + } + if (index > position) { + const sliced = sourceLine.slice(position, index); + const trimmed = (previousNode ? sliced.trimStart() : sliced).trimEnd(); + const preserveLeadingSpace = previousNode?.type === "matrix" && /^\s/.test(sliced); + const preserveTrailingSpace = node.type === "matrix" && /\s$/.test(sliced); + const text = trimmed + ? `${preserveLeadingSpace ? " " : ""}${trimmed}${preserveTrailingSpace ? " " : ""}` + : preserveLeadingSpace || preserveTrailingSpace + ? " " + : ""; + layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); + } + if (node.type === "fraction") { + const numerator = renderLayout(node.numerator, nodes); + const denominator = renderLayout(node.denominator, nodes); + const contentWidth = Math.max(numerator.width, denominator.width, 1); + const width = contentWidth + 2; + layouts.push({ + lines: [ + ...numerator.lines.map((line) => padLayoutLine(line, width, true)), + ` ${"─".repeat(contentWidth)} `, + ...denominator.lines.map((line) => padLayoutLine(line, width, true)), + ], + width, + baseline: numerator.lines.length, + }); + } else if (node.type === "operator") { + const contentWidth = Math.max( + visibleWidth(node.operator), + node.lower === undefined ? 0 : visibleWidth(node.lower), + node.upper === undefined ? 0 : visibleWidth(node.upper), + ); + const lines: string[] = []; + if (node.upper !== undefined) { + lines.push(`${padLayoutLine(node.upper, contentWidth, true)} `); + } + lines.push(`${padLayoutLine(node.operator, contentWidth, true)} `); + if (node.lower !== undefined) { + lines.push(`${padLayoutLine(node.lower, contentWidth, true)} `); + } + layouts.push({ + lines, + width: contentWidth + 1, + baseline: node.upper === undefined ? 0 : 1, + }); + } else { + const width = Math.max(0, ...node.lines.map((line) => visibleWidth(line))); + layouts.push({ + lines: node.lines.map((line) => padLayoutLine(line, width)), + width, + baseline: node.baseline, + }); + } + position = index + match[0].length; + previousNode = node; + } + if (position < sourceLine.length) { + const sliced = sourceLine.slice(position); + const trimmed = previousNode ? sliced.trimStart() : sliced; + const text = previousNode?.type === "matrix" && /^\s/.test(sliced) ? ` ${trimmed}` : trimmed; + layouts.push({ lines: [text], width: visibleWidth(text), baseline: 0 }); + } + const lineLayout = joinLayouts(layouts); + if (renderedLines.length === 0) { + firstBaseline = lineLayout.baseline; + } + renderedLines.push(...lineLayout.lines); + } + return { + lines: renderedLines, + width: Math.max(0, ...renderedLines.map((line) => visibleWidth(line))), + baseline: firstBaseline, + }; +} + +class LatexParser { + private readonly source: string; + private readonly layoutNodes: LayoutNode[]; + private readonly display: boolean; + private position = 0; + private supported = true; + private stackFractions = true; + + constructor(source: string, layoutNodes: LayoutNode[], display: boolean) { + this.source = source; + this.layoutNodes = layoutNodes; + this.display = display; + } + + render(): string | undefined { + const rendered = this.parseSequence(); + if (!this.supported || this.position !== this.source.length) { + return undefined; + } + return normalizeOutput(rendered); + } + + private parseSequence(endCharacter?: string): string { + let result = ""; + while (this.position < this.source.length) { + const character = this.source[this.position]!; + if (endCharacter && character === endCharacter) { + this.position++; + return result; + } + + if (character === "}") { + this.supported = false; + return result; + } + + if (character === "{") { + this.position++; + result += this.parseSequence("}"); + continue; + } + + if (character === "\\") { + const command = this.parseCommand(); + if (command === NEGATIVE_SPACE) { + result = result.trimEnd(); + if (result.endsWith(NAMED_OPERATOR_END)) { + result = result.slice(0, -NAMED_OPERATOR_END.length); + } + } else { + result += command; + } + continue; + } + + if (character === "^" || character === "_") { + this.position++; + result = result.trimEnd(); + const script = formatScript(this.parseRequiredArgument(false), character === "_" ? "sub" : "sup"); + if (result.endsWith(NAMED_OPERATOR_END)) { + result = `${result.slice(0, -NAMED_OPERATOR_END.length)}${script}${NAMED_OPERATOR_END}`; + } else { + result += script; + } + continue; + } + + if (/\s/.test(character)) { + result += this.parseWhitespace(); + continue; + } + + if (character === "=" || character === "<" || character === ">") { + result = `${result.trimEnd()} ${character} `; + this.position++; + continue; + } + + if (character === "&") { + this.position++; + continue; + } + + if (character === "~") { + this.position++; + result += " "; + continue; + } + + if (character === ".") { + const marker = TRAILING_LAYOUT_MARKER_PATTERN.exec(result); + const node = marker ? this.layoutNodes[Number(marker[1])] : undefined; + if (node?.type === "matrix") { + const lastLine = node.lines.length - 1; + node.lines[lastLine] = `${node.lines[lastLine] ?? ""}${character}`; + this.position++; + continue; + } + } + + result += character; + this.position++; + } + + if (endCharacter) { + this.supported = false; + } + return result; + } + + private parseWhitespace(): string { + while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) { + this.position++; + } + return " "; + } + + private parseCommand(): string { + this.position++; + if (this.position >= this.source.length) { + this.supported = false; + return ""; + } + + let command = ""; + const first = this.source[this.position] ?? ""; + if (/[A-Za-z]/.test(first)) { + const start = this.position; + while (this.position < this.source.length && /[A-Za-z]/.test(this.source[this.position] ?? "")) { + this.position++; + } + command = this.source.slice(start, this.position); + } else { + command = first; + this.position++; + } + + if (command === "\\") { + return "\n"; + } + if (SPACING_COMMANDS.has(command)) { + return " "; + } + if (NEGATIVE_SPACING_COMMANDS.has(command)) { + return NEGATIVE_SPACE; + } + if (IGNORED_COMMANDS.has(command)) { + return ""; + } + if ( + command === "{" || + command === "}" || + command === "$" || + command === "%" || + command === "#" || + command === "_" || + command === "&" + ) { + return command; + } + if (command === "|") { + return "‖"; + } + if (command === "not") { + const value = this.parseRequiredArgument(false).trim(); + const negated = NEGATED_SYMBOLS[value]; + if (negated !== undefined) { + return ` ${negated} `; + } + const characters = Array.from(value); + if (characters.length === 0) { + this.supported = false; + return ""; + } + return ` ${characters[0]}\u0338${characters.slice(1).join("")} `; + } + if (LIMIT_OPERATORS.has(command)) { + return this.parseOperator(command, "bracket", true, true); + } + + const symbol = SYMBOLS[command]; + if (symbol !== undefined) { + if (DISPLAY_LIMIT_SYMBOLS.has(command)) { + return this.parseOperator(symbol, "script", true); + } + return command === "cdot" || command === "times" || RELATION_COMMANDS.has(command) ? ` ${symbol} ` : symbol; + } + if (NAMED_OPERATORS.has(command)) { + return `${NAMED_OPERATOR_START}${command}${NAMED_OPERATOR_END}`; + } + if (SIZE_COMMANDS.has(command)) { + return ""; + } + if (command === "left" || command === "middle" || command === "right") { + if (this.source[this.position] === ".") { + this.position++; + } + return ""; + } + if (command === "frac" || command === "dfrac" || command === "tfrac") { + const shouldStack = this.display && this.stackFractions && command !== "tfrac"; + const numerator = this.parseRequiredArgument(!shouldStack); + const denominator = this.parseRequiredArgument(!shouldStack); + if (shouldStack) { + const index = + this.layoutNodes.push({ + type: "fraction", + numerator: normalizeOutput(numerator), + denominator: normalizeOutput(denominator), + }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + return formatFraction(numerator, denominator); + } + if (command === "sqrt") { + const degree = this.parseOptionalArgument()?.trim(); + const value = this.parseRequiredArgument(); + if (degree === undefined || degree === "2") { + return formatRoot(value); + } + if (degree === "3") { + return formatRoot(value, "∛"); + } + if (degree === "4") { + return formatRoot(value, "∜"); + } + return `${formatScript(degree, "sup")}${formatRoot(value)}`; + } + if (command === "boxed" || command === "fbox") { + return `[${this.parseRequiredArgument().trim()}]`; + } + if (command === "binom" || command === "dbinom" || command === "tbinom") { + return `(${this.parseRequiredArgument()} choose ${this.parseRequiredArgument()})`; + } + const accent = ACCENTS[command]; + if (accent !== undefined) { + const value = this.parseRequiredArgument(); + return Array.from(value).length === 1 ? `${value}${accent}` : `${command}(${value})`; + } + if (command === "mathbb") { + const value = this.parseRequiredArgument(); + return Array.from(value, (character) => BLACKBOARD[character] ?? character).join(""); + } + if (command === "operatorname") { + const starred = this.source[this.position] === "*"; + if (starred) { + this.position++; + } + const operator = normalizeOutput(this.parseRequiredArgument()).trim(); + return this.parseOperator(operator, "bracket", starred, true); + } + if (command === "mod" || command === "bmod") { + return " mod "; + } + if (command === "pmod" || command === "pod") { + const value = this.parseRequiredArgument().trim(); + return command === "pmod" ? ` (mod ${value})` : ` (${value})`; + } + if (command === "overset" || command === "stackrel") { + const upper = this.parseRequiredArgument(); + const value = this.parseRequiredArgument().trim(); + return `${value}${formatScript(upper, "sup")}`; + } + if (command === "underset") { + const lower = this.parseRequiredArgument(); + const value = this.parseRequiredArgument().trim(); + return `${value}${formatScript(lower, "sub")}`; + } + if (PLAIN_WRAPPERS.has(command)) { + const value = this.parseRequiredArgument(); + return command.startsWith("text") || command === "mbox" ? value : value.trim(); + } + if (command === "begin") { + return this.parseEnvironment(); + } + if (command === "end") { + this.supported = false; + return ""; + } + + this.supported = false; + return `\\${command}`; + } + + private parseOperator( + operator: string, + inlineLowerStyle: "bracket" | "script", + displayLimits: boolean, + spaced = false, + ): string { + let useDisplayLimits = displayLimits; + let modifierPosition = this.position; + while (modifierPosition < this.source.length && /[ \t]/.test(this.source[modifierPosition] ?? "")) { + modifierPosition++; + } + const modifier = /^\\(limits|nolimits)(?![A-Za-z])/.exec(this.source.slice(modifierPosition)); + if (modifier) { + useDisplayLimits = modifier[1] === "limits"; + this.position = modifierPosition + modifier[0].length; + } + + let lower: string | undefined; + let upper: string | undefined; + while (true) { + let scriptPosition = this.position; + while (scriptPosition < this.source.length && /[ \t]/.test(this.source[scriptPosition] ?? "")) { + scriptPosition++; + } + const kind = this.source[scriptPosition]; + if (kind !== "_" && kind !== "^") { + break; + } + this.position = scriptPosition + 1; + const value = normalizeOutput(this.parseRequiredArgument(false)).replaceAll(" ", ""); + if (kind === "_") { + if (lower !== undefined) { + this.supported = false; + } + lower = value; + } else { + if (upper !== undefined) { + this.supported = false; + } + upper = value; + } + } + + if (this.display && useDisplayLimits && (lower !== undefined || upper !== undefined)) { + const index = this.layoutNodes.push({ type: "operator", operator, lower, upper }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + + let rendered = operator; + if (lower !== undefined) { + rendered += inlineLowerStyle === "bracket" ? `[${lower}]` : formatScript(lower, "sub"); + } + if (upper !== undefined) { + rendered += formatScript(upper, "sup"); + } + return spaced ? ` ${rendered} ` : rendered; + } + + private parseRequiredArgument(stackFractions = true): string { + const previousStackFractions = this.stackFractions; + this.stackFractions = previousStackFractions && stackFractions; + const value = this.parseRequiredArgumentValue(); + this.stackFractions = previousStackFractions; + return value; + } + + private parseRequiredArgumentValue(): string { + while (this.position < this.source.length && /[ \t]/.test(this.source[this.position] ?? "")) { + this.position++; + } + if (this.position >= this.source.length) { + this.supported = false; + return ""; + } + if (this.source[this.position] === "{") { + this.position++; + return this.parseSequence("}"); + } + if (this.source[this.position] === "\\") { + return this.parseCommand(); + } + const value = this.source[this.position] ?? ""; + this.position++; + return value; + } + + private parseOptionalArgument(): string | undefined { + while (this.position < this.source.length && /[ \t]/.test(this.source[this.position] ?? "")) { + this.position++; + } + if (this.source[this.position] !== "[") { + return undefined; + } + const end = this.source.indexOf("]", this.position + 1); + if (end < 0) { + this.supported = false; + return undefined; + } + const value = this.source.slice(this.position + 1, end); + this.position = end + 1; + return this.renderNested(value); + } + + private readRawGroup(): string | undefined { + while (this.position < this.source.length && /[ \t]/.test(this.source[this.position] ?? "")) { + this.position++; + } + if (this.source[this.position] !== "{") { + this.supported = false; + return undefined; + } + + const start = ++this.position; + let depth = 1; + while (this.position < this.source.length) { + const character = this.source[this.position]; + if (character === "\\") { + this.position += 2; + continue; + } + if (character === "{") depth++; + if (character === "}") depth--; + if (depth === 0) { + const value = this.source.slice(start, this.position); + this.position++; + return value; + } + this.position++; + } + this.supported = false; + return undefined; + } + + private splitEnvironmentRows(body: string): string[] { + return body.split(/\\\\(?:\[[^\]\n]*\])?/); + } + + private parseEnvironment(): string { + const environment = this.readRawGroup(); + if (!environment) { + return ""; + } + const endMarker = `\\end{${environment}}`; + const end = this.source.indexOf(endMarker, this.position); + if (end < 0) { + this.supported = false; + return ""; + } + const body = this.source.slice(this.position, end); + this.position = end + endMarker.length; + + if (environment === "equation" || environment === "equation*" || environment === "displaymath") { + return this.renderNested(body).trim(); + } + + if ( + environment === "aligned" || + environment === "align" || + environment === "align*" || + environment === "alignedat" || + environment === "alignat" || + environment === "alignat*" || + environment === "gather" || + environment === "gathered" || + environment === "multline" || + environment === "multline*" || + environment === "split" + ) { + const alignedAt = ["alignedat", "alignat", "alignat*"].includes(environment); + const alignedBody = alignedAt ? body.replace(/^\s*\{[^}]*\}/, "") : body; + return this.splitEnvironmentRows(alignedBody) + .map((row) => { + const cells = row.split("&"); + const source = alignedAt + ? Array.from({ length: Math.ceil(cells.length / 2) }, (_, index) => + cells.slice(index * 2, index * 2 + 2).join(""), + ).join(" ") + : cells.join(""); + return this.renderNested(source).trim(); + }) + .filter(Boolean) + .join("\n"); + } + + if (environment === "cases" || environment === "cases*") { + const rows = this.splitEnvironmentRows(body) + .map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())) + .filter((row) => row.some(Boolean)); + return rows + .map((row, index) => { + const value = (row[0] ?? "").replace(/,\s*$/, ""); + const condition = row[1] ?? ""; + const delimiter = index === 0 ? "⎧" : index === rows.length - 1 ? "⎩" : "⎨"; + const conditionPrefix = /^(?:if|when|for|otherwise)\b/i.test(condition) ? " " : " if "; + return `${delimiter} ${value}${condition ? `${conditionPrefix}${condition}` : ""}`; + }) + .join("\n"); + } + + if ( + ["array", "matrix", "smallmatrix", "pmatrix", "bmatrix", "Bmatrix", "vmatrix", "Vmatrix"].includes(environment) + ) { + const matrixBody = environment === "array" ? body.replace(/^\s*\{[^}]*\}/, "") : body; + return this.renderMatrix(environment, matrixBody); + } + + this.supported = false; + return body; + } + + private renderMatrix(environment: string, body: string): string { + const matrix = this.splitEnvironmentRows(body) + .map((row) => row.split("&").map((cell) => this.renderNested(cell, false).trim())) + .filter((row) => row.some(Boolean)); + const columnCount = Math.max(0, ...matrix.map((row) => row.length)); + const columnWidths = Array.from({ length: columnCount }, (_, column) => + Math.max(0, ...matrix.map((row) => visibleWidth(row[column] ?? ""))), + ); + const rows = matrix.map((row) => + Array.from({ length: columnCount }, (_, column) => { + const cell = row[column] ?? ""; + return `${cell}${PROTECTED_SPACE.repeat(Math.max(0, (columnWidths[column] ?? 0) - visibleWidth(cell)))}`; + }).join(" │ "), + ); + + let lines: string[]; + if (environment === "array" || environment === "matrix" || environment === "smallmatrix") { + lines = rows; + } else { + const delimiters: Readonly> = { + pmatrix: ["⎛", "⎞", "⎜", "⎟", "⎝", "⎠"], + bmatrix: ["⎡", "⎤", "⎢", "⎥", "⎣", "⎦"], + Bmatrix: ["⎧", "⎫", "⎨", "⎬", "⎩", "⎭"], + vmatrix: ["│", "│", "│", "│", "│", "│"], + Vmatrix: ["║", "║", "║", "║", "║", "║"], + }; + const delimiter = delimiters[environment]; + if (!delimiter) { + this.supported = false; + return rows.join("\n"); + } + lines = rows.map((row, index) => { + const left = index === 0 ? delimiter[0] : index === rows.length - 1 ? delimiter[4] : delimiter[2]; + const right = index === 0 ? delimiter[1] : index === rows.length - 1 ? delimiter[5] : delimiter[3]; + return `${left} ${row} ${right}`; + }); + } + + if (lines.length <= 1) { + return lines[0] ?? ""; + } + const index = this.layoutNodes.push({ type: "matrix", lines, baseline: 0 }) - 1; + return `${LAYOUT_MARKER_START}${index}${LAYOUT_MARKER_END}`; + } + + private renderNested(source: string, stackFractions = true): string { + const rendered = new LatexParser(source, this.layoutNodes, this.display && stackFractions).render(); + if (rendered === undefined) { + this.supported = false; + return source; + } + return rendered; + } +} + +export interface RenderLatexOptions { + /** Stack fractions and operator limits vertically for display math (default: false). */ + display?: boolean; +} + +/** + * Render a basic LaTeX math expression as terminal-friendly Unicode text. + * Returns undefined when the expression contains unsupported or malformed syntax. + */ +export function renderLatex(source: string, options: RenderLatexOptions = {}): string | undefined { + const layoutNodes: LayoutNode[] = []; + const rendered = new LatexParser(source, layoutNodes, options.display === true).render(); + if (rendered === undefined) { + return undefined; + } + if (layoutNodes.length === 0) { + return rendered.replaceAll(PROTECTED_SPACE, " "); + } + const lines = renderLayout(rendered, layoutNodes).lines; + const indentation = Math.min( + ...lines.filter((line) => line.trim()).map((line) => line.length - line.trimStart().length), + ); + return lines + .map((line) => line.slice(indentation).trimEnd()) + .join("\n") + .trimEnd() + .replaceAll(PROTECTED_SPACE, " "); +} diff --git a/packages/pi-tui/src/layout-node.ts b/packages/pi-tui/src/layout-node.ts new file mode 100644 index 00000000000..a3c06b24564 --- /dev/null +++ b/packages/pi-tui/src/layout-node.ts @@ -0,0 +1,51 @@ +import type { Component } from "./tui.ts"; + +export const LAYOUT_NODE = Symbol.for("@earendil-works/pi-tui/layout-node"); + +export interface LayoutViewport { + width: number; + height: number; +} + +export interface StackLayoutEntry { + component: Component; + basis?: number | "auto"; + grow?: number; + shrink?: number; + minSize?: number; + maxSize?: number; + visible?: (viewport: LayoutViewport) => boolean; +} + +export interface StackLayoutNode { + type: "vstack" | "hstack"; + entries: readonly StackLayoutEntry[]; + gap: number; + align: "stretch" | "start" | "center" | "end"; +} + +export interface ScrollLayoutState { + readonly scrollTop: number; + readonly primary: boolean; + readonly overscroll: "chain" | "contain"; + readonly viewportHeight: number; + getContentWidth(width: number): number; + updateLayout(contentHeight: number, viewportHeight: number, requestRender: () => void): void; +} + +export interface ScrollLayoutNode { + type: "scroll"; + component: Component; + state: ScrollLayoutState; +} + +export type LayoutNode = StackLayoutNode | ScrollLayoutNode; + +export interface LayoutComponent extends Component { + [LAYOUT_NODE](): LayoutNode; +} + +export function getLayoutNode(component: Component): LayoutNode | undefined { + const candidate = component as Partial; + return typeof candidate[LAYOUT_NODE] === "function" ? candidate[LAYOUT_NODE]() : undefined; +} diff --git a/packages/pi-tui/src/layout.ts b/packages/pi-tui/src/layout.ts new file mode 100644 index 00000000000..a139a2fff81 --- /dev/null +++ b/packages/pi-tui/src/layout.ts @@ -0,0 +1,402 @@ +import type { ScrollView } from "./components/scroll-view.ts"; +import { allocateStackSizes, visibleStackEntries } from "./components/stack.ts"; +import { getLayoutNode } from "./layout-node.ts"; +import { cropKittyImageLine, getKittyImageMetadata, isImageLine } from "./terminal-image.ts"; +import { type Component, CURSOR_MARKER, compositeTuiLine } from "./tui.ts"; +import { extractAnsiCode, getGraphemeCellRange, sliceByColumn, visibleWidth } from "./utils.ts"; + +const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/; + +export interface LayoutRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface LayoutBox { + component: Component; + rect: LayoutRect; + clip: LayoutRect; + children: LayoutBox[]; + parent?: LayoutBox; + lines?: readonly string[]; + lineOffset?: number; + scrollView?: ScrollView; + scrollContentLines?: readonly string[]; + layer: number; +} + +export interface LayoutFrame { + root: LayoutBox; + width: number; + height: number; + lines: string[]; + primaryScrollView?: ScrollView; +} + +export interface ScrollbarGeometry { + column: number; + trackTop: number; + trackHeight: number; + thumbTop: number; + thumbHeight: number; + maxScrollTop: number; +} + +interface LayoutContext { + viewport: { width: number; height: number }; + renderCache: Map>; + requestRender: () => void; + primaryScrollView: ScrollView | undefined; +} + +function intersect(a: LayoutRect, b: LayoutRect): LayoutRect { + const x = Math.max(a.x, b.x); + const y = Math.max(a.y, b.y); + const right = Math.min(a.x + a.width, b.x + b.width); + const bottom = Math.min(a.y + a.height, b.y + b.height); + return { x, y, width: Math.max(0, right - x), height: Math.max(0, bottom - y) }; +} + +function renderCached(context: LayoutContext, component: Component, width: number): string[] { + const safeWidth = Math.max(1, Math.floor(width)); + let widths = context.renderCache.get(component); + if (!widths) { + widths = new Map(); + context.renderCache.set(component, widths); + } + let lines = widths.get(safeWidth); + if (!lines) { + lines = component.render(safeWidth); + widths.set(safeWidth, lines); + } + return lines; +} + +function measureHeight(context: LayoutContext, component: Component, width: number): number { + return renderCached(context, component, width).length; +} + +function measureWidth(context: LayoutContext, component: Component, width: number): number { + return renderCached(context, component, width).reduce((max, line) => Math.max(max, visibleWidth(line)), 0); +} + +function withParent(box: LayoutBox, parent: LayoutBox): LayoutBox { + box.parent = parent; + return box; +} + +function translateBox(box: LayoutBox, deltaY: number): void { + box.rect.y += deltaY; + for (const child of box.children) translateBox(child, deltaY); +} + +function updateClips(box: LayoutBox, parentClip: LayoutRect): void { + box.clip = intersect(parentClip, box.rect); + for (const child of box.children) updateClips(child, box.clip); +} + +function layoutComponent( + context: LayoutContext, + component: Component, + x: number, + y: number, + width: number, + height: number | undefined, + clip: LayoutRect, +): LayoutBox { + const safeWidth = Math.max(1, Math.floor(width)); + const node = getLayoutNode(component); + if (!node) { + const lines = renderCached(context, component, safeWidth); + const allocatedHeight = height === undefined ? lines.length : Math.max(0, Math.floor(height)); + let lineOffset = 0; + if (lines.length > allocatedHeight && allocatedHeight > 0) { + const cursorLine = lines.findIndex((line) => line.includes(CURSOR_MARKER)); + if (cursorLine >= allocatedHeight) lineOffset = cursorLine - allocatedHeight + 1; + } + return { + component, + rect: { x, y, width: safeWidth, height: allocatedHeight }, + clip: intersect(clip, { x, y, width: safeWidth, height: allocatedHeight }), + children: [], + lines, + lineOffset, + layer: 0, + }; + } + + if (node.type === "scroll") { + const previousScrollTop = node.state.scrollTop; + const contentWidth = node.state.getContentWidth(safeWidth); + const childBox = layoutComponent( + context, + node.component, + x, + y - previousScrollTop, + contentWidth, + undefined, + clip, + ); + const contentHeight = childBox.rect.height; + const viewportHeight = height === undefined ? contentHeight : Math.max(0, Math.floor(height)); + node.state.updateLayout(contentHeight, viewportHeight, context.requestRender); + translateBox(childBox, previousScrollTop - node.state.scrollTop); + const scrollView = node.state as ScrollView; + if (node.state.primary || !context.primaryScrollView) context.primaryScrollView = scrollView; + const rect = { x, y, width: safeWidth, height: viewportHeight }; + const childClip = intersect(clip, rect); + const box: LayoutBox = { + component, + rect, + clip: childClip, + children: [childBox], + scrollView, + scrollContentLines: renderCached(context, node.component, contentWidth), + layer: 0, + }; + childBox.parent = box; + updateClips(childBox, childClip); + return box; + } + + const entries = visibleStackEntries(node.entries, context.viewport); + const gapTotal = Math.max(0, entries.length - 1) * node.gap; + if (node.type === "vstack") { + const intrinsicHeights = entries.map((entry) => + typeof entry.basis === "number" ? entry.basis : measureHeight(context, entry.component, safeWidth), + ); + const sizes = allocateStackSizes(entries, intrinsicHeights, height, node.gap); + const naturalHeight = sizes.reduce((sum, size) => sum + size, 0) + gapTotal; + const allocatedHeight = height === undefined ? naturalHeight : Math.max(0, Math.floor(height)); + const rect = { x, y, width: safeWidth, height: allocatedHeight }; + const box: LayoutBox = { + component, + rect, + clip: intersect(clip, rect), + children: [], + layer: 0, + }; + let childY = y; + for (let index = 0; index < entries.length; index++) { + box.children.push( + withParent( + layoutComponent(context, entries[index]!.component, x, childY, safeWidth, sizes[index]!, box.clip), + box, + ), + ); + childY += sizes[index]! + node.gap; + } + return box; + } + + const intrinsicWidths = entries.map((entry) => + typeof entry.basis === "number" ? entry.basis : measureWidth(context, entry.component, safeWidth), + ); + const widths = allocateStackSizes(entries, intrinsicWidths, safeWidth, node.gap); + const intrinsicHeights = entries.map((entry, index) => + measureHeight(context, entry.component, Math.max(1, widths[index]!)), + ); + const allocatedHeight = + height === undefined + ? intrinsicHeights.reduce((max, childHeight) => Math.max(max, childHeight), 0) + : Math.max(0, height); + const rect = { x, y, width: safeWidth, height: allocatedHeight }; + const box: LayoutBox = { + component, + rect, + clip: intersect(clip, rect), + children: [], + layer: 0, + }; + let childX = x; + for (let index = 0; index < entries.length; index++) { + const naturalChildHeight = intrinsicHeights[index]!; + const childHeight = node.align === "stretch" ? allocatedHeight : Math.min(allocatedHeight, naturalChildHeight); + let childY = y; + if (node.align === "center") childY += Math.floor((allocatedHeight - childHeight) / 2); + else if (node.align === "end") childY += allocatedHeight - childHeight; + const childWidth = widths[index]!; + if (childWidth === 0) { + box.children.push({ + component: entries[index]!.component, + rect: { x: childX, y: childY, width: 0, height: childHeight }, + clip: { x: childX, y: childY, width: 0, height: 0 }, + children: [], + parent: box, + layer: 0, + }); + } else { + box.children.push( + withParent( + layoutComponent(context, entries[index]!.component, childX, childY, childWidth, childHeight, box.clip), + box, + ), + ); + } + childX += childWidth + node.gap; + } + return box; +} + +function styleScrollbarCell(line: string, column: number, totalWidth: number, style: (text: string) => string): string { + if (isImageLine(line)) return line; + + const graphemeRange = getGraphemeCellRange(line, column); + const start = graphemeRange?.start ?? column; + const end = graphemeRange?.end ?? column + 1; + const before = sliceByColumn(line, 0, start, true); + const target = sliceByColumn(line, start, end - start, true); + const after = sliceByColumn(line, end, Math.max(0, totalWidth - end), true); + + let targetPrefix = ""; + let targetIndex = 0; + while (targetIndex < target.length) { + const ansi = extractAnsiCode(target, targetIndex); + if (!ansi) break; + targetPrefix += ansi.code; + targetIndex += ansi.length; + } + const targetText = target.slice(targetIndex) || " ".repeat(end - start); + const beforePadding = " ".repeat(Math.max(0, start - visibleWidth(before))); + return `${before}${beforePadding}${targetPrefix}${style(targetText)}${after}`; +} + +export function getScrollbarGeometry(box: LayoutBox): ScrollbarGeometry | undefined { + if (!box.scrollView?.isScrollbarVisible || box.rect.width <= 0 || box.rect.height <= 0) return undefined; + + const contentHeight = box.children[0]?.rect.height ?? box.scrollContentLines?.length ?? 0; + const trackHeight = box.rect.height; + + const minThumbHeight = Math.min(2, trackHeight); + const thumbHeight = Math.max( + minThumbHeight, + Math.min(trackHeight, Math.round((trackHeight * trackHeight) / contentHeight)), + ); + const maxScrollTop = Math.max(0, contentHeight - trackHeight); + const maxThumbTop = trackHeight - thumbHeight; + const thumbOffset = maxScrollTop === 0 ? 0 : Math.round((box.scrollView.scrollTop / maxScrollTop) * maxThumbTop); + const column = box.rect.x + box.rect.width - 1; + if (column < box.clip.x || column >= box.clip.x + box.clip.width) return undefined; + + return { + column, + trackTop: box.rect.y, + trackHeight, + thumbTop: box.rect.y + thumbOffset, + thumbHeight, + maxScrollTop, + }; +} + +function paintScrollbar(box: LayoutBox, screen: string[], totalWidth: number): void { + const geometry = getScrollbarGeometry(box); + if (!geometry || !box.scrollView) return; + + for (let offset = 0; offset < geometry.thumbHeight; offset++) { + const row = geometry.thumbTop + offset; + if (row < box.clip.y || row >= box.clip.y + box.clip.height || row < 0 || row >= screen.length) continue; + screen[row] = styleScrollbarCell(screen[row] ?? "", geometry.column, totalWidth, box.scrollView.scrollbarStyle); + } +} + +function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void { + if (box.lines) { + const offset = box.lineOffset ?? 0; + const firstRow = Math.max(box.rect.y, box.clip.y, 0); + const lastRow = Math.min(box.rect.y + box.rect.height, box.clip.y + box.clip.height, screen.length); + for (let row = firstRow; row < lastRow; row++) { + const sourceLine = box.lines[offset + row - box.rect.y]; + if (sourceLine === undefined) continue; + let line = sourceLine.replace(OSC133_ZONE_PREFIX, ""); + const imageMetadata = getKittyImageMetadata(line); + if (imageMetadata) { + const clipBottom = Math.min(screen.length, box.clip.y + box.clip.height); + const visibleRows = Math.min(imageMetadata.rows, clipBottom - row); + if (visibleRows < imageMetadata.rows) line = cropKittyImageLine(line, 0, visibleRows); + } + if (isImageLine(line) && box.rect.x === 0 && box.rect.width >= totalWidth) screen[row] = line; + else screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth); + } + } + for (const child of box.children) paintBox(child, screen, totalWidth); + + if (box.scrollView && box.scrollContentLines && box.scrollView.scrollTop > 0 && box.rect.height > 0) { + for (let imageRow = box.scrollView.scrollTop - 1; imageRow >= 0; imageRow--) { + const imageLine = box.scrollContentLines[imageRow] ?? ""; + const metadata = getKittyImageMetadata(imageLine); + if (metadata) { + const hiddenRows = box.scrollView.scrollTop - imageRow; + if (hiddenRows < metadata.rows) { + const visibleRows = Math.min(box.rect.height, metadata.rows - hiddenRows); + const cropped = cropKittyImageLine(imageLine, hiddenRows, visibleRows); + if (box.rect.x === 0 && box.rect.width >= totalWidth) screen[box.rect.y] = cropped; + } + break; + } + if (imageLine !== "") break; + } + } + + paintScrollbar(box, screen, totalWidth); +} + +export function renderLayoutFrame( + root: Component, + width: number, + height: number, + requestRender: () => void, +): LayoutFrame { + const safeWidth = Math.max(1, Math.floor(width)); + const safeHeight = Math.max(1, Math.floor(height)); + const context: LayoutContext = { + viewport: { width: safeWidth, height: safeHeight }, + renderCache: new Map(), + requestRender, + primaryScrollView: undefined, + }; + const rootBox = layoutComponent(context, root, 0, 0, safeWidth, safeHeight, { + x: 0, + y: 0, + width: safeWidth, + height: safeHeight, + }); + const lines = Array.from({ length: safeHeight }, () => ""); + paintBox(rootBox, lines, safeWidth); + return { + root: rootBox, + width: safeWidth, + height: safeHeight, + lines, + ...(context.primaryScrollView === undefined ? {} : { primaryScrollView: context.primaryScrollView }), + }; +} + +function containsPoint(rect: LayoutRect, x: number, y: number): boolean { + return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height; +} + +export function getScrollViewBox(frame: LayoutFrame, scrollView: ScrollView): LayoutBox | undefined { + const visit = (box: LayoutBox): LayoutBox | undefined => { + if (box.scrollView === scrollView) return box; + for (const child of box.children) { + const match = visit(child); + if (match) return match; + } + return undefined; + }; + return visit(frame.root); +} + +export function getScrollViewsAt(frame: LayoutFrame, x: number, y: number): ScrollView[] { + const result: Array<{ scrollView: ScrollView; depth: number }> = []; + const visit = (box: LayoutBox, depth: number): void => { + if (!containsPoint(box.clip, x, y)) return; + if (box.scrollView && containsPoint(box.rect, x, y)) result.push({ scrollView: box.scrollView, depth }); + for (const child of box.children) visit(child, depth + 1); + }; + visit(frame.root, 0); + result.sort((a, b) => b.depth - a.depth); + return result.map((entry) => entry.scrollView); +} diff --git a/packages/pi-tui/src/native-modifiers.ts b/packages/pi-tui/src/native-modifiers.ts index e2cd631cbdd..549ce47a93f 100644 --- a/packages/pi-tui/src/native-modifiers.ts +++ b/packages/pi-tui/src/native-modifiers.ts @@ -21,12 +21,19 @@ function isNativeModifiersHelper(value: unknown): value is NativeModifiersHelper function loadNativeModifiersHelper(): NativeModifiersHelper | undefined { if (nativeModifiersHelper !== undefined) return nativeModifiersHelper ?? undefined; nativeModifiersHelper = null; - if (process.platform !== "darwin") return undefined; const arch = process.arch; if (arch !== "x64" && arch !== "arm64") return undefined; + let nativePath: string; + if (process.platform === "darwin") { + nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node"); + } else if (process.platform === "win32") { + nativePath = path.join("native", "win32", "prebuilds", `win32-${arch}`, "win32-console-mode.node"); + } else { + return undefined; + } + const moduleDir = path.dirname(fileURLToPath(import.meta.url)); - const nativePath = path.join("native", "darwin", "prebuilds", `darwin-${arch}`, "darwin-modifiers.node"); const candidates = [ path.join(moduleDir, "..", nativePath), path.join(moduleDir, nativePath), diff --git a/packages/pi-tui/src/terminal-colors.ts b/packages/pi-tui/src/terminal-colors.ts index fec02c6b9d1..e700ee476ce 100644 --- a/packages/pi-tui/src/terminal-colors.ts +++ b/packages/pi-tui/src/terminal-colors.ts @@ -26,7 +26,7 @@ function parseOscHexChannel(channel: string): number | undefined { } const OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN = /^\x1b\]11;([^\x07\x1b]*)(?:\x07|\x1b\\)$/i; -const COLOR_SCHEME_REPORT_PATTERN = /^\x1b\[\?997;(1|2)n$/; +const COLOR_SCHEME_REPORT_PATTERN = /^(?:\x1b\[\?997;(1|2)n)+$/; export function isOsc11BackgroundColorResponse(data: string): boolean { return OSC11_BACKGROUND_COLOR_RESPONSE_PATTERN.test(data); diff --git a/packages/pi-tui/src/terminal-image.ts b/packages/pi-tui/src/terminal-image.ts index e7878de7ac2..60f3612cb72 100644 --- a/packages/pi-tui/src/terminal-image.ts +++ b/packages/pi-tui/src/terminal-image.ts @@ -1,4 +1,7 @@ import { execSync } from "node:child_process"; +import { homedir } from "node:os"; +import { isAbsolute } from "node:path"; +import { pathToFileURL } from "node:url"; export type ImageProtocol = "kitty" | "iterm2" | null; @@ -68,6 +71,7 @@ export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeT const term = process.env['TERM']?.toLowerCase() || ""; const colorTerm = process.env['COLORTERM']?.toLowerCase() || ""; const hasTrueColorHint = colorTerm === "truecolor" || colorTerm === "24bit"; + const isWindowsConsole = process.platform === "win32"; // Emit OSC 8 hyperlinks only when tmux confirms it forwards. // Image protocols are unreliable under tmux, so leave `images: null`. @@ -117,6 +121,13 @@ export function detectCapabilities(tmuxForwardsHyperlink: () => boolean = probeT return { images: null, trueColor: true, hyperlinks: false }; } + // Windows Terminal does not always set WT_SESSION, for example when it hosts + // a cmd.exe launched directly from Win+R. Modern Windows consoles support + // truecolor; keep hyperlinks off unless we positively detected support above. + if (isWindowsConsole) { + return { images: null, trueColor: true, hyperlinks: false }; + } + // Unknown terminal: be conservative. OSC 8 is rendered invisibly as "just // text" on terminals that swallow it, which means the URL disappears from // the rendered output. Default to the legacy `text (url)` behavior unless we @@ -224,6 +235,11 @@ export function deleteAllKittyImages(): string { return "\x1b_Ga=d,d=A,q=2\x1b\\"; } +/** Delete all visible Kitty placements while retaining their uploaded image data. */ +export function deleteAllKittyPlacements(): string { + return "\x1b_Ga=d,d=a,q=2\x1b\\"; +} + export function encodeITerm2( base64Data: string, options: { @@ -234,7 +250,10 @@ export function encodeITerm2( inline?: boolean; } = {}, ): string { - const params: string[] = [`inline=${options.inline !== false ? 1 : 0}`]; + const params: string[] = [ + `inline=${options.inline !== false ? 1 : 0}`, + `size=${Buffer.byteLength(base64Data, "base64")}`, + ]; if (options.width !== undefined) params.push(`width=${options.width}`); if (options.height !== undefined) params.push(`height=${options.height}`); @@ -254,6 +273,126 @@ export interface ImageCellSize { rows: number; } +export interface KittyImageMetadata extends ImageCellSize { + imageId: number; + widthPx: number; + heightPx: number; +} + +interface RegisteredKittyImageMetadata extends KittyImageMetadata { + transmissionGeneration: number; +} + +export interface KittyImagePlacement { + imageId: number; + transmissionGeneration: number; + transmissionBytes: number; + estimatedDecodedBytes: number; + sequence: string; + replacementLine: string; +} + +const kittyImageMetadata = new Map(); +let kittyTransmissionGeneration = 0; + +export function registerKittyImageMetadata(metadata: KittyImageMetadata): void { + kittyTransmissionGeneration += 1; + kittyImageMetadata.delete(metadata.imageId); + kittyImageMetadata.set(metadata.imageId, { ...metadata, transmissionGeneration: kittyTransmissionGeneration }); + if (kittyImageMetadata.size > 1000) { + const oldestImageId = kittyImageMetadata.keys().next().value; + if (oldestImageId !== undefined) kittyImageMetadata.delete(oldestImageId); + } +} + +function getRegisteredKittyImageMetadata(line: string): RegisteredKittyImageMetadata | 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 getKittyImageMetadata(line: string): KittyImageMetadata | undefined { + const metadata = getRegisteredKittyImageMetadata(line); + if (!metadata) return undefined; + return { + imageId: metadata.imageId, + columns: metadata.columns, + rows: metadata.rows, + widthPx: metadata.widthPx, + heightPx: metadata.heightPx, + }; +} + +const KITTY_PLACEMENT_CONTROL_KEYS = new Set([ + "i", + "p", + "x", + "y", + "w", + "h", + "X", + "Y", + "c", + "r", + "C", + "U", + "z", + "P", + "Q", + "H", + "V", +]); + +/** Build a placement-only command for an image line emitted by {@link renderImage}. */ +export function getKittyImagePlacement(line: string): KittyImagePlacement | undefined { + const match = /\x1b_G([^;]*);/.exec(line); + const metadata = getRegisteredKittyImageMetadata(line); + if (!match || !metadata) return undefined; + + let commandStart = match.index; + let commandControls = match[1]!; + let transmissionEnd: number; + while (true) { + const terminator = line.indexOf("\x1b\\", commandStart + KITTY_PREFIX.length); + if (terminator === -1) return undefined; + transmissionEnd = terminator + 2; + if (!/(?:^|,)m=1(?:,|$)/.test(commandControls)) break; + commandStart = transmissionEnd; + if (!line.startsWith(KITTY_PREFIX, commandStart)) return undefined; + const controlsEnd = line.indexOf(";", commandStart + KITTY_PREFIX.length); + if (controlsEnd === -1) return undefined; + commandControls = line.slice(commandStart + KITTY_PREFIX.length, controlsEnd); + } + + const controls = match[1]! + .split(",") + .filter((control) => KITTY_PLACEMENT_CONTROL_KEYS.has(control.split("=", 1)[0] ?? "")); + const sequence = `\x1b_Ga=p,q=2,${controls.join(",")}\x1b\\`; + return { + imageId: metadata.imageId, + transmissionGeneration: metadata.transmissionGeneration, + transmissionBytes: transmissionEnd - match.index, + estimatedDecodedBytes: metadata.widthPx * metadata.heightPx * 4, + sequence, + replacementLine: `${line.slice(0, match.index)}${sequence}${line.slice(transmissionEnd)}`, + }; +} + +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 || hiddenRows >= metadata.rows || visibleRows <= 0) return line; + const croppedRows = Math.min(visibleRows, metadata.rows - hiddenRows); + if (hiddenRows === 0 && croppedRows === metadata.rows) return line; + const sourceY = Math.floor((metadata.heightPx * hiddenRows) / metadata.rows); + const sourceEnd = Math.ceil((metadata.heightPx * (hiddenRows + croppedRows)) / 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=${croppedRows}`); + return `${line.slice(0, match.index)}\x1b_G${controls.join(",")};${line.slice(match.index + match[0].length)}`; +} + export function calculateImageCellSize( imageDimensions: ImageDimensions, maxWidthCells: number, @@ -433,7 +572,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) { @@ -444,13 +583,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") { @@ -459,7 +607,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; @@ -479,9 +627,30 @@ export function hyperlink(text: string, url: string): string { return `\x1b]8;;${url}\x1b\\${text}\x1b]8;;\x1b\\`; } +/** Shorten home-prefixed absolute paths to ~/... for compact display. */ +function shortenImagePath(filename: string): string { + const home = homedir(); + if (home && (filename === home || filename.startsWith(`${home}/`) || filename.startsWith(`${home}\\`))) { + return `~${filename.slice(home.length)}`; + } + return filename; +} + +/** + * Text fallback when the terminal cannot render inline images. + * Absolute paths are shown shortened (~/...) and, when OSC 8 hyperlinks are + * available, linked to file:// so the full path remains openable. + */ export function imageFallback(mimeType: string, dimensions?: ImageDimensions, filename?: string): string { const parts: string[] = []; - if (filename) parts.push(filename); + if (filename) { + const display = shortenImagePath(filename); + if (getCapabilities().hyperlinks && isAbsolute(filename)) { + parts.push(hyperlink(display, pathToFileURL(filename).href)); + } else { + parts.push(display); + } + } parts.push(`[${mimeType}]`); if (dimensions) parts.push(`${dimensions.widthPx}x${dimensions.heightPx}`); return `[Image: ${parts.join(" ")}]`; diff --git a/packages/pi-tui/src/terminal.ts b/packages/pi-tui/src/terminal.ts index 3caba9789b1..8cf084c443c 100644 --- a/packages/pi-tui/src/terminal.ts +++ b/packages/pi-tui/src/terminal.ts @@ -10,8 +10,8 @@ const cjsRequire = createRequire(import.meta.url); const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000; const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07"; -const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07"; -const APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE = "\x1b[13;2u"; +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`; @@ -41,11 +41,19 @@ export function isAppleTerminalSession(): boolean { return process.platform === "darwin" && process.env['TERM_PROGRAM'] === "Apple_Terminal"; } -export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string { - if (isAppleTerminal && data === "\r" && isShiftPressed) return APPLE_TERMINAL_SHIFT_ENTER_SEQUENCE; +export function normalizeNativeShiftEnterInput( + data: string, + shouldDetectNativeShiftEnter: boolean, + isShiftPressed: boolean, +): string { + if (shouldDetectNativeShiftEnter && data === "\r" && isShiftPressed) return NATIVE_SHIFT_ENTER_SEQUENCE; return data; } +export function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string { + return normalizeNativeShiftEnterInput(data, isAppleTerminal, isShiftPressed); +} + /** * Minimal terminal interface for TUI */ @@ -308,11 +316,12 @@ export class ProcessTerminal implements Terminal { private forwardInputSequence(sequence: string): void { if (!this.inputHandler) return; - const isAppleTerminal = sequence === "\r" && isAppleTerminalSession(); - const input = normalizeAppleTerminalInput( + const shouldDetectNativeShiftEnter = + sequence === "\r" && (isAppleTerminalSession() || process.platform === "win32"); + const input = normalizeNativeShiftEnterInput( sequence, - isAppleTerminal, - isAppleTerminal && isNativeModifierPressed("shift"), + shouldDetectNativeShiftEnter, + shouldDetectNativeShiftEnter && isNativeModifierPressed("shift"), ); this.inputHandler(input); } diff --git a/packages/pi-tui/src/tui-alt-screen.ts b/packages/pi-tui/src/tui-alt-screen.ts new file mode 100644 index 00000000000..78a9a128641 --- /dev/null +++ b/packages/pi-tui/src/tui-alt-screen.ts @@ -0,0 +1,1055 @@ +import { AltScreenFlashContainer } from "./components/alt-screen-flash.ts"; +import { ScrollView } from "./components/scroll-view.ts"; +import { getKeybindings } from "./keybindings.ts"; +import { isKeyRelease } from "./keys.ts"; +import { + getScrollbarGeometry, + getScrollViewBox, + getScrollViewsAt, + type LayoutFrame, + renderLayoutFrame, + type ScrollbarGeometry, +} from "./layout.ts"; +import type { Terminal } from "./terminal.ts"; +import { + deleteAllKittyImages, + deleteAllKittyPlacements, + deleteKittyImage, + getCapabilities, + getKittyImagePlacement, + type ImageProtocol, + isImageLine, + setCapabilities, + type TerminalCapabilities, +} from "./terminal-image.ts"; +import { + type Component, + CURSOR_MARKER, + compositeTuiLine, + TuiBase, + type TuiStopOptions, + VIEWPORT_TUI, + type ViewportTUI, +} from "./tui.ts"; +import { + extractAnsiCode, + getGraphemeCellRange, + getOsc8LinkAtColumn, + getWordSegmenter, + 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_BUTTON_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h"; +const ENABLE_ALL_MOTION_MOUSE = "\x1b[?1000h\x1b[?1002h\x1b[?1003h\x1b[?1004h\x1b[?1006h"; +const DISABLE_MOUSE = "\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; +const FOCUS_IN = "\x1b[I"; +const FOCUS_OUT = "\x1b[O"; +const BEGIN_SYNCHRONIZED_OUTPUT = "\x1b[?2026h"; +const END_SYNCHRONIZED_OUTPUT = "\x1b[?2026l"; +const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/; +const OSC133_PROMPT_START = /^\x1b\]133;A(?:\x07|\x1b\\)/; +const PAGE_SCROLL_OVERLAP = 4; +const MAX_CACHED_OFFSCREEN_KITTY_IMAGES = 16; +const MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES = 32 * 1024 * 1024; +const MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES = 64 * 1024 * 1024; +const DOUBLE_CLICK_INTERVAL_MS = 500; +const wordSegmenter = getWordSegmenter(); + +interface CachedKittyImage { + transmissionGeneration: number; + transmissionBytes: number; + estimatedDecodedBytes: number; +} + +interface SelectionPoint { + row: number; + col: number; + scrollView?: ScrollView; + /** Whether this point lies between terminal cells rather than on a cell. */ + boundary?: boolean; +} + +interface SelectionRange { + start: SelectionPoint; + end: SelectionPoint; +} + +type SelectionGranularity = "character" | "word" | "line"; + +interface ClickTarget { + timestamp: number; + count: number; + row: number; + scrollView?: ScrollView; + wordStart: number; + wordEnd: number; +} + +interface SgrMouseEvent { + button: number; + x: number; + y: number; + release: boolean; +} + +interface WheelEvent { + direction: -1 | 1; + x: number; + y: number; +} + +interface ScrollbarDrag { + scrollView: ScrollView; + grabOffset: number; +} + +interface ScrollbarTarget { + scrollView: ScrollView; + geometry: ScrollbarGeometry; +} + +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; + /** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */ + onRightClickPaste?: () => void; +} + +/** Alternate-screen TUI with a scrollable, application-owned viewport. */ +export class TuiAltScreen extends TuiBase implements ViewportTUI { + readonly mode = "fullscreen" as const; + readonly [VIEWPORT_TUI] = true as const; + private previousScreen: string[] = []; + private lastDocument: string[] = []; + private previousScreenWidth = 0; + private previousScreenHeight = 0; + private layoutRoot: Component | undefined; + private currentLayout: LayoutFrame | undefined; + private readonly implicitDocument: Component; + private readonly implicitScrollView: ScrollView; + private readonly flashes: AltScreenFlashContainer; + private altScreenActive = false; + private imageProtocol: ImageProtocol = null; + private savedCapabilities?: TerminalCapabilities; + private readonly uploadedKittyImages = new Map(); + private selectionAnchor?: SelectionPoint; + private selectionFocus?: SelectionPoint; + private selectionGranularity: SelectionGranularity = "character"; + private selectionInitialRange?: SelectionRange; + private lastClick?: ClickTarget; + private selectionDragPointer?: { x: number; y: number }; + private selectionAutoScrollDirection: -1 | 0 | 1 = 0; + private selectionAutoScrollTimer?: NodeJS.Timeout; + private selectionPressActive = false; + private scrollbarDrag?: ScrollbarDrag; + private scrollbarHover?: ScrollView; + private pressedUrl?: string; + private selectionDragged = false; + private readonly wheelScrollLines: number; + private readonly mouseEnabled: boolean; + private readonly openUrl?: (url: string) => void; + private readonly onRightClickPaste?: () => void; + + constructor( + terminal: Terminal, + showHardwareCursor?: boolean, + logDirectory?: string, + options: TuiAltScreenOptions = {}, + ) { + super(terminal, showHardwareCursor, logDirectory); + this.implicitDocument = { + render: (width) => super.render(width), + invalidate: () => { + for (const child of this.children) child.invalidate(); + }, + }; + this.implicitScrollView = new ScrollView(this.implicitDocument, { follow: "end", primary: true }); + this.flashes = new AltScreenFlashContainer(() => this.requestRender()); + this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1)); + this.mouseEnabled = options.mouse ?? true; + this.openUrl = options.openUrl; + this.onRightClickPaste = options.onRightClickPaste; + this.addInputListener((data) => this.handleViewportInput(data)); + } + + get viewportTop(): number { + return this.getPrimaryScrollView().scrollTop; + } + + get isFollowingOutput(): boolean { + return this.getPrimaryScrollView().isFollowingEnd; + } + + setLayoutRoot(component: Component | undefined): void { + if (this.layoutRoot === component) return; + this.layoutRoot = component; + this.currentLayout = undefined; + this.requestRender(); + } + + getLayoutRoot(): Component | undefined { + return this.layoutRoot; + } + + override render(width: number): string[] { + return this.layoutRoot?.render(width) ?? super.render(width); + } + + protected override getMountedRoots(): readonly Component[] { + return this.layoutRoot ? [this.layoutRoot] : this.children; + } + + private getPrimaryScrollView(): ScrollView { + return this.currentLayout?.primaryScrollView ?? this.implicitScrollView; + } + + protected override beforeTerminalStart(): void { + this.stopSelectionAutoScroll(); + this.selectionPressActive = false; + this.stopScrollbarHover(); + this.stopScrollbarDrag(); + this.flashes.dispose(); + this.altScreenActive = true; + const capabilities = getCapabilities(); + this.imageProtocol = capabilities.images; + this.uploadedKittyImages.clear(); + if (capabilities.images === "iterm2") { + this.savedCapabilities = capabilities; + setCapabilities({ ...capabilities, images: null }); + this.invalidate(); + } + this.lastDocument = []; + this.selectionAnchor = undefined; + this.selectionFocus = undefined; + this.selectionGranularity = "character"; + this.selectionInitialRange = undefined; + this.lastClick = undefined; + this.pressedUrl = undefined; + this.selectionDragged = false; + this.resetRenderState(); + const term = process.env['TERM']?.toLowerCase() ?? ""; + // Multiplexers can lag when every pointer movement is forwarded. Button-motion + // tracking preserves clicks, wheel events, selections, and scrollbar dragging. + const mouseSequence = + process.env['TMUX'] !== undefined || + process.env['ZELLIJ'] !== undefined || + process.env['STY'] !== undefined || + term.startsWith("tmux") || + term.startsWith("screen") + ? ENABLE_BUTTON_MOTION_MOUSE + : ENABLE_ALL_MOTION_MOUSE; + this.terminal.write( + `${ENTER_ALT_SCREEN}${DISABLE_AUTOWRAP}${this.mouseEnabled ? mouseSequence : ""}\x1b[2J\x1b[H\x1b[?25l`, + ); + } + + protected override beforeTerminalStop(_options: TuiStopOptions): void { + this.stopSelectionAutoScroll(); + this.selectionPressActive = false; + this.stopScrollbarHover(); + this.stopScrollbarDrag(); + this.flashes.dispose(); + if (!this.altScreenActive) return; + this.terminal.write( + `${BEGIN_SYNCHRONIZED_OUTPUT}${this.deleteKittyImages()}${this.mouseEnabled ? DISABLE_MOUSE : ""}${ENABLE_AUTOWRAP}${END_SYNCHRONIZED_OUTPUT}`, + ); + this.uploadedKittyImages.clear(); + } + + protected override afterTerminalStop(options: TuiStopOptions): void { + if (!this.altScreenActive) return; + this.altScreenActive = false; + if (options.preserveScreen) { + this.terminal.write(`${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`); + } else { + const width = Math.max(1, this.terminal.columns); + const documentLines = this.render(width).map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + this.lastDocument = this.applyLineResets(documentLines.map((line) => line.replaceAll(CURSOR_MARKER, ""))).map( + (line) => (isImageLine(line) || visibleWidth(line) <= width ? line : sliceByColumn(line, 0, width, true)), + ); + let buffer = `${BEGIN_SYNCHRONIZED_OUTPUT}${EXIT_ALT_SCREEN}${DISABLE_AUTOWRAP}`; + for (let row = 0; row < this.lastDocument.length; row++) { + if (row > 0) buffer += "\r\n"; + buffer += `\r\x1b[2K${this.lastDocument[row] ?? ""}`; + } + buffer += `\x1b[0m${ENABLE_AUTOWRAP}\r\n\x1b[?25h${END_SYNCHRONIZED_OUTPUT}`; + this.terminal.write(buffer); + } + if (this.savedCapabilities) { + setCapabilities(this.savedCapabilities); + this.savedCapabilities = undefined; + } + } + + private deleteKittyImages(): string { + return this.imageProtocol === "kitty" ? deleteAllKittyImages() : ""; + } + + private prepareKittyScreen(screen: string[]): { lines: string[]; evictedImageDeletion: string } { + const visibleImageIds = new Set(); + const lines = screen.map((line) => { + const placement = getKittyImagePlacement(line); + if (!placement) return line; + visibleImageIds.add(placement.imageId); + + const cachedImage = this.uploadedKittyImages.get(placement.imageId); + const nextCachedImage = { + transmissionGeneration: placement.transmissionGeneration, + transmissionBytes: placement.transmissionBytes, + estimatedDecodedBytes: placement.estimatedDecodedBytes, + }; + if (cachedImage) this.uploadedKittyImages.delete(placement.imageId); + this.uploadedKittyImages.set(placement.imageId, nextCachedImage); + + return cachedImage?.transmissionGeneration === placement.transmissionGeneration + ? placement.replacementLine + : line; + }); + + let cachedOffscreenImageCount = 0; + let cachedOffscreenTransmissionBytes = 0; + let cachedOffscreenDecodedBytes = 0; + for (const [imageId, cachedImage] of this.uploadedKittyImages) { + if (visibleImageIds.has(imageId)) continue; + cachedOffscreenImageCount += 1; + cachedOffscreenTransmissionBytes += cachedImage.transmissionBytes; + cachedOffscreenDecodedBytes += cachedImage.estimatedDecodedBytes; + } + + let evictedImageDeletion = ""; + for (const [imageId, cachedImage] of this.uploadedKittyImages) { + if ( + cachedOffscreenImageCount <= MAX_CACHED_OFFSCREEN_KITTY_IMAGES && + cachedOffscreenTransmissionBytes <= MAX_CACHED_OFFSCREEN_KITTY_TRANSMISSION_BYTES && + cachedOffscreenDecodedBytes <= MAX_CACHED_OFFSCREEN_KITTY_DECODED_BYTES + ) { + break; + } + if (visibleImageIds.has(imageId)) continue; + evictedImageDeletion += deleteKittyImage(imageId); + this.uploadedKittyImages.delete(imageId); + cachedOffscreenImageCount -= 1; + cachedOffscreenTransmissionBytes -= cachedImage.transmissionBytes; + cachedOffscreenDecodedBytes -= cachedImage.estimatedDecodedBytes; + } + return { lines, evictedImageDeletion }; + } + + protected override resetRenderState(): void { + this.previousScreen = []; + this.previousScreenWidth = 0; + this.previousScreenHeight = 0; + this.currentLayout = undefined; + } + + scrollBy(lines: number): void { + this.getPrimaryScrollView().scrollBy(lines); + this.requestRender(); + } + + scrollToTop(): void { + this.getPrimaryScrollView().scrollToStart(); + this.requestRender(); + } + + scrollToBottom(): void { + this.getPrimaryScrollView().scrollToEnd(); + this.requestRender(); + } + + private scrollToPrompt(direction: -1 | 1): void { + if (!this.currentLayout) return; + const scrollView = this.getPrimaryScrollView(); + const lines = getScrollViewBox(this.currentLayout, scrollView)?.scrollContentLines; + if (!lines) return; + + for (let row = scrollView.scrollTop + direction; row >= 0 && row < lines.length; row += direction) { + if (!OSC133_PROMPT_START.test(lines[row] ?? "")) continue; + scrollView.scrollTo(row); + this.requestRender(); + return; + } + } + + /** Show a transient message in the alternate-screen flash stack. */ + flash(message: string, durationMs?: number): void { + this.flashes.flash(message, durationMs); + } + + private handleViewportInput(data: string): { consume?: boolean } | undefined { + if (data === FOCUS_OUT) { + const hadActiveSelection = this.selectionPressActive; + this.selectionPressActive = false; + this.stopSelectionAutoScroll(); + this.stopScrollbarHover(); + this.stopScrollbarDrag(); + this.pressedUrl = undefined; + this.selectionDragged = false; + if (hadActiveSelection) { + this.selectionAnchor = undefined; + this.selectionFocus = undefined; + this.selectionGranularity = "character"; + this.selectionInitialRange = undefined; + } + this.lastClick = undefined; + this.requestRender(); + return { consume: true }; + } + if (data === FOCUS_IN) return { consume: true }; + + const wheelEvent = this.parseWheelEvent(data); + if (wheelEvent) { + this.routeWheel(wheelEvent); + return { consume: true }; + } + const mouseEvent = this.parseSgrMouseEvent(data); + if (mouseEvent) { + if (this.handleRightClickPaste(mouseEvent)) return { consume: true }; + const handled = this.handleScrollbarMouseEvent(mouseEvent); + if (!this.scrollbarDrag) this.updateScrollbarHover(mouseEvent.x, mouseEvent.y); + if (!handled) this.handleSelectionMouseEvent(mouseEvent); + return { consume: true }; + } + if (this.isMouseSequence(data)) return { consume: true }; + + const keybindings = getKeybindings(); + const isRelease = isKeyRelease(data); + // When the primary scroll view has nothing to scroll (short content, or a + // full-screen component mounted as the layout root), let navigation keys + // fall through to the focused component instead of consuming them. + const primaryScrollable = this.getPrimaryScrollView().canScroll; + if (primaryScrollable && keybindings.matches(data, "tui.altScreen.pageUp")) { + if (!isRelease) { + this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP)); + } + return { consume: true }; + } + if (primaryScrollable && keybindings.matches(data, "tui.altScreen.pageDown")) { + if (!isRelease) { + this.scrollBy(Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP)); + } + return { consume: true }; + } + if (primaryScrollable && keybindings.matches(data, "tui.altScreen.halfPageUp")) { + if (!isRelease) this.scrollBy(-Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2))); + return { consume: true }; + } + if (primaryScrollable && keybindings.matches(data, "tui.altScreen.halfPageDown")) { + if (!isRelease) this.scrollBy(Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2))); + return { consume: true }; + } + if (primaryScrollable && keybindings.matches(data, "tui.altScreen.previousPrompt")) { + if (!isRelease) this.scrollToPrompt(-1); + return { consume: true }; + } + if (primaryScrollable && keybindings.matches(data, "tui.altScreen.nextPrompt")) { + if (!isRelease) this.scrollToPrompt(1); + return { consume: true }; + } + if (primaryScrollable && keybindings.matches(data, "tui.altScreen.top")) { + if (!isRelease) this.scrollToTop(); + return { consume: true }; + } + if (primaryScrollable && keybindings.matches(data, "tui.altScreen.bottom")) { + if (!isRelease) this.scrollToBottom(); + return { consume: true }; + } + return undefined; + } + + private parseWheelEvent(data: string): WheelEvent | 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 && direction !== 1) return undefined; + return { + direction: direction === 0 ? -1 : 1, + x: Number.parseInt(sgr[2]!, 10) - 1, + y: Number.parseInt(sgr[3]!, 10) - 1, + }; + } + 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 && direction !== 1) return undefined; + return { + direction: direction === 0 ? -1 : 1, + x: data.charCodeAt(4) - 33, + y: data.charCodeAt(5) - 33, + }; + } + return undefined; + } + + private routeWheel(event: WheelEvent): void { + let remaining = event.direction * this.wheelScrollLines; + const seen = new Set(); + for (const scrollView of this.currentLayout ? getScrollViewsAt(this.currentLayout, event.x, event.y) : []) { + seen.add(scrollView); + remaining = scrollView.scrollBy(remaining); + if (remaining === 0 || scrollView.overscroll === "contain") break; + } + const primary = this.getPrimaryScrollView(); + if (remaining !== 0 && !seen.has(primary)) primary.scrollBy(remaining); + this.updateScrollbarHover(event.x, event.y); + this.requestRender(); + } + + 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 handleRightClickPaste(event: SgrMouseEvent): boolean { + if (!this.onRightClickPaste || process.platform !== "win32" || event.release || event.button !== 2) { + return false; + } + try { + this.onRightClickPaste(); + } catch { + // Clipboard paste is best-effort. + } + return true; + } + + private getScrollbarTargetAt(x: number, y: number): ScrollbarTarget | undefined { + if (this.hasOverlay() || !this.currentLayout) return undefined; + for (const scrollView of getScrollViewsAt(this.currentLayout, x, y)) { + const box = getScrollViewBox(this.currentLayout, scrollView); + const geometry = box ? getScrollbarGeometry(box) : undefined; + if ( + geometry && + x === geometry.column && + y >= geometry.thumbTop && + y < geometry.thumbTop + geometry.thumbHeight + ) { + return { scrollView, geometry }; + } + } + return undefined; + } + + private setScrollbarHover(scrollView: ScrollView | undefined): void { + if (scrollView === this.scrollbarHover) return; + this.scrollbarHover?.setScrollbarActive(false); + this.scrollbarHover = scrollView; + this.scrollbarHover?.setScrollbarActive(true); + } + + private updateScrollbarHover(x: number, y: number): void { + this.setScrollbarHover(this.getScrollbarTargetAt(x, y)?.scrollView); + } + + private stopScrollbarHover(): void { + this.setScrollbarHover(undefined); + } + + private handleScrollbarMouseEvent(event: SgrMouseEvent): boolean { + if (this.scrollbarDrag) { + if (event.release) { + this.stopScrollbarDrag(); + return true; + } + const box = this.currentLayout + ? getScrollViewBox(this.currentLayout, this.scrollbarDrag.scrollView) + : undefined; + const geometry = box ? getScrollbarGeometry(box) : undefined; + if (geometry) { + const maxThumbOffset = geometry.trackHeight - geometry.thumbHeight; + const thumbOffset = Math.max( + 0, + Math.min(maxThumbOffset, event.y - geometry.trackTop - this.scrollbarDrag.grabOffset), + ); + const scrollTop = + maxThumbOffset === 0 ? 0 : Math.round((thumbOffset / maxThumbOffset) * geometry.maxScrollTop); + this.scrollbarDrag.scrollView.scrollTo(scrollTop); + } + return true; + } + + if (event.release || (event.button & 32) !== 0 || (event.button & 3) !== 0) return false; + const target = this.getScrollbarTargetAt(event.x, event.y); + if (!target) return false; + this.stopSelectionAutoScroll(); + this.selectionPressActive = false; + this.selectionAnchor = undefined; + this.selectionFocus = undefined; + this.selectionGranularity = "character"; + this.selectionInitialRange = undefined; + this.lastClick = undefined; + this.pressedUrl = undefined; + this.selectionDragged = false; + this.setScrollbarHover(target.scrollView); + this.scrollbarDrag = { + scrollView: target.scrollView, + grabOffset: event.y - target.geometry.thumbTop, + }; + return true; + } + + private stopScrollbarDrag(): void { + this.scrollbarDrag = undefined; + } + + private getScrollSelectionPoint(scrollView: ScrollView, x: number, y: number): SelectionPoint | undefined { + if (!this.currentLayout) return undefined; + const box = getScrollViewBox(this.currentLayout, scrollView); + if (!box || box.rect.height <= 0 || box.clip.height <= 0) return undefined; + const visibleTop = Math.max(0, box.rect.y, box.clip.y); + const visibleBottom = Math.min( + this.terminal.rows - 1, + box.rect.y + box.rect.height - 1, + box.clip.y + box.clip.height - 1, + ); + if (visibleBottom < visibleTop) return undefined; + const pointerRow = Math.max(visibleTop, Math.min(visibleBottom, y)); + const maxContentRow = Math.max(0, (box.scrollContentLines?.length ?? 1) - 1); + return { + row: Math.max(0, Math.min(maxContentRow, scrollView.scrollTop + pointerRow - box.rect.y)), + col: Math.max(0, Math.min(box.rect.width - 1, x - box.rect.x)), + scrollView, + }; + } + + private getSelectionPoint(event: SgrMouseEvent, scrollView?: ScrollView): SelectionPoint { + if (scrollView) { + const point = this.getScrollSelectionPoint(scrollView, event.x, event.y); + if (point) return point; + } + return { + row: Math.max(0, Math.min(this.terminal.rows - 1, event.y)), + col: Math.max(0, Math.min(this.terminal.columns - 1, event.x)), + }; + } + + private getSelectionSourceLine(point: SelectionPoint): string { + if (point.scrollView && this.currentLayout) { + const lines = getScrollViewBox(this.currentLayout, point.scrollView)?.scrollContentLines; + if (lines) return lines[point.row] ?? ""; + } + return this.previousScreen[point.row] ?? ""; + } + + private getWordSelection(point: SelectionPoint): SelectionRange | undefined { + const line = stripTerminalSequences(this.getSelectionSourceLine(point)); + let start = 0; + for (const segment of wordSegmenter.segment(line)) { + const end = start + visibleWidth(segment.segment); + if (point.col >= start && point.col < end) { + return { + start: { ...point, col: start }, + end: { ...point, col: end, boundary: true }, + }; + } + start = end; + } + return undefined; + } + + private getLineSelection(point: SelectionPoint): SelectionRange { + return { + start: { ...point, col: 0 }, + end: { ...point, col: visibleWidth(this.getSelectionSourceLine(point)), boundary: true }, + }; + } + + private updateSelectionFocus(point: SelectionPoint): void { + if (this.selectionGranularity === "character" || !this.selectionInitialRange) { + this.selectionFocus = point; + return; + } + const range = this.selectionGranularity === "word" ? this.getWordSelection(point) : this.getLineSelection(point); + if (!range) return; + const initial = this.selectionInitialRange; + const targetBeforeInitial = + range.start.row < initial.start.row || + (range.start.row === initial.start.row && range.start.col < initial.start.col); + if (targetBeforeInitial) { + this.selectionAnchor = initial.end; + this.selectionFocus = range.start; + } else { + this.selectionAnchor = initial.start; + this.selectionFocus = range.end; + } + } + + private getClickCount(point: SelectionPoint, word: SelectionRange | undefined): number { + const now = Date.now(); + const previous = this.lastClick; + const count = + word && + previous && + now - previous.timestamp <= DOUBLE_CLICK_INTERVAL_MS && + previous.row === point.row && + previous.scrollView === point.scrollView && + previous.wordStart === word.start.col && + previous.wordEnd === word.end.col + ? (previous.count % 3) + 1 + : 1; + this.lastClick = word + ? { + timestamp: now, + count, + row: point.row, + scrollView: point.scrollView, + wordStart: word.start.col, + wordEnd: word.end.col, + } + : undefined; + return count; + } + + private updateSelectionAutoScroll(event: SgrMouseEvent): void { + const scrollView = this.selectionAnchor?.scrollView; + if (!scrollView || !this.currentLayout) { + this.stopSelectionAutoScroll(); + return; + } + const box = getScrollViewBox(this.currentLayout, scrollView); + if (!box || box.rect.height <= 0 || box.clip.height <= 0) { + this.stopSelectionAutoScroll(); + return; + } + const visibleTop = Math.max(0, box.rect.y, box.clip.y); + const visibleBottom = Math.min( + this.terminal.rows - 1, + box.rect.y + box.rect.height - 1, + box.clip.y + box.clip.height - 1, + ); + this.selectionDragPointer = { x: event.x, y: event.y }; + this.selectionAutoScrollDirection = event.y <= visibleTop ? -1 : event.y >= visibleBottom ? 1 : 0; + if (this.selectionAutoScrollDirection === 0) { + this.stopSelectionAutoScroll(); + return; + } + if (this.selectionAutoScrollTimer) return; + this.selectionAutoScrollTimer = setInterval(() => this.autoScrollSelection(), 50); + this.selectionAutoScrollTimer.unref(); + } + + private autoScrollSelection(): void { + const scrollView = this.selectionAnchor?.scrollView; + const pointer = this.selectionDragPointer; + const direction = this.selectionAutoScrollDirection; + if (!scrollView || !pointer || direction === 0) { + this.stopSelectionAutoScroll(); + return; + } + const remaining = scrollView.scrollBy(direction); + if (remaining === direction) { + this.stopSelectionAutoScroll(); + return; + } + const point = this.getScrollSelectionPoint(scrollView, pointer.x, pointer.y); + if (point) this.updateSelectionFocus(point); + this.requestRender(); + } + + private stopSelectionAutoScroll(): void { + if (this.selectionAutoScrollTimer) { + clearInterval(this.selectionAutoScrollTimer); + this.selectionAutoScrollTimer = undefined; + } + this.selectionAutoScrollDirection = 0; + this.selectionDragPointer = undefined; + } + + private handleSelectionMouseEvent(event: SgrMouseEvent): void { + if ((event.button & 3) !== 0) return; + const anchorScrollView = this.selectionAnchor?.scrollView; + const point = this.getSelectionPoint(event, anchorScrollView); + if (event.release) { + if (!this.selectionPressActive) return; + this.selectionPressActive = false; + this.stopSelectionAutoScroll(); + if (!this.selectionAnchor) return; + this.updateSelectionFocus(point); + const clickedUrl = + !this.selectionDragged && + this.selectionAnchor.scrollView === point.scrollView && + 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.selectionPressActive || !this.selectionAnchor) return; + this.selectionDragged = true; + this.lastClick = undefined; + this.pressedUrl = undefined; + this.updateSelectionFocus(point); + this.updateSelectionAutoScroll(event); + this.requestRender(); + return; + } + this.stopSelectionAutoScroll(); + this.selectionPressActive = true; + const scrollView = + !this.hasOverlay() && this.currentLayout + ? getScrollViewsAt(this.currentLayout, event.x, event.y)[0] + : undefined; + const anchor = this.getSelectionPoint(event, scrollView); + const word = this.getWordSelection(anchor); + const clickCount = this.getClickCount(anchor, word); + const range = clickCount === 2 ? word : clickCount === 3 ? this.getLineSelection(anchor) : undefined; + this.selectionGranularity = range ? (clickCount === 2 ? "word" : "line") : "character"; + this.selectionInitialRange = range; + this.selectionAnchor = range?.start ?? anchor; + this.selectionFocus = range?.end ?? anchor; + this.selectionDragged = false; + this.pressedUrl = range + ? undefined + : getOsc8LinkAtColumn( + this.previousScreen[Math.max(0, Math.min(this.terminal.rows - 1, event.y))] ?? "", + Math.max(0, Math.min(this.terminal.columns - 1, event.x)), + ); + this.requestRender(); + } + + private getSelectionBounds(): { start: SelectionPoint; end: SelectionPoint } | undefined { + if (!this.selectionAnchor || !this.selectionFocus) return undefined; + if (this.selectionAnchor.scrollView !== this.selectionFocus.scrollView) 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 }, + minColumn = 0, + maxColumn = visibleWidth(line), + ): { start: number; end: number } { + const lineWidth = visibleWidth(line); + let start = Math.max(0, minColumn); + let end = Math.min(lineWidth, maxColumn); + if (row === selection.start.row) { + start = getGraphemeCellRange(line, selection.start.col)?.start ?? Math.min(selection.start.col, lineWidth); + } + if (row === selection.end.row) { + end = selection.end.boundary + ? Math.min(selection.end.col, lineWidth) + : (getGraphemeCellRange(line, selection.end.col)?.end ?? Math.min(selection.end.col + 1, lineWidth)); + } + return { start: Math.max(minColumn, start), end: Math.min(maxColumn, end) }; + } + + private copySelectionToClipboard(): void { + const selection = this.getSelectionBounds(); + if (!selection) return; + let sourceLines: readonly string[] = this.previousScreen; + if (selection.start.scrollView) { + if (!this.currentLayout) return; + const box = getScrollViewBox(this.currentLayout, selection.start.scrollView); + if (!box?.scrollContentLines) return; + sourceLines = box.scrollContentLines; + } + const lines: string[] = []; + for (let row = selection.start.row; row <= selection.end.row; row++) { + const line = sourceLines[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`); + this.flash("Copied!"); + } + + private applySelectionHighlight(text: string): string { + let result = "\x1b[7m"; + let index = 0; + while (index < text.length) { + const ansi = extractAnsiCode(text, index); + if (!ansi) { + result += text[index]; + index += 1; + continue; + } + result += ansi.code; + if (ansi.code.endsWith("m")) result += "\x1b[7m"; + index += ansi.length; + } + return `${result}\x1b[27m`; + } + + private applySelection(screen: string[], layout = this.currentLayout): string[] { + const selection = this.getSelectionBounds(); + if (!selection) return screen; + let screenSelection = selection; + let minRow = 0; + let maxRow = screen.length - 1; + let minColumn = 0; + let maxColumn = this.terminal.columns; + if (selection.start.scrollView) { + if (!layout) return screen; + const box = getScrollViewBox(layout, selection.start.scrollView); + if (!box) return screen; + minRow = Math.max(0, box.rect.y, box.clip.y); + maxRow = Math.min(screen.length - 1, box.rect.y + box.rect.height - 1, box.clip.y + box.clip.height - 1); + minColumn = Math.max(0, box.rect.x, box.clip.x); + maxColumn = Math.min(this.terminal.columns, box.rect.x + box.rect.width, box.clip.x + box.clip.width); + screenSelection = { + start: { + ...selection.start, + row: box.rect.y + selection.start.row - selection.start.scrollView.scrollTop, + col: box.rect.x + selection.start.col, + }, + end: { + ...selection.end, + row: box.rect.y + selection.end.row - selection.start.scrollView.scrollTop, + col: box.rect.x + selection.end.col, + }, + }; + } + return screen.map((line, row) => { + if ( + row < minRow || + row > maxRow || + row < screenSelection.start.row || + row > screenSelection.end.row || + isImageLine(line) + ) { + return line; + } + const lineWidth = visibleWidth(line); + const columns = this.getSelectionColumns(line, row, screenSelection, minColumn, maxColumn); + 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}${this.applySelectionHighlight(selected)}${after}`; + }); + } + + private isMouseSequence(data: string): boolean { + return /^\x1b\[<\d+;\d+;\d+[Mm]$/.test(data) || (data.length === 6 && data.startsWith("\x1b[M")); + } + + private compositeFlashes(screen: string[], width: number, height: number): string[] { + const flashLines = this.flashes.render(width).slice(-height); + if (flashLines.length === 0) return screen; + const result = [...screen]; + while (result.length < height) result.push(""); + for (let row = 0; row < flashLines.length; row++) { + const line = flashLines[row]!; + const flashWidth = visibleWidth(line); + if (flashWidth === 0) continue; + result[row] = compositeTuiLine(result[row] ?? "", line, width - flashWidth, flashWidth, width); + } + return result; + } + + 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 root = this.layoutRoot ?? this.implicitScrollView; + const nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + screen = this.compositeOverlays(screen, width, height); + if (screen.length > height) screen = screen.slice(screen.length - height); + screen = this.applySelection(screen, nextLayout); + screen = this.compositeFlashes(screen, width, height); + + 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] ?? "")), + ); + const redrawImages = fullRedraw || imagesNeedRedraw; + const hadUploadedKittyImages = this.uploadedKittyImages.size > 0; + const preparedKittyScreen = + redrawImages && this.imageProtocol === "kitty" + ? this.prepareKittyScreen(screen) + : { lines: screen, evictedImageDeletion: "" }; + + let buffer = BEGIN_SYNCHRONIZED_OUTPUT; + if (fullRedraw) { + this.fullRedrawCount += 1; + const clearImages = + this.imageProtocol === "kitty" && hadUploadedKittyImages + ? deleteAllKittyPlacements() + : this.deleteKittyImages(); + buffer += `${clearImages}\x1b[2J`; + } else if (imagesNeedRedraw) { + if (this.imageProtocol === "iterm2") buffer += "\x1b[2J"; + else if (this.imageProtocol === "kitty") buffer += deleteAllKittyPlacements(); + } + buffer += preparedKittyScreen.evictedImageDeletion; + + for (let row = 0; row < height; row++) { + if (!fullRedraw && !imagesNeedRedraw && screen[row] === this.previousScreen[row]) continue; + buffer += `\x1b[${row + 1};1H\x1b[2K${preparedKittyScreen.lines[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; + this.currentLayout = nextLayout; + } +} diff --git a/packages/pi-tui/src/tui-main-screen.ts b/packages/pi-tui/src/tui-main-screen.ts new file mode 100644 index 00000000000..d674b95ed5c --- /dev/null +++ b/packages/pi-tui/src/tui-main-screen.ts @@ -0,0 +1,629 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { deleteKittyImage, isImageLine } from "./terminal-image.ts"; +import { SEGMENT_RESET, type TUI, TuiBase, type TuiStopOptions } from "./tui.ts"; +import { asciiVisibleWidth, normalizeTerminalOutput, sliceByColumn, visibleWidth } from "./utils.ts"; + +const KITTY_SEQUENCE_PREFIX = "\x1b_G"; + +/** Shared empty id list for non-image lines in the per-line image-id cache. */ +const EMPTY_IMAGE_IDS: readonly number[] = []; + +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']); +} + +export interface TuiMainScreenRenderState { + previousLines: string[]; + previousWidth: number; + previousHeight: number; + cursorRow: number; + hardwareCursorRow: number; + maxLinesRendered: number; + previousViewportTop: number; +} + +/** TUI implementation that renders into the terminal's main screen and scrollback. */ +export class TuiMainScreen extends TuiBase implements TUI { + readonly mode = "regular" as const; + private previousLines: string[] = []; + /** + * Raw (pre-processing) lines of the previous frame, aligned with + * {@link previousLines}. Component render caches return identical string + * references for unchanged content, which lets each frame reuse the + * processed output for every untouched line instead of re-normalizing and + * re-comparing the whole transcript (see doRender). + */ + private previousRawLines: string[] = []; + /** Per-line kitty image ids of the previous frame, aligned with previousRawLines. */ + private previousLineImageIds: ReadonlyArray[] = []; + private previousKittyImageIds = new Set(); + private previousWidth = 0; + private previousHeight = 0; + private cursorRow = 0; + private hardwareCursorRow = 0; + private maxLinesRendered = 0; + private previousViewportTop = 0; + + captureRenderState(): TuiMainScreenRenderState { + return { + previousLines: [...this.previousLines], + previousWidth: this.previousWidth, + previousHeight: this.previousHeight, + cursorRow: this.cursorRow, + hardwareCursorRow: this.hardwareCursorRow, + maxLinesRendered: this.maxLinesRendered, + previousViewportTop: this.previousViewportTop, + }; + } + + restoreRenderState(state: TuiMainScreenRenderState): void { + this.previousLines = state.previousLines.map((line) => (isImageLine(line) ? "" : line)); + this.previousRawLines = []; + this.previousLineImageIds = []; + this.previousKittyImageIds = new Set(); + this.previousWidth = state.previousWidth; + this.previousHeight = state.previousHeight; + this.cursorRow = state.cursorRow; + this.hardwareCursorRow = state.hardwareCursorRow; + this.maxLinesRendered = state.maxLinesRendered; + this.previousViewportTop = state.previousViewportTop; + } + + protected override resetRenderState(): void { + this.previousLines = []; + this.previousRawLines = []; + this.previousLineImageIds = []; + this.previousWidth = -1; + this.previousHeight = -1; + this.cursorRow = 0; + this.hardwareCursorRow = 0; + this.maxLinesRendered = 0; + this.previousViewportTop = 0; + } + + protected override beforeTerminalStop(options: TuiStopOptions): void { + if (options.preserveScreen || 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 unionKittyImageIds(lineImageIds: ReadonlyArray[]): Set { + const ids = new Set(); + for (const lineIds of lineImageIds) { + for (const id of lineIds) { + ids.add(id); + } + } + return ids; + } + + private deleteKittyImages(ids: Iterable): string { + let buffer = ""; + for (const id of ids) { + buffer += deleteKittyImage(id); + } + return buffer; + } + + private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number { + const rows = extractKittyImageRows(lines[index] ?? ""); + if (rows <= 1) return 1; + + const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index); + let reservedRows = 1; + while (reservedRows < maxRows) { + const line = lines[index + reservedRows] ?? ""; + if (isImageLine(line) || visibleWidth(line) > 0) break; + reservedRows++; + } + return reservedRows; + } + + private expandChangedRangeForKittyImages( + firstChanged: number, + lastChanged: number, + newLines: string[], + newLineImageIds: ReadonlyArray[], + ): { firstChanged: number; lastChanged: number } { + let expandedFirstChanged = firstChanged; + let expandedLastChanged = lastChanged; + const expandForLines = (lines: string[], lineImageIds: ReadonlyArray[]): void => { + for (let i = 0; i < lines.length; i++) { + if ((lineImageIds[i] ?? EMPTY_IMAGE_IDS).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, this.previousLineImageIds); + expandForLines(newLines, newLineImageIds); + return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged }; + } + + private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string { + if (firstChanged < 0 || lastChanged < firstChanged) return ""; + + const ids = new Set(); + const maxLine = Math.min(lastChanged, this.previousLines.length - 1); + for (let i = firstChanged; i <= maxLine; i++) { + for (const id of this.previousLineImageIds[i] ?? EMPTY_IMAGE_IDS) { + 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); + + // Process raw lines for output. Never write a line wider than the + // terminal: truncate defensively instead of crashing. Extremely narrow + // terminals can make components overflow by a column (e.g. wide + // graphemes at width 1). The trailing segment reset is appended after + // truncation, so truncated lines still get their reset and cannot leak + // styles. + // + // Lines whose raw string is reference-identical to the previous frame's + // reuse their processed output verbatim: component render caches return + // the same string references for unchanged content, so a steady frame + // only pays for the lines that actually changed instead of + // re-normalizing the whole transcript. + const rawLines = newLines; + const reuseProcessed = !widthChanged && this.previousRawLines.length > 0; + const processedLines: string[] = new Array(rawLines.length); + const lineImageIds: ReadonlyArray[] = new Array(rawLines.length); + for (let i = 0; i < rawLines.length; i++) { + const rawLine = rawLines[i]!; + if (reuseProcessed && rawLine === this.previousRawLines[i]) { + processedLines[i] = this.previousLines[i]!; + lineImageIds[i] = this.previousLineImageIds[i]!; + continue; + } + let line = rawLine; + let imageIds: readonly number[] = EMPTY_IMAGE_IDS; + if (isImageLine(line)) { + imageIds = extractKittyImageIds(line); + } else { + const lineWidth = asciiVisibleWidth(line, width) ?? visibleWidth(line); + if (lineWidth > width) { + line = sliceByColumn(line, 0, width, true); + } + line = normalizeTerminalOutput(line) + SEGMENT_RESET; + } + processedLines[i] = line; + lineImageIds[i] = imageIds; + } + newLines = processedLines; + + // 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.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; + this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); + 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, + lineImageIds, + ); + 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; + // Processed output is unchanged, but keep the raw/image-id caches in + // sync so future frames keep hitting the reuse fast path (e.g. the + // cursor-marker line gets a fresh string every frame). + this.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; + 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.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; + this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); + 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 + 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.previousRawLines = rawLines; + this.previousLineImageIds = lineImageIds; + this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); + this.previousWidth = width; + this.previousHeight = height; + } + + /** + * Position the hardware cursor for IME candidate window. + * @param cursorPos The cursor position extracted from rendered output, or null + * @param totalLines Total number of rendered lines + */ + private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void { + if (!cursorPos || totalLines <= 0) { + this.terminal.hideCursor(); + return; + } + + // Clamp cursor position to valid range + const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1)); + const targetCol = Math.max(0, cursorPos.col); + + // Move cursor from current position to target + const rowDelta = targetRow - this.hardwareCursorRow; + let buffer = ""; + if (rowDelta > 0) { + buffer += `\x1b[${rowDelta}B`; // Move down + } else if (rowDelta < 0) { + buffer += `\x1b[${-rowDelta}A`; // Move up + } + // Move to absolute column (1-indexed) + buffer += `\x1b[${targetCol + 1}G`; + + if (buffer) { + this.terminal.write(buffer); + } + + this.hardwareCursorRow = targetRow; + if (this.getShowHardwareCursor()) { + this.terminal.showCursor(); + } else { + this.terminal.hideCursor(); + } + } +} diff --git a/packages/pi-tui/src/tui.ts b/packages/pi-tui/src/tui.ts index e4556a8f47b..6ca99c596be 100644 --- a/packages/pi-tui/src/tui.ts +++ b/packages/pi-tui/src/tui.ts @@ -2,7 +2,6 @@ * Minimal TUI implementation with differential rendering */ -import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { performance } from "node:perf_hooks"; @@ -15,58 +14,8 @@ import { type RgbColor, type TerminalColorScheme, } from "./terminal-colors.ts"; -import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; -import { - asciiVisibleWidth, - extractSegments, - normalizeTerminalOutput, - sliceByColumn, - sliceWithWidth, - visibleWidth, -} from "./utils.ts"; - -const KITTY_SEQUENCE_PREFIX = "\x1b_G"; - -/** Shared empty id list for non-image lines in the per-line image-id cache. */ -const EMPTY_IMAGE_IDS: readonly number[] = []; - -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; -} +import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; +import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; /** * Component interface - all components must implement this @@ -97,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; @@ -170,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%"). @@ -305,57 +250,140 @@ export class Container implements Component { /** * TUI - Main class for managing terminal UI with differential rendering */ -export class TUI extends Container { +export 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 type TuiMode = "regular" | "fullscreen"; + +export interface TuiStopOptions { + /** Leave renderer output in place for another TUI taking over the same terminal. */ + preserveScreen?: boolean; +} + +export interface TUI extends Component { + readonly mode: TuiMode; + 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(options?: TuiStopOptions): void; + renderNow(force?: boolean): void; + requestRender(force?: boolean): void; + addInputListener(listener: TuiInputListener): () => void; + removeInputListener(listener: TuiInputListener): void; + onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; + setTerminalColorSchemeNotifications(enabled: boolean): void; + queryTerminalBackgroundColor(options: { timeoutMs: number }): Promise; + queryTerminalColorScheme(options: { timeoutMs: number }): Promise; +} + +export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport"); + +export interface ViewportTUI extends TUI { + readonly [VIEWPORT_TUI]: true; + setLayoutRoot(component: Component | undefined): void; +} + +export function isViewportTUI(tui: TUI): tui is ViewportTUI { + return (tui as Partial)[VIEWPORT_TUI] === true; +} + +export abstract class TuiBase extends Container implements TUI { + abstract readonly mode: TuiMode; public terminal: Terminal; - private previousLines: string[] = []; - /** - * Raw (pre-processing) lines of the previous frame, aligned with - * {@link previousLines}. Component render caches return identical string - * references for unchanged content, which lets each frame reuse the - * processed output for every untouched line instead of re-normalizing and - * re-comparing the whole transcript (see doRender). - */ - private previousRawLines: string[] = []; - /** Per-line kitty image ids of the previous frame, aligned with previousRawLines. */ - private previousLineImageIds: ReadonlyArray[] = []; - private previousKittyImageIds = new Set(); - private previousWidth = 0; - private previousHeight = 0; private focusedComponent: Component | null = null; - private inputListeners = new Set(); + private inputListeners = new Set(); /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ public onDebug?: () => void; private renderRequested = false; + private immediateRenderScheduled = false; 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; + protected readonly logDirectory: string; // Overlay stack for modal components rendered on top of base content private focusOrderCounter = 0; private overlayStack: OverlayStackEntry[] = []; + + get hasOverlayEntries(): boolean { + return this.overlayStack.length > 0; + } private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" }; - constructor(terminal: Terminal, showHardwareCursor?: boolean) { + constructor(terminal: Terminal, showHardwareCursor?: boolean, logDirectory?: string) { super(); this.terminal = terminal; + this.logDirectory = logDirectory ?? process.env['PI_CODING_AGENT_DIR'] ?? path.join(os.homedir(), ".pi", "agent"); if (showHardwareCursor !== undefined) { this.showHardwareCursor = showHardwareCursor; } } + protected abstract doRender(): void; + + protected resetRenderState(): void {} + + protected beforeTerminalStart(): void {} + + protected afterTerminalStart(): void {} + + protected beforeTerminalStop(_options: TuiStopOptions): void {} + + protected afterTerminalStop(_options: TuiStopOptions): void {} + get fullRedraws(): number { return this.fullRedrawCount; } @@ -386,6 +414,10 @@ export class TUI extends Container { this.clearOnShrink = enabled; } + getFocusedComponent(): Component | null { + return this.focusedComponent; + } + setFocus(component: Component | null): void { this.setFocusInternal({ component, overlayFocusRestore: "clear" }); } @@ -499,8 +531,12 @@ export class TUI extends Container { } } + protected getMountedRoots(): readonly Component[] { + return this.children; + } + private isComponentMounted(component: Component): boolean { - return this.children.some((child) => this.containsComponent(child, component)); + return this.getMountedRoots().some((child) => this.containsComponent(child, component)); } private containsComponent(root: Component, target: Component): boolean { @@ -651,16 +687,18 @@ export class TUI extends Container { } override invalidate(): void { - super.invalidate(); - for (const overlay of this.overlayStack) overlay.component.invalidate?.(); + for (const root of this.getMountedRoots()) root.invalidate(); + for (const overlay of this.overlayStack) overlay.component.invalidate(); } 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"); @@ -669,14 +707,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); } @@ -707,53 +745,30 @@ export class TUI extends Container { this.terminal.write("\x1b[16t"); } - stop(): void { + stop(options: TuiStopOptions = {}): void { this.stopped = true; - if (this.renderTimer) { - clearTimeout(this.renderTimer); - this.renderTimer = undefined; - } + this.cancelRenderTimer(); 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) { - 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(options); this.terminal.showCursor(); this.terminal.stop(); + this.afterTerminalStop(options); + } + + renderNow(force = false): void { + if (force) this.resetRenderState(); + this.renderRequested = false; + this.cancelRenderTimer(); + this.lastRenderAt = performance.now(); + this.doRender(); } 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; - if (this.renderTimer) { - clearTimeout(this.renderTimer); - this.renderTimer = undefined; - } - this.renderRequested = true; - process.nextTick(() => { - if (this.stopped || !this.renderRequested) { - return; - } - this.renderRequested = false; - this.lastRenderAt = performance.now(); - this.doRender(); - }); + this.resetRenderState(); + this.requestImmediateRender(); return; } if (this.renderRequested) return; @@ -761,12 +776,35 @@ export class TUI extends Container { process.nextTick(() => this.scheduleRender()); } + private requestImmediateRender(): void { + this.cancelRenderTimer(); + this.renderRequested = true; + if (this.immediateRenderScheduled) return; + this.immediateRenderScheduled = true; + process.nextTick(() => { + this.immediateRenderScheduled = false; + if (this.stopped || !this.renderRequested) return; + // A previously queued scheduleRender() can create a timer before this + // callback runs. User input must preempt that throttled frame. + this.cancelRenderTimer(); + this.renderRequested = false; + this.lastRenderAt = performance.now(); + this.doRender(); + }); + } + + private cancelRenderTimer(): void { + if (!this.renderTimer) return; + clearTimeout(this.renderTimer); + this.renderTimer = undefined; + } + private scheduleRender(): void { if (this.stopped || this.renderTimer || !this.renderRequested) { 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) { @@ -781,7 +819,7 @@ export class TUI extends Container { }, delay); } - private handleInput(data: string): void { + private handleTerminalInput(data: string): void { if (this.consumeOsc11BackgroundResponse(data)) { return; } @@ -853,7 +891,9 @@ export class TUI extends Container { return; } this.focusedComponent.handleInput(data); - this.requestRender(); + // Keyboard input is latency-sensitive. Avoid the throttled timer path, + // where even setTimeout(0) can take a full 16 ms tick on Windows. + this.requestImmediateRender(); } } @@ -1052,7 +1092,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]; @@ -1113,79 +1153,17 @@ export class TUI extends Container { return result; } - private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; - - private unionKittyImageIds(lineImageIds: ReadonlyArray[]): Set { - const ids = new Set(); - for (const lineIds of lineImageIds) { - for (const id of lineIds) { - ids.add(id); + protected applyLineResets(lines: string[]): string[] { + const reset = SEGMENT_RESET; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]!; + if (!isImageLine(line)) { + lines[i] = normalizeTerminalOutput(line) + reset; } } - return ids; - } - - private deleteKittyImages(ids: Iterable): string { - let buffer = ""; - for (const id of ids) { - buffer += deleteKittyImage(id); - } - return buffer; - } - - private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number { - const rows = extractKittyImageRows(lines[index] ?? ""); - if (rows <= 1) return 1; - - const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index); - let reservedRows = 1; - while (reservedRows < maxRows) { - const line = lines[index + reservedRows] ?? ""; - if (isImageLine(line) || visibleWidth(line) > 0) break; - reservedRows++; - } - return reservedRows; - } - - private expandChangedRangeForKittyImages( - firstChanged: number, - lastChanged: number, - newLines: string[], - newLineImageIds: ReadonlyArray[], - ): { firstChanged: number; lastChanged: number } { - let expandedFirstChanged = firstChanged; - let expandedLastChanged = lastChanged; - const expandForLines = (lines: string[], lineImageIds: ReadonlyArray[]): void => { - for (let i = 0; i < lines.length; i++) { - if ((lineImageIds[i] ?? EMPTY_IMAGE_IDS).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, this.previousLineImageIds); - expandForLines(newLines, newLineImageIds); - return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged }; - } - - private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string { - if (firstChanged < 0 || lastChanged < firstChanged) return ""; - - const ids = new Set(); - const maxLine = Math.min(lastChanged, this.previousLines.length - 1); - for (let i = firstChanged; i <= maxLine; i++) { - for (const id of this.previousLineImageIds[i] ?? EMPTY_IMAGE_IDS) { - ids.add(id); - } - } - - return this.deleteKittyImages(ids); + return lines; } - /** Splice overlay content into a base line at a specific column. Single-pass optimized. */ private compositeLineAt( baseLine: string, overlayLine: string, @@ -1193,47 +1171,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); } /** @@ -1244,7 +1182,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--) { @@ -1264,437 +1202,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); - - // Process raw lines for output. Never write a line wider than the - // terminal: truncate defensively instead of crashing. Extremely narrow - // terminals can make components overflow by a column (e.g. wide - // graphemes at width 1). The trailing segment reset is appended after - // truncation, so truncated lines still get their reset and cannot leak - // styles. - // - // Lines whose raw string is reference-identical to the previous frame's - // reuse their processed output verbatim: component render caches return - // the same string references for unchanged content, so a steady frame - // only pays for the lines that actually changed instead of - // re-normalizing the whole transcript. - const rawLines = newLines; - const reuseProcessed = !widthChanged && this.previousRawLines.length > 0; - const processedLines: string[] = new Array(rawLines.length); - const lineImageIds: ReadonlyArray[] = new Array(rawLines.length); - for (let i = 0; i < rawLines.length; i++) { - const rawLine = rawLines[i]!; - if (reuseProcessed && rawLine === this.previousRawLines[i]) { - processedLines[i] = this.previousLines[i]!; - lineImageIds[i] = this.previousLineImageIds[i]!; - continue; - } - let line = rawLine; - let imageIds: readonly number[] = EMPTY_IMAGE_IDS; - if (isImageLine(line)) { - imageIds = extractKittyImageIds(line); - } else { - const lineWidth = asciiVisibleWidth(line, width) ?? visibleWidth(line); - if (lineWidth > width) { - line = sliceByColumn(line, 0, width, true); - } - line = normalizeTerminalOutput(line) + TUI.SEGMENT_RESET; - } - processedLines[i] = line; - lineImageIds[i] = imageIds; - } - newLines = processedLines; - - // 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.previousRawLines = rawLines; - this.previousLineImageIds = lineImageIds; - this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); - 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(os.homedir(), ".pi", "agent", "pi-debug.log"); - const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`; - 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, - lineImageIds, - ); - 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; - // Processed output is unchanged, but keep the raw/image-id caches in - // sync so future frames keep hitting the reuse fast path (e.g. the - // cursor-marker line gets a fresh string every frame). - this.previousRawLines = rawLines; - this.previousLineImageIds = lineImageIds; - 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.previousRawLines = rawLines; - this.previousLineImageIds = lineImageIds; - this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); - 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 - 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.previousRawLines = rawLines; - this.previousLineImageIds = lineImageIds; - this.previousKittyImageIds = this.unionKittyImageIds(lineImageIds); - this.previousWidth = width; - this.previousHeight = height; - } - - /** - * Position the hardware cursor for IME candidate window. - * @param cursorPos The cursor position extracted from rendered output, or null - * @param totalLines Total number of rendered lines - */ - private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void { - if (!cursorPos || totalLines <= 0) { - this.terminal.hideCursor(); - return; - } - - // Clamp cursor position to valid range - const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1)); - const targetCol = Math.max(0, cursorPos.col); - - // Move cursor from current position to target - const rowDelta = targetRow - this.hardwareCursorRow; - let buffer = ""; - if (rowDelta > 0) { - buffer += `\x1b[${rowDelta}B`; // Move down - } else if (rowDelta < 0) { - buffer += `\x1b[${-rowDelta}A`; // Move up - } - // Move to absolute column (1-indexed) - buffer += `\x1b[${targetCol + 1}G`; - - if (buffer) { - this.terminal.write(buffer); - } - - this.hardwareCursorRow = targetRow; - if (this.showHardwareCursor) { - this.terminal.showCursor(); - } else { - this.terminal.hideCursor(); - } - } - /** * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`). * @param timeoutMs Query timeout in milliseconds. diff --git a/packages/pi-tui/src/utils.ts b/packages/pi-tui/src/utils.ts index 88b1baac154..2d82c51acdc 100644 --- a/packages/pi-tui/src/utils.ts +++ b/packages/pi-tui/src/utils.ts @@ -39,6 +39,12 @@ function couldBeEmoji(segment: string): boolean { // Regexes for character classification (same as string-width library) const zeroWidthRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Mark}|\p{Surrogate})+$/v; const leadingNonPrintingRegex = /^[\p{Default_Ignorable_Code_Point}\p{Control}\p{Format}\p{Mark}\p{Surrogate}]+/v; +const nonPrintingCharRegex = /^(?:\p{Default_Ignorable_Code_Point}|\p{Control}|\p{Format}|\p{Mark}|\p{Surrogate})$/v; +const markCharRegex = /^\p{Mark}$/v; +// Marks that terminals allocate cells for when attached to a base character. +// This includes Unicode spacing marks and non-spacing exceptions in legacy wcwidth tables. +const terminalSpacingMarkRegex = + /^(?:[\p{Spacing_Mark}--[\u1734\u302E\u302F]]|[\u065F\u0F7F\u102B\u102C\u1031\u1033-\u1035\u1038\u103A-\u103E])+$/v; const rgiEmojiRegex = /^\p{RGI_Emoji}$/v; // Cache for non-ASCII strings @@ -147,13 +153,14 @@ function finalizeTruncatedResult( pad: boolean, ): string { const reset = "\x1b[0m"; + const hyperlinkClose = getActiveOsc8Close(prefix); const visibleWidth = prefixWidth + ellipsisWidth; let result: string; if (ellipsis.length > 0) { - result = `${prefix}${reset}${ellipsis}${reset}`; + result = `${prefix}${hyperlinkClose}${reset}${ellipsis}${reset}`; } else { - result = `${prefix}${reset}`; + result = `${prefix}${hyperlinkClose}${reset}`; } return pad ? result + " ".repeat(Math.max(0, maxWidth - visibleWidth)) : result; @@ -169,6 +176,11 @@ function graphemeWidth(segment: string): number { return 3; } + // Some marks occupy cells even without a base character. + if (terminalSpacingMarkRegex.test(segment)) { + return [...segment].length; + } + // Zero-width clusters if (zeroWidthRegex.test(segment)) { return 0; @@ -195,15 +207,27 @@ function graphemeWidth(segment: string): number { let width = eastAsianWidth(cp); - // Trailing halfwidth/fullwidth forms and AM vowels that segment with a base. - if (segment.length > 1) { - for (const char of segment.slice(1)) { + // Intl.Segmenter can group multiple terminal-spacing code points into one + // grapheme. Count trailing visible code points that terminals may allocate + // cells for: Indic consonants after marks, halfwidth/fullwidth forms, and + // Thai/Lao AM vowels. + let followsMark = false; + const chars = [...base]; + for (const char of chars.slice(1)) { + if (terminalSpacingMarkRegex.test(char)) { + width += 1; + followsMark = false; + } else if (markCharRegex.test(char)) { + followsMark = true; + } else if (!nonPrintingCharRegex.test(char)) { const c = char.codePointAt(0)!; - if (c >= 0xff00 && c <= 0xffef) { + if (followsMark || (c >= 0xff00 && c <= 0xffef)) { + // halfwidth + fullwidth forms width += eastAsianWidth(c); } else if (c === 0x0e33 || c === 0x0eb3) { width += 1; } + followsMark = false; } } @@ -270,6 +294,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; +} + /** * Fast visible-width scan for lines whose printable content is plain ASCII, * skipping over ANSI escape sequences. Returns the visible width, or @@ -300,14 +395,35 @@ export function asciiVisibleWidth(line: string, limit: number): number | undefin * Normalize text for terminal output without changing logical editor content. * Some terminals render precomposed Thai/Lao AM vowels inconsistently during * differential repaint. Their compatibility decompositions have the same cell - * width but avoid stale-cell artifacts in terminal renderers. + * width but avoid stale-cell artifacts in terminal renderers. Visible tabs are + * expanded to the fixed width used by layout so terminal tab stops cannot wrap + * a logical line, while tabs inside terminal string sequences stay untouched. */ const THAI_LAO_AM_REGEX = /[\u0e33\u0eb3]/; const THAI_LAO_AM_GLOBAL_REGEX = /[\u0e33\u0eb3]/g; export function normalizeTerminalOutput(str: string): string { - if (!THAI_LAO_AM_REGEX.test(str)) return str; - return str.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => (char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2")); + let normalized = str; + if (THAI_LAO_AM_REGEX.test(normalized)) { + normalized = normalized.replace(THAI_LAO_AM_GLOBAL_REGEX, (char) => + char === "\u0e33" ? "\u0e4d\u0e32" : "\u0ecd\u0eb2", + ); + } + if (!normalized.includes("\t")) return normalized; + + let result = ""; + let i = 0; + while (i < normalized.length) { + const ansi = extractAnsiCode(normalized, i); + if (ansi) { + result += ansi.code; + i += ansi.length; + continue; + } + result += normalized[i] === "\t" ? " " : normalized[i]; + i++; + } + return result; } /** @@ -389,6 +505,28 @@ function formatOsc8Close(terminator: Osc8Terminator): string { return `\x1b]8;;${terminator}`; } +function getActiveOsc8Close(prefix: string): string { + if (!prefix.includes("\x1b]8;")) { + return ""; + } + + let activeHyperlink: ActiveHyperlink | null = null; + let i = 0; + while (i < prefix.length) { + const ansi = extractAnsiCode(prefix, i); + if (ansi) { + const hyperlink = parseOsc8Hyperlink(ansi.code); + if (hyperlink !== undefined) { + activeHyperlink = hyperlink; + } + i += ansi.length; + } else { + i++; + } + } + return activeHyperlink ? formatOsc8Close(activeHyperlink.terminator) : ""; +} + /** * Track active ANSI SGR codes to preserve styling across line breaks. */ @@ -724,7 +862,7 @@ export function wrapTextWithAnsi(text: string, width: number): string[] { // Handle newlines by processing each line separately // Track ANSI state across lines so styles carry over after literal newlines - const inputLines = text.split("\n"); + const inputLines = text.split(/\r\n|\r|\n/); const result: string[] = []; const tracker = new AnsiCodeTracker(); diff --git a/packages/pi-tui/test/chat-simple.ts b/packages/pi-tui/test/chat-simple.ts index b6ccd1a8597..5f2c2802e45 100644 --- a/packages/pi-tui/test/chat-simple.ts +++ b/packages/pi-tui/test/chat-simple.ts @@ -9,14 +9,15 @@ import { Loader } from "../src/components/loader.ts"; import { Markdown } from "../src/components/markdown.ts"; import { Text } from "../src/components/text.ts"; import { ProcessTerminal } from "../src/terminal.ts"; -import { TUI } from "../src/tui.ts"; +import type { TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; import { defaultEditorTheme, defaultMarkdownTheme } from "./test-themes.ts"; // Create terminal const terminal = new ProcessTerminal(); // Create TUI -const tui = new TUI(terminal); +const tui: TUI = new TuiMainScreen(terminal); // Create chat container with some initial messages tui.addChild( diff --git a/packages/pi-tui/test/editor-history-keybindings.test.ts b/packages/pi-tui/test/editor-history-keybindings.test.ts new file mode 100644 index 00000000000..c26a4cf26c6 --- /dev/null +++ b/packages/pi-tui/test/editor-history-keybindings.test.ts @@ -0,0 +1,43 @@ +import assert from "node:assert"; +import { afterEach, describe, it } from "node:test"; +import { Editor } from "../src/components/editor.ts"; +import { KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; +import { defaultEditorTheme } from "./test-themes.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +afterEach(() => { + setKeybindings(new KeybindingsManager(TUI_KEYBINDINGS)); +}); + +describe("Editor prompt history keybindings", () => { + it("browses history directly without first moving the cursor", () => { + setKeybindings( + new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.editor.historyPrevious": "ctrl+p", + "tui.editor.historyNext": "ctrl+n", + }), + ); + const editor = new Editor(new TuiMainScreen(new VirtualTerminal()), defaultEditorTheme); + editor.addToHistory("older prompt"); + editor.addToHistory("newer\nmultiline prompt"); + editor.setText("draft"); + editor.handleInput("\x1b[D"); + editor.handleInput("\x1b[D"); + + editor.handleInput("\x10"); // Ctrl+P + assert.strictEqual(editor.getText(), "newer\nmultiline prompt"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 0 }); + + editor.handleInput("\x10"); // Ctrl+P + assert.strictEqual(editor.getText(), "older prompt"); + + editor.handleInput("\x0e"); // Ctrl+N + assert.strictEqual(editor.getText(), "newer\nmultiline prompt"); + assert.deepStrictEqual(editor.getCursor(), { line: 1, col: 16 }); + + editor.handleInput("\x0e"); // Ctrl+N + assert.strictEqual(editor.getText(), "draft"); + assert.deepStrictEqual(editor.getCursor(), { line: 0, col: 3 }); + }); +}); diff --git a/packages/pi-tui/test/editor.test.ts b/packages/pi-tui/test/editor.test.ts index a47f45dd7d5..379594db583 100644 --- a/packages/pi-tui/test/editor.test.ts +++ b/packages/pi-tui/test/editor.test.ts @@ -4,14 +4,15 @@ 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 { TUI } from "../src/tui.ts"; +import type { TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.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 */ @@ -979,6 +980,31 @@ describe("Editor component", () => { }); }); + describe("Scroll indicators", () => { + it("keeps truncated scroll indicators within width and preserves their color (issue #6962)", () => { + const width = 10; + const borderColor = (text: string) => `\x1b[35m${text}\x1b[39m`; + const editor = new Editor(createTestTUI(width), { ...defaultEditorTheme, borderColor }); + editor.setText(Array.from({ length: 20 }, (_, index) => `line ${index}`).join("\n")); + + // Render once to initialize wrapping, then move the cursor so content remains above and below the viewport. + editor.render(width); + for (let index = 0; index < 10; index++) editor.handleInput("\x1b[A"); + + const lines = editor.render(width); + const topBorder = lines[0]!; + const bottomBorder = lines.at(-1)!; + + assert.match(stripVTControlCharacters(topBorder), /^─── ↑/); + assert.match(stripVTControlCharacters(bottomBorder), /^─── ↓/); + assert.strictEqual(topBorder, borderColor(stripVTControlCharacters(topBorder))); + assert.strictEqual(bottomBorder, borderColor(stripVTControlCharacters(bottomBorder))); + for (const line of lines) { + assert.strictEqual(visibleWidth(line), width, `line exceeds width ${width}: ${JSON.stringify(line)}`); + } + }); + }); + describe("Grapheme-aware text wrapping", () => { it("wraps lines correctly when text contains wide emojis", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); @@ -3833,6 +3859,11 @@ describe("Editor component", () => { return editor.getText(); } + /** Helper: 12-line paste content with a distinguishing tag */ + function bigPaste(tag: string): string { + return Array.from({ length: 12 }, (_, i) => `${tag}${i}`).join("\n"); + } + it("creates a paste marker for large pastes", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); const text = pasteWithMarker(editor); @@ -3970,6 +4001,92 @@ describe("Editor component", () => { assert.strictEqual(editor.getText(), textBefore); }); + it("undo after paste marker deletion restores the paste registry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (t) => { + submitted = t; + }; + + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + editor.handleInput("\x7f"); // delete the marker + editor.handleInput("\x1b[45;5u"); // undo: restores marker text and registry + editor.handleInput("\r"); + assert.strictEqual(submitted, paste); + }); + + it("undo after deleting the first of two paste markers restores both registry entries", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (t) => { + submitted = t; + }; + + const pasteA = bigPaste("alpha"); + const pasteB = bigPaste("beta"); + editor.handleInput(`\x1b[200~${pasteA}\x1b[201~`); // #1 = A + editor.handleInput(`\x1b[200~${pasteB}\x1b[201~`); // #2 = B, cursor at end + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput("\x1b[C"); // right over marker #1 + editor.handleInput("\x7f"); // delete marker #1, renumbers #2 -> #1 + editor.handleInput("\x1b[45;5u"); // undo + editor.handleInput("\r"); + assert.strictEqual(submitted, pasteA + pasteB); + }); + + it("renumbers the paste registry in ascending id order when markers are out of order in text", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (t) => { + submitted = t; + }; + + const pasteA = bigPaste("alpha"); + const pasteB = bigPaste("beta"); + const pasteC = bigPaste("gamma"); + editor.handleInput(`\x1b[200~${pasteA}\x1b[201~`); // #1 = A + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput(`\x1b[200~${pasteB}\x1b[201~`); // #2 = B, text: [#2][#1] + editor.handleInput("\x01"); // Ctrl+A + editor.handleInput(`\x1b[200~${pasteC}\x1b[201~`); // #3 = C, text: [#3][#2][#1] + editor.handleInput("\x05"); // Ctrl+E + editor.handleInput("\x7f"); // delete marker #1, renumber #3 -> #2 and #2 -> #1 + editor.handleInput("\r"); + assert.strictEqual(submitted, pasteC + pasteB); + }); + + it("undo after setText restores paste markers and registry", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (t) => { + submitted = t; + }; + + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); + editor.setText("replacement"); + editor.handleInput("\x1b[45;5u"); // undo + editor.handleInput("\r"); + assert.strictEqual(submitted, paste); + }); + + it("setText with preservePasteRegistry keeps the registry for surviving markers", () => { + const editor = new Editor(createTestTUI(), defaultEditorTheme); + let submitted = ""; + editor.onSubmit = (t) => { + submitted = t; + }; + + const paste = bigPaste("alpha"); + editor.handleInput(`\x1b[200~${paste}\x1b[201~`); // #1 = alpha + // A programmatic replace that still contains the marker (e.g. a subclass + // expanding one of several paste markers) must not orphan its entry. + editor.setText(editor.getText(), { preservePasteRegistry: true }); + editor.handleInput("\r"); + assert.strictEqual(submitted, paste); + }); + it("handles multiple paste markers in same line", () => { const editor = new Editor(createTestTUI(), defaultEditorTheme); pasteWithMarker(editor); @@ -4420,7 +4537,7 @@ describe("Editor narrow width rendering", () => { it("renders inside a TUI at 5 columns without crashing or overflowing", async () => { const terminal = new VirtualTerminal(5, 12); - const tui = new TUI(terminal); + const tui = new TuiMainScreen(terminal); const editor = new Editor(tui, defaultEditorTheme, { paddingX: 4 }); tui.addChild(editor); editor.setText("你好世界"); diff --git a/packages/pi-tui/test/image-test.ts b/packages/pi-tui/test/image-test.ts index 116c97d0201..73a26862d9f 100644 --- a/packages/pi-tui/test/image-test.ts +++ b/packages/pi-tui/test/image-test.ts @@ -4,7 +4,8 @@ import { Spacer } from "../src/components/spacer.ts"; import { Text } from "../src/components/text.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"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; const testImagePath = process.argv[2] || "/tmp/test-image.png"; @@ -27,7 +28,7 @@ console.log("Image dimensions:", dims); console.log(""); const terminal = new ProcessTerminal(); -const tui = new TUI(terminal); +const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new Text("Image Rendering Test", 1, 1)); tui.addChild(new Spacer(1)); diff --git a/packages/pi-tui/test/key-tester.ts b/packages/pi-tui/test/key-tester.ts index dce060d4fcf..06373c7d765 100755 --- a/packages/pi-tui/test/key-tester.ts +++ b/packages/pi-tui/test/key-tester.ts @@ -1,7 +1,8 @@ #!/usr/bin/env node import { matchesKey } from "../src/keys.ts"; import { ProcessTerminal } from "../src/terminal.ts"; -import { type Component, TUI } from "../src/tui.ts"; +import type { Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; import { truncateToWidth } from "../src/utils.ts"; /** @@ -102,7 +103,7 @@ class KeyLogger implements Component { // Set up TUI const terminal = new ProcessTerminal(); -const tui = new TUI(terminal); +const tui: TUI = new TuiMainScreen(terminal); const logger = new KeyLogger(tui, terminal); tui.addChild(logger); diff --git a/packages/pi-tui/test/keybindings.test.ts b/packages/pi-tui/test/keybindings.test.ts index 8bd7f4387e7..c79a024aa0d 100644 --- a/packages/pi-tui/test/keybindings.test.ts +++ b/packages/pi-tui/test/keybindings.test.ts @@ -11,6 +11,35 @@ describe("KeybindingsManager", () => { assert.strictEqual(keybindings.matches("\x1b[106;5u", "tui.input.newLine"), true); }); + 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.pageUp"), ["pageUp", "ctrl+pageUp"]); + assert.deepStrictEqual(keybindings.getKeys("tui.editor.pageDown"), ["pageDown", "ctrl+pageDown"]); + }); + + it("leaves dedicated prompt history navigation unbound by default", () => { + const keybindings = new KeybindingsManager(TUI_KEYBINDINGS); + + assert.deepStrictEqual(keybindings.getKeys("tui.editor.historyPrevious"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.editor.historyNext"), []); + }); + + it("binds unmodified terminal viewport shortcuts to alternate-screen navigation", () => { + const keybindings = new KeybindingsManager(TUI_KEYBINDINGS); + + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageUp"), ["pageUp"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageDown"), ["pageDown"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageUp"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageDown"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.top"), ["home"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.bottom"), ["end"]); + }); + it("does not evict selector confirm when input submit is rebound", () => { const keybindings = new KeybindingsManager(TUI_KEYBINDINGS, { "tui.input.submit": ["enter", "ctrl+enter"], diff --git a/packages/pi-tui/test/keys.test.ts b/packages/pi-tui/test/keys.test.ts index e06844e40e9..68f594359c1 100644 --- a/packages/pi-tui/test/keys.test.ts +++ b/packages/pi-tui/test/keys.test.ts @@ -431,6 +431,10 @@ describe("matchesKey", () => { assert.strictEqual(parseKey("\x1ba"), "alt+a"); assert.strictEqual(matchesKey("\x1b1", "alt+1"), true); assert.strictEqual(parseKey("\x1b1"), "alt+1"); + assert.strictEqual(matchesKey("\x1b,", "alt+,"), true); + assert.strictEqual(parseKey("\x1b,"), "alt+,"); + assert.strictEqual(matchesKey("\x1b.", "alt+."), true); + assert.strictEqual(parseKey("\x1b."), "alt+."); assert.strictEqual(matchesKey("\x1by", "alt+y"), true); assert.strictEqual(parseKey("\x1by"), "alt+y"); assert.strictEqual(matchesKey("\x1bz", "alt+z"), true); @@ -451,6 +455,10 @@ describe("matchesKey", () => { assert.strictEqual(parseKey("\x1ba"), undefined); assert.strictEqual(matchesKey("\x1b1", "alt+1"), false); assert.strictEqual(parseKey("\x1b1"), undefined); + assert.strictEqual(matchesKey("\x1b,", "alt+,"), false); + assert.strictEqual(parseKey("\x1b,"), undefined); + assert.strictEqual(matchesKey("\x1b.", "alt+."), false); + assert.strictEqual(parseKey("\x1b."), undefined); assert.strictEqual(matchesKey("\x1by", "alt+y"), false); assert.strictEqual(parseKey("\x1by"), undefined); setKittyProtocolActive(false); @@ -472,6 +480,17 @@ describe("matchesKey", () => { assert.strictEqual(matchesKey("\x1bOF", "end"), true); }); + it("should match xterm Ctrl-modified viewport navigation", () => { + assert.strictEqual(matchesKey("\x1b[1;5H", "ctrl+home"), true); + assert.strictEqual(matchesKey("\x1b[1;5F", "ctrl+end"), true); + assert.strictEqual(matchesKey("\x1b[5;5~", "ctrl+pageUp"), true); + assert.strictEqual(matchesKey("\x1b[6;5~", "ctrl+pageDown"), true); + assert.strictEqual(parseKey("\x1b[1;5H"), "ctrl+home"); + assert.strictEqual(parseKey("\x1b[1;5F"), "ctrl+end"); + assert.strictEqual(parseKey("\x1b[5;5~"), "ctrl+pageUp"); + assert.strictEqual(parseKey("\x1b[6;5~"), "ctrl+pageDown"); + }); + it("should match legacy function keys and clear", () => { assert.strictEqual(matchesKey("\x1bOP", "f1"), true); assert.strictEqual(matchesKey("\x1b[24~", "f12"), true); diff --git a/packages/pi-tui/test/latex.test.ts b/packages/pi-tui/test/latex.test.ts new file mode 100644 index 00000000000..3644ec42b32 --- /dev/null +++ b/packages/pi-tui/test/latex.test.ts @@ -0,0 +1,483 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { renderLatex } from "../src/index.ts"; + +type LatexCase = readonly [source: string, expected: string]; + +function defineCases(cases: readonly LatexCase[]): void { + for (const [source, expected] of cases) { + it(`renders ${JSON.stringify(source)}`, () => { + assert.strictEqual(renderLatex(source), expected); + }); + } +} + +describe("renderLatex", () => { + describe("Jacobian conjecture session using dollar delimiters", () => { + defineCases([ + [String.raw`\mathbb{C}^3 \to \mathbb{C}^3`, "ℂ³ → ℂ³"], + [ + String.raw`\{3x+2y,\; 27x^2-4z-1,\; x(x-1)(x+1)\} \quad\Rightarrow\quad x \in \{0, \pm 1\},`, + "{3x+2y, 27x²-4z-1, x(x-1)(x+1)} ⇒ x ∈ {0, ± 1},", + ], + [String.raw`F_1 = -\frac{1}{4x^2}.`, "F₁ = -1/(4x²)."], + ["-2", "-2"], + ["(0,0,-1/4)", "(0,0,-1/4)"], + ["(1,-3/2,13/2)", "(1,-3/2,13/2)"], + ["(1,1,1)", "(1,1,1)"], + ["(2,1,0)", "(2,1,0)"], + ["(-1/4, 0, 0)", "(-1/4, 0, 0)"], + [String.raw`\{(0,0,-1/4), (1,-3/2,13/2), (-1,3/2,13/2)\}`, "{(0,0,-1/4), (1,-3/2,13/2), (-1,3/2,13/2)}"], + ["(2,1,1)", "(2,1,1)"], + ["(7/3,-2/5,11/7)", "(7/3,-2/5,11/7)"], + [String.raw`\{y - p(x),\; q(x)\}`, "{y - p(x), q(x)}"], + [String.raw`\deg q = 3`, "deg q = 3"], + [String.raw`[\mathbb{C}(x,y,z):\mathbb{C}(F_1,F_2,F_3)] = 3`, "[ℂ(x,y,z):ℂ(F₁,F₂,F₃)] = 3"], + ["u = 1+xy", "u = 1+xy"], + ["G = u^2 z + y^2(4+3xy)", "G = u² z + y²(4+3xy)"], + ["F_1 = uG", "F₁ = uG"], + ["F_2 = y + 3xG", "F₂ = y + 3xG"], + ["x=0", "x = 0"], + ["F_2 = F_3 = 0", "F₂ = F₃ = 0"], + ["xy = -3/2", "xy = -3/2"], + ["x^2 z = 13/2", "x² z = 13/2"], + [String.raw`\mathbb{C}^*`, "ℂ^*"], + [String.raw`s \mapsto (s,\, -\tfrac{3}{2s},\, \tfrac{13}{2s^2})`, "s ↦ (s, -3/(2s), 13/(2s²))"], + ["X", "X"], + [String.raw`p_\pm`, "p_±"], + ["F(-x,-y,z) = (F_1, -F_2, -F_3)", "F(-x,-y,z) = (F₁, -F₂, -F₃)"], + ["p_0", "p₀"], + [String.raw`s \to \infty`, "s → ∞"], + ["(0,0,0)", "(0,0,0)"], + [String.raw`\Rightarrow`, "⇒"], + [String.raw`\ge 2`, "≥ 2"], + [String.raw`\ge 3`, "≥ 3"], + ["1", "1"], + [String.raw`\mathrm{diag}(-1/2,1,1)`, "diag(-1/2,1,1)"], + ["4+3xy", "4+3xy"], + ]); + }); + + describe("satellite calculation session using bracket delimiters", () => { + defineCases([ + [ + String.raw`E \approx \frac{0.1\ \text{lux}}{100\ \text{lm/W}} = 0.001\ \text{W/m}^2`, + "E ≈ (0.1 lux)/(100 lm/W) = 0.001 W/m²", + ], + [String.raw`\boxed{1\ \text{milliwatt per square metre}}`, "[1 milliwatt per square metre]"], + [String.raw`5\ \text{km}^2 = 5{,}000{,}000\ \text{m}^2`, "5 km² = 5,000,000 m²"], + [ + String.raw`P_{\text{light}} = 0.001 \times 5{,}000{,}000 += \boxed{5{,}000\ \text{W}}`, + "P_light = 0.001 × 5,000,000 = [5,000 W]", + ], + [ + String.raw`P_{\text{electric}} = 5\ \text{kW} \times 0.2 += \boxed{1\ \text{kW}}`, + "P_electric = 5 kW × 0.2 = [1 kW]", + ], + [String.raw`\pi(2.5\ \text{km})^2 = 19.6\ \text{km}^2`, "π(2.5 km)² = 19.6 km²"], + [ + String.raw`0.001\ \text{W/m}^2 \times 19.6 \times 10^6\ \text{m}^2 +\approx \boxed{20\ \text{kW optical}}`, + "0.001 W/m² × 19.6 × 10⁶ m² ≈ [20 kW optical]", + ], + [ + String.raw`1\ \text{kW} \times \frac{1}{3600}\ \text{hour} += \boxed{0.28\ \text{Wh}}`, + "1 kW × 1/3600 hour = [0.28 Wh]", + ], + ]); + }); + + describe("Jacobian conjecture sessions using parenthesis and bracket delimiters", () => { + defineCases([ + [ + String.raw`\det\!\left(\frac{\partial(F_1,F_2,F_3)}{\partial(x,y,z)}\right)=-2.`, + "det((∂(F₁,F₂,F₃))/(∂(x,y,z))) = -2.", + ], + [ + String.raw`\begin{aligned} +F(0,0,-\tfrac14)&=(-\tfrac14,0,0),\\ +F(1,-\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0),\\ +F(-1,\tfrac32,\tfrac{13}2)&=(-\tfrac14,0,0). +\end{aligned}`, + "F(0,0,-1/4) = (-1/4,0,0),\nF(1,-3/2,13/2) = (-1/4,0,0),\nF(-1,3/2,13/2) = (-1/4,0,0).", + ], + ["F=(F_1,F_2,F_3)", "F = (F₁,F₂,F₃)"], + ["F", "F"], + ["3", "3"], + ]); + }); + + describe("Jacobian matrix session using dollar delimiters", () => { + defineCases([ + [ + String.raw`J = \begin{pmatrix} +\frac{\partial f_1}{\partial x} & \frac{\partial f_1}{\partial y} & \frac{\partial f_1}{\partial z} \\ +\frac{\partial f_2}{\partial x} & \frac{\partial f_2}{\partial y} & \frac{\partial f_2}{\partial z} \\ +\frac{\partial f_3}{\partial x} & \frac{\partial f_3}{\partial y} & \frac{\partial f_3}{\partial z} +\end{pmatrix}`, + "J = ⎛ (∂ f₁)/(∂ x) │ (∂ f₁)/(∂ y) │ (∂ f₁)/(∂ z) ⎞\n ⎜ (∂ f₂)/(∂ x) │ (∂ f₂)/(∂ y) │ (∂ f₂)/(∂ z) ⎟\n ⎝ (∂ f₃)/(∂ x) │ (∂ f₃)/(∂ y) │ (∂ f₃)/(∂ z) ⎠", + ], + [ + String.raw`\begin{aligned} +f_1 &= (1+xy)^3 z + y^2(1+xy)(4+3xy) \\ +f_2 &= y + 3x(1+xy)^2 z + 3xy^2(4+3xy) \\ +f_3 &= 2x - 3x^2y - x^3z +\end{aligned}`, + "f₁ = (1+xy)³ z + y²(1+xy)(4+3xy)\nf₂ = y + 3x(1+xy)² z + 3xy²(4+3xy)\nf₃ = 2x - 3x²y - x³z", + ], + ["x, y, z", "x, y, z"], + ["(x, y, z)", "(x, y, z)"], + [String.raw`(0,\; 0,\; -\tfrac14)`, "(0, 0, -1/4)"], + [String.raw`(-\tfrac14,\; 0,\; 0)`, "(-1/4, 0, 0)"], + [String.raw`(1,\; -\tfrac32,\; \tfrac{13}{2})`, "(1, -3/2, 13/2)"], + [String.raw`(-1,\; \tfrac32,\; \tfrac{13}{2})`, "(-1, 3/2, 13/2)"], + [String.raw`(-\frac14, 0, 0)`, "(-1/4, 0, 0)"], + [String.raw`F: \mathbb{C}^3 \to \mathbb{C}^3`, "F: ℂ³ → ℂ³"], + [ + String.raw`F(0,0,-\tfrac14) = F(1,-\tfrac32,\tfrac{13}{2}) = F(-1,\tfrac32,\tfrac{13}{2}) = (-\tfrac14, 0, 0)`, + "F(0,0,-1/4) = F(1,-3/2,13/2) = F(-1,3/2,13/2) = (-1/4, 0, 0)", + ], + [String.raw`\mathbb{C}^3`, "ℂ³"], + [ + String.raw`\begin{aligned} +f_1 &= \frac{f_1^{\text{ut}}(u,t)}{x^2}, \quad +f_2 = \frac{f_2^{\text{ut}}(u,t)}{x}, \quad +f_3 = x\,(2 - 3u - t) +\end{aligned}`, + "f₁ = (f₁ᵘᵗ(u,t))/(x²), f₂ = (f₂ᵘᵗ(u,t))/x, f₃ = x (2 - 3u - t)", + ], + [String.raw`\det J_F`, "det J_F"], + [String.raw`(-\tfrac14, 0, 0)`, "(-1/4, 0, 0)"], + ["u = xy", "u = xy"], + ["t = x^2z", "t = x²z"], + [String.raw`x \neq 0`, "x ≠ 0"], + [String.raw`f_1^{\text{ut}}, f_2^{\text{ut}}`, "f₁ᵘᵗ, f₂ᵘᵗ"], + ["u,t", "u,t"], + ["x", "x"], + ["x, x^2", "x, x²"], + [String.raw`\mathbb{C}^n \to \mathbb{C}^n`, "ℂⁿ → ℂⁿ"], + [String.raw`n \geq 2`, "n ≥ 2"], + [String.raw`\mathbb{P}^3`, "ℙ³"], + ]); + }); + + describe("extended formulas from a renderer stress-test session", () => { + defineCases([ + [String.raw`e^{i\pi}+1=0`, "e^(iπ)+1 = 0"], + [ + String.raw`\boxed{ +\mathcal{Z}(\beta) += +\int_{\mathcal M} +\exp\!\left( +-\beta\left[ +\frac12 g^{ij}(x)\,\partial_i\phi\,\partial_j\phi ++V(\phi) +\right]\right) +\mathcal D\phi +}`, + "[Z(β) = ∫_M exp( -β[ 1/2 gⁱʲ(x) ∂ᵢϕ ∂ⱼϕ +V(ϕ) ]) Dϕ]", + ], + [ + String.raw`\begin{aligned} +\nabla_\mu T^{\mu\nu} +&= +\frac{1}{\sqrt{-g}} +\partial_\mu\!\left(\sqrt{-g}\,T^{\mu\nu}\right) ++\Gamma^\nu_{\mu\lambda}T^{\mu\lambda} +=0, \\[4pt] +R_{\mu\nu}-\frac12 Rg_{\mu\nu}+\Lambda g_{\mu\nu} +&= +\frac{8\pi G}{c^4}T_{\mu\nu}. +\end{aligned}`, + "∇_μ T^(μν) = 1/(√(-g)) ∂_μ(√(-g) T^(μν)) +Γ^ν_(μλ)T^(μλ) = 0,\nR_(μν)-1/2 Rg_(μν)+Λ g_(μν) = (8π G)/(c⁴)T_(μν).", + ], + [ + String.raw`f(z) += +\frac{1}{2\pi i} +\oint_{\gamma} +\frac{f(\zeta)}{\zeta-z}\,d\zeta, +\qquad +\det\!\begin{pmatrix} +\lambda-a & -b & 0\\ +-c & \lambda-d & -e\\ +0 & -f & \lambda-g +\end{pmatrix} +=0.`, + [ + "f(z) = 1/(2π i) ∮_γ (f(ζ))/(ζ-z) dζ, det⎛ λ-a │ -b │ 0 ⎞ = 0.", + `${" ".repeat(40)}⎜ -c │ λ-d │ -e ⎟`, + `${" ".repeat(40)}⎝ 0 │ -f │ λ-g ⎠`, + ].join("\n"), + ], + [ + String.raw`\Psi(x,t)= +\sum_{n=1}^{\infty} +\underbrace{ +c_n +\sqrt{\frac{2}{L}} +\sin\!\left(\frac{n\pi x}{L}\right) +}_{\text{spatial eigenmode}} +\exp\!\left(-\frac{i\hbar n^2\pi^2}{2mL^2}t\right), +\qquad +|\Psi(x,t)|^2 += +\begin{cases} +\Psi^\ast\Psi, & 0 { + assert.strictEqual( + renderLatex(String.raw`\sum_{i=0}^n \alpha_i + \int_0^\infty e^{-x^2}\,dx = \sqrt{\pi}`), + "∑ᵢ₌₀ⁿ αᵢ + ∫₀^∞ e^(-x²) dx = √π", + ); + }); + + it("renders common accents and binomial notation", () => { + assert.strictEqual( + renderLatex(String.raw`\binom{n}{k}+\vec{x}+\hat{y}+\overline{AB}`), + "(n choose k)+x⃗+ŷ+overline(AB)", + ); + }); + + it("renders extended symbols and negated relations", () => { + assert.strictEqual( + renderLatex(String.raw`\epsilon+\varepsilon+\varsigma+\varkappa+\oplus+\otimes+\therefore+\because`), + "ϵ+ε+ς+ϰ+⊕+⊗+∴+∵", + ); + assert.strictEqual(renderLatex(String.raw`A\not\subseteq B,\quad x\not\in X`), "A ⊈ B, x ∉ X"); + }); + + it("renders delimiter commands and invisible delimiters", () => { + assert.strictEqual( + renderLatex(String.raw`\lvert{x}\rvert+\lVert{v}\rVert+\left.\frac{dy}{dx}\right|_{x=0}`), + "|x|+‖v‖+dy/(dx)|ₓ₌₀", + ); + assert.strictEqual(renderLatex(String.raw`\left\lbrace x \middle| x>0 \right\rbrace`), "{ x | x > 0 }"); + }); + + it("renders named, modular, overlaid, and underlaid operators", () => { + assert.strictEqual(renderLatex(String.raw`\operatorname*{arg\,max}_{x\in X} f(x)`), "arg max[x∈X] f(x)"); + assert.strictEqual(renderLatex(String.raw`a\bmod n,\quad a\equiv b\pmod n`), "a mod n, a ≡ b (mod n)"); + assert.strictEqual(renderLatex(String.raw`\overset{!}{=}+\underset{n}{x}+\stackrel{def}{=}`), "=^!+xₙ+=ᵈᵉᶠ"); + }); + + it("renders indexed roots and additional accents and wrappers", () => { + assert.strictEqual( + renderLatex(String.raw`\sqrt[2]{x}+\sqrt[3]{x}+\sqrt[4]{x}+\sqrt[n]{x}+\sqrt[k]{x+1}`), + "√x+∛x+∜x+ⁿ√x+ᵏ√(x+1)", + ); + assert.strictEqual( + renderLatex(String.raw`\acute{x}+\grave{y}+\widehat{xyz}+\overrightarrow{AB}`), + "x́+ỳ+widehat(xyz)+overrightarrow(AB)", + ); + assert.strictEqual(renderLatex(String.raw`\textnormal{hello}+\mbox{world}+\boldsymbol{x}`), "hello+world+x"); + }); + + it("renders additional display environments", () => { + assert.strictEqual( + renderLatex(String.raw`\begin{equation}\begin{split}a&=b\\&=c\end{split}\end{equation}`), + "a = b\n= c", + ); + assert.strictEqual( + renderLatex(String.raw`\begin{alignedat}{2}a&=b&\quad c&=d\\e&=f&g&=h\end{alignedat}`), + "a = b c = d\ne = f g = h", + ); + }); + + it("uses natural case conditions and aligns matrix columns", () => { + assert.strictEqual( + renderLatex(String.raw`\begin{cases}a & x<0 \\ b & \text{if }x=0 \\ c & \text{otherwise}\end{cases}`), + "⎧ a if x < 0\n⎨ b if x = 0\n⎩ c otherwise", + ); + assert.strictEqual( + renderLatex(String.raw`\begin{pmatrix}1&200\\3000&4\end{pmatrix}`), + "⎛ 1 │ 200 ⎞\n⎝ 3000 │ 4 ⎠", + ); + }); + + it("composes matrices with fractions and adjacent matrices", () => { + assert.strictEqual( + renderLatex( + String.raw`R\left(\frac{\pi}{4}\right) += +\begin{pmatrix} +\frac{\sqrt{2}}{2} & -\frac{\sqrt{2}}{2}\\ +\frac{\sqrt{2}}{2} & \frac{\sqrt{2}}{2} +\end{pmatrix}.`, + { display: true }, + ), + " π\nR( ─ ) = ⎛ (√2)/2 │ -(√2)/2 ⎞\n 4 ⎝ (√2)/2 │ (√2)/2 ⎠.", + ); + assert.strictEqual( + renderLatex( + String.raw`\mathbf w += +R\left(\frac{\pi}{4}\right) +\begin{pmatrix}1\\0\end{pmatrix} += +\begin{pmatrix}\frac{\sqrt{2}}{2}\\\frac{\sqrt{2}}{2}\end{pmatrix}.`, + { display: true }, + ), + " π\nw = R( ─ ) ⎛ 1 ⎞ = ⎛ (√2)/2 ⎞\n 4 ⎝ 0 ⎠ ⎝ (√2)/2 ⎠.", + ); + assert.strictEqual( + renderLatex( + String.raw`A\mathbf e_1=\begin{pmatrix}\pi\\0\end{pmatrix},\qquad A\mathbf e_2=\begin{pmatrix}0\\\frac{1}{\pi}\end{pmatrix}.`, + { display: true }, + ), + "Ae₁ = ⎛ π ⎞, Ae₂ = ⎛ 0 ⎞\n ⎝ 0 ⎠ ⎝ 1/π ⎠.", + ); + assert.strictEqual( + renderLatex(String.raw`\sum_{i=0}^n x_i=\begin{pmatrix}a&b\\c&d\end{pmatrix}.`, { display: true }), + " n\n ∑ xᵢ = ⎛ a │ b ⎞\ni=0 ⎝ c │ d ⎠.", + ); + }); + + it("normalizes relation, multiplication, and named-operator spacing", () => { + for (const source of ["x=y", "x =y", "x=\ny", "x\n=\ny"]) { + assert.strictEqual(renderLatex(source), "x = y"); + } + assert.strictEqual(renderLatex("x_{i=0}"), "xᵢ₌₀"); + assert.strictEqual(renderLatex(String.raw`x\neq0`), "x ≠ 0"); + assert.strictEqual(renderLatex(String.raw`A\to B`), "A → B"); + assert.strictEqual(renderLatex(String.raw`\pi\cdot\frac{1}{\pi}`), "π · 1/π"); + assert.strictEqual(renderLatex(String.raw`\sin\theta`), "sin θ"); + assert.strictEqual(renderLatex(String.raw`\sin^2 x`), "sin² x"); + assert.strictEqual(renderLatex(String.raw`-\sin\theta`), "-sin θ"); + assert.strictEqual(renderLatex(String.raw`i\sin\theta`), "i sin θ"); + assert.strictEqual(renderLatex(String.raw`\det(A)`), "det(A)"); + }); + + it("stacks operator limits in display mode", () => { + assert.strictEqual(renderLatex(String.raw`\sum_{i=0}^n x_i`, { display: true }), " n\n ∑ xᵢ\ni=0"); + assert.strictEqual(renderLatex(String.raw`\min_{x\in X} f(x)`, { display: true }), "min f(x)\nx∈X"); + assert.strictEqual( + renderLatex(String.raw`\operatorname*{arg\,max}_{x\in X} f(x)`, { + display: true, + }), + "arg max f(x)\n x∈X", + ); + assert.strictEqual(renderLatex(String.raw`\int\nolimits_0^1 f(x)\,dx`, { display: true }), "∫₀¹ f(x) dx"); + assert.strictEqual(renderLatex(String.raw`\int\limits_0^1 f(x)\,dx`, { display: true }), "1\n∫ f(x) dx\n0"); + }); + + it("uses the middle brace for intermediate case rows", () => { + assert.strictEqual( + renderLatex(String.raw`\begin{cases}a & x<0 \\ b & x=0 \\ c & x>0\end{cases}`), + "⎧ a if x < 0\n⎨ b if x = 0\n⎩ c if x > 0", + ); + }); + + it("stacks fractions in display mode", () => { + assert.strictEqual( + renderLatex(String.raw`x=\frac{-b\pm\sqrt{b^2-4ac}}{2a}`, { + display: true, + }), + " -b±√(b²-4ac)\nx = ────────────\n 2a", + ); + assert.strictEqual(renderLatex(String.raw`\frac{x^2+1}{x-1}`, { display: true }), "x²+1\n────\nx-1"); + }); + + it("keeps nested display fractions linear", () => { + const cases: Array<[string, string]> = [ + [ + String.raw`\frac{\frac{x^2+1}{x-1}-\frac{2x}{x+1}}{\frac{x}{x^2-1}}`, + "(x²+1)/(x-1)-2x/(x+1)\n─────────────────────\n x/(x²-1)", + ], + [ + String.raw`\lim_{x\to 0}\frac{\frac{\sin x}{x}-1}{\frac{e^x-1}{x}-1}=0`, + " (sin x)/x-1\nlim ─────────── = 0\nx→0 (eˣ-1)/x-1", + ], + [ + String.raw`\frac{1+\frac{1}{1+\frac{1}{x}}}{1-\frac{1}{1-\frac{1}{x}}}`, + "1+1/(1+1/x)\n───────────\n1-1/(1-1/x)", + ], + ]; + for (const [source, expected] of cases) { + assert.strictEqual(renderLatex(source, { display: true }), expected); + } + }); + + it("keeps fractions linear in scripts and text-style fractions", () => { + assert.strictEqual(renderLatex(String.raw`e^{\frac{1}{2}}`, { display: true }), "e^(1/2)"); + assert.strictEqual(renderLatex(String.raw`\tfrac{1}{2}`, { display: true }), "1/2"); + }); + + it("returns undefined for unsupported commands", () => { + assert.strictEqual(renderLatex(String.raw`x + \unknown{y}`), undefined); + }); + + it("returns undefined for malformed groups and environments", () => { + const malformed = [String.raw`\frac{1}{x`, "x}", String.raw`\begin{matrix}1 & 2`, "x\\"]; + for (const source of malformed) { + assert.strictEqual(renderLatex(source), undefined); + } + }); +}); diff --git a/packages/pi-tui/test/layout.test.ts b/packages/pi-tui/test/layout.test.ts new file mode 100644 index 00000000000..8569542719f --- /dev/null +++ b/packages/pi-tui/test/layout.test.ts @@ -0,0 +1,306 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { HStack } from "../src/components/h-stack.ts"; +import { ScrollView } from "../src/components/scroll-view.ts"; +import { Text } from "../src/components/text.ts"; +import { VStack } from "../src/components/v-stack.ts"; +import { renderLayoutFrame } from "../src/layout.ts"; +import { encodeKitty, registerKittyImageMetadata } from "../src/terminal-image.ts"; +import { stripTerminalSequences } from "../src/utils.ts"; + +function visibleLines(lines: string[]): string[] { + return lines.map((line) => stripTerminalSequences(line).trimEnd()); +} + +describe("viewport layout", () => { + it("allocates vertical grow space deterministically", () => { + const frame = renderLayoutFrame( + new VStack([ + { component: new Text("top", 0, 0), basis: 1, shrink: 0 }, + { component: new Text("body", 0, 0), basis: 0, grow: 1 }, + ]), + 10, + 4, + () => {}, + ); + + assert.deepStrictEqual( + frame.root.children.map((child) => child.rect.height), + [1, 3], + ); + assert.deepStrictEqual(visibleLines(frame.lines), ["top", "body", "", ""]); + }); + + it("does not render fixed-basis scroll content during stack measurement", () => { + let renderCount = 0; + const transcript = new ScrollView({ + render: () => { + renderCount += 1; + return ["one", "two", "three"]; + }, + invalidate: () => {}, + }); + const root = new VStack([ + { component: transcript, basis: 0, grow: 1 }, + { component: new Text("dock", 0, 0), basis: "auto" }, + ]); + renderLayoutFrame(root, 10, 3, () => {}); + assert.strictEqual(renderCount, 1); + }); + + it("paints only clipped rows from very large scroll content", () => { + const lineCount = 1_000_000_000; + const lines: string[] = []; + lines.length = lineCount; + lines[lineCount - 4] = "before"; + lines[lineCount - 3] = "visible 1"; + lines[lineCount - 2] = "visible 2"; + lines[lineCount - 1] = "visible 3"; + const transcript = new ScrollView( + { + render: () => lines, + invalidate: () => {}, + }, + { follow: "end" }, + ); + + const frame = renderLayoutFrame(transcript, 10, 3, () => {}); + assert.deepStrictEqual(visibleLines(frame.lines), ["visible 1", "visible 2", "visible 3"]); + }); + + it("shrinks entries to their minimum sizes", () => { + const frame = renderLayoutFrame( + new VStack([ + { component: new Text("a1\na2\na3", 0, 0), shrink: 1, minSize: 1 }, + { component: new Text("b1\nb2\nb3", 0, 0), shrink: 0 }, + ]), + 10, + 4, + () => {}, + ); + + assert.deepStrictEqual( + frame.root.children.map((child) => child.rect.height), + [1, 3], + ); + assert.deepStrictEqual(visibleLines(frame.lines), ["a1", "b1", "b2", "b3"]); + }); + + it("includes nested minimum sizes in intrinsic stack measurement", () => { + const dock = new VStack([ + new Text("top1\ntop2\ntop3", 0, 0), + { component: new Text("selector", 0, 0), minSize: 3 }, + new Text("below", 0, 0), + { component: new Text("footer", 0, 0), minSize: 1 }, + ]); + const frame = renderLayoutFrame( + new VStack([ + { component: new Text("body", 0, 0), basis: 0, grow: 1, minSize: 1 }, + { component: dock, basis: "auto", minSize: 1 }, + ]), + 10, + 9, + () => {}, + ); + + assert.deepStrictEqual(visibleLines(frame.lines), [ + "body", + "top1", + "top2", + "top3", + "selector", + "", + "", + "below", + "footer", + ]); + }); + + it("omits gaps around invisible entries", () => { + const stack = new VStack( + [new Text("one", 0, 0), { component: new Text("hidden", 0, 0), visible: () => false }, new Text("two", 0, 0)], + { gap: 1 }, + ); + assert.deepStrictEqual( + stack.render(10).map((line) => line.trimEnd()), + ["one", "", "two"], + ); + }); + + it("crops Kitty images at a scroll view's lower boundary", () => { + const imageId = 124; + const imageLine = encodeKitty("AAAA", { columns: 2, rows: 3, imageId, moveCursor: false }); + registerKittyImageMetadata({ imageId, columns: 2, rows: 3, widthPx: 100, heightPx: 100 }); + const transcript = new ScrollView({ + render: () => ["one", "two", imageLine, "", ""], + invalidate: () => {}, + }); + const frame = renderLayoutFrame( + new VStack([{ component: transcript, basis: 0, grow: 1 }, new Text("dock", 0, 0)]), + 20, + 4, + () => {}, + ); + + assert.ok(frame.lines[2]?.includes("y=0,h=34,r=1")); + }); + + it("composes horizontal children at allocated widths", () => { + const frame = renderLayoutFrame( + new HStack([ + { component: new Text("left", 0, 0), basis: 6, shrink: 0 }, + { component: new Text("right", 0, 0), basis: 6, shrink: 0 }, + ]), + 12, + 1, + () => {}, + ); + assert.deepStrictEqual(visibleLines(frame.lines), ["left right"]); + }); + + it("does not paint zero-width horizontal children", () => { + const frame = renderLayoutFrame( + new HStack([ + { component: new Text("hidden", 0, 0), basis: 0, shrink: 0 }, + { component: new Text("shown", 0, 0), basis: 0, grow: 1 }, + ]), + 5, + 1, + () => {}, + ); + assert.deepStrictEqual(visibleLines(frame.lines), ["shown"]); + }); + + it("tracks follow-end state and returns unused scroll delta", () => { + const scrollView = new ScrollView(new Text("1\n2\n3\n4\n5\n6", 0, 0), { + follow: "end", + primary: true, + }); + renderLayoutFrame(scrollView, 10, 3, () => {}); + assert.strictEqual(scrollView.scrollTop, 3); + assert.strictEqual(scrollView.isFollowingEnd, true); + + assert.strictEqual(scrollView.scrollBy(-2), 0); + assert.strictEqual(scrollView.scrollTop, 1); + assert.strictEqual(scrollView.isFollowingEnd, false); + assert.strictEqual(scrollView.scrollBy(-3), -2); + assert.strictEqual(scrollView.scrollTop, 0); + assert.strictEqual(scrollView.scrollBy(10), 7); + assert.strictEqual(scrollView.scrollTop, 3); + assert.strictEqual(scrollView.isFollowingEnd, true); + }); + + it("renders a transient proportional scrollbar without replacing cell content", async () => { + const sourceLines = ["abcd界", "abcde2", "abcde3", "abcde4", "abcde5", "abcde6", "abcde7", "abcde8"]; + const contentBackground = "\x1b[42m"; + const scrollbarBackground = "\x1b[48;5;1m"; + const scrollbarStyle = (text: string) => `${scrollbarBackground}${text}\x1b[49m`; + const content = new Text(sourceLines.join("\n"), 0, 0, (text) => `${contentBackground}${text}\x1b[49m`); + const scrollView = new ScrollView(content, { + scrollbar: "auto", + scrollbarStyle, + scrollbarHideDelayMs: 10, + }); + const render = () => renderLayoutFrame(scrollView, 6, 4, () => {}).lines; + const thumbRows = (lines: string[]) => lines.map((line) => line.includes(scrollbarBackground)); + + let lines = render(); + assert.deepStrictEqual(thumbRows(lines), [false, false, false, false]); + assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(0, 4)); + + scrollView.scrollBy(2); + lines = render(); + assert.deepStrictEqual(thumbRows(lines), [false, true, true, false]); + assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(2, 6)); + assert.ok(lines[1]!.lastIndexOf(contentBackground) < lines[1]!.lastIndexOf(scrollbarBackground)); + + await new Promise((resolve) => setTimeout(resolve, 30)); + lines = render(); + assert.deepStrictEqual(thumbRows(lines), [false, false, false, false]); + + scrollView.scrollToEnd(); + lines = render(); + assert.deepStrictEqual(thumbRows(lines), [false, false, true, true]); + assert.deepStrictEqual(lines.map(stripTerminalSequences), sourceLines.slice(4)); + + const followedContent = new Text(sourceLines.join("\n"), 0, 0); + const followed = new ScrollView(followedContent, { + follow: "end", + scrollbar: "auto", + scrollbarStyle, + }); + renderLayoutFrame(followed, 6, 4, () => {}); + assert.strictEqual(followed.scrollTop, 4); + followedContent.setText(`${sourceLines.join("\n")}\nabcde9`); + const growthFrame = renderLayoutFrame(followed, 6, 4, () => {}); + assert.strictEqual(followed.scrollTop, 5); + assert.ok(growthFrame.lines.every((line) => !line.includes(scrollbarBackground))); + + const fittingContent = new Text("1\n2", 0, 0); + const automatic = new ScrollView(fittingContent, { scrollbar: "auto", scrollbarStyle }); + renderLayoutFrame(automatic, 6, 4, () => {}); + automatic.scrollBy(1); + assert.ok( + renderLayoutFrame(automatic, 6, 4, () => {}).lines.every((line) => !line.includes(scrollbarBackground)), + ); + + const alwaysFitting = new ScrollView(fittingContent, { scrollbar: "always", scrollbarStyle }); + const alwaysFittingFrame = renderLayoutFrame(alwaysFitting, 6, 4, () => {}); + assert.strictEqual(alwaysFittingFrame.root.children[0]?.rect.width, 5); + assert.ok(alwaysFittingFrame.lines.every((line) => line.includes(scrollbarBackground))); + + const alwaysOverflowing = new ScrollView(content, { scrollbar: "always", scrollbarStyle }); + const alwaysOverflowingFrame = renderLayoutFrame(alwaysOverflowing, 6, 4, () => {}); + assert.strictEqual(alwaysOverflowingFrame.root.children[0]?.rect.width, 5); + assert.strictEqual(alwaysOverflowingFrame.lines.filter((line) => line.includes(scrollbarBackground)).length, 2); + + const thumbHeightFor = (contentHeight: number) => { + const sized = new ScrollView(new Text(Array.from({ length: contentHeight }, () => "x").join("\n"), 0, 0), { + scrollbar: "auto", + scrollbarStyle, + }); + renderLayoutFrame(sized, 6, 20, () => {}); + sized.scrollBy(1); + return renderLayoutFrame(sized, 6, 20, () => {}).lines.filter((line) => line.includes(scrollbarBackground)) + .length; + }; + assert.strictEqual(thumbHeightFor(21), 19); + assert.strictEqual(thumbHeightFor(40), 10); + assert.strictEqual(thumbHeightFor(100), 4); + assert.strictEqual(thumbHeightFor(400), 2); + }); + + it("updates reserved scrollbar layout at runtime", () => { + const scrollView = new ScrollView(new Text("123456", 0, 0), { scrollbar: "always" }); + const render = () => renderLayoutFrame(new HStack([scrollView], { align: "start" }), 6, 2, () => {}); + const always = render(); + assert.deepStrictEqual(visibleLines(always.lines), ["12345", "6"]); + assert.strictEqual(always.root.children[0]?.rect.width, 6); + assert.strictEqual(always.root.children[0]?.children[0]?.rect.width, 5); + + scrollView.setScrollbar("hidden"); + assert.strictEqual(render().root.children[0]?.children[0]?.rect.width, 6); + assert.strictEqual(scrollView.isScrollbarVisible, false); + }); + + it("measures nested scroll content from constrained child geometry", () => { + const inner = new ScrollView(new Text("1\n2\n3\n4\n5\n6", 0, 0)); + const outer = new ScrollView(new VStack([{ component: inner, basis: 2 }, new Text("tail", 0, 0)])); + renderLayoutFrame(outer, 10, 2, () => {}); + + assert.strictEqual(inner.viewportHeight, 2); + assert.strictEqual(outer.scrollBy(10), 9); + assert.strictEqual(outer.scrollTop, 1); + }); + + it("rebuilds geometry after content changes", () => { + const text = new Text("one", 0, 0); + const root = new VStack([text]); + const first = renderLayoutFrame(root, 10, 4, () => {}); + text.setText("one\ntwo\nthree"); + const second = renderLayoutFrame(root, 10, 4, () => {}); + + assert.strictEqual(first.root.children[0]?.lines?.length, 1); + assert.strictEqual(second.root.children[0]?.lines?.length, 3); + }); +}); diff --git a/packages/pi-tui/test/markdown.test.ts b/packages/pi-tui/test/markdown.test.ts index 895bfa6e400..ace5c216315 100644 --- a/packages/pi-tui/test/markdown.test.ts +++ b/packages/pi-tui/test/markdown.test.ts @@ -4,7 +4,8 @@ import type { Terminal as XtermTerminalType } from "@xterm/headless"; import { Chalk } from "chalk"; import { Markdown } from "../src/components/markdown.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 { TuiMainScreen } from "../src/tui-main-screen.ts"; import { defaultMarkdownTheme } from "./test-themes.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; @@ -36,6 +37,44 @@ function stripAnsi(line: string): string { } describe("Markdown component", () => { + describe("Transforms", () => { + it("caches transformed Markdown by source and available width", () => { + const calls: Array<{ source: string; availableWidth: number }> = []; + const markdown = new Markdown("source", 2, 0, defaultMarkdownTheme, undefined, { + transform: (source, availableWidth) => { + calls.push({ source, availableWidth }); + return `${source} ${availableWidth}`; + }, + }); + + assert.deepStrictEqual( + markdown.render(80).map((line) => stripAnsi(line).trim()), + ["source 76"], + ); + markdown.render(80); + assert.deepStrictEqual( + markdown.render(60).map((line) => stripAnsi(line).trim()), + ["source 56"], + ); + assert.deepStrictEqual(calls, [ + { source: "source", availableWidth: 76 }, + { source: "source", availableWidth: 56 }, + ]); + + markdown.setText("updated"); + assert.deepStrictEqual( + markdown.render(60).map((line) => stripAnsi(line).trim()), + ["updated 56"], + ); + assert.deepStrictEqual(calls.at(-1), { source: "updated", availableWidth: 56 }); + + markdown.invalidate(); + markdown.render(60); + assert.deepStrictEqual(calls.at(-1), { source: "updated", availableWidth: 56 }); + assert.strictEqual(calls.length, 4); + }); + }); + describe("Lists", () => { it("should render simple nested list", () => { const markdown = new Markdown( @@ -674,6 +713,212 @@ describe("Markdown component", () => { }); }); + describe("LaTeX math", () => { + it("renders inline dollar and parenthesis delimiters", () => { + const markdown = new Markdown( + String.raw`A map $\mathbb{C}^3 \to \mathbb{C}^3$, $xy$, $x-y$, $-x$, $\frac{1}{2}$, and \(s \to \infty\).`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["A map ℂ³ → ℂ³, xy, x-y, -x, 1/2, and s → ∞."]); + }); + + it("renders display dollar delimiters without Markdown escape corruption", () => { + const markdown = new Markdown( + String.raw`Before + +$$\{3x+2y,\; x \in \{0, \pm 1\}\}$$ + +after`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Before", "", "{3x+2y, x ∈ {0, ± 1}}", "", "after"]); + }); + + it("renders display bracket delimiters", () => { + const markdown = new Markdown( + String.raw`Before + +\[ +E \approx \frac{0.1\ \text{lux}}{100\ \text{lm/W}} +\] + +after`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Before", "", " 0.1 lux", "E ≈ ────────", " 100 lm/W", "", "after"]); + }); + + it("aligns matrix rows with the opening delimiter", () => { + const markdown = new Markdown( + String.raw`Consider the matrix + +\[ +A= +\begin{pmatrix} +\pi & 0\\ +0 & \frac{1}{\pi} +\end{pmatrix}. +\]`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Consider the matrix", "", "A = ⎛ π │ 0 ⎞", " ⎝ 0 │ 1/π ⎠."]); + }); + + it("renders lower limits beneath display operators", () => { + const markdown = new Markdown( + String.raw`\[ +\lim_{x\to 0}\frac{\frac{\sin x}{x}-1}{\frac{e^x-1}{x}-1}=0 +\]`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [" (sin x)/x-1", "lim ─────────── = 0", "x→0 (eˣ-1)/x-1"]); + }); + + it("renders math inside lists and tables", () => { + const markdown = new Markdown( + String.raw`- Formula: $F_1 = u^2$ + +| Value | +| --- | +| $\mathbb{C}^3$ |`, + 0, + 0, + defaultMarkdownTheme, + ); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + const output = lines.join("\n"); + + assert.ok(output.includes("- Formula: F₁ = u²")); + assert.ok(output.includes("│ ℂ³")); + }); + + it("does not treat currency, shell variables, or code spans as math", () => { + const source = "Costs $5 and $10 or $8k–$12k; use `$x$`, $HOME, and $" + "{PATH}."; + const markdown = new Markdown(source, 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Costs $5 and $10 or $8k–$12k; use $x$, $HOME, and $" + "{PATH}."]); + + const shellVariables = "Paths: $HOME/$USER and $XDG_CONFIG_HOME/$APP_CONFIG"; + const shellLines = new Markdown(shellVariables, 0, 0, defaultMarkdownTheme) + .render(80) + .map((line) => stripAnsi(line).trimEnd()); + assert.deepStrictEqual(shellLines, [shellVariables]); + }); + + it("preserves unsupported and incomplete LaTeX exactly", () => { + const cases = [String.raw`Unknown $x + \unknown{y}$ after`, String.raw`Streaming $\mathbb{C}^3`]; + + for (const source of cases) { + const markdown = new Markdown(source, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + assert.deepStrictEqual(lines, [source]); + } + }); + + it("preserves incomplete backslash delimiters while streaming", () => { + const inline = new Markdown(String.raw`Map \(\mathbb{C}^3`, 0, 0, defaultMarkdownTheme); + assert.deepStrictEqual( + inline.render(80).map((line) => stripAnsi(line).trimEnd()), + [String.raw`Map \(\mathbb{C}^3`], + ); + + const display = new Markdown("\\[\nx^2", 0, 0, defaultMarkdownTheme); + assert.deepStrictEqual( + display.render(80).map((line) => stripAnsi(line).trimEnd()), + ["\\[", "x^2"], + ); + }); + + it("does not render LaTeX inside escaped delimiters or code fences", () => { + const source = [String.raw`Escaped \$x-y\$.`, "", "```text", String.raw`$\mathbb{C}^3$`, "```"].join("\n"); + const markdown = new Markdown(source, 0, 0, defaultMarkdownTheme); + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, ["Escaped $x-y$.", "", "```text", " $\\mathbb{C}^3$", "```"]); + }); + + it("allows LaTeX rendering to be disabled", () => { + const markdown = new Markdown( + String.raw`Map $\mathbb{C}^3 \to \mathbb{C}^3$`, + 0, + 0, + defaultMarkdownTheme, + undefined, + { + renderLatex: false, + }, + ); + + assert.deepStrictEqual( + markdown.render(80).map((line) => stripAnsi(line).trimEnd()), + [String.raw`Map $\mathbb{C}^3 \to \mathbb{C}^3$`], + ); + }); + + it("switches from raw to rendered math when a streamed delimiter closes", () => { + const markdown = new Markdown(String.raw`Map $\mathbb{C}^3`, 0, 0, defaultMarkdownTheme); + assert.deepStrictEqual( + markdown.render(80).map((line) => stripAnsi(line).trimEnd()), + [String.raw`Map $\mathbb{C}^3`], + ); + + markdown.setText(String.raw`Map $\mathbb{C}^3$`); + + assert.deepStrictEqual( + markdown.render(80).map((line) => stripAnsi(line).trimEnd()), + ["Map ℂ³"], + ); + }); + }); + + describe("Backslash escapes", () => { + it("should normalize escaped punctuation by default", () => { + const markdown = new Markdown(String.raw`"\"`, 0, 0, defaultMarkdownTheme); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [`""`]); + }); + + it("should preserve source backslash escapes when configured", () => { + const markdown = new Markdown(String.raw`"\"`, 0, 0, defaultMarkdownTheme, undefined, { + preserveBackslashEscapes: true, + }); + + const lines = markdown.render(80).map((line) => stripAnsi(line).trimEnd()); + + assert.deepStrictEqual(lines, [String.raw`"\"`]); + }); + }); + describe("Pre-styled text (thinking traces)", () => { it("should preserve gray italic styling after inline code", () => { // This replicates how thinking content is rendered in assistant-message.ts @@ -755,7 +1000,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 +1441,7 @@ bar`, it("should not leak h1 underline into padding when inline code is the last token", async () => { const markdown = new Markdown("# Important distinction from `open()`", 0, 0, defaultMarkdownTheme); const terminal = new VirtualTerminal(80, 4); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(markdown); tui.start(); await terminal.waitForRender(); diff --git a/packages/pi-tui/test/overlay-non-capturing.test.ts b/packages/pi-tui/test/overlay-non-capturing.test.ts index 64ce620396c..075fed08be7 100644 --- a/packages/pi-tui/test/overlay-non-capturing.test.ts +++ b/packages/pi-tui/test/overlay-non-capturing.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import type { Component, Focusable } from "../src/tui.ts"; -import { Container, TUI } from "../src/tui.ts"; +import { Container, type TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticOverlay implements Component { @@ -55,7 +56,7 @@ describe("TUI overlay non-capturing", () => { describe("focus management", () => { it("non-capturing overlay preserves focus on creation", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -73,7 +74,7 @@ describe("TUI overlay non-capturing", () => { it("focus() transfers focus to the overlay", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -93,7 +94,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus() restores previous focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -114,7 +115,7 @@ describe("TUI overlay non-capturing", () => { it("setHidden(false) on non-capturing overlay does not auto-focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -134,7 +135,7 @@ describe("TUI overlay non-capturing", () => { it("hide() when overlay is not focused does not change focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -152,7 +153,7 @@ describe("TUI overlay non-capturing", () => { it("hide() when focused restores focus correctly", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -172,7 +173,7 @@ describe("TUI overlay non-capturing", () => { it("capturing overlay removed with non-capturing below restores focus to editor", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const nonCapturing = new FocusableOverlay(["NC"]); const capturing = new FocusableOverlay(["CAP"]); @@ -194,7 +195,7 @@ describe("TUI overlay non-capturing", () => { it("sub-overlay cleanup then hideOverlay restores focus and input to editor", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const timer = new FocusableOverlay(["TIMER"]); const controller = new FocusableOverlay(["CTRL"]); @@ -224,7 +225,7 @@ describe("TUI overlay non-capturing", () => { it("removed focused child overlay does not become parent overlay fallback", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const child = new FocusableOverlay(["CHILD"]); const parent = new FocusableOverlay(["PARENT"]); @@ -253,7 +254,7 @@ describe("TUI overlay non-capturing", () => { it("microtask-deferred sub-overlay pattern (showExtensionCustom simulation) restores focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const timer = new FocusableOverlay(["TIMER"]); const controller = new FocusableOverlay(["CTRL"]); @@ -309,7 +310,7 @@ describe("TUI overlay non-capturing", () => { it("handleInput redirection skips non-capturing overlays when focused overlay becomes invisible", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const fallbackCapturing = new FocusableOverlay(["FALLBACK"]); const nonCapturing = new FocusableOverlay(["NC"]); @@ -337,7 +338,7 @@ describe("TUI overlay non-capturing", () => { it("active base focus replacement receives close input before overlay restore", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const replacement = new FocusableOverlay(["REPLACEMENT"]); const overlay = new FocusableOverlay(["OVERLAY"]); @@ -379,7 +380,7 @@ describe("TUI overlay non-capturing", () => { it("active replacement still receives input when it is another overlay preFocus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const replacement = new FocusableOverlay(["REPLACEMENT"]); const passive = new FocusableOverlay(["PASSIVE"]); @@ -421,7 +422,7 @@ describe("TUI overlay non-capturing", () => { it("blocked replacement can move focus internally before overlay restore", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const base = new Container(); const editor = new FocusableOverlay(["EDITOR"]); const firstReplacement = new FocusableOverlay(["FIRST"]); @@ -470,7 +471,7 @@ describe("TUI overlay non-capturing", () => { it("removed replacement restores overlay even when overlay preFocus differs from next focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const base = new Container(); const editor = new FocusableOverlay(["EDITOR"]); const palette = new FocusableOverlay(["PALETTE"]); @@ -513,7 +514,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus target releases a blocked overlay while replacement remains focused", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const fallback = new FocusableOverlay(["FALLBACK"]); const target = new FocusableOverlay(["TARGET"]); const replacement = new FocusableOverlay(["REPLACEMENT"]); @@ -552,7 +553,7 @@ describe("TUI overlay non-capturing", () => { it("handleInput restores focus to a visible focused overlay after base focus steal", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const replacement = new FocusableOverlay(["REPLACEMENT"]); const overlay = new FocusableOverlay(["OVERLAY"]); @@ -576,7 +577,7 @@ describe("TUI overlay non-capturing", () => { it("handleInput restores focus to explicitly focused raw sub-overlay after base focus steal", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const controller = new FocusableOverlay(["CONTROLLER"]); const subOverlay = new FocusableOverlay(["SUB"]); @@ -600,7 +601,7 @@ describe("TUI overlay non-capturing", () => { it("passive non-capturing overlay does not regain input after base focus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const passive = new FocusableOverlay(["PASSIVE"]); tui.addChild(new EmptyContent()); @@ -620,7 +621,7 @@ describe("TUI overlay non-capturing", () => { it("explicitly focused non-capturing overlay regains input after base focus steal", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["NC"]); tui.addChild(new EmptyContent()); @@ -641,7 +642,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus() prevents visible overlay from regaining input", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -662,7 +663,7 @@ describe("TUI overlay non-capturing", () => { it("setFocus(null) explicitly clears visible overlay restore", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); tui.start(); @@ -680,7 +681,7 @@ describe("TUI overlay non-capturing", () => { it("blocked replacement setFocus(null) resumes the visible overlay", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const replacement = new FocusableOverlay(["REPLACEMENT"]); const overlay = new FocusableOverlay(["OVERLAY"]); replacement.handleInput = (data: string) => { @@ -710,7 +711,7 @@ describe("TUI overlay non-capturing", () => { it("temporarily invisible focused overlay falls back without losing restore eligibility", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); let visible = true; @@ -737,7 +738,7 @@ describe("TUI overlay non-capturing", () => { it("temporarily invisible focused overlay with null preFocus restores when visible again", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new FocusableOverlay(["OVERLAY"]); let visible = true; tui.addChild(new EmptyContent()); @@ -759,7 +760,7 @@ describe("TUI overlay non-capturing", () => { it("cyclic overlay preFocus ancestry does not hang focus changes", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -780,7 +781,7 @@ describe("TUI overlay non-capturing", () => { it("handleInput restores the focus-order top overlay after base focus steal", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const lower = new FocusableOverlay(["LOWER"]); const upper = new FocusableOverlay(["UPPER"]); @@ -804,7 +805,7 @@ describe("TUI overlay non-capturing", () => { it("hideOverlay() does not reassign focus when topmost overlay is non-capturing", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const capturing = new FocusableOverlay(["CAP"]); const nonCapturing = new FocusableOverlay(["NC"]); @@ -825,7 +826,7 @@ describe("TUI overlay non-capturing", () => { it("multiple capturing and non-capturing overlays restore focus through removals", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const c1 = new FocusableOverlay(["C1"]); const n1 = new FocusableOverlay(["N1"]); @@ -853,7 +854,7 @@ describe("TUI overlay non-capturing", () => { it("capturing overlay unfocus() on topmost capturing overlay falls back to preFocus", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const capturing = new FocusableOverlay(["CAP"]); tui.addChild(new EmptyContent()); @@ -875,7 +876,7 @@ describe("TUI overlay non-capturing", () => { describe("no-op guards", () => { it("focus() on hidden overlay is a no-op", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -895,7 +896,7 @@ describe("TUI overlay non-capturing", () => { it("focus() after hide() is a no-op", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -915,7 +916,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus() when overlay does not have focus is a no-op", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); @@ -934,7 +935,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus() with null preFocus clears focus and does not route input back to overlay", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); tui.start(); @@ -956,7 +957,7 @@ describe("TUI overlay non-capturing", () => { describe("focus cycle prevention", () => { it("toggle focus between non-capturing overlays then unfocus returns to editor", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const a = new FocusableOverlay(["A"]); const b = new FocusableOverlay(["B"]); @@ -981,7 +982,7 @@ describe("TUI overlay non-capturing", () => { it("explicit unfocus target supports cycling between three overlays and editor", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const a = new FocusableOverlay(["A"]); const b = new FocusableOverlay(["B"]); @@ -1025,7 +1026,7 @@ describe("TUI overlay non-capturing", () => { it("explicit null unfocus target clears focus without restoring overlays", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new FocusableOverlay(["OVERLAY"]); tui.addChild(new EmptyContent()); tui.start(); @@ -1043,7 +1044,7 @@ describe("TUI overlay non-capturing", () => { it("hiding focused overlay falls back to next visual-frontmost overlay", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); const a = new FocusableOverlay(["A"]); const b = new FocusableOverlay(["B"]); @@ -1072,7 +1073,7 @@ describe("TUI overlay non-capturing", () => { describe("rendering order", () => { it("focus() on already-focused overlay bumps visual order", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); tui.addChild(new EmptyContent()); tui.setFocus(editor); @@ -1095,7 +1096,7 @@ describe("TUI overlay non-capturing", () => { it("default rendering order for overlapping overlays follows creation order", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); tui.start(); try { @@ -1110,7 +1111,7 @@ describe("TUI overlay non-capturing", () => { it("focus() on lower overlay renders it on top", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); tui.start(); try { @@ -1128,7 +1129,7 @@ describe("TUI overlay non-capturing", () => { it("focusing middle overlay places it on top while preserving others relative order", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); tui.start(); try { @@ -1153,7 +1154,7 @@ describe("TUI overlay non-capturing", () => { it("capturing overlay hidden and shown again renders on top after unhide", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); tui.start(); try { @@ -1175,7 +1176,7 @@ describe("TUI overlay non-capturing", () => { it("unfocus() does not change visual order until another overlay is focused", async () => { const terminal = new VirtualTerminal(20, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const editor = new FocusableOverlay(["EDITOR"]); tui.addChild(new EmptyContent()); tui.setFocus(editor); diff --git a/packages/pi-tui/test/overlay-options.test.ts b/packages/pi-tui/test/overlay-options.test.ts index c93f26cbd4f..db50f5db9f1 100644 --- a/packages/pi-tui/test/overlay-options.test.ts +++ b/packages/pi-tui/test/overlay-options.test.ts @@ -1,7 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import type { Component } from "../src/tui.ts"; -import { TUI } from "../src/tui.ts"; +import type { Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticOverlay implements Component { @@ -39,7 +39,7 @@ describe("TUI overlay options", () => { describe("width overflow protection", () => { it("should truncate overlay lines that exceed declared width", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Overlay declares width 20 but renders lines much wider const overlay = new StaticOverlay(["X".repeat(100)]); @@ -60,7 +60,7 @@ describe("TUI overlay options", () => { it("should handle overlay with complex ANSI sequences without crashing", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Simulate complex ANSI content like the crash log showed const complexLine = "\x1b[48;2;40;50;40m \x1b[38;2;128;128;128mSome styled content\x1b[39m\x1b[49m" + @@ -81,7 +81,7 @@ describe("TUI overlay options", () => { it("should handle overlay composited on styled base content", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Base content with styling class StyledContent implements Component { @@ -108,7 +108,7 @@ describe("TUI overlay options", () => { it("should handle wide characters at overlay boundary", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Wide chars (each takes 2 columns) at the edge of declared width const wideCharLine = "中文日本語한글テスト漢字"; // Mix of CJK chars const overlay = new StaticOverlay([wideCharLine]); @@ -126,7 +126,7 @@ describe("TUI overlay options", () => { it("should handle overlay positioned at terminal edge", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Overlay positioned at right edge with content that exceeds declared width const overlay = new StaticOverlay(["X".repeat(50)]); @@ -144,7 +144,7 @@ describe("TUI overlay options", () => { it("should handle overlay on base content with OSC sequences", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Base content with OSC 8 hyperlinks (like file paths in agent output) class HyperlinkContent implements Component { @@ -173,7 +173,7 @@ describe("TUI overlay options", () => { describe("width percentage", () => { it("should render overlay at percentage of terminal width", async () => { const terminal = new VirtualTerminal(100, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["test"]); tui.addChild(new EmptyContent()); @@ -187,7 +187,7 @@ describe("TUI overlay options", () => { it("should respect minWidth when widthPercent results in smaller width", async () => { const terminal = new VirtualTerminal(100, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["test"]); tui.addChild(new EmptyContent()); @@ -203,7 +203,7 @@ describe("TUI overlay options", () => { describe("anchor positioning", () => { it("should position overlay at top-left", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["TOP-LEFT"]); tui.addChild(new EmptyContent()); @@ -218,7 +218,7 @@ describe("TUI overlay options", () => { it("should position overlay at bottom-right", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["BTM-RIGHT"]); tui.addChild(new EmptyContent()); @@ -236,7 +236,7 @@ describe("TUI overlay options", () => { it("should position overlay at top-center", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["CENTERED"]); tui.addChild(new EmptyContent()); @@ -258,7 +258,7 @@ describe("TUI overlay options", () => { describe("margin", () => { it("should clamp negative margins to zero", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["NEG-MARGIN"]); tui.addChild(new EmptyContent()); @@ -279,7 +279,7 @@ describe("TUI overlay options", () => { it("should respect margin as number", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["MARGIN"]); tui.addChild(new EmptyContent()); @@ -300,7 +300,7 @@ describe("TUI overlay options", () => { it("should respect margin object", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["MARGIN"]); tui.addChild(new EmptyContent()); @@ -323,7 +323,7 @@ describe("TUI overlay options", () => { describe("offset", () => { it("should apply offsetX and offsetY from anchor position", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["OFFSET"]); tui.addChild(new EmptyContent()); @@ -342,7 +342,7 @@ describe("TUI overlay options", () => { describe("percentage positioning", () => { it("should position with rowPercent and colPercent", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["PCT"]); tui.addChild(new EmptyContent()); @@ -367,7 +367,7 @@ describe("TUI overlay options", () => { it("rowPercent 0 should position at top", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["TOP"]); tui.addChild(new EmptyContent()); @@ -382,7 +382,7 @@ describe("TUI overlay options", () => { it("rowPercent 100 should position at bottom", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["BOTTOM"]); tui.addChild(new EmptyContent()); @@ -399,7 +399,7 @@ describe("TUI overlay options", () => { describe("maxHeight", () => { it("should truncate overlay to maxHeight", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["Line 1", "Line 2", "Line 3", "Line 4", "Line 5"]); tui.addChild(new EmptyContent()); @@ -419,7 +419,7 @@ describe("TUI overlay options", () => { it("should truncate overlay to maxHeightPercent", async () => { const terminal = new VirtualTerminal(80, 10); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // 10 lines in a 10 row terminal with 50% maxHeight should show 5 lines const overlay = new StaticOverlay(["L1", "L2", "L3", "L4", "L5", "L6", "L7", "L8", "L9", "L10"]); @@ -440,7 +440,7 @@ describe("TUI overlay options", () => { describe("absolute positioning", () => { it("row and col should override anchor", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const overlay = new StaticOverlay(["ABSOLUTE"]); tui.addChild(new EmptyContent()); @@ -460,7 +460,7 @@ describe("TUI overlay options", () => { describe("stacked overlays", () => { it("should render multiple overlays with later ones on top", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); @@ -485,7 +485,7 @@ describe("TUI overlay options", () => { it("should handle overlays at different positions without interference", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); @@ -509,7 +509,7 @@ describe("TUI overlay options", () => { it("should properly hide overlays in stack order", async () => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new EmptyContent()); diff --git a/packages/pi-tui/test/overlay-short-content.test.ts b/packages/pi-tui/test/overlay-short-content.test.ts index 135d8cb9e4b..3a3b423af17 100644 --- a/packages/pi-tui/test/overlay-short-content.test.ts +++ b/packages/pi-tui/test/overlay-short-content.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { type Component, TUI } from "../src/tui.ts"; +import type { Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class SimpleContent implements Component { @@ -27,7 +28,7 @@ describe("TUI overlay with short content", () => { it("should render overlay when content is shorter than terminal height", async () => { // Terminal has 24 rows, but content only has 3 lines const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); // Only 3 lines of content tui.addChild(new SimpleContent(["Line 1", "Line 2", "Line 3"])); diff --git a/packages/pi-tui/test/regression-overlay-cjk-boundary.test.ts b/packages/pi-tui/test/regression-overlay-cjk-boundary.test.ts index 2ae1f5ed807..5aa7f5c1608 100644 --- a/packages/pi-tui/test/regression-overlay-cjk-boundary.test.ts +++ b/packages/pi-tui/test/regression-overlay-cjk-boundary.test.ts @@ -1,29 +1,7 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { TUI } from "../src/tui.ts"; +import { compositeTuiLine } from "../src/tui.ts"; import { extractSegments, sliceByColumn, visibleWidth } from "../src/utils.ts"; -import { VirtualTerminal } from "./virtual-terminal.ts"; - -type TuiComposite = { - compositeLineAt( - baseLine: string, - overlayLine: string, - startCol: number, - overlayWidth: number, - totalWidth: number, - ): string; -}; - -function compositeLineAt( - baseLine: string, - overlayLine: string, - startCol: number, - overlayWidth: number, - totalWidth: number, -): string { - const tui = new TUI(new VirtualTerminal(totalWidth, 10)) as unknown as TuiComposite; - return tui.compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth); -} describe("overlay CJK boundary regression", () => { it("excludes a wide grapheme from before when overlay starts inside it", () => { @@ -45,7 +23,7 @@ describe("overlay CJK boundary regression", () => { }); it("composites an overlay at the requested column when it starts inside a wide grapheme", () => { - const out = compositeLineAt("abcd让EFGH", "│XX│", 5, 4, 20); + const out = compositeTuiLine("abcd让EFGH", "│XX│", 5, 4, 20); const prefix = sliceByColumn(out, 0, 5, true); const overlay = sliceByColumn(out, 5, 4, true); @@ -57,7 +35,7 @@ describe("overlay CJK boundary regression", () => { }); it("composites an overlay when it starts at a wide grapheme boundary", () => { - const out = compositeLineAt("abcd让EFGH", "│XX│", 4, 4, 20); + const out = compositeTuiLine("abcd让EFGH", "│XX│", 4, 4, 20); const overlay = sliceByColumn(out, 4, 4, true); assert.strictEqual(out.includes("让"), false); diff --git a/packages/pi-tui/test/settings-list.test.ts b/packages/pi-tui/test/settings-list.test.ts new file mode 100644 index 00000000000..07a7ff09fc5 --- /dev/null +++ b/packages/pi-tui/test/settings-list.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { SettingsList, type SettingsListTheme } from "../src/components/settings-list.ts"; + +const testTheme: SettingsListTheme = { + label: (text) => text, + value: (text) => text, + description: (text) => text, + cursor: "> ", + hint: (text) => text, +}; + +const items = [ + { + id: "tui-mode", + label: "TUI mode", + currentValue: "regular", + values: ["regular", "fullscreen"], + }, +]; + +describe("SettingsList", () => { + it("includes spaces in an active search instead of changing the selected setting", () => { + const changes: Array<{ id: string; value: string }> = []; + const list = new SettingsList( + items.map((item) => ({ ...item })), + 10, + testTheme, + (id, value) => changes.push({ id, value }), + () => {}, + { enableSearch: true }, + ); + + for (const character of "TUI mode") list.handleInput(character); + + assert.deepStrictEqual(changes, []); + assert.match(list.render(80)[0] ?? "", /TUI mode/); + + list.handleInput("\r"); + assert.deepStrictEqual(changes, [{ id: "tui-mode", value: "fullscreen" }]); + }); + + it("keeps Space as a change shortcut before a search query is entered", () => { + const changes: Array<{ id: string; value: string }> = []; + const list = new SettingsList( + items.map((item) => ({ ...item })), + 10, + testTheme, + (id, value) => changes.push({ id, value }), + () => {}, + { enableSearch: true }, + ); + + list.handleInput(" "); + + assert.deepStrictEqual(changes, [{ id: "tui-mode", value: "fullscreen" }]); + }); +}); diff --git a/packages/pi-tui/test/tab-width.test.ts b/packages/pi-tui/test/tab-width.test.ts index 427a77db796..b7396e4854c 100644 --- a/packages/pi-tui/test/tab-width.test.ts +++ b/packages/pi-tui/test/tab-width.test.ts @@ -1,6 +1,38 @@ import assert from "node:assert"; import { describe, it } from "node:test"; -import { extractSegments, sliceWithWidth, visibleWidth } from "../src/utils.ts"; +import type { Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; +import { extractSegments, normalizeTerminalOutput, sliceWithWidth, visibleWidth } from "../src/utils.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class FullViewportContent implements Component { + render(width: number): string[] { + return ["base 0", "base 1", "base 2"].map((line) => line.padEnd(width)); + } + + invalidate(): void {} +} + +class CapturingVirtualTerminal extends VirtualTerminal { + private output = ""; + + override write(data: string): void { + this.output += data; + super.write(data); + } + + getOutput(): string { + return this.output; + } +} + +class TabStatusOverlay implements Component { + render(): string[] { + return ["\tX"]; + } + + invalidate(): void {} +} describe("tab width accounting", () => { it("keeps slice helper widths consistent with visible width", () => { @@ -25,4 +57,32 @@ describe("tab width accounting", () => { assert.strictEqual(tabFits.beforeWidth, 11); assert.strictEqual(visibleWidth(tabFits.before), tabFits.beforeWidth); }); + + it("keeps tabs inside terminal control sequences byte-identical", () => { + const controlSequences = [ + "\x1b]8;;https://example.test/a\tb\x07", + "\x1b]0;window\ttitle\x1b\\", + "\x1b_payload\tdata\x1b\\", + ]; + + for (const controlSequence of controlSequences) { + assert.strictEqual(normalizeTerminalOutput(`${controlSequence}label\ttext`), `${controlSequence}label text`); + } + }); + + it("keeps tab-containing overlays on one physical terminal row", async () => { + const terminal = new CapturingVirtualTerminal(16, 3); + const tui: TUI = new TuiMainScreen(terminal); + tui.addChild(new FullViewportContent()); + tui.showOverlay(new TabStatusOverlay(), { width: 4, row: 1, col: 4 }); + tui.start(); + + try { + await terminal.waitForRender(); + assert.deepStrictEqual(terminal.getViewport(), ["base 0 ", "base X ", "base 2 "]); + assert.ok(!terminal.getOutput().includes("\t")); + } finally { + tui.stop(); + } + }); }); diff --git a/packages/pi-tui/test/terminal-colors.test.ts b/packages/pi-tui/test/terminal-colors.test.ts index d777e06171b..229549732a2 100644 --- a/packages/pi-tui/test/terminal-colors.test.ts +++ b/packages/pi-tui/test/terminal-colors.test.ts @@ -5,7 +5,8 @@ import { parseOsc11BackgroundColor, parseTerminalColorSchemeReport, type Terminal, - TUI, + type TUI, + TuiMainScreen, } from "../src/index.ts"; class TestTerminal implements Terminal { @@ -114,6 +115,8 @@ describe("parseTerminalColorSchemeReport", () => { it("parses color scheme reports", () => { assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n"), "dark"); assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;2n"), "light"); + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;2n\x1b[?997;1n\x1b[?997;1n"), "dark"); + assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;1n\x1b[?997;2n\x1b[?997;2n"), "light"); assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?997;3n"), undefined); assert.strictEqual(parseTerminalColorSchemeReport("\x1b[?996n"), undefined); assert.strictEqual(parseTerminalColorSchemeReport("x\x1b[?997;1n"), undefined); @@ -123,7 +126,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 +142,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 +167,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 +192,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 +225,7 @@ describe("TUI.queryTerminalBackgroundColor", () => { it("keeps consuming a late OSC 11 reply after timeout", async () => { const terminal = new TestTerminal(); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const component = new InputRecorder(); const listenerInputs: string[] = []; tui.addChild(component); diff --git a/packages/pi-tui/test/terminal-image.test.ts b/packages/pi-tui/test/terminal-image.test.ts index cc7e01e5941..ec52878be01 100644 --- a/packages/pi-tui/test/terminal-image.test.ts +++ b/packages/pi-tui/test/terminal-image.test.ts @@ -3,20 +3,30 @@ */ import assert from "node:assert"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { describe, it } from "node:test"; import { Image } from "../src/components/image.ts"; import { + cropKittyImageLine, deleteAllKittyImages, + deleteAllKittyPlacements, deleteKittyImage, detectCapabilities, + encodeITerm2, encodeKitty, + getKittyImageMetadata, + getKittyImagePlacement, hyperlink, + imageFallback, isImageLine, + registerKittyImageMetadata, renderImage, resetCapabilitiesCache, setCapabilities, setCellDimensions, } from "../src/terminal-image.ts"; +import { visibleWidth } from "../src/utils.ts"; const ENV_KEYS = [ "TERM", @@ -366,6 +376,13 @@ describe("detectCapabilities", () => { }); }); +describe("iTerm2 image encoding", () => { + it("includes the decoded payload size in OSC 1337 metadata", () => { + const sequence = encodeITerm2("AAAA", { width: 2, height: "auto" }); + assert.strictEqual(sequence, "\x1b]1337;File=inline=1;size=3;width=2;height=auto:AAAA\x07"); + }); +}); + describe("Kitty image cursor movement", () => { it("can request no terminal-side cursor movement", () => { const sequence = encodeKitty("AAAA", { columns: 2, rows: 2, moveCursor: false }); @@ -375,6 +392,7 @@ describe("Kitty image cursor movement", () => { it("suppresses Kitty replies for delete commands", () => { assert.strictEqual(deleteKittyImage(42), "\x1b_Ga=d,d=I,i=42,q=2\x1b\\"); assert.strictEqual(deleteAllKittyImages(), "\x1b_Ga=d,d=A,q=2\x1b\\"); + assert.strictEqual(deleteAllKittyPlacements(), "\x1b_Ga=d,d=a,q=2\x1b\\"); }); it("preserves renderImage's default terminal-side cursor movement", () => { @@ -405,6 +423,48 @@ 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("creates placement-only commands for uploaded and cropped images", () => { + registerKittyImageMetadata({ imageId: 42, columns: 3, rows: 3, widthPx: 100, heightPx: 100 }); + const transmission = encodeKitty("A".repeat(8192), { + columns: 3, + rows: 3, + imageId: 42, + moveCursor: false, + }); + const line = `left ${cropKittyImageLine(transmission, 2, 1)} right`; + const placement = getKittyImagePlacement(line); + assert.ok(placement); + assert.strictEqual(placement.transmissionBytes, line.length - "left ".length - " right".length); + assert.strictEqual(placement.estimatedDecodedBytes, 100 * 100 * 4); + assert.strictEqual(placement.sequence, "\x1b_Ga=p,q=2,C=1,c=3,i=42,y=66,h=34,r=1\x1b\\"); + assert.strictEqual(placement.replacementLine, `left ${placement.sequence} right`); + assert.ok(!placement.replacementLine.includes("AAAA")); + }); + it("honors maxHeightCells by reducing rendered width", () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); setCellDimensions({ widthPx: 10, heightPx: 10 }); @@ -463,6 +523,86 @@ describe("Kitty image cursor movement", () => { setCellDimensions({ widthPx: 9, heightPx: 18 }); } }); + + it("truncates long image fallback lines to render width", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + try { + const longPath = join( + homedir(), + "images", + `${"generated-image-with-a-very-long-absolute-path".repeat(4)}.png`, + ); + const width = 40; + const image = new Image( + "AAAA", + "image/png", + { fallbackColor: (value) => `\x1b[33m${value}\x1b[0m` }, + { filename: longPath }, + { widthPx: 1280, heightPx: 720 }, + ); + const lines = image.render(width); + assert.strictEqual(lines.length, 1); + assert.ok( + visibleWidth(lines[0]) <= width, + `fallback line wider than ${width}: visible=${visibleWidth(lines[0])} raw=${JSON.stringify(lines[0])}`, + ); + assert.ok(lines[0].includes("..."), "expected ellipsis when truncating long fallback path"); + assert.ok(lines[0].includes("~"), "expected home-shortened path in fallback"); + } finally { + resetCapabilitiesCache(); + } + }); +}); + +describe("imageFallback", () => { + it("shortens home-prefixed absolute paths without hyperlinks", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + try { + const abs = join(homedir(), ".pi", "agent", "shot.png"); + const result = imageFallback("image/png", { widthPx: 1280, heightPx: 720 }, abs); + assert.strictEqual(result, "[Image: ~/.pi/agent/shot.png [image/png] 1280x720]"); + } finally { + resetCapabilitiesCache(); + } + }); + + it("wraps shortened absolute paths in OSC 8 file links when hyperlinks are enabled", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: true }); + try { + const abs = join(homedir(), ".pi", "agent", "shot.png"); + const result = imageFallback("image/png", { widthPx: 10, heightPx: 10 }, abs); + assert.ok(result.includes("\x1b]8;;file://"), "expected OSC 8 file link"); + assert.ok( + result.includes(abs.replaceAll("\\", "/")) || result.includes(abs), + "file URL should target absolute path", + ); + // Visible text must use ~/... not the expanded home path. + const visible = result.replace(/\x1b\]8;;.*?\x1b\\/g, ""); + assert.strictEqual(visible, "[Image: ~/.pi/agent/shot.png [image/png] 10x10]"); + } finally { + resetCapabilitiesCache(); + } + }); + + it("leaves bare basenames unchanged and does not hyperlink them", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: true }); + try { + const result = imageFallback("image/png", { widthPx: 1, heightPx: 1 }, "clankolas.png"); + assert.strictEqual(result, "[Image: clankolas.png [image/png] 1x1]"); + assert.ok(!result.includes("\x1b]8;"), "basename must not be hyperlinked"); + } finally { + resetCapabilitiesCache(); + } + }); + + it("omits filename segment when not provided", () => { + setCapabilities({ images: null, trueColor: false, hyperlinks: false }); + try { + assert.strictEqual(imageFallback("image/png", { widthPx: 8, heightPx: 6 }), "[Image: [image/png] 8x6]"); + } finally { + resetCapabilitiesCache(); + } + }); }); describe("hyperlink", () => { diff --git a/packages/pi-tui/test/terminal.test.ts b/packages/pi-tui/test/terminal.test.ts index a356bff1d51..06c751dbc5a 100644 --- a/packages/pi-tui/test/terminal.test.ts +++ b/packages/pi-tui/test/terminal.test.ts @@ -1,7 +1,26 @@ import assert from "node:assert"; import { describe, it, mock } from "node:test"; import { setKittyProtocolActive } from "../src/keys.ts"; -import { normalizeAppleTerminalInput, ProcessTerminal } from "../src/terminal.ts"; +import { normalizeAppleTerminalInput, normalizeNativeShiftEnterInput, ProcessTerminal } from "../src/terminal.ts"; + +describe("normalizeNativeShiftEnterInput", () => { + it("rewrites Return to CSI-u Shift+Enter when native Shift detection is enabled and Shift is pressed", () => { + assert.equal(normalizeNativeShiftEnterInput("\r", true, true), "\x1b[13;2u"); + }); + + it("leaves Return unchanged when native Shift detection is disabled", () => { + assert.equal(normalizeNativeShiftEnterInput("\r", false, true), "\r"); + }); + + it("leaves Return unchanged when Shift is not pressed", () => { + assert.equal(normalizeNativeShiftEnterInput("\r", true, false), "\r"); + }); + + it("leaves non-Return input unchanged", () => { + assert.equal(normalizeNativeShiftEnterInput("\x1b[13;2u", true, true), "\x1b[13;2u"); + assert.equal(normalizeNativeShiftEnterInput("a", true, true), "a"); + }); +}); describe("normalizeAppleTerminalInput", () => { it("rewrites Apple Terminal Return to CSI-u Shift+Enter when Shift is pressed", () => { @@ -190,6 +209,26 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { }); }); +describe("ProcessTerminal progress", () => { + it("writes a valid OSC 9;4 clear sequence", () => { + const terminal = new ProcessTerminal(); + const writes: string[] = []; + const previousWrite = process.stdout.write; + + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + + try { + terminal.setProgress(false); + assert.deepEqual(writes, ["\x1b]9;4;0\x07"]); + } finally { + process.stdout.write = previousWrite; + } + }); +}); + describe("ProcessTerminal dimensions", () => { it("falls back to COLUMNS and LINES before default dimensions", () => { const previousColumnsDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "columns"); diff --git a/packages/pi-tui/test/truncate-to-width.test.ts b/packages/pi-tui/test/truncate-to-width.test.ts index a0e4424f0a9..1928f045306 100644 --- a/packages/pi-tui/test/truncate-to-width.test.ts +++ b/packages/pi-tui/test/truncate-to-width.test.ts @@ -20,6 +20,14 @@ describe("truncateToWidth", () => { assert.strictEqual(truncated.endsWith("\x1b[0m…\x1b[0m"), true); }); + it("closes a BEL-terminated OSC 8 link when truncating its label", () => { + const open = "\x1b]8;;https://example.com\x07"; + const close = "\x1b]8;;\x07"; + const text = `${open}some-longer-label-here${close}`; + + assert.strictEqual(truncateToWidth(text, 15), `${open}some-longer-${close}\x1b[0m...\x1b[0m`); + }); + it("handles malformed ANSI escape prefixes without hanging", () => { const text = `abc\x1bnot-ansi ${"🙂".repeat(1000)}`; const truncated = truncateToWidth(text, 20, "…"); @@ -60,6 +68,49 @@ describe("visibleWidth", () => { assert.strictEqual(visibleWidth("\t\x1b[31m界\x1b[0m"), 5); }); + it("counts Indic conjunct spacing code points within grapheme clusters", () => { + assert.strictEqual(visibleWidth("र्क"), 2); + assert.strictEqual(visibleWidth("नेटवर्क"), 5); + assert.strictEqual(visibleWidth("सर्वाधिकार सुरक्षित। ऑर्डर पर क्लिक करें"), 33); + assert.strictEqual(visibleWidth("র্ক"), 2); + assert.strictEqual(visibleWidth("ર્ક"), 2); + assert.strictEqual(visibleWidth("ର୍କ"), 2); + assert.strictEqual(visibleWidth("ర్క"), 2); + assert.strictEqual(visibleWidth("ര്‍ക"), 2); + }); + + it("keeps ordinary combining marks zero-width", () => { + assert.strictEqual(visibleWidth("e\u0301"), 1); + assert.strictEqual(visibleWidth("čřžůú"), 5); + assert.strictEqual(visibleWidth("שָׁ"), 1); + assert.strictEqual(visibleWidth("بّ"), 1); + assert.strictEqual(visibleWidth("རྐ"), 1); + assert.strictEqual(visibleWidth("ᜠ᜴"), 1); + assert.strictEqual(visibleWidth("가〮"), 2); + assert.strictEqual(visibleWidth("가〯"), 2); + }); + + it("keeps CJK and Japanese width accounting unchanged", () => { + assert.strictEqual(visibleWidth("网络"), 4); + assert.strictEqual(visibleWidth("ネットワーク"), 12); + assert.strictEqual(visibleWidth("が"), 2); + assert.strictEqual(visibleWidth("か\u3099"), 2); + }); + + it("counts Myanmar marks that terminals allocate cells for", () => { + assert.strictEqual(visibleWidth("ကာ"), 2); + assert.strictEqual(visibleWidth("ကေ"), 2); + assert.strictEqual(visibleWidth("က်"), 2); + assert.strictEqual(visibleWidth("ကျ"), 2); + assert.strictEqual(visibleWidth("ကြ"), 2); + assert.strictEqual(visibleWidth("ကဳ"), 2); + assert.strictEqual(visibleWidth("ကဴ"), 2); + assert.strictEqual(visibleWidth("ကဵ"), 2); + assert.strictEqual(visibleWidth("ကး"), 2); + assert.strictEqual(visibleWidth("ကို"), 1); + assert.strictEqual(visibleWidth("က္"), 1); + }); + it("keeps Thai and Lao AM clusters at their normal cell width", () => { assert.strictEqual(visibleWidth("ำ"), 1); assert.strictEqual(visibleWidth("ຳ"), 1); diff --git a/packages/pi-tui/test/tui-alt-screen.test.ts b/packages/pi-tui/test/tui-alt-screen.test.ts new file mode 100644 index 00000000000..ec4b020255a --- /dev/null +++ b/packages/pi-tui/test/tui-alt-screen.test.ts @@ -0,0 +1,1100 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import { HStack } from "../src/components/h-stack.ts"; +import { Image } from "../src/components/image.ts"; +import { ScrollView } from "../src/components/scroll-view.ts"; +import { Text } from "../src/components/text.ts"; +import { VStack } from "../src/components/v-stack.ts"; +import { getKeybindings, KeybindingsManager, setKeybindings, TUI_KEYBINDINGS } from "../src/keybindings.ts"; +import { + encodeKitty, + hyperlink, + registerKittyImageMetadata, + resetCapabilitiesCache, + setCapabilities, +} from "../src/terminal-image.ts"; +import { TuiAltScreen } from "../src/tui-alt-screen.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +const OSC133_ZONE_START = "\x1b]133;A\x07"; + +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 6", "line 7", "line 8", "line 9"], + ); + assert.strictEqual(tui.viewportTop, 5); + 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 6", "line 7", "line 8", "line 9"], + ); + + tui.stop(); + }); + + it("keeps an explicit dock fixed while the transcript scrolls", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TuiAltScreen(terminal); + const transcriptText = new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0); + const transcript = new ScrollView(transcriptText, { follow: "end", primary: true }); + const dock = new VStack([new Text("editor", 0, 0), new Text("footer", 0, 0)]); + tui.setLayoutRoot( + new VStack([ + { component: transcript, basis: 0, grow: 1, minSize: 1 }, + { component: dock, basis: "auto", minSize: 1 }, + ]), + ); + tui.start(); + await terminal.waitForRender(); + + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 5", "line 6", "line 7", "line 8", "editor", "footer"], + ); + + // Wheel over the dock falls back to the primary transcript scroll view. + terminal.sendInput("\x1b[<64;1;6M"); + await terminal.waitForRender(); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 4", "line 5", "line 6", "line 7", "editor", "footer"], + ); + assert.strictEqual(transcript.isFollowingEnd, false); + + transcriptText.setText(Array.from({ length: 10 }, (_, 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", "editor", "footer"], + ); + + tui.scrollToBottom(); + await terminal.waitForRender(); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 7", "line 8", "line 9", "line 10", "editor", "footer"], + ); + tui.stop(); + }); + + it("invalidates overlays with an explicit layout root", () => { + const tui = new TuiAltScreen(new VirtualTerminal()); + const overlay = new Text("overlay", 0, 0); + let invalidated = false; + overlay.invalidate = () => { + invalidated = true; + }; + tui.setLayoutRoot(new Text("root", 0, 0)); + tui.showOverlay(overlay); + + tui.invalidate(); + + assert.strictEqual(invalidated, true); + tui.stop(); + }); + + it("routes wheel input to the scroll view under the pointer", async () => { + const terminal = new VirtualTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + const left = new ScrollView(new Text("a1\na2\na3\na4\na5\na6\na7", 0, 0), { + follow: "end", + primary: true, + }); + const right = new ScrollView(new Text("b1\nb2\nb3\nb4\nb5\nb6\nb7", 0, 0), { follow: "end" }); + tui.setLayoutRoot( + new HStack([ + { component: left, basis: 10, shrink: 0 }, + { component: right, basis: 10, shrink: 0 }, + ]), + ); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<64;15;1M"); + await terminal.waitForRender(); + assert.strictEqual(left.scrollTop, 3); + assert.strictEqual(right.scrollTop, 2); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["a4 b3", "a5 b4", "a6 b5", "a7 b6"], + ); + tui.stop(); + }); + + it("uses button-motion tracking inside terminal multiplexers", () => { + const environmentKeys = ["TMUX", "ZELLIJ", "STY", "TERM"] as const; + const previousEnvironment = new Map(environmentKeys.map((key) => [key, process.env[key]])); + try { + for (const key of environmentKeys) delete process.env[key]; + process.env.TERM = "xterm-256color"; + const directTerminal = new RecordingTerminal(); + const directTui = new TuiAltScreen(directTerminal); + directTui.start(); + const directWrites = directTerminal.events + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(directWrites.includes("\x1b[?1003h")); + directTui.stop(); + + const multiplexers = [ + { name: "tmux environment", environment: { TMUX: "/tmp/tmux/default,1,0" } }, + { name: "tmux TERM", environment: { TERM: "tmux-256color" } }, + { name: "Zellij environment", environment: { ZELLIJ: "0" } }, + { name: "Screen environment", environment: { STY: "123.session" } }, + { name: "Screen TERM", environment: { TERM: "screen-256color" } }, + ]; + for (const { name, environment } of multiplexers) { + for (const key of environmentKeys) delete process.env[key]; + for (const [key, value] of Object.entries(environment)) process.env[key] = value; + const terminal = new RecordingTerminal(); + const tui = new TuiAltScreen(terminal); + tui.start(); + const writes = terminal.events + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(writes.includes("\x1b[?1002h"), `${name} should enable button-motion tracking`); + assert.ok(!writes.includes("\x1b[?1003h"), `${name} should not enable all-motion tracking`); + assert.ok(writes.includes("\x1b[?1006h"), `${name} should enable SGR mouse encoding`); + tui.stop(); + } + } finally { + for (const key of environmentKeys) { + const value = previousEnvironment.get(key); + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + } + }); + + it("invokes the right-click paste handler only on Windows", () => { + const platformDescriptor = Object.getOwnPropertyDescriptor(process, "platform"); + assert.ok(platformDescriptor); + const terminal = new VirtualTerminal(); + let pasteCount = 0; + const tui = new TuiAltScreen(terminal, undefined, undefined, { + onRightClickPaste: () => { + pasteCount += 1; + }, + }); + try { + Object.defineProperty(process, "platform", { configurable: true, value: "win32" }); + tui.start(); + terminal.sendInput("\x1b[<2;1;1M"); + terminal.sendInput("\x1b[<2;1;1m"); + assert.strictEqual(pasteCount, 1); + + Object.defineProperty(process, "platform", { configurable: true, value: "linux" }); + terminal.sendInput("\x1b[<2;1;1M"); + assert.strictEqual(pasteCount, 1); + } finally { + tui.stop(); + Object.defineProperty(process, "platform", platformDescriptor); + } + }); + + it("drags a visible scrollbar thumb and keeps it visible until release", async () => { + const terminal = new RecordingTerminal(10, 5); + const tui = new TuiAltScreen(terminal); + const scrollView = new ScrollView( + new Text(Array.from({ length: 20 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0), + { + primary: true, + scrollbar: "auto", + scrollbarHideDelayMs: 50, + }, + ); + tui.setLayoutRoot(scrollView); + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(scrollView.isScrollbarVisible, false); + + terminal.sendInput("\x1b[<65;10;1M"); + await terminal.waitForRender(); + assert.strictEqual(scrollView.scrollTop, 1); + assert.strictEqual(scrollView.isScrollbarVisible, true); + + terminal.sendInput("\x1b[<0;10;1M"); + await terminal.waitForRender(); + await new Promise((resolve) => setTimeout(resolve, 70)); + assert.strictEqual(scrollView.isScrollbarVisible, true); + + terminal.sendInput("\x1b[<32;10;4M"); + await terminal.waitForRender(); + assert.strictEqual(scrollView.scrollTop, 15); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 16", "line 17", "line 18", "line 19", "line 20"], + ); + + terminal.sendInput("\x1b[<0;10;4m"); + await terminal.waitForRender(); + assert.strictEqual(scrollView.isScrollbarVisible, true); + await new Promise((resolve) => setTimeout(resolve, 70)); + assert.strictEqual(scrollView.isScrollbarVisible, true); + terminal.sendInput("\x1b[<35;9;4M"); + await new Promise((resolve) => setTimeout(resolve, 70)); + assert.strictEqual(scrollView.isScrollbarVisible, false); + + terminal.sendInput("\x1b[<64;10;5M"); + await terminal.waitForRender(); + assert.strictEqual(scrollView.scrollTop, 14); + await new Promise((resolve) => setTimeout(resolve, 70)); + assert.strictEqual(scrollView.isScrollbarVisible, true); + terminal.sendInput("\x1b[<35;9;5M"); + await new Promise((resolve) => setTimeout(resolve, 70)); + assert.strictEqual(scrollView.isScrollbarVisible, false); + + assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;"))); + tui.stop(); + }); + + it("keeps the scrollbar column selectable while the thumb is hidden", async () => { + const terminal = new RecordingTerminal(10, 2); + const tui = new TuiAltScreen(terminal); + const scrollView = new ScrollView(new Text("123456789A\nabcdefghij\nmore\nlines", 0, 0), { + scrollbar: "auto", + }); + tui.setLayoutRoot(scrollView); + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(scrollView.isScrollbarVisible, false); + + terminal.sendInput("\x1b[<0;10;1M"); + terminal.sendInput("\x1b[<32;10;2M"); + terminal.sendInput("\x1b[<0;10;2m"); + await terminal.waitForRender(); + + const expected = `\x1b]52;c;${Buffer.from("A\nabcdefghij").toString("base64")}\x07`; + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes(expected)), + JSON.stringify(terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;"))), + ); + tui.stop(); + }); + + it("chains unused wheel delta to an outer scroll view", async () => { + const terminal = new VirtualTerminal(20, 4); + const tui = new TuiAltScreen(terminal, undefined, undefined, { wheelScrollLines: 3 }); + const inner = new ScrollView(new Text("i1\ni2\ni3\ni4\ni5\ni6", 0, 0)); + const outer = new ScrollView( + new VStack([{ component: inner, basis: 2 }, new Text("tail1\ntail2\ntail3\ntail4\ntail5", 0, 0)]), + { primary: true }, + ); + tui.setLayoutRoot(outer); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<65;1;1M"); + await terminal.waitForRender(); + assert.strictEqual(inner.scrollTop, 3); + assert.strictEqual(outer.scrollTop, 0); + + terminal.sendInput("\x1b[<65;1;1M"); + await terminal.waitForRender(); + assert.strictEqual(inner.scrollTop, 4); + assert.strictEqual(outer.scrollTop, 2); + tui.stop(); + }); + + it("supports configurable keyboard viewport navigation with four rows of page overlap", async () => { + const terminal = new VirtualTerminal(20, 8); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[57421u"); + terminal.sendInput("\x1b[57421;1:3u"); + 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("\x1b[57422u"); + terminal.sendInput("\x1b[57422;1:3u"); + await terminal.waitForRender(); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 5", "line 6", "line 7", "line 8", "line 9", "line 10", "line 11", "line 12"], + ); + + terminal.sendInput("\x1bOH"); + 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"); + await terminal.waitForRender(); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 5", "line 6", "line 7", "line 8", "line 9", "line 10", "line 11", "line 12"], + ); + + tui.stop(); + }); + + it("scrolls the transcript by half a page with custom bindings", async () => { + const originalKeybindings = getKeybindings(); + const terminal = new VirtualTerminal(20, 10); + const tui = new TuiAltScreen(terminal); + setKeybindings( + new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.altScreen.halfPageUp": "ctrl+u", + "tui.altScreen.halfPageDown": "ctrl+d", + }), + ); + try { + tui.addChild(new Text(Array.from({ length: 30 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + + terminal.sendInput("\x15"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 15); + + terminal.sendInput("\x04"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + } finally { + tui.stop(); + setKeybindings(originalKeybindings); + } + }); + + it("lets viewport navigation keys reach the focused component when nothing can scroll", async () => { + const terminal = new VirtualTerminal(20, 10); + const tui = new TuiAltScreen(terminal); + // Content fits the viewport: the primary scroll view cannot scroll, so + // navigation keys belong to the focused component (e.g. a full-screen + // viewer mounted as the layout root with its own scrolling). + const transcript = new ScrollView(new Text("fits", 0, 0), { follow: "end", primary: true }); + const editorInputs: string[] = []; + const editor = { + focused: false, + render: () => ["editor"], + invalidate: () => {}, + handleInput: (data: string) => editorInputs.push(data), + }; + tui.setLayoutRoot( + new VStack([ + { component: transcript, basis: 0, grow: 1, minSize: 1 }, + { component: editor, basis: 1, shrink: 0 }, + ]), + ); + tui.setFocus(editor); + tui.start(); + await terminal.waitForRender(); + + const inputs = ["\x1b[5~", "\x1b[6~", "\x1b[H", "\x1b[F"]; + for (const input of inputs) terminal.sendInput(input); + await terminal.waitForRender(); + assert.strictEqual(transcript.scrollTop, 0); + assert.deepStrictEqual(editorInputs, inputs); + + tui.stop(); + }); + + it("routes Ctrl-modified viewport navigation to the focused component", async () => { + const terminal = new VirtualTerminal(20, 6); + const tui = new TuiAltScreen(terminal); + const transcript = new ScrollView( + new Text(Array.from({ length: 12 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0), + { follow: "end", primary: true }, + ); + const editorInputs: string[] = []; + const editor = { + focused: false, + render: () => ["editor"], + invalidate: () => {}, + handleInput: (data: string) => editorInputs.push(data), + }; + tui.setLayoutRoot( + new VStack([ + { component: transcript, basis: 0, grow: 1, minSize: 1 }, + { component: editor, basis: 1, shrink: 0 }, + ]), + ); + tui.setFocus(editor); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1bOH"); + 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[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); + + tui.stop(); + }); + + it("jumps between OSC 133 semantic prompt markers", async () => { + const terminal = new VirtualTerminal(20, 3); + const tui = new TuiAltScreen(terminal); + tui.addChild( + new Text( + [1, 2, 3, 4].flatMap((message) => [`${OSC133_ZONE_START}message ${message}`, "detail"]).join("\n"), + 0, + 0, + ), + ); + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 5); + + terminal.sendInput("\x1b[57419;6u"); + terminal.sendInput("\x1b[57419;6:3u"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 4); + assert.strictEqual(terminal.getViewport()[0]?.trimEnd(), "message 3"); + + terminal.sendInput("\x1b[1;6A"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 2); + assert.strictEqual(terminal.getViewport()[0]?.trimEnd(), "message 2"); + + terminal.sendInput("\x1b[57420;6u"); + terminal.sendInput("\x1b[57420;6:3u"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 4); + assert.strictEqual(terminal.getViewport()[0]?.trimEnd(), "message 3"); + + terminal.sendInput("\x1b[1;6B"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 5); + assert.strictEqual(terminal.getViewport()[1]?.trimEnd(), "message 4"); + assert.strictEqual(tui.isFollowingOutput, true); + + 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("reuses moved Kitty images without dropping HStack siblings", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + try { + const terminal = new RecordingTerminal(20, 6); + const tui = new TuiAltScreen(terminal); + const label = new Text("left", 0, 0); + const image = new Image( + "A".repeat(8192), + "image/png", + { fallbackColor: (value) => value }, + {}, + { widthPx: 100, heightPx: 100 }, + ); + const header = new Text("header", 0, 0); + const row = new HStack([ + { component: label, basis: 10 }, + { component: image, basis: 10 }, + ]); + tui.setLayoutRoot( + new VStack([ + { component: header, basis: "auto" }, + { component: row, basis: 4 }, + ]), + ); + tui.start(); + await terminal.waitForRender(); + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b_Ga=T"))); + + const eventCount = terminal.events.length; + label.setText("changed"); + header.setText("header\nsecond"); + tui.requestRender(); + await terminal.waitForRender(); + const redrawWrites = terminal.events + .slice(eventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + const placementIndex = redrawWrites.indexOf("\x1b_Ga=p,q=2"); + assert.ok(redrawWrites.includes("\x1b_Ga=d,d=a,q=2\x1b\\")); + assert.ok(placementIndex > redrawWrites.indexOf("changed")); + assert.ok(!redrawWrites.includes("\x1b_Ga=T")); + assert.ok(redrawWrites.length < 2000, `expected placement-only redraw, got ${redrawWrites.length} bytes`); + assert.ok(terminal.getViewport().some((line) => line.trimEnd() === "changed")); + tui.stop(); + } finally { + resetCapabilitiesCache(); + } + }); + + it("retains recently offscreen Kitty images for placement-only reuse", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + try { + const terminal = new RecordingTerminal(20, 1); + const tui = new TuiAltScreen(terminal); + const imageId = 321; + const imageLine = encodeKitty("AAAA", { columns: 2, rows: 1, imageId, moveCursor: false }); + registerKittyImageMetadata({ imageId, columns: 2, rows: 1, widthPx: 100, heightPx: 50 }); + tui.setLayoutRoot( + new ScrollView( + { + render: () => [imageLine, "after"], + invalidate: () => {}, + }, + { primary: true }, + ), + ); + tui.start(); + await terminal.waitForRender(); + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b_Ga=T"))); + + const eventCount = terminal.events.length; + tui.scrollBy(1); + await terminal.waitForRender(); + tui.scrollBy(-1); + await terminal.waitForRender(); + const reentryWrites = terminal.events + .slice(eventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(reentryWrites.includes("\x1b_Ga=p,q=2")); + assert.ok(!reentryWrites.includes("\x1b_Ga=T")); + assert.ok(!reentryWrites.includes(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`)); + tui.stop(); + } finally { + resetCapabilitiesCache(); + } + }); + + it("evicts the least recently visible Kitty image when the cache is full", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + try { + const terminal = new RecordingTerminal(20, 1); + const tui = new TuiAltScreen(terminal); + const firstImageId = 500; + const imageLines = Array.from({ length: 18 }, (_, index) => { + const imageId = firstImageId + index; + registerKittyImageMetadata({ imageId, columns: 2, rows: 1, widthPx: 100, heightPx: 50 }); + return encodeKitty("AAAA", { columns: 2, rows: 1, imageId, moveCursor: false }); + }); + tui.setLayoutRoot( + new ScrollView( + { + render: () => imageLines, + invalidate: () => {}, + }, + { primary: true }, + ), + ); + tui.start(); + await terminal.waitForRender(); + for (let index = 1; index < imageLines.length; index++) { + tui.scrollBy(1); + await terminal.waitForRender(); + } + assert.ok( + terminal.events.some( + (event) => event.type === "write" && event.data.includes(`\x1b_Ga=d,d=I,i=${firstImageId},q=2\x1b\\`), + ), + ); + + const eventCount = terminal.events.length; + tui.scrollToTop(); + await terminal.waitForRender(); + const reentryWrites = terminal.events + .slice(eventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(reentryWrites.includes("\x1b_Ga=T")); + tui.stop(); + } finally { + resetCapabilitiesCache(); + } + }); + + it("evicts offscreen Kitty images when decoded raster memory exceeds the cache quota", async () => { + setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); + try { + const terminal = new RecordingTerminal(20, 1); + const tui = new TuiAltScreen(terminal); + const firstImageId = 600; + const imageLines = Array.from({ length: 4 }, (_, index) => { + const imageId = firstImageId + index; + registerKittyImageMetadata({ imageId, columns: 2, rows: 1, widthPx: 3840, heightPx: 2160 }); + return encodeKitty("AAAA", { columns: 2, rows: 1, imageId, moveCursor: false }); + }); + tui.setLayoutRoot( + new ScrollView( + { + render: () => imageLines, + invalidate: () => {}, + }, + { primary: true }, + ), + ); + tui.start(); + await terminal.waitForRender(); + for (let index = 1; index < imageLines.length; index++) { + tui.scrollBy(1); + await terminal.waitForRender(); + } + assert.ok( + terminal.events.some( + (event) => event.type === "write" && event.data.includes(`\x1b_Ga=d,d=I,i=${firstImageId},q=2\x1b\\`), + ), + ); + tui.stop(); + } finally { + resetCapabilitiesCache(); + } + }); + + 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"))); + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m\x1b[0m\x1b[7m")), + "selection inverse must be reapplied after layout segment resets", + ); + assert.ok(terminal.getViewport().some((line) => line.includes("Copied!"))); + + tui.stop(); + }); + + it("does not append whitespace to double-click word highlighting", async () => { + const terminal = new RecordingTerminal(20, 1); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("foo bar", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<0;1;1m"); + terminal.sendInput("\x1b[<0;3;1M"); + await terminal.waitForRender(); + + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("foo\x1b[27m"))); + tui.stop(); + }); + + it("highlights a complete whitespace segment during a word drag", async () => { + const terminal = new RecordingTerminal(20, 1); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("foo bar", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<0;1;1m"); + terminal.sendInput("\x1b[<0;2;1M"); + terminal.sendInput("\x1b[<32;4;1M"); + await terminal.waitForRender(); + + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("foo \x1b[27m"))); + tui.stop(); + }); + + it("selects whole words on double click, extends word drags, and selects lines on triple click", async () => { + const terminal = new RecordingTerminal(20, 2); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("zero alpha beta\ngamma delta", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + // The second click lands on a different character in alpha. + terminal.sendInput("\x1b[<0;6;1M"); + terminal.sendInput("\x1b[<0;6;1m"); + terminal.sendInput("\x1b[<0;10;1M"); + terminal.sendInput("\x1b[<0;10;1m"); + await terminal.waitForRender(); + const alpha = `\x1b]52;c;${Buffer.from("alpha").toString("base64")}\x07`; + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(alpha))); + + // A double-click drag includes each word touched, rather than partial words. + terminal.sendInput("\x1b[<0;12;1M"); + terminal.sendInput("\x1b[<0;12;1m"); + terminal.sendInput("\x1b[<0;14;1M"); + terminal.sendInput("\x1b[<32;3;2M"); + terminal.sendInput("\x1b[<0;3;2m"); + await terminal.waitForRender(); + const words = `\x1b]52;c;${Buffer.from("beta\ngamma").toString("base64")}\x07`; + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(words))); + + terminal.sendInput("\x1b[<0;7;2M"); + terminal.sendInput("\x1b[<0;7;2m"); + terminal.sendInput("\x1b[<0;9;2M"); + terminal.sendInput("\x1b[<0;9;2m"); + terminal.sendInput("\x1b[<0;11;2M"); + terminal.sendInput("\x1b[<0;11;2m"); + await terminal.waitForRender(); + const line = `\x1b]52;c;${Buffer.from("gamma delta").toString("base64")}\x07`; + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(line))); + + tui.stop(); + }); + + it("ignores orphan selection events and cancels an active selection on focus loss", 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(); + + const clipboardWriteCount = () => + terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;")).length; + + // A completed click leaves a zero-width anchor, but later orphaned drag/release events must not extend it. + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[<0;1;1m"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + assert.strictEqual(clipboardWriteCount(), 0); + + // Losing focus also cancels a press whose matching release never arrived. + terminal.sendInput("\x1b[<0;1;1M"); + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + assert.strictEqual(clipboardWriteCount(), 0); + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1004h"))); + + tui.stop(); + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1004l"))); + }); + + it("stacks flash messages and collapses them as they expire", async () => { + const terminal = new VirtualTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("one\ntwo\nthree\nfour", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + tui.flash("First", 80); + tui.flash("Second", 500); + await terminal.waitForRender(); + let viewport = terminal.getViewport(); + assert.ok(viewport[0]?.endsWith(" First ")); + assert.ok(viewport[1]?.endsWith(" Second ")); + + await new Promise((resolve) => setTimeout(resolve, 100)); + await terminal.waitForRender(); + viewport = terminal.getViewport(); + assert.ok(viewport[0]?.endsWith(" Second ")); + assert.ok(!viewport.some((line) => line.includes("First"))); + + tui.stop(); + }); + + it("auto-scrolls and extends a drag selection held at the viewport edge", async () => { + const terminal = new RecordingTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(Array.from({ length: 10 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 6); + + terminal.sendInput("\x1b[<0;1;3M"); + terminal.sendInput("\x1b[<32;1;1M"); + await new Promise((resolve) => setTimeout(resolve, 130)); + await terminal.waitForRender(); + + const selectionTop = tui.viewportTop; + assert.ok(selectionTop < 6, `expected auto-scroll above row 6, got ${selectionTop}`); + terminal.sendInput("\x1b[<0;1;1m"); + await terminal.waitForRender(); + + const selectedLines = Array.from({ length: 8 - selectionTop }, (_, index) => `line ${selectionTop + index + 1}`); + selectedLines.push("l"); + const expectedClipboardSequence = `\x1b]52;c;${Buffer.from(selectedLines.join("\n")).toString("base64")}\x07`; + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes(expectedClipboardSequence)), + JSON.stringify(terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;"))), + ); + tui.stop(); + }); + + it("snaps mouse selection to CJK, emoji, and combining grapheme boundaries", async () => { + const terminal = new RecordingTerminal(20, 2); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("A界🙂éZ", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + const wideSelection = `\x1b]52;c;${Buffer.from("界🙂").toString("base64")}\x07`; + terminal.sendInput("\x1b[<0;3;1M"); + terminal.sendInput("\x1b[<32;4;1M"); + terminal.sendInput("\x1b[<0;4;1m"); + await terminal.waitForRender(); + assert.strictEqual( + terminal.events.filter((event) => event.type === "write" && event.data.includes(wideSelection)).length, + 1, + ); + + terminal.sendInput("\x1b[<0;5;1M"); + terminal.sendInput("\x1b[<32;2;1M"); + terminal.sendInput("\x1b[<0;2;1m"); + await terminal.waitForRender(); + assert.strictEqual( + terminal.events.filter((event) => event.type === "write" && event.data.includes(wideSelection)).length, + 2, + ); + + const combiningSelection = `\x1b]52;c;${Buffer.from("éZ").toString("base64")}\x07`; + terminal.sendInput("\x1b[<0;6;1M"); + terminal.sendInput("\x1b[<32;7;1M"); + terminal.sendInput("\x1b[<0;7;1m"); + await terminal.waitForRender(); + assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes(combiningSelection))); + + tui.stop(); + }); + + it("ignores horizontal trackpad wheel events", async () => { + const terminal = new VirtualTerminal(20, 4); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text(Array.from({ length: 8 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[<66;1;1M"); + terminal.sendInput("\x1b[<67;1;1M"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 4); + assert.deepStrictEqual( + terminal.getViewport().map((line) => line.trimEnd()), + ["line 5", "line 6", "line 7", "line 8"], + ); + + tui.stop(); + }); + + it("restores keyboard state before leaving alt mode and prints the full document", async () => { + const terminal = new RecordingTerminal(20, 3); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("first\nsecond\nthird\nfourth\nfifth\nsixth", 0, 0)); + tui.start(); + await terminal.waitForRender(); + tui.stop(); + + const startIndex = terminal.events.findIndex((event) => event.type === "start"); + const altScreenEnterIndex = terminal.events.findIndex( + (event) => event.type === "write" && event.data.includes("\x1b[?1049h"), + ); + const stopIndex = terminal.events.findIndex((event) => event.type === "stop"); + const mouseDisableIndex = terminal.events.findIndex( + (event) => event.type === "write" && event.data.includes("\x1b[?1006l"), + ); + const mainScreenRestoreIndex = terminal.events.findIndex( + (event) => event.type === "write" && event.data.includes("\x1b[?1049l"), + ); + assert.ok(altScreenEnterIndex >= 0 && altScreenEnterIndex < startIndex); + assert.ok(mouseDisableIndex >= 0 && mouseDisableIndex < stopIndex); + assert.ok(mainScreenRestoreIndex > stopIndex); + + const restoreEvent = terminal.events[mainScreenRestoreIndex]; + assert.strictEqual(restoreEvent?.type, "write"); + if (restoreEvent?.type === "write") { + assert.ok(restoreEvent.data.includes("first")); + assert.ok(restoreEvent.data.includes("second")); + assert.ok(restoreEvent.data.includes("third")); + assert.ok(restoreEvent.data.includes("fourth")); + assert.ok(restoreEvent.data.includes("fifth")); + assert.ok(restoreEvent.data.includes("sixth")); + assert.ok(restoreEvent.data.indexOf("first") < restoreEvent.data.indexOf("sixth")); + } + }); +}); diff --git a/packages/pi-tui/test/tui-cell-size-input.test.ts b/packages/pi-tui/test/tui-cell-size-input.test.ts index fab915cac0e..c0e370b4fa7 100644 --- a/packages/pi-tui/test/tui-cell-size-input.test.ts +++ b/packages/pi-tui/test/tui-cell-size-input.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert"; import { describe, it } from "node:test"; 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 { TuiMainScreen } from "../src/tui-main-screen.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class InputRecorder implements Component { @@ -45,7 +46,7 @@ describe("TUI cell size responses", () => { it("forwards bare escape even when a cell size query was sent at startup", () => { withImageTerminal(() => { const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const recorder = new InputRecorder(); tui.setFocus(recorder); @@ -63,7 +64,7 @@ describe("TUI cell size responses", () => { setCellDimensions({ widthPx: 9, heightPx: 18 }); const terminal = new VirtualTerminal(80, 24); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); const recorder = new InputRecorder(); tui.setFocus(recorder); diff --git a/packages/pi-tui/test/tui-overlay-style-leak.test.ts b/packages/pi-tui/test/tui-overlay-style-leak.test.ts index a44b699ee97..dc575059d69 100644 --- a/packages/pi-tui/test/tui-overlay-style-leak.test.ts +++ b/packages/pi-tui/test/tui-overlay-style-leak.test.ts @@ -1,7 +1,8 @@ import assert from "node:assert"; import { describe, it } from "node:test"; import type { Terminal as XtermTerminalType } from "@xterm/headless"; -import { type Component, TUI } from "../src/tui.ts"; +import type { Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class StaticLines implements Component { @@ -54,7 +55,7 @@ describe("TUI overlay compositing", () => { const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`; const terminal = new VirtualTerminal(width, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new StaticLines([baseLine, "INPUT"])); tui.start(); await renderAndFlush(tui, terminal); @@ -67,7 +68,7 @@ describe("TUI overlay compositing", () => { const baseLine = `\x1b[3m${"X".repeat(width)}\x1b[23m`; const terminal = new VirtualTerminal(width, 6); - const tui = new TUI(terminal); + const tui: TUI = new TuiMainScreen(terminal); tui.addChild(new StaticLines([baseLine, "INPUT"])); tui.showOverlay(new StaticOverlay("OVR"), { row: 0, col: 5, width: 3 }); diff --git a/packages/pi-tui/test/tui-render.test.ts b/packages/pi-tui/test/tui-render.test.ts index d40811d0e8c..f6bb16dde2e 100644 --- a/packages/pi-tui/test/tui-render.test.ts +++ b/packages/pi-tui/test/tui-render.test.ts @@ -1,4 +1,7 @@ import assert from "node:assert"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +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"; @@ -10,7 +13,8 @@ import { setCapabilities, setCellDimensions, } from "../src/terminal-image.ts"; -import { type Component, Container, TUI } from "../src/tui.ts"; +import { type Component, Container, type TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; import { VirtualTerminal } from "./virtual-terminal.ts"; class TestComponent implements Component { @@ -21,6 +25,19 @@ class TestComponent implements Component { invalidate(): void {} } +class InputComponent extends TestComponent { + renderCount = 0; + + override render(width: number): string[] { + this.renderCount += 1; + return super.render(width); + } + + handleInput(data: string): void { + this.lines = [data]; + } +} + class LoggingVirtualTerminal extends VirtualTerminal { private writes: string[] = []; @@ -72,13 +89,61 @@ function getCellItalic(terminal: VirtualTerminal, row: number, col: number): num return cell.isItalic(); } +describe("TUI render scheduling", () => { + it("renders keyboard input without waiting for a throttled frame", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui: TUI = new TuiMainScreen(terminal); + const component = new InputComponent(); + component.lines = ["initial"]; + tui.addChild(component); + tui.setFocus(component); + tui.start(); + tui.renderNow(); + const renderCountBeforeInput = component.renderCount; + + // Queue a normal throttled render first. Keyboard input should preempt it. + component.lines = ["pending"]; + tui.requestRender(); + terminal.sendInput("first"); + terminal.sendInput("second"); + terminal.sendInput("typed"); + await new Promise((resolve) => process.nextTick(resolve)); + + assert.strictEqual(component.renderCount, renderCountBeforeInput + 1); + assert.deepStrictEqual(component.lines, ["typed"]); + tui.stop(); + }); +}); + +describe("TUI debug logging", () => { + it("writes redraw logs to the provided directory", async () => { + const logDir = mkdtempSync(join(tmpdir(), "pi-tui-log-")); + try { + await withEnv({ PI_DEBUG_REDRAW: "1" }, async () => { + const terminal = new VirtualTerminal(40, 10); + const tui: TUI = new TuiMainScreen(terminal, undefined, logDir); + const component = new TestComponent(); + tui.addChild(component); + component.lines = ["test"]; + tui.start(); + await terminal.waitForRender(); + + assert.match(readFileSync(join(logDir, "pi-debug.log"), "utf-8"), /fullRender: first render/); + tui.stop(); + }); + } finally { + rmSync(logDir, { recursive: true, force: true }); + } + }); +}); + describe("TUI Kitty image cleanup", () => { it("clears reserved Kitty image rows before drawing appended image placements", async () => { setCapabilities({ images: "kitty", trueColor: true, hyperlinks: true }); 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); @@ -122,7 +187,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); @@ -158,7 +223,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); @@ -204,7 +269,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); @@ -244,7 +309,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); @@ -271,7 +336,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); @@ -298,7 +363,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); @@ -326,7 +391,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); @@ -353,7 +418,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); @@ -381,7 +446,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); @@ -405,7 +470,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); @@ -437,7 +502,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); @@ -460,7 +525,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); @@ -486,7 +551,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); @@ -515,7 +580,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); @@ -542,7 +607,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); @@ -556,7 +621,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); @@ -580,7 +645,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); @@ -604,7 +669,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); @@ -629,7 +694,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); @@ -660,7 +725,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); @@ -682,7 +747,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); @@ -711,7 +776,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); @@ -796,7 +861,7 @@ describe("Container width clamping", () => { describe("TUI overwide line handling", () => { it("truncates lines wider than the terminal instead of throwing", async () => { const terminal = new VirtualTerminal(4, 10); - const tui = new TUI(terminal); + const tui = new TuiMainScreen(terminal); const component = new TestComponent(); component.lines = ["ok"]; tui.addChild(component); @@ -833,7 +898,7 @@ describe("Text negative width safety", () => { describe("TUI steady-frame processed-line reuse", () => { it("writes only the changed line when one component updates in a long transcript", async () => { const terminal = new LoggingVirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui = new TuiMainScreen(terminal); const staticComponent = new TestComponent(); staticComponent.lines = Array.from({ length: 30 }, (_, i) => `static-${String(i).padStart(2, "0")}`); const spinner = new TestComponent(); @@ -858,7 +923,7 @@ describe("TUI steady-frame processed-line reuse", () => { it("writes nothing but the cursor hide for an identical frame", async () => { const terminal = new LoggingVirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui = new TuiMainScreen(terminal); const component = new TestComponent(); component.lines = ["alpha", "beta", "gamma"]; tui.addChild(component); @@ -876,7 +941,7 @@ describe("TUI steady-frame processed-line reuse", () => { it("does not serve stale processed lines when a line toggles between two values", async () => { const terminal = new LoggingVirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui = new TuiMainScreen(terminal); const component = new TestComponent(); component.lines = ["head", "value-a", "tail"]; tui.addChild(component); @@ -899,7 +964,7 @@ describe("TUI steady-frame processed-line reuse", () => { it("fully redraws when the terminal width changes", async () => { const terminal = new LoggingVirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui = new TuiMainScreen(terminal); const component = new TestComponent(); component.lines = ["one", "two", "three"]; tui.addChild(component); @@ -921,7 +986,7 @@ describe("TUI steady-frame processed-line reuse", () => { setCellDimensions({ widthPx: 10, heightPx: 10 }); try { const terminal = new LoggingVirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); const image = new Image( @@ -960,7 +1025,7 @@ describe("TUI steady-frame processed-line reuse", () => { setCellDimensions({ widthPx: 10, heightPx: 10 }); try { const terminal = new LoggingVirtualTerminal(40, 10); - const tui = new TUI(terminal); + const tui = new TuiMainScreen(terminal); const component = new TestComponent(); tui.addChild(component); const image = new Image( diff --git a/packages/pi-tui/test/tui-shrink.test.ts b/packages/pi-tui/test/tui-shrink.test.ts new file mode 100644 index 00000000000..dac98a4ee5f --- /dev/null +++ b/packages/pi-tui/test/tui-shrink.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import type { Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; +import { VirtualTerminal } from "./virtual-terminal.ts"; + +class Lines implements Component { + private lines: string[]; + + constructor(lines: string[]) { + this.lines = lines; + } + + render(): string[] { + return this.lines; + } + + invalidate(): void {} +} + +describe("TUI shrinking content", () => { + it("clears all rendered lines when content shrinks to zero", async () => { + const terminal = new VirtualTerminal(40, 10); + const tui: TUI = new TuiMainScreen(terminal); + const content = new Lines(["first", "second", "third"]); + tui.addChild(content); + tui.start(); + await terminal.waitForRender(); + + assert.ok(terminal.getViewport().some((line) => line.includes("first"))); + assert.ok(terminal.getViewport().some((line) => line.includes("second"))); + assert.ok(terminal.getViewport().some((line) => line.includes("third"))); + + tui.clear(); + tui.requestRender(); + await terminal.waitForRender(); + + const viewport = terminal.getViewport(); + assert.ok(!viewport.some((line) => line.includes("first")), "first line should be cleared"); + assert.ok(!viewport.some((line) => line.includes("second")), "second line should be cleared"); + assert.ok(!viewport.some((line) => line.includes("third")), "third line should be cleared"); + + tui.stop(); + }); +}); diff --git a/packages/pi-tui/test/viewport-overwrite-repro.ts b/packages/pi-tui/test/viewport-overwrite-repro.ts index 1ef432a03b1..1826af06852 100644 --- a/packages/pi-tui/test/viewport-overwrite-repro.ts +++ b/packages/pi-tui/test/viewport-overwrite-repro.ts @@ -17,8 +17,10 @@ * - When content exceeds the viewport and new lines arrive after a tool-call pause, * some earlier PRE-TOOL lines near the bottom are overwritten by POST-TOOL lines. */ + import { ProcessTerminal } from "../src/terminal.ts"; -import { type Component, TUI } from "../src/tui.ts"; +import type { Component, TUI } from "../src/tui.ts"; +import { TuiMainScreen } from "../src/tui-main-screen.ts"; const sleep = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -52,7 +54,7 @@ async function streamLines(buffer: Lines, label: string, count: number, delayMs: } async function main(): Promise { - const ui = new TUI(new ProcessTerminal()); + const ui: TUI = new TuiMainScreen(new ProcessTerminal()); const buffer = new Lines(); ui.addChild(buffer); ui.start(); @@ -96,7 +98,7 @@ async function main(): Promise { main().catch((error) => { // Ensure terminal is restored if something goes wrong. try { - const ui = new TUI(new ProcessTerminal()); + const ui: TUI = new TuiMainScreen(new ProcessTerminal()); ui.stop(); } catch { // Ignore restore errors. diff --git a/packages/pi-tui/test/wrap-ansi.test.ts b/packages/pi-tui/test/wrap-ansi.test.ts index a1183f750e5..618be2347d3 100644 --- a/packages/pi-tui/test/wrap-ansi.test.ts +++ b/packages/pi-tui/test/wrap-ansi.test.ts @@ -101,6 +101,26 @@ describe("wrapTextWithAnsi", () => { }); describe("basic wrapping", () => { + it("should handle LF, CRLF, and CR line endings", () => { + assert.deepStrictEqual(wrapTextWithAnsi("first\nsecond\r\nthird\rfourth", 80), [ + "first", + "second", + "third", + "fourth", + ]); + }); + + it("should preserve ANSI state across CRLF and CR line endings", () => { + const red = "\x1b[31m"; + const reset = "\x1b[0m"; + + assert.deepStrictEqual(wrapTextWithAnsi(`${red}first\r\nsecond\rthird${reset}`, 80), [ + `${red}first`, + `${red}second`, + `${red}third${reset}`, + ]); + }); + it("should wrap plain text correctly", () => { const text = "hello world this is a test"; const wrapped = wrapTextWithAnsi(text, 10); From edb8c566a3b41b1092cdf3e0bae711d96190a117 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 00:01:26 +0800 Subject: [PATCH 02/17] feat(pi-tui): merge upstream main through 40a3d85 (post-0.84.1) Bring in upstream's merged-but-unreleased changes on top of the v0.84.1 re-baseline: - Fullscreen transcript search (ctrl+shift+f, next/previous navigation) - Alternate-screen render-churn reduction (9-18x less per-frame allocation by painting full-width rows as direct line references) - Unbound single-line scroll actions (tui.altScreen.lineUp/lineDown), wired into the fork's canScroll gating like the other viewport keys - SSH-aware escape-timeout default and PI_TUI_ESC_TIMEOUT override - Search snapping and SGR-mouse fragmentation fixes; LaTeX newline argument fix Conflicts resolved by union: upstream's search/line scroll bindings stay ungated, fork's primaryScrollable guard applies to all scroll actions. --- .changeset/cli-tui-mode-fullscreen.md | 2 +- .changeset/pi-tui-upstream-rebaseline.md | 2 +- packages/pi-tui/CHANGELOG.md | 2 +- packages/pi-tui/src/alt-screen-search.ts | 157 +++++++++++ packages/pi-tui/src/components/scroll-view.ts | 39 ++- packages/pi-tui/src/index.ts | 7 +- packages/pi-tui/src/keybindings.ts | 30 +++ packages/pi-tui/src/latex.ts | 2 +- packages/pi-tui/src/layout.ts | 12 +- packages/pi-tui/src/stdin-buffer.ts | 18 +- packages/pi-tui/src/terminal.ts | 21 +- packages/pi-tui/src/tui-alt-screen.ts | 248 +++++++++++++++++- packages/pi-tui/test/keybindings.test.ts | 6 + packages/pi-tui/test/latex.test.ts | 1 + packages/pi-tui/test/render-churn-bench.ts | 203 ++++++++++++++ packages/pi-tui/test/stdin-buffer.test.ts | 68 +++++ packages/pi-tui/test/terminal.test.ts | 32 ++- packages/pi-tui/test/tui-alt-screen.test.ts | 212 ++++++++++++++- 18 files changed, 1031 insertions(+), 31 deletions(-) create mode 100644 packages/pi-tui/src/alt-screen-search.ts create mode 100644 packages/pi-tui/test/render-churn-bench.ts diff --git a/.changeset/cli-tui-mode-fullscreen.md b/.changeset/cli-tui-mode-fullscreen.md index c8c06b77edf..f8504ff983d 100644 --- a/.changeset/cli-tui-mode-fullscreen.md +++ b/.changeset/cli-tui-mode-fullscreen.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add a `tui_mode` preference for a fullscreen alternate-screen UI: the transcript becomes a scrollable viewport with mouse wheel scrolling, text selection, and a scrollbar, while the editor and chrome stay docked at the bottom. Set `tui_mode = "fullscreen"` in ~/.kimi-code/tui.toml and restart. +Add a `tui_mode` preference for a fullscreen alternate-screen UI: the transcript becomes a scrollable viewport with mouse wheel scrolling, text selection, a scrollbar, and transcript search (Ctrl+Shift+F), while the editor and chrome stay docked at the bottom. Set `tui_mode = "fullscreen"` in ~/.kimi-code/tui.toml and restart. diff --git a/.changeset/pi-tui-upstream-rebaseline.md b/.changeset/pi-tui-upstream-rebaseline.md index f2071fc3a90..9be1036307a 100644 --- a/.changeset/pi-tui-upstream-rebaseline.md +++ b/.changeset/pi-tui-upstream-rebaseline.md @@ -2,4 +2,4 @@ "@moonshot-ai/pi-tui": patch --- -Re-baseline the fork on upstream pi-tui v0.84.1. The upstream renderer is now split into main-screen and alternate-screen implementations (the `TUI` class is now an interface implemented by `TuiMainScreen` and `TuiAltScreen`), and the Markdown component gained opt-out LaTeX math rendering. All local patches are retained: narrow-terminal hardening, processed-line render caching, editor history hooks, the paste-burst fallback, and multi-root `@` completion. `Editor.setText` accepts a `preservePasteRegistry` option so subclasses can replace text without orphaning live paste markers (upstream resets the registry on every `setText`). +Re-baseline the fork on upstream pi-tui v0.84.1 plus upstream main up to `40a3d85` (2026-08-11), which adds the fullscreen transcript search (`ctrl+shift+f`), single-line scroll actions, the ~9-18x alternate-screen render-churn reduction, and an SSH-aware escape-timeout default. The upstream renderer is now split into main-screen and alternate-screen implementations (the `TUI` class is now an interface implemented by `TuiMainScreen` and `TuiAltScreen`), and the Markdown component gained opt-out LaTeX math rendering. All local patches are retained: narrow-terminal hardening, processed-line render caching, editor history hooks, the paste-burst fallback, and multi-root `@` completion. `Editor.setText` accepts a `preservePasteRegistry` option so subclasses can replace text without orphaning live paste markers (upstream resets the registry on every `setText`). diff --git a/packages/pi-tui/CHANGELOG.md b/packages/pi-tui/CHANGELOG.md index d909a022531..dc057767b28 100644 --- a/packages/pi-tui/CHANGELOG.md +++ b/packages/pi-tui/CHANGELOG.md @@ -4,7 +4,7 @@ ### Patch Changes -- Re-baseline the fork on upstream `@earendil-works/pi-tui` v0.84.1, keeping all local patches (narrow-terminal hardening, processed-line render caching, editor history hooks, paste-burst fallback, multi-root `@` completion). The upstream renderer is now split into main-screen and alternate-screen implementations, the `TUI` class is now an interface implemented by `TuiMainScreen` and `TuiAltScreen`, and the Markdown component gained opt-out LaTeX math rendering (`renderLatex`). The version line now tracks the upstream baseline it forks from. +- Re-baseline the fork on upstream `@earendil-works/pi-tui` v0.84.1 plus upstream main up to `40a3d85` (2026-08-11), keeping all local patches (narrow-terminal hardening, processed-line render caching, editor history hooks, paste-burst fallback, multi-root `@` completion). The upstream renderer is now split into main-screen and alternate-screen implementations, the `TUI` class is now an interface implemented by `TuiMainScreen` and `TuiAltScreen`, and the Markdown component gained opt-out LaTeX math rendering (`renderLatex`). The post-release main merge adds the fullscreen transcript search, single-line scroll actions, the alternate-screen render-churn reduction, and the SSH-aware escape-timeout default. The version line now tracks the upstream baseline it forks from. ## 0.80.8 diff --git a/packages/pi-tui/src/alt-screen-search.ts b/packages/pi-tui/src/alt-screen-search.ts new file mode 100644 index 00000000000..98926523f96 --- /dev/null +++ b/packages/pi-tui/src/alt-screen-search.ts @@ -0,0 +1,157 @@ +import { Input } from "./components/input.ts"; +import type { Component, Focusable } from "./tui.ts"; +import { getGraphemeSegmenter, stripTerminalSequences, truncateToWidth, visibleWidth } from "./utils.ts"; + +const segmenter = getGraphemeSegmenter(); + +interface SearchSourceSpan { + row: number; + startCol: number; + endCol: number; +} + +export interface AltScreenSearchSegment { + row: number; + startCol: number; + endCol: number; +} + +export interface AltScreenSearchMatch { + segments: AltScreenSearchSegment[]; +} + +function appendMappedText( + text: string, + span: SearchSourceSpan | undefined, + corpus: { text: string; source: Array }, +): void { + corpus.text += text; + for (let index = 0; index < text.length; index++) corpus.source.push(span); +} + +function buildSearchCorpus(lines: readonly string[]): { + text: string; + source: Array; +} { + const corpus: { text: string; source: Array } = { text: "", source: [] }; + let pendingSeparator = false; + + for (let row = 0; row < lines.length; row++) { + const line = stripTerminalSequences(lines[row] ?? ""); + let column = 0; + for (const grapheme of segmenter.segment(line)) { + const text = grapheme.segment; + const width = visibleWidth(text); + if (/^\s+$/u.test(text)) { + if (corpus.text.length > 0) pendingSeparator = true; + column += width; + continue; + } + if (pendingSeparator) { + appendMappedText(" ", undefined, corpus); + pendingSeparator = false; + } + appendMappedText(text, { row, startCol: column, endCol: column + width }, corpus); + column += width; + } + if (corpus.text.length > 0) pendingSeparator = true; + } + + return corpus; +} + +function normalizeQuery(query: string): string { + return query.replace(/\s+/gu, " ").trim(); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +export function findAltScreenSearchMatches(lines: readonly string[], query: string): AltScreenSearchMatch[] { + const normalizedQuery = normalizeQuery(query); + if (!normalizedQuery) return []; + + const corpus = buildSearchCorpus(lines); + const expression = new RegExp(escapeRegExp(normalizedQuery), "giu"); + const matches: AltScreenSearchMatch[] = []; + + for (const match of corpus.text.matchAll(expression)) { + const start = match.index; + const end = start + match[0].length; + const segments: AltScreenSearchSegment[] = []; + for (let index = start; index < end; index++) { + const span = corpus.source[index]; + if (!span) continue; + const previous = segments[segments.length - 1]; + if (previous && previous.row === span.row && span.startCol <= previous.endCol) { + previous.endCol = Math.max(previous.endCol, span.endCol); + } else { + segments.push({ ...span }); + } + } + if (segments.length > 0) matches.push({ segments }); + } + + return matches; +} + +export function getAltScreenSearchMatchKey(match: AltScreenSearchMatch): string { + const first = match.segments[0]; + const last = match.segments[match.segments.length - 1]; + return first && last ? `${first.row}:${first.startCol}:${last.row}:${last.endCol}` : ""; +} + +export class AltScreenSearchComponent implements Component, Focusable { + private readonly input = new Input(); + private readonly onQueryChange: (query: string) => void; + private resultCount = 0; + private resultIndex = -1; + private _focused = false; + + constructor(onQueryChange: (query: string) => void) { + this.onQueryChange = onQueryChange; + } + + get focused(): boolean { + return this._focused; + } + + set focused(value: boolean) { + this._focused = value; + this.input.focused = value; + } + + setResult(index: number, count: number): void { + this.resultIndex = index; + this.resultCount = count; + } + + handleInput(data: string): void { + const previous = this.input.getValue(); + this.input.handleInput(data); + const query = this.input.getValue(); + if (query !== previous) this.onQueryChange(query); + } + + invalidate(): void { + this.input.invalidate(); + } + + render(width: number): string[] { + const safeWidth = Math.max(1, width); + const label = " Find transcript"; + const query = this.input.getValue(); + const status = !query + ? "" + : this.resultCount === 0 + ? "No matches " + : `${this.resultIndex + 1}/${this.resultCount} `; + const labelWidth = visibleWidth(label); + const statusWidth = visibleWidth(status); + const gap = " ".repeat(Math.max(1, safeWidth - labelWidth - statusWidth)); + const title = truncateToWidth(`${label}${gap}${status}`, safeWidth, ""); + const padding = " ".repeat(Math.max(0, safeWidth - visibleWidth(title))); + return [`\x1b[7m${title}${padding}\x1b[27m`, ...this.input.render(safeWidth)]; + } +} diff --git a/packages/pi-tui/src/components/scroll-view.ts b/packages/pi-tui/src/components/scroll-view.ts index 92bce6465ca..f279a862da5 100644 --- a/packages/pi-tui/src/components/scroll-view.ts +++ b/packages/pi-tui/src/components/scroll-view.ts @@ -13,6 +13,11 @@ export interface ScrollViewOptions { scrollbarHideDelayMs?: number; } +export interface ScrollViewScrollToOptions { + /** Keep follow-end disabled even when the target is the current content end. */ + disableFollow?: boolean; +} + export class ScrollView extends Container { private readonly child: Component; private readonly followEnd: boolean; @@ -25,6 +30,7 @@ export class ScrollView extends Container { private contentHeight = 0; private currentViewportHeight = 0; private followingEnd: boolean; + private followSuppressedAtEnd = false; private requestRenderCallback: (() => void) | undefined; private transientScrollbarVisible = false; private scrollbarActive = false; @@ -115,14 +121,24 @@ export class ScrollView extends Container { this.markScrollbarActivity(); } - scrollTo(scrollTop: number): void { + scrollTo(scrollTop: number, options: ScrollViewScrollToOptions = {}): void { const requested = Number.isFinite(scrollTop) ? Math.trunc(scrollTop) : this.currentScrollTop; const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); const next = Math.max(0, Math.min(maxScrollTop, requested)); - if (next === this.currentScrollTop) return; + const nextFollowSuppressedAtEnd = options.disableFollow === true && next === maxScrollTop; + const nextFollowingEnd = !nextFollowSuppressedAtEnd && this.followEnd && next === maxScrollTop; + if ( + next === this.currentScrollTop && + nextFollowingEnd === this.followingEnd && + nextFollowSuppressedAtEnd === this.followSuppressedAtEnd + ) { + return; + } + const moved = next !== this.currentScrollTop; this.currentScrollTop = next; - this.followingEnd = this.followEnd && next === maxScrollTop; - this.markScrollbarActivity(); + this.followingEnd = nextFollowingEnd; + this.followSuppressedAtEnd = nextFollowSuppressedAtEnd; + if (moved) this.markScrollbarActivity(); this.requestRenderCallback?.(); } @@ -133,12 +149,12 @@ export class ScrollView extends Container { const start = this.followingEnd ? maxScrollTop : this.currentScrollTop; const next = Math.max(0, Math.min(maxScrollTop, start + requested)); const moved = next - start; + const wasFollowingEnd = this.followingEnd; this.currentScrollTop = next; this.followingEnd = this.followEnd && next === maxScrollTop; - if (moved !== 0) { - this.markScrollbarActivity(); - this.requestRenderCallback?.(); - } + this.followSuppressedAtEnd = false; + if (moved !== 0) this.markScrollbarActivity(); + if (moved !== 0 || this.followingEnd !== wasFollowingEnd) this.requestRenderCallback?.(); return requested - moved; } @@ -148,6 +164,7 @@ export class ScrollView extends Container { this.followingEnd !== (this.followEnd && this.contentHeight <= this.currentViewportHeight); this.currentScrollTop = 0; this.followingEnd = this.followEnd && this.contentHeight <= this.currentViewportHeight; + this.followSuppressedAtEnd = false; if (changed) { this.markScrollbarActivity(); this.requestRenderCallback?.(); @@ -159,6 +176,7 @@ export class ScrollView extends Container { const changed = this.currentScrollTop !== next || this.followingEnd !== this.followEnd; this.currentScrollTop = next; this.followingEnd = this.followEnd; + this.followSuppressedAtEnd = false; if (changed) { this.markScrollbarActivity(); this.requestRenderCallback?.(); @@ -172,7 +190,10 @@ export class ScrollView extends Container { const maxScrollTop = Math.max(0, this.contentHeight - this.currentViewportHeight); if (this.followingEnd) this.currentScrollTop = maxScrollTop; else this.currentScrollTop = Math.max(0, Math.min(this.currentScrollTop, maxScrollTop)); - if (this.followEnd && this.currentScrollTop === maxScrollTop) this.followingEnd = true; + if (this.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false; + if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) { + this.followingEnd = true; + } if (this.contentHeight <= this.currentViewportHeight) this.hideTransientScrollbar(); } diff --git a/packages/pi-tui/src/index.ts b/packages/pi-tui/src/index.ts index 00e9d414f44..0d5a4a1093b 100644 --- a/packages/pi-tui/src/index.ts +++ b/packages/pi-tui/src/index.ts @@ -18,7 +18,12 @@ export { Image, type ImageOptions, type ImageTheme } from "./components/image.ts export { Input } from "./components/input.ts"; export { Loader, type LoaderIndicatorOptions } from "./components/loader.ts"; export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.ts"; -export { ScrollView, type ScrollViewOptions, type ScrollViewScrollbar } from "./components/scroll-view.ts"; +export { + ScrollView, + type ScrollViewOptions, + type ScrollViewScrollbar, + type ScrollViewScrollToOptions, +} from "./components/scroll-view.ts"; export { type SelectItem, SelectList, diff --git a/packages/pi-tui/src/keybindings.ts b/packages/pi-tui/src/keybindings.ts index 8a08ca10dc0..1930e8cde54 100644 --- a/packages/pi-tui/src/keybindings.ts +++ b/packages/pi-tui/src/keybindings.ts @@ -46,8 +46,14 @@ export interface Keybindings { "tui.altScreen.pageDown": true; "tui.altScreen.halfPageUp": true; "tui.altScreen.halfPageDown": true; + "tui.altScreen.lineUp": true; + "tui.altScreen.lineDown": true; "tui.altScreen.previousPrompt": true; "tui.altScreen.nextPrompt": true; + "tui.altScreen.search": true; + "tui.altScreen.searchNext": true; + "tui.altScreen.searchPrevious": true; + "tui.altScreen.searchClose": true; "tui.altScreen.top": true; "tui.altScreen.bottom": true; } @@ -167,6 +173,14 @@ export const TUI_KEYBINDINGS = { defaultKeys: [], description: "Scroll viewport down half a page", }, + "tui.altScreen.lineUp": { + defaultKeys: [], + description: "Scroll viewport up one line", + }, + "tui.altScreen.lineDown": { + defaultKeys: [], + description: "Scroll viewport down one line", + }, "tui.altScreen.previousPrompt": { defaultKeys: "ctrl+shift+up", description: "Jump to previous semantic prompt", @@ -175,6 +189,22 @@ export const TUI_KEYBINDINGS = { defaultKeys: "ctrl+shift+down", description: "Jump to next semantic prompt", }, + "tui.altScreen.search": { + defaultKeys: "ctrl+shift+f", + description: "Search the primary scroll view", + }, + "tui.altScreen.searchNext": { + defaultKeys: ["enter", "ctrl+g"], + description: "Select the next search match", + }, + "tui.altScreen.searchPrevious": { + defaultKeys: ["shift+enter", "ctrl+shift+g"], + description: "Select the previous search match", + }, + "tui.altScreen.searchClose": { + 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" }, } as const satisfies KeybindingDefinitions; diff --git a/packages/pi-tui/src/latex.ts b/packages/pi-tui/src/latex.ts index 2b2e16deb48..841f9f9611a 100644 --- a/packages/pi-tui/src/latex.ts +++ b/packages/pi-tui/src/latex.ts @@ -1145,7 +1145,7 @@ class LatexParser { } private parseRequiredArgumentValue(): string { - while (this.position < this.source.length && /[ \t]/.test(this.source[this.position] ?? "")) { + while (this.position < this.source.length && /\s/.test(this.source[this.position] ?? "")) { this.position++; } if (this.position >= this.source.length) { diff --git a/packages/pi-tui/src/layout.ts b/packages/pi-tui/src/layout.ts index a139a2fff81..51f738ecded 100644 --- a/packages/pi-tui/src/layout.ts +++ b/packages/pi-tui/src/layout.ts @@ -316,8 +316,16 @@ function paintBox(box: LayoutBox, screen: string[], totalWidth: number): void { const visibleRows = Math.min(imageMetadata.rows, clipBottom - row); if (visibleRows < imageMetadata.rows) line = cropKittyImageLine(line, 0, visibleRows); } - if (isImageLine(line) && box.rect.x === 0 && box.rect.width >= totalWidth) screen[row] = line; - else screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth); + // Fast path: a full-width box painting onto an untouched row can use the + // source line reference directly. Compositing here would rebuild the row + // string through ANSI/grapheme segmentation every frame; padding is + // unnecessary because rows are written with erase-line and the final + // width clamp still truncates over-wide lines. + if (box.rect.x === 0 && box.rect.width >= totalWidth && (isImageLine(line) || !screen[row])) { + screen[row] = line; + } else { + screen[row] = compositeTuiLine(screen[row] ?? "", line, box.rect.x, box.rect.width, totalWidth); + } } } for (const child of box.children) paintBox(child, screen, totalWidth); diff --git a/packages/pi-tui/src/stdin-buffer.ts b/packages/pi-tui/src/stdin-buffer.ts index 89ddfdf38dc..a5b4b07e250 100644 --- a/packages/pi-tui/src/stdin-buffer.ts +++ b/packages/pi-tui/src/stdin-buffer.ts @@ -20,6 +20,8 @@ import { EventEmitter } from "events"; const ESC = "\x1b"; +const DEFAULT_SEQUENCE_TIMEOUT_MS = 50; +const DEFAULT_ESCAPE_TIMEOUT_MS = 10; const BRACKETED_PASTE_START = "\x1b[200~"; const BRACKETED_PASTE_END = "\x1b[201~"; @@ -256,10 +258,15 @@ function extractCompleteSequences(buffer: string): { sequences: string[]; remain export type StdinBufferOptions = { /** - * Maximum time to wait for sequence completion (default: 10ms) - * After this time, the buffer is flushed even if incomplete + * Maximum time to wait for an incomplete sequence such as CSI or mouse + * (default: 50ms). */ timeout?: number; + /** + * Maximum time to wait after a lone ESC before treating it as Escape + * (default: 10ms). Increase for high-latency Alt+key input (SSH). + */ + escapeTimeout?: number; }; export type StdinBufferEventMap = { @@ -275,13 +282,15 @@ export class StdinBuffer extends EventEmitter { private buffer: string = ""; private timeout: ReturnType | null = null; private readonly timeoutMs: number; + private readonly escapeTimeoutMs: number; private pasteMode: boolean = false; private pasteBuffer: string = ""; private pendingKittyPrintableCodepoint: number | undefined; constructor(options: StdinBufferOptions = {}) { super(); - this.timeoutMs = options.timeout ?? 10; + this.timeoutMs = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT_MS; + this.escapeTimeoutMs = options.escapeTimeout ?? DEFAULT_ESCAPE_TIMEOUT_MS; } public process(data: string | Buffer): void { @@ -376,13 +385,14 @@ export class StdinBuffer extends EventEmitter { } if (this.buffer.length > 0) { + const timeoutMs = this.buffer === ESC ? this.escapeTimeoutMs : this.timeoutMs; this.timeout = setTimeout(() => { const flushed = this.flush(); for (const sequence of flushed) { this.emitDataSequence(sequence); } - }, this.timeoutMs); + }, timeoutMs); } } diff --git a/packages/pi-tui/src/terminal.ts b/packages/pi-tui/src/terminal.ts index 8cf084c443c..fd01434c522 100644 --- a/packages/pi-tui/src/terminal.ts +++ b/packages/pi-tui/src/terminal.ts @@ -101,6 +101,25 @@ export interface Terminal { setProgress(active: boolean): void; } +const DEFAULT_ESCAPE_TIMEOUT_MS = 10; +const DEFAULT_SSH_ESCAPE_TIMEOUT_MS = 100; + +/** + * Resolve how long to wait for the rest of an escape sequence before + * dispatching a lone ESC as the Escape key. Legacy Alt+key input is ESC plus + * another byte, so high-latency transports need a longer reassembly window. + */ +export function resolveEscapeTimeoutMs(env: NodeJS.ProcessEnv = process.env): number { + const configured = Number(env['PI_TUI_ESC_TIMEOUT']); + if (Number.isFinite(configured) && configured > 0) { + return configured; + } + if (env['SSH_CONNECTION'] || env['SSH_TTY']) { + return DEFAULT_SSH_ESCAPE_TIMEOUT_MS; + } + return DEFAULT_ESCAPE_TIMEOUT_MS; +} + /** * Real terminal using process.stdin/stdout */ @@ -183,7 +202,7 @@ export class ProcessTerminal implements Terminal { * to handle the case where the response arrives split across multiple events. */ private setupStdinBuffer(): void { - this.stdinBuffer = new StdinBuffer({ timeout: 10 }); + this.stdinBuffer = new StdinBuffer({ escapeTimeout: resolveEscapeTimeoutMs() }); // Forward individual sequences to the input handler this.stdinBuffer.on("data", (sequence) => { diff --git a/packages/pi-tui/src/tui-alt-screen.ts b/packages/pi-tui/src/tui-alt-screen.ts index 78a9a128641..211680dc068 100644 --- a/packages/pi-tui/src/tui-alt-screen.ts +++ b/packages/pi-tui/src/tui-alt-screen.ts @@ -1,3 +1,9 @@ +import { + AltScreenSearchComponent, + type AltScreenSearchMatch, + findAltScreenSearchMatches, + getAltScreenSearchMatchKey, +} from "./alt-screen-search.ts"; import { AltScreenFlashContainer } from "./components/alt-screen-flash.ts"; import { ScrollView } from "./components/scroll-view.ts"; import { getKeybindings } from "./keybindings.ts"; @@ -26,6 +32,7 @@ import { type Component, CURSOR_MARKER, compositeTuiLine, + type OverlayHandle, TuiBase, type TuiStopOptions, VIEWPORT_TUI, @@ -114,11 +121,34 @@ interface ScrollbarTarget { geometry: ScrollbarGeometry; } +type SearchSelectionMode = "query" | "retain" | "next" | "previous"; + +interface ActiveSearch { + component: AltScreenSearchComponent; + overlay?: OverlayHandle; + query: string; + matches: AltScreenSearchMatch[]; + selectedIndex: number; + selectedKey?: string; + anchorRow: number; + selectionMode: SearchSelectionMode; +} + +interface SearchHighlightRange { + startCol: number; + endCol: number; + current: 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; + /** Style a non-current transcript search match. */ + searchMatchStyle?: (text: string) => string; + /** Style the current transcript search match. */ + searchCurrentMatchStyle?: (text: string) => string; /** Open an OSC 8 hyperlink activated with a primary-button click. */ openUrl?: (url: string) => void; /** Handle an unmodified secondary-button press for clipboard paste. Currently enabled on Windows only. */ @@ -153,10 +183,13 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private selectionPressActive = false; private scrollbarDrag?: ScrollbarDrag; private scrollbarHover?: ScrollView; + private activeSearch?: ActiveSearch; private pressedUrl?: string; private selectionDragged = false; private readonly wheelScrollLines: number; private readonly mouseEnabled: boolean; + private readonly searchMatchStyle: (text: string) => string; + private readonly searchCurrentMatchStyle: (text: string) => string; private readonly openUrl?: (url: string) => void; private readonly onRightClickPaste?: () => void; @@ -177,6 +210,8 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.flashes = new AltScreenFlashContainer(() => this.requestRender()); this.wheelScrollLines = Math.max(1, Math.floor(options.wheelScrollLines ?? 1)); this.mouseEnabled = options.mouse ?? true; + this.searchMatchStyle = options.searchMatchStyle ?? ((text) => `\x1b[4m${text}\x1b[24m`); + this.searchCurrentMatchStyle = options.searchCurrentMatchStyle ?? ((text) => `\x1b[1;7m${text}\x1b[22;27m`); this.openUrl = options.openUrl; this.onRightClickPaste = options.onRightClickPaste; this.addInputListener((data) => this.handleViewportInput(data)); @@ -254,6 +289,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } protected override beforeTerminalStop(_options: TuiStopOptions): void { + this.closeSearch(); this.stopSelectionAutoScroll(); this.selectionPressActive = false; this.stopScrollbarHover(); @@ -381,6 +417,113 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { } } + private openSearch(): void { + if (this.activeSearch) { + this.activeSearch.overlay?.focus(); + return; + } + const component = new AltScreenSearchComponent((query) => this.updateSearchQuery(query)); + const search: ActiveSearch = { + component, + query: "", + matches: [], + selectedIndex: -1, + anchorRow: this.getPrimaryScrollView().scrollTop, + selectionMode: "query", + }; + this.activeSearch = search; + search.overlay = this.showOverlay(component, { + anchor: "top-right", + width: "40%", + minWidth: 24, + margin: 1, + }); + } + + private closeSearch(): void { + const search = this.activeSearch; + if (!search) return; + this.activeSearch = undefined; + search.overlay?.hide(); + this.requestRender(); + } + + private updateSearchQuery(query: string): void { + const search = this.activeSearch; + if (!search || query === search.query) return; + const selected = search.matches[search.selectedIndex]; + search.anchorRow = selected?.segments[0]?.row ?? this.getPrimaryScrollView().scrollTop; + search.query = query; + search.selectionMode = "query"; + search.component.setResult(-1, 0); + this.requestRender(); + } + + private navigateSearch(direction: -1 | 1): void { + const search = this.activeSearch; + if (!search?.query) return; + search.selectionMode = direction < 0 ? "previous" : "next"; + this.requestRender(); + } + + private refreshSearch(layout: LayoutFrame): boolean { + const search = this.activeSearch; + if (!search) return false; + const scrollView = layout.primaryScrollView ?? this.implicitScrollView; + const box = getScrollViewBox(layout, scrollView); + const lines = box?.scrollContentLines; + if (!lines || !search.query.trim()) { + search.matches = []; + search.selectedIndex = -1; + search.selectedKey = undefined; + search.selectionMode = "retain"; + search.component.setResult(-1, 0); + return false; + } + + const shouldRevealSelection = search.selectionMode !== "retain"; + const matches = findAltScreenSearchMatches(lines, search.query); + const exactIndex = search.selectedKey + ? matches.findIndex((match) => getAltScreenSearchMatchKey(match) === search.selectedKey) + : -1; + let selectedIndex = -1; + if (matches.length > 0) { + if (search.selectionMode === "query") { + selectedIndex = matches.findIndex((match) => (match.segments[0]?.row ?? 0) >= search.anchorRow); + if (selectedIndex < 0) selectedIndex = 0; + } else if (search.selectionMode === "next") { + const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1); + selectedIndex = baseIndex < 0 ? 0 : (baseIndex + 1) % matches.length; + } else if (search.selectionMode === "previous") { + const baseIndex = exactIndex >= 0 ? exactIndex : Math.min(search.selectedIndex, matches.length - 1); + selectedIndex = baseIndex < 0 ? matches.length - 1 : (baseIndex - 1 + matches.length) % matches.length; + } else { + selectedIndex = + exactIndex >= 0 ? exactIndex : Math.min(Math.max(0, search.selectedIndex), matches.length - 1); + } + } + + search.matches = matches; + search.selectedIndex = selectedIndex; + search.selectedKey = selectedIndex >= 0 ? getAltScreenSearchMatchKey(matches[selectedIndex]!) : undefined; + search.selectionMode = "retain"; + search.component.setResult(selectedIndex, matches.length); + if (!shouldRevealSelection) return false; + + const selected = matches[selectedIndex]; + const firstSegment = selected?.segments[0]; + const lastSegment = selected?.segments[selected.segments.length - 1]; + if (!box || !firstSegment || !lastSegment || scrollView.viewportHeight <= 0) return false; + const before = scrollView.scrollTop; + const visibleBottom = before + scrollView.viewportHeight - 1; + let target = before; + if (firstSegment.row < before || lastSegment.row > visibleBottom) { + target = firstSegment.row - Math.floor(scrollView.viewportHeight / 3); + } + scrollView.scrollTo(target, { disableFollow: true }); + return scrollView.scrollTop !== before; + } + /** Show a transient message in the alternate-screen flash stack. */ flash(message: string, durationMs?: number): void { this.flashes.flash(message, durationMs); @@ -389,6 +532,7 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { private handleViewportInput(data: string): { consume?: boolean } | undefined { if (data === FOCUS_OUT) { const hadActiveSelection = this.selectionPressActive; + const hadNonEmptyActiveSelection = hadActiveSelection && this.getSelectionBounds() !== undefined; this.selectionPressActive = false; this.stopSelectionAutoScroll(); this.stopScrollbarHover(); @@ -400,9 +544,9 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.selectionFocus = undefined; this.selectionGranularity = "character"; this.selectionInitialRange = undefined; + if (hadNonEmptyActiveSelection) this.requestRender(); } this.lastClick = undefined; - this.requestRender(); return { consume: true }; } if (data === FOCUS_IN) return { consume: true }; @@ -428,6 +572,24 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { // full-screen component mounted as the layout root), let navigation keys // fall through to the focused component instead of consuming them. const primaryScrollable = this.getPrimaryScrollView().canScroll; + if (keybindings.matches(data, "tui.altScreen.search")) { + if (!isRelease) this.openSearch(); + return { consume: true }; + } + if (this.activeSearch?.overlay?.isFocused()) { + if (keybindings.matches(data, "tui.altScreen.searchNext")) { + if (!isRelease) this.navigateSearch(1); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.searchPrevious")) { + if (!isRelease) this.navigateSearch(-1); + return { consume: true }; + } + if (keybindings.matches(data, "tui.altScreen.searchClose")) { + if (!isRelease) this.closeSearch(); + return { consume: true }; + } + } if (primaryScrollable && keybindings.matches(data, "tui.altScreen.pageUp")) { if (!isRelease) { this.scrollBy(-Math.max(1, this.getPrimaryScrollView().viewportHeight - PAGE_SCROLL_OVERLAP)); @@ -448,6 +610,14 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (!isRelease) this.scrollBy(Math.max(1, Math.floor(this.getPrimaryScrollView().viewportHeight / 2))); return { consume: true }; } + if (primaryScrollable && keybindings.matches(data, "tui.altScreen.lineUp")) { + if (!isRelease) this.scrollBy(-1); + return { consume: true }; + } + if (primaryScrollable && keybindings.matches(data, "tui.altScreen.lineDown")) { + if (!isRelease) this.scrollBy(1); + return { consume: true }; + } if (primaryScrollable && keybindings.matches(data, "tui.altScreen.previousPrompt")) { if (!isRelease) this.scrollToPrompt(-1); return { consume: true }; @@ -904,6 +1074,76 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { this.flash("Copied!"); } + private applySearchTextHighlight(text: string, current: boolean): string { + const style = current ? this.searchCurrentMatchStyle : this.searchMatchStyle; + let result = ""; + let plainStart = 0; + let index = 0; + while (index < text.length) { + const ansi = extractAnsiCode(text, index); + if (!ansi) { + index += 1; + continue; + } + if (index > plainStart) result += style(text.slice(plainStart, index)); + result += ansi.code; + index += ansi.length; + plainStart = index; + } + if (plainStart < text.length) result += style(text.slice(plainStart)); + return result; + } + + private applySearchHighlights(screen: string[], layout: LayoutFrame): string[] { + const search = this.activeSearch; + if (!search || search.selectedIndex < 0 || search.matches.length === 0) return screen; + const scrollView = layout.primaryScrollView ?? this.implicitScrollView; + const box = getScrollViewBox(layout, scrollView); + if (!box) return screen; + + const rangesByRow = new Map(); + const scrollbarColumn = getScrollbarGeometry(box)?.column; + const minRow = Math.max(0, box.rect.y, box.clip.y); + const maxRow = Math.min(screen.length, box.rect.y + box.rect.height, box.clip.y + box.clip.height); + const minColumn = Math.max(0, box.rect.x, box.clip.x); + const maxColumn = Math.min( + this.terminal.columns, + box.rect.x + box.rect.width, + box.clip.x + box.clip.width, + scrollbarColumn ?? Number.POSITIVE_INFINITY, + ); + for (let matchIndex = 0; matchIndex < search.matches.length; matchIndex++) { + for (const segment of search.matches[matchIndex]!.segments) { + const row = box.rect.y + segment.row - scrollView.scrollTop; + if (row < minRow || row >= maxRow) continue; + const startCol = Math.max(minColumn, box.rect.x + segment.startCol); + const endCol = Math.min(maxColumn, box.rect.x + segment.endCol); + if (endCol <= startCol) continue; + const ranges = rangesByRow.get(row) ?? []; + ranges.push({ startCol, endCol, current: matchIndex === search.selectedIndex }); + rangesByRow.set(row, ranges); + } + } + + const result = [...screen]; + for (const [row, ranges] of rangesByRow) { + let line = result[row] ?? ""; + if (isImageLine(line)) continue; + const lineWidth = visibleWidth(line); + for (const range of ranges.sort((a, b) => b.startCol - a.startCol)) { + const startCol = Math.min(range.startCol, lineWidth); + const endCol = Math.min(range.endCol, lineWidth); + if (endCol <= startCol) continue; + const before = sliceByColumn(line, 0, startCol, true); + const highlighted = sliceByColumn(line, startCol, endCol - startCol, true); + const after = sliceByColumn(line, endCol, Math.max(0, lineWidth - endCol), true); + line = `${before}${this.applySearchTextHighlight(highlighted, range.current)}${after}`; + } + result[row] = line; + } + return result; + } + private applySelectionHighlight(text: string): string { let result = "\x1b[7m"; let index = 0; @@ -993,8 +1233,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { const width = Math.max(1, this.terminal.columns); const height = Math.max(1, this.terminal.rows); const root = this.layoutRoot ?? this.implicitScrollView; - const nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + let nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + if (this.refreshSearch(nextLayout)) { + nextLayout = renderLayoutFrame(root, width, height, () => this.requestRender()); + } let screen = nextLayout.lines.map((line) => line.replace(OSC133_ZONE_PREFIX, "")); + screen = this.applySearchHighlights(screen, nextLayout); screen = this.compositeOverlays(screen, width, height); if (screen.length > height) screen = screen.slice(screen.length - height); screen = this.applySelection(screen, nextLayout); diff --git a/packages/pi-tui/test/keybindings.test.ts b/packages/pi-tui/test/keybindings.test.ts index c79a024aa0d..9a4ecb8817e 100644 --- a/packages/pi-tui/test/keybindings.test.ts +++ b/packages/pi-tui/test/keybindings.test.ts @@ -34,8 +34,14 @@ describe("KeybindingsManager", () => { assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.pageDown"), ["pageDown"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageUp"), []); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.halfPageDown"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineUp"), []); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.lineDown"), []); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.previousPrompt"), ["ctrl+shift+up"]); assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.nextPrompt"), ["ctrl+shift+down"]); + assert.deepStrictEqual(keybindings.getKeys("tui.altScreen.search"), ["ctrl+shift+f"]); + 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"]); }); diff --git a/packages/pi-tui/test/latex.test.ts b/packages/pi-tui/test/latex.test.ts index 3644ec42b32..a9aca930b62 100644 --- a/packages/pi-tui/test/latex.test.ts +++ b/packages/pi-tui/test/latex.test.ts @@ -443,6 +443,7 @@ R\left(\frac{\pi}{4}\right) " -b±√(b²-4ac)\nx = ────────────\n 2a", ); assert.strictEqual(renderLatex(String.raw`\frac{x^2+1}{x-1}`, { display: true }), "x²+1\n────\nx-1"); + assert.strictEqual(renderLatex("\\frac{1}\n{2}", { display: true }), "1\n─\n2"); }); it("keeps nested display fractions linear", () => { diff --git a/packages/pi-tui/test/render-churn-bench.ts b/packages/pi-tui/test/render-churn-bench.ts new file mode 100644 index 00000000000..103c9561bf7 --- /dev/null +++ b/packages/pi-tui/test/render-churn-bench.ts @@ -0,0 +1,203 @@ +/** + * Alt-screen render churn benchmark. + * + * Measures cumulative JS allocation and wall time for repeated TuiAltScreen + * frames on a layout mirroring pi's fullscreen interactive mode: + * VStack [ ScrollView(transcript), dock VStack [status, editor, footer] ]. + * + * Two scenarios: + * - static: nothing changes between frames (pure recomposite churn) + * - editor: one character appended to the editor per frame (doc scenario + * "30 editor updates") + * + * Allocation is estimated with the V8 sampling heap profiler including + * objects collected by minor/major GC, i.e. it measures churn, not retention. + * + * Run from packages/tui: node test/render-churn-bench.ts + */ + +import { Session } from "node:inspector/promises"; +import { performance } from "node:perf_hooks"; +import { ScrollView } from "../src/components/scroll-view.ts"; +import { Text } from "../src/components/text.ts"; +import { VStack } from "../src/components/v-stack.ts"; +import type { Terminal } from "../src/terminal.ts"; +import { type Component, Container, CURSOR_MARKER } from "../src/tui.ts"; +import { TuiAltScreen } from "../src/tui-alt-screen.ts"; + +const COLUMNS = 100; +const ROWS = 30; +const WARMUP_FRAMES = 20; +const FRAMES = 300; +const SAMPLING_INTERVAL = 4096; + +/** Terminal that discards output; keeps xterm parsing out of the measurement. */ +class NullTerminal implements Terminal { + bytesWritten = 0; + start(_onInput: (data: string) => void, _onResize: () => void): void {} + stop(): void {} + async drainInput(): Promise {} + write(data: string): void { + this.bytesWritten += data.length; + } + get columns(): number { + return COLUMNS; + } + get rows(): number { + return ROWS; + } + get kittyProtocolActive(): boolean { + return false; + } + moveBy(_lines: number): void {} + hideCursor(): void {} + showCursor(): void {} + clearLine(): void {} + clearFromCursor(): void {} + clearScreen(): void {} + setTitle(_title: string): void {} + setProgress(_active: boolean): void {} +} + +/** Editor stand-in: caches lines per (text, width), re-renders when text changes. */ +class EditorSim implements Component { + private text = ""; + private cachedText?: string; + private cachedWidth?: number; + private cachedLines?: string[]; + + append(char: string): void { + this.text += char; + } + + invalidate(): void { + this.cachedText = undefined; + this.cachedWidth = undefined; + this.cachedLines = undefined; + } + + render(width: number): string[] { + if (this.cachedLines && this.cachedText === this.text && this.cachedWidth === width) { + return this.cachedLines; + } + const border = `\x1b[90m${"─".repeat(Math.max(1, width - 2))}\x1b[39m`; + const lines = [border, ` > ${this.text}${CURSOR_MARKER}`, border]; + this.cachedText = this.text; + this.cachedWidth = width; + this.cachedLines = lines; + return lines; + } +} + +function buildTranscript(): Container { + const container = new Container(); + for (let i = 0; i < 150; i++) { + const styled = + i % 3 === 0 + ? `\x1b[1m\x1b[36muser ${i}\x1b[39m\x1b[22m message with some \x1b[33mstyled\x1b[39m content padding padding` + : `assistant ${i} plain response line with enough text to be representative of a transcript row`; + container.addChild(new Text(styled, 1, 0)); + } + return container; +} + +interface SamplingNode { + selfSize: number; + children: SamplingNode[]; +} + +function sumProfile(node: SamplingNode): number { + let total = node.selfSize; + for (const child of node.children) total += sumProfile(child); + return total; +} + +interface ScenarioResult { + allocatedBytes: number; + elapsedMs: number; + bytesWritten: number; +} + +async function runScenario( + session: Session, + terminal: NullTerminal, + tui: TuiAltScreen, + frame: (index: number) => void, +): Promise { + const writtenBefore = terminal.bytesWritten; + await session.post("HeapProfiler.startSampling", { + samplingInterval: SAMPLING_INTERVAL, + includeObjectsCollectedByMajorGC: true, + includeObjectsCollectedByMinorGC: true, + }); + const start = performance.now(); + for (let i = 0; i < FRAMES; i++) { + frame(i); + tui.renderNow(); + } + const elapsedMs = performance.now() - start; + const { profile } = await session.post("HeapProfiler.stopSampling"); + return { + allocatedBytes: sumProfile(profile.head as SamplingNode), + elapsedMs, + bytesWritten: terminal.bytesWritten - writtenBefore, + }; +} + +function report(name: string, result: ScenarioResult): void { + const perFrameKiB = result.allocatedBytes / FRAMES / 1024; + const totalMiB = result.allocatedBytes / 1024 / 1024; + const msPerFrame = result.elapsedMs / FRAMES; + console.log( + `${name.padEnd(8)} allocated ${totalMiB.toFixed(1).padStart(7)} MiB total ` + + `${perFrameKiB.toFixed(1).padStart(8)} KiB/frame ` + + `${msPerFrame.toFixed(3).padStart(7)} ms/frame ` + + `${(result.bytesWritten / FRAMES).toFixed(0).padStart(6)} written bytes/frame`, + ); +} + +async function main(): Promise { + const terminal = new NullTerminal(); + const tui = new TuiAltScreen(terminal, false, "/tmp/pi-tui-bench"); + + const transcript = buildTranscript(); + const editor = new EditorSim(); + const scrollView = new ScrollView(transcript, { + follow: "end", + primary: true, + overscroll: "chain", + scrollbar: "auto", + }); + const status = new Text("\x1b[2mstatus: idle\x1b[22m", 1, 0); + const footer = new Text("\x1b[2m~/workspaces/pi main 100k tokens\x1b[22m", 1, 0); + const dock = new VStack([ + { component: status, shrink: 1, minSize: 0 }, + { component: editor, shrink: 1, minSize: 3 }, + { component: footer, shrink: 1, minSize: 1 }, + ]); + const root = new VStack([ + { component: scrollView, basis: 0, grow: 1, shrink: 1, minSize: 1 }, + { component: dock, basis: "auto", grow: 0, shrink: 1, minSize: 1 }, + ]); + tui.setLayoutRoot(root); + tui.start(); + + for (let i = 0; i < WARMUP_FRAMES; i++) tui.renderNow(); + + const session = new Session(); + session.connect(); + + const staticResult = await runScenario(session, terminal, tui, () => {}); + const editorResult = await runScenario(session, terminal, tui, (i) => { + editor.append(String.fromCharCode(97 + (i % 26))); + }); + + session.disconnect(); + tui.stop(); + + console.log(`frames=${FRAMES} viewport=${COLUMNS}x${ROWS} transcript=${transcript.render(COLUMNS).length} lines`); + report("static", staticResult); + report("editor", editorResult); +} + +await main(); diff --git a/packages/pi-tui/test/stdin-buffer.test.ts b/packages/pi-tui/test/stdin-buffer.test.ts index e72c149665a..4ed8a09136a 100644 --- a/packages/pi-tui/test/stdin-buffer.test.ts +++ b/packages/pi-tui/test/stdin-buffer.test.ts @@ -7,6 +7,7 @@ import assert from "node:assert"; import { beforeEach, describe, it } from "node:test"; +import { matchesKey } from "../src/keys.ts"; import { StdinBuffer } from "../src/stdin-buffer.ts"; describe("StdinBuffer", () => { @@ -133,6 +134,62 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b[<35"]); }); + + it("should flush a lone ESC as Escape when CR arrives after the timeout", async () => { + // Legacy-mode Alt+Enter is ESC + CR; when the terminal/transport splits + // the bytes further apart than the timeout, ESC is flushed alone and the + // host sees Escape (interrupt) instead of Alt+Enter. This locks in the + // behavior so the configurable timeout in ProcessTerminal stays honest. + processInput("\x1b"); + await wait(20); // buffer timeout is 10ms in beforeEach + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true); + }); + + it("should merge ESC + CR split across chunks within a larger timeout", async () => { + buffer = new StdinBuffer({ escapeTimeout: 100 }); + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + + processInput("\x1b"); + await wait(20); // > 10ms default escapeTimeout, < 100ms configured escapeTimeout + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "alt+enter"), true); + }); + + it("does not apply the sequence timeout to a lone ESC", async () => { + buffer = new StdinBuffer({ timeout: 100 }); + emittedSequences = []; + buffer.on("data", (sequence) => { + emittedSequences.push(sequence); + }); + + processInput("\x1b"); + await wait(20); + processInput("\r"); + + assert.deepStrictEqual(emittedSequences, ["\x1b", "\r"]); + assert.equal(matchesKey(emittedSequences[0] ?? "", "escape"), true); + }); + + it("keeps fragmented mouse sequences buffered across delayed chunks by default", async () => { + const delayedBuffer = new StdinBuffer(); + const delayedSequences: string[] = []; + delayedBuffer.on("data", (sequence) => delayedSequences.push(sequence)); + + delayedBuffer.process("\x1b["); + await wait(20); + assert.deepStrictEqual(delayedSequences, []); + delayedBuffer.process("<65;48;39M"); + assert.deepStrictEqual(delayedSequences, ["\x1b[<65;48;39M"]); + delayedBuffer.destroy(); + }); }); describe("Mixed Content", () => { @@ -314,6 +371,17 @@ describe("StdinBuffer", () => { assert.deepStrictEqual(emittedSequences, ["\x1b"]); }); + it("flushes a lone escape promptly with the longer default sequence timeout", async () => { + const defaultBuffer = new StdinBuffer(); + const defaultSequences: string[] = []; + defaultBuffer.on("data", (sequence) => defaultSequences.push(sequence)); + + defaultBuffer.process("\x1b"); + await wait(20); + assert.deepStrictEqual(defaultSequences, ["\x1b"]); + defaultBuffer.destroy(); + }); + it("should handle lone escape character with explicit flush", () => { processInput("\x1b"); assert.deepStrictEqual(emittedSequences, []); diff --git a/packages/pi-tui/test/terminal.test.ts b/packages/pi-tui/test/terminal.test.ts index 06c751dbc5a..80eadd6264c 100644 --- a/packages/pi-tui/test/terminal.test.ts +++ b/packages/pi-tui/test/terminal.test.ts @@ -1,7 +1,35 @@ import assert from "node:assert"; import { describe, it, mock } from "node:test"; import { setKittyProtocolActive } from "../src/keys.ts"; -import { normalizeAppleTerminalInput, normalizeNativeShiftEnterInput, ProcessTerminal } from "../src/terminal.ts"; +import { + normalizeAppleTerminalInput, + normalizeNativeShiftEnterInput, + ProcessTerminal, + resolveEscapeTimeoutMs, +} from "../src/terminal.ts"; + +describe("resolveEscapeTimeoutMs", () => { + it("uses PI_TUI_ESC_TIMEOUT when configured", () => { + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80" }), 80); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "80", SSH_TTY: "/dev/pts/1" }), 80); + }); + + it("ignores invalid PI_TUI_ESC_TIMEOUT values", () => { + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "abc" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "0" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "-5" }), 10); + assert.equal(resolveEscapeTimeoutMs({ PI_TUI_ESC_TIMEOUT: "" }), 10); + }); + + it("defaults to 100ms over SSH", () => { + assert.equal(resolveEscapeTimeoutMs({ SSH_CONNECTION: "10.0.0.1 22" }), 100); + assert.equal(resolveEscapeTimeoutMs({ SSH_TTY: "/dev/pts/1" }), 100); + }); + + it("defaults to 10ms otherwise", () => { + assert.equal(resolveEscapeTimeoutMs({}), 10); + }); +}); describe("normalizeNativeShiftEnterInput", () => { it("rewrites Return to CSI-u Shift+Enter when native Shift detection is enabled and Shift is pressed", () => { @@ -195,7 +223,7 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { const harness = setupNegotiation(); try { harness.send("\x1b["); - mock.timers.tick(10); + mock.timers.tick(50); // StdinBuffer sequence timeout, not the lone-ESC timeout assert.equal(harness.getInput(), undefined); diff --git a/packages/pi-tui/test/tui-alt-screen.test.ts b/packages/pi-tui/test/tui-alt-screen.test.ts index ec4b020255a..827d4fb58c6 100644 --- a/packages/pi-tui/test/tui-alt-screen.test.ts +++ b/packages/pi-tui/test/tui-alt-screen.test.ts @@ -1,5 +1,6 @@ import assert from "node:assert"; import { describe, it } from "node:test"; +import { findAltScreenSearchMatches } from "../src/alt-screen-search.ts"; import { HStack } from "../src/components/h-stack.ts"; import { Image } from "../src/components/image.ts"; import { ScrollView } from "../src/components/scroll-view.ts"; @@ -379,6 +380,105 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("searches normalized rendered transcript text across rows", () => { + assert.deepStrictEqual(findAltScreenSearchMatches(["alpha QUICK", "brown fox"], "quick brown"), [ + { + segments: [ + { row: 0, startCol: 6, endCol: 11 }, + { row: 1, startCol: 0, endCol: 5 }, + ], + }, + ]); + }); + + it("uses configured styles for current and non-current search matches", async () => { + const terminal = new RecordingTerminal(60, 4); + const tui = new TuiAltScreen(terminal, undefined, undefined, { + searchMatchStyle: (text) => `\x1b[41m${text}\x1b[49m`, + searchCurrentMatchStyle: (text) => `\x1b[42m${text}\x1b[49m`, + }); + tui.addChild(new Text("needle first\nmiddle\nneedle second\nend", 0, 0)); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[102;6u"); + terminal.sendInput("needle"); + await terminal.waitForRender(); + + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[42mneedle\x1b[49m")), + ); + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[41mneedle\x1b[49m")), + ); + tui.stop(); + }); + + it("searches the transcript with Ctrl+Shift+F and restores editor focus on close", async () => { + const terminal = new RecordingTerminal(60, 8); + const tui = new TuiAltScreen(terminal); + const transcriptText = new Text( + Array.from({ length: 12 }, (_, index) => { + if (index === 4) return "line 5 needle one"; + if (index === 9) return "line 10 needle two"; + return `line ${index + 1}`; + }).join("\n"), + 0, + 0, + ); + const transcript = new ScrollView(transcriptText, { follow: "end", primary: true }); + const editorInputs: string[] = []; + const editor = { + focused: false, + render: () => ["editor"], + invalidate: () => {}, + handleInput: (data: string) => editorInputs.push(data), + }; + tui.setLayoutRoot( + new VStack([ + { component: transcript, basis: 0, grow: 1, minSize: 1 }, + { component: editor, basis: 1, shrink: 0 }, + ]), + ); + tui.setFocus(editor); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[102;6u"); + terminal.sendInput("needle"); + await terminal.waitForRender(); + assert.strictEqual(transcript.isFollowingEnd, false); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("2/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 10 needle two"))); + assert.deepStrictEqual(editorInputs, []); + assert.ok( + terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[1;7mneedle\x1b[22;27m")), + ); + + for (let index = 0; index < 6; index++) terminal.sendInput("\x1b[<64;1;4M"); + await terminal.waitForRender(); + assert.strictEqual(transcript.scrollTop, 0); + assert.ok(terminal.getViewport().some((line) => line.includes("> needle"))); + + terminal.sendInput("\x07"); + await terminal.waitForRender(); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("1/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 5 needle one"))); + + terminal.sendInput("\x1b[103;6u"); + await terminal.waitForRender(); + assert.ok(terminal.getViewport().some((line) => line.includes("Find transcript") && line.includes("2/2"))); + assert.ok(terminal.getViewport().some((line) => line.includes("line 10 needle two"))); + + terminal.sendInput("\x1b"); + terminal.sendInput("x"); + await terminal.waitForRender(); + assert.ok(!terminal.getViewport().some((line) => line.includes("Find transcript"))); + assert.deepStrictEqual(editorInputs, ["x"]); + + tui.stop(); + }); + it("scrolls the transcript by half a page with custom bindings", async () => { const originalKeybindings = getKeybindings(); const terminal = new VirtualTerminal(20, 10); @@ -441,6 +541,35 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("scrolls the transcript by one line with custom bindings", async () => { + const originalKeybindings = getKeybindings(); + const terminal = new VirtualTerminal(20, 10); + const tui = new TuiAltScreen(terminal); + setKeybindings( + new KeybindingsManager(TUI_KEYBINDINGS, { + "tui.altScreen.lineUp": "ctrl+y", + "tui.altScreen.lineDown": "ctrl+e", + }), + ); + try { + tui.addChild(new Text(Array.from({ length: 30 }, (_, index) => `line ${index + 1}`).join("\n"), 0, 0)); + tui.start(); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + + terminal.sendInput("\x19"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 19); + + terminal.sendInput("\x05"); + await terminal.waitForRender(); + assert.strictEqual(tui.viewportTop, 20); + } finally { + tui.stop(); + setKeybindings(originalKeybindings); + } + }); + it("routes Ctrl-modified viewport navigation to the focused component", async () => { const terminal = new VirtualTerminal(20, 6); const tui = new TuiAltScreen(terminal); @@ -825,7 +954,7 @@ describe("TuiAltScreen", () => { 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.addChild(new Text("\x1b[1mal\x1b[0mpha\nbeta\ngamma\ndelta", 0, 0)); tui.start(); await terminal.waitForRender(); @@ -844,8 +973,8 @@ describe("TuiAltScreen", () => { ); assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m"))); assert.ok( - terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[7m\x1b[0m\x1b[7m")), - "selection inverse must be reapplied after layout segment resets", + terminal.events.some((event) => event.type === "write" && event.data.includes("al\x1b[0m\x1b[7mpha")), + "selection inverse must be reapplied after a reset inside the selection", ); assert.ok(terminal.getViewport().some((line) => line.includes("Copied!"))); @@ -924,16 +1053,23 @@ describe("TuiAltScreen", () => { tui.stop(); }); - it("ignores orphan selection events and cancels an active selection on focus loss", async () => { + it("does not repaint idle or zero-width selections on focus loss", 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(); + const writeCount = () => terminal.events.filter((event) => event.type === "write").length; const clipboardWriteCount = () => terminal.events.filter((event) => event.type === "write" && event.data.includes("\x1b]52;c;")).length; + const idleWriteCount = writeCount(); + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(writeCount(), idleWriteCount); + // A completed click leaves a zero-width anchor, but later orphaned drag/release events must not extend it. terminal.sendInput("\x1b[<0;1;1M"); terminal.sendInput("\x1b[<0;1;1m"); @@ -942,10 +1078,14 @@ describe("TuiAltScreen", () => { await terminal.waitForRender(); assert.strictEqual(clipboardWriteCount(), 0); - // Losing focus also cancels a press whose matching release never arrived. - terminal.sendInput("\x1b[<0;1;1M"); + // Losing focus after a press without a drag cancels the press without repainting. + terminal.sendInput("\x1b[<0;1;3M"); + await terminal.waitForRender(); + const pressedWriteCount = writeCount(); terminal.sendInput("\x1b[O"); terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(writeCount(), pressedWriteCount); terminal.sendInput("\x1b[<32;4;2M"); terminal.sendInput("\x1b[<0;4;2m"); await terminal.waitForRender(); @@ -956,6 +1096,66 @@ describe("TuiAltScreen", () => { assert.ok(terminal.events.some((event) => event.type === "write" && event.data.includes("\x1b[?1004l"))); }); + it("clears an active visible selection on focus loss and ignores orphan events", 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"); + await terminal.waitForRender(); + const focusLossEventCount = terminal.events.length; + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + const focusLossWrites = terminal.events + .slice(focusLossEventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(focusLossWrites.includes("alpha")); + assert.ok(focusLossWrites.includes("beta")); + assert.ok(!focusLossWrites.includes("\x1b[7m")); + + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + assert.ok(terminal.events.every((event) => event.type !== "write" || !event.data.includes("\x1b]52;c;"))); + tui.stop(); + }); + + it("retains a completed visible selection across focus changes", 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 completedWriteCount = terminal.events.filter((event) => event.type === "write").length; + terminal.sendInput("\x1b[O"); + terminal.sendInput("\x1b[I"); + await terminal.waitForRender(); + assert.strictEqual(terminal.events.filter((event) => event.type === "write").length, completedWriteCount); + + const redrawEventCount = terminal.events.length; + tui.renderNow(true); + const redrawWrites = terminal.events + .slice(redrawEventCount) + .filter((event): event is { type: "write"; data: string } => event.type === "write") + .map((event) => event.data) + .join(""); + assert.ok(redrawWrites.includes("alpha")); + assert.ok(redrawWrites.includes("beta")); + assert.ok(redrawWrites.includes("\x1b[7m")); + tui.stop(); + }); + it("stacks flash messages and collapses them as they expire", async () => { const terminal = new VirtualTerminal(20, 4); const tui = new TuiAltScreen(terminal); From 42a4a5a7ae44877bd673e9dd950b011bdd3cc4b8 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 00:50:18 +0800 Subject: [PATCH 03/17] fix(kimi-code): keep fullscreen dock from crushing the editor box The fullscreen layout gave the transcript ScrollView its intrinsic content height as basis and let the dock participate in shrink distribution with no minSize. Once the transcript exceeded the screen, the VStack shrink pass crushed the dock to a couple of rows, and the editor (3 rows: top border / input / bottom border) lost its bottom border row to clipping. Adopt pi's sizing contract: the ScrollView starts from basis 0 and grows, the dock keeps its intrinsic height, the editor never shrinks below 3 rows, and the footer below 1. Adds a VirtualTerminal-level regression test that replays a full streaming cycle in fullscreen. --- .changeset/cli-fullscreen-dock-sizing.md | 5 + apps/kimi-code/src/tui/kimi-tui.ts | 4 +- apps/kimi-code/src/tui/tui-state.ts | 18 ++- .../test/tui/fullscreen-layout.test.ts | 129 ++++++++++++++++++ 4 files changed, 148 insertions(+), 8 deletions(-) create mode 100644 .changeset/cli-fullscreen-dock-sizing.md create mode 100644 apps/kimi-code/test/tui/fullscreen-layout.test.ts diff --git a/.changeset/cli-fullscreen-dock-sizing.md b/.changeset/cli-fullscreen-dock-sizing.md new file mode 100644 index 00000000000..d3b32896713 --- /dev/null +++ b/.changeset/cli-fullscreen-dock-sizing.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix the fullscreen layout clipping the editor's bottom border (and squeezing other dock chrome) whenever the transcript grew past the screen height: the dock now keeps its intrinsic height and the editor never shrinks below its 3 rows. diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index a14f32aea9e..b88b60a8f72 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1096,7 +1096,9 @@ export class KimiTUI { footerWrap.addChild(this.state.footer); const dock = this.state.dockContainer; if (dock !== undefined) { - dock.addChild(footerWrap); + // Dock sizing contract: the footer may shrink to 1 row under extreme + // height pressure, but never disappears (see createTUIState). + dock.addChild(footerWrap, { shrink: 1, minSize: 1 }); return; } this.state.ui.addChild(footerWrap); diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index f85a8e365ff..cf95d5a529d 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -99,6 +99,10 @@ export function createTUIState(options: KimiTUIOptions): TUIState { // Fullscreen (alternate screen): the transcript scrolls inside the primary // ScrollView while the rest of the chrome stays docked at the bottom. The // footer joins the dock later via mountFooter(). + // Sizing contract (mirrors pi's interactive layout): the transcript starts + // from basis 0 and grows; the dock keeps its intrinsic height, with the + // editor never squeezed below its 3 rows (top border / input / bottom + // border) and the footer below 1 — otherwise the box outline gets clipped. const scrollView = new ScrollView(transcriptContainer, { follow: 'end', primary: true, @@ -106,14 +110,14 @@ export function createTUIState(options: KimiTUIOptions): TUIState { scrollbar: 'auto', }); dockContainer = new VStack(); - dockContainer.addChild(activityContainer); - dockContainer.addChild(todoPanelContainer); - dockContainer.addChild(queueContainer); - dockContainer.addChild(btwPanelContainer); - dockContainer.addChild(editorContainer); + dockContainer.addChild(activityContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(todoPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(queueContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(btwPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(editorContainer, { shrink: 1, minSize: 3 }); const root = new VStack(); - root.addChild(scrollView, { grow: 1, shrink: 1 }); - root.addChild(dockContainer, { basis: 'auto' }); + root.addChild(scrollView, { basis: 0, grow: 1, shrink: 1, minSize: 1 }); + root.addChild(dockContainer, { basis: 'auto', grow: 0, shrink: 1, minSize: 1 }); ui.setLayoutRoot(root); } diff --git a/apps/kimi-code/test/tui/fullscreen-layout.test.ts b/apps/kimi-code/test/tui/fullscreen-layout.test.ts new file mode 100644 index 00000000000..4e94337b6b4 --- /dev/null +++ b/apps/kimi-code/test/tui/fullscreen-layout.test.ts @@ -0,0 +1,129 @@ +/** + * Fullscreen layout contract tests: the docked chrome must keep the editor's + * full height (top border / input / bottom border) even when the transcript + * far exceeds the screen. Regression: the dock used to participate in VStack + * shrink distribution with no minSize, so a tall transcript crushed it and + * the editor's bottom border row was clipped off screen. + */ +import { describe, expect, it } from 'vitest'; + +import { Spacer, type Terminal, TuiAltScreen } from '@moonshot-ai/pi-tui'; +import { VirtualTerminal } from '../../../../packages/pi-tui/test/virtual-terminal'; + +import { GutterContainer } from '#/tui/components/chrome/gutter-container'; +import { MoonLoader } from '#/tui/components/chrome/moon-loader'; +import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; +import { StatusMessageComponent } from '#/tui/components/messages/status-message'; +import { UserMessageComponent } from '#/tui/components/messages/user-message'; +import { ActivityPaneComponent } from '#/tui/components/panes/activity-pane'; +import { CHROME_GUTTER } from '#/tui/constant/rendering'; +import { createTUIState, type KimiTUIOptions } from '#/tui/kimi-tui'; +import type { AppState } from '#/tui/types'; + +const WIDTH = 120; +const HEIGHT = 30; + +function fakeInitialAppState(): AppState { + return { + model: 'test-model', + workDir: '/tmp/kimi-test', + additionalDirs: [], + sessionId: 'sess-1', + permissionMode: 'manual', + planMode: false, + inputMode: 'prompt', + swarmMode: false, + thinkingEffort: 'off', + contextUsage: 0, + contextTokens: 0, + maxContextTokens: 0, + isCompacting: false, + isReplaying: false, + streamingPhase: 'idle', + streamingStartTime: 0, + theme: 'dark', + version: '0.0.0-test', + editorCommand: null, + notifications: { enabled: true, condition: 'unfocused' }, + upgrade: { autoInstall: true }, + availableModels: {}, + availableProviders: {}, + sessionTitle: null, + mcpServersSummary: null, + }; +} + +function stripAnsi(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07]*\x07/g, ''); +} + +const LONG_MARKDOWN = Array.from( + { length: 40 }, + (_, i) => `### Section ${i + 1}\n\nSome **bold** and \`code\` content in paragraph ${i + 1}.\n`, +).join('\n'); + +async function mountFullscreen(): Promise<{ + state: ReturnType; + vt: VirtualTerminal; +}> { + const opts: KimiTUIOptions = { + initialAppState: { ...fakeInitialAppState(), tuiMode: 'fullscreen' }, + startup: { continueLast: false, yolo: false, auto: false, plan: false }, + }; + const state = createTUIState(opts); + const vt = new VirtualTerminal(WIDTH, HEIGHT); + (state.ui as { terminal: Terminal }).terminal = vt; + + // Footer is mounted into the dock after init (mirrors mountFooter()). + const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + footerWrap.addChild(state.footer); + state.dockContainer?.addChild(footerWrap, { shrink: 1, minSize: 1 }); + state.editorContainer.addChild(state.editor); + state.ui.setFocus(state.editor); + state.ui.start(); + await vt.waitForRender(); + return { state, vt }; +} + +describe('fullscreen layout', () => { + it('keeps the editor bottom border visible after a streaming grow/shrink cycle', async () => { + const { state, vt } = await mountFullscreen(); + expect(state.ui).toBeInstanceOf(TuiAltScreen); + + const screenRows = (): string[] => { + const rows: string[] = []; + for (let i = 0; i < HEIGHT; i++) rows.push(stripAnsi(vt.getViewport()[i] ?? '').trimEnd()); + return rows; + }; + + // User message, then a streaming assistant message with the activity pane up. + state.transcriptContainer.addChild(new UserMessageComponent('分析下这个项目')); + const spinner = new MoonLoader(state.ui); + state.activityContainer.addChild( + new ActivityPaneComponent({ mode: 'tool', spinner, tip: 'streaming' }), + ); + const assistant = new AssistantMessageComponent(); + state.transcriptContainer.addChild(assistant); + assistant.updateContent(LONG_MARKDOWN, { transient: true }); + state.ui.requestRender(true); + await vt.waitForRender(); + + // Streaming ends: final highlight, spinner -> one-row placeholder, debug line. + assistant.updateContent(LONG_MARKDOWN, { transient: false }); + state.activityContainer.clear(); + state.activityContainer.addChild(new Spacer(1)); + state.transcriptContainer.addChild( + new StatusMessageComponent('[Debug] TTFT: 4.3s | TPS: 203 tok/s'), + ); + state.ui.requestRender(true); + await vt.waitForRender(); + + const rows = screenRows(); + const promptRow = rows.findIndex((line) => /│\s*>/.test(line)); + expect(promptRow).toBeGreaterThan(0); + expect(rows[promptRow + 1]).toContain('╰'); + + state.ui.stop(); + }); +}); From d3d388e981061b0f8f5130ecfc6d6e7a70b224dd Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 00:57:26 +0800 Subject: [PATCH 04/17] docs(kimi-code): document the tui_mode preference in tui.toml --- .changeset/cli-tui-mode-fullscreen.md | 2 +- docs/en/configuration/config-files.md | 2 ++ docs/zh/configuration/config-files.md | 2 ++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/cli-tui-mode-fullscreen.md b/.changeset/cli-tui-mode-fullscreen.md index f8504ff983d..7056791a549 100644 --- a/.changeset/cli-tui-mode-fullscreen.md +++ b/.changeset/cli-tui-mode-fullscreen.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add a `tui_mode` preference for a fullscreen alternate-screen UI: the transcript becomes a scrollable viewport with mouse wheel scrolling, text selection, a scrollbar, and transcript search (Ctrl+Shift+F), while the editor and chrome stay docked at the bottom. Set `tui_mode = "fullscreen"` in ~/.kimi-code/tui.toml and restart. +Add a `tui_mode` preference for a fullscreen alternate-screen UI: the transcript becomes a scrollable viewport with mouse wheel scrolling, text selection, a scrollbar, and transcript search (Ctrl-Shift-F), while the editor and chrome stay docked at the bottom. Set `tui_mode = "fullscreen"` in ~/.kimi-code/tui.toml and restart. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 27f5f03f366..c468b6dc1b1 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -428,6 +428,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | Field | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, `light`, or the name of a [custom theme](../customization/themes.md) | +| `tui_mode` | `string` | `regular` | UI renderer: `regular` (inline viewport) or `fullscreen` (alternate-screen UI with a scrollable transcript viewport, mouse text selection, a scrollbar, and Ctrl-Shift-F transcript search; applies on next start) | | `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line | | `cache_expiry_hint` | `boolean` | `true` | Show a dialog when resuming a long-idle session or submitting after a long idle stretch, warning that the context cache has likely expired and offering to compact or start a new session (v2 engine only) | | `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` | @@ -440,6 +441,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c ```toml # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | custom theme name +tui_mode = "regular" # "regular" | "fullscreen" (alternate-screen UI; applies on next start) disable_paste_burst = false # true disables non-bracketed paste-burst fallback cache_expiry_hint = true # false disables the "cache expired" dialog on resume / idle submit diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index f102efba0f8..233951800b3 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -428,6 +428,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark`、`light`,或[自定义主题](../customization/themes.md)的名字 | +| `tui_mode` | `string` | `regular` | 界面渲染模式:`regular`(内联视口)或 `fullscreen`(alternate-screen 全屏界面:transcript 为可滚动视口,支持鼠标选择、滚动条与 Ctrl-Shift-F 搜索;重启后生效) | | `disable_paste_burst` | `boolean` | `false` | 禁用非 bracketed paste 的粘贴突发兜底;默认开启,避免快速多行粘贴被逐行提交 | | `cache_expiry_hint` | `boolean` | `true` | resume 长时间未活动的会话、或长时间空闲后发送消息时,若上下文缓存可能已过期则弹出提醒,可选择先压缩或新建会话(仅 v2 引擎) | | `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` | @@ -440,6 +441,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod ```toml # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | 自定义主题名 +tui_mode = "regular" # "regular" | "fullscreen"(alternate-screen 全屏界面,重启后生效) disable_paste_burst = false # true 表示禁用非 bracketed paste 的粘贴突发兜底 cache_expiry_hint = true # false 表示关闭 resume / 空闲提交时的"缓存已过期"提醒弹窗 From 488e622974865802aa354bdd4ace787c5de7a5ec Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 13:45:38 +0800 Subject: [PATCH 05/17] fix(pi-tui): let terminal focus reports fan out in fullscreen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TuiAltScreen's viewport input listener consumed FOCUS_IN/FOCUS_OUT reports. Since the renderer installs that listener at construction — before any app-level listeners — terminal focus tracking and clipboard-image hints never saw focus transitions in fullscreen mode (notification_condition = "unfocused" went blind, refocus clipboard hints stopped). Keep the selection cleanup but stop consuming, matching the main-screen fan-out. Addresses Codex review on PR #2830. --- .changeset/pi-tui-alt-screen-groundwork.md | 2 +- packages/pi-tui/src/tui-alt-screen.ts | 7 +++++-- packages/pi-tui/test/tui-alt-screen.test.ts | 23 +++++++++++++++++++++ 3 files changed, 29 insertions(+), 3 deletions(-) diff --git a/.changeset/pi-tui-alt-screen-groundwork.md b/.changeset/pi-tui-alt-screen-groundwork.md index 7619f5df01e..2a98bff37d1 100644 --- a/.changeset/pi-tui-alt-screen-groundwork.md +++ b/.changeset/pi-tui-alt-screen-groundwork.md @@ -2,4 +2,4 @@ "@moonshot-ai/pi-tui": patch --- -Add fullscreen groundwork to the alternate-screen renderer: `ScrollView.canScroll`, `TuiAltScreen.getLayoutRoot()`, and viewport navigation keys (PageUp/PageDown/Home/End and friends) now fall through to the focused component when the primary scroll view has nothing to scroll. +Add fullscreen groundwork to the alternate-screen renderer: `ScrollView.canScroll`, `TuiAltScreen.getLayoutRoot()`, and viewport navigation keys (PageUp/PageDown/Home/End and friends) now fall through to the focused component when the primary scroll view has nothing to scroll. Terminal focus in/out reports are no longer consumed by the viewport input handler, so app-level listeners (focus-aware notifications, clipboard-image hints) keep working in fullscreen. diff --git a/packages/pi-tui/src/tui-alt-screen.ts b/packages/pi-tui/src/tui-alt-screen.ts index 211680dc068..4acaf77c323 100644 --- a/packages/pi-tui/src/tui-alt-screen.ts +++ b/packages/pi-tui/src/tui-alt-screen.ts @@ -547,9 +547,12 @@ export class TuiAltScreen extends TuiBase implements ViewportTUI { if (hadNonEmptyActiveSelection) this.requestRender(); } this.lastClick = undefined; - return { consume: true }; + // Do not consume focus reports: app-level input listeners (terminal + // focus tracking for notifications, clipboard-image hints) rely on + // them too, and the main-screen renderer lets them through. + return undefined; } - if (data === FOCUS_IN) return { consume: true }; + if (data === FOCUS_IN) return undefined; const wheelEvent = this.parseWheelEvent(data); if (wheelEvent) { diff --git a/packages/pi-tui/test/tui-alt-screen.test.ts b/packages/pi-tui/test/tui-alt-screen.test.ts index 827d4fb58c6..69c494e4a53 100644 --- a/packages/pi-tui/test/tui-alt-screen.test.ts +++ b/packages/pi-tui/test/tui-alt-screen.test.ts @@ -541,6 +541,29 @@ describe("TuiAltScreen", () => { tui.stop(); }); + it("lets focus reports reach app-level input listeners", async () => { + const terminal = new VirtualTerminal(20, 10); + const tui = new TuiAltScreen(terminal); + tui.addChild(new Text("content", 0, 0)); + // App-level listeners install after the renderer's own viewport listener; + // focus reports must still fan out to them (the main-screen path lets them + // through, and notification/clipboard features depend on it). + const seenByApp: string[] = []; + tui.addInputListener((data) => { + seenByApp.push(data); + return undefined; + }); + tui.start(); + await terminal.waitForRender(); + + terminal.sendInput("\x1b[O"); // FOCUS_OUT + terminal.sendInput("\x1b[I"); // FOCUS_IN + await terminal.waitForRender(); + assert.deepStrictEqual(seenByApp, ["\x1b[O", "\x1b[I"]); + + tui.stop(); + }); + it("scrolls the transcript by one line with custom bindings", async () => { const originalKeybindings = getKeybindings(); const terminal = new VirtualTerminal(20, 10); From a80c31608cda22288888a4ec10e5a7d45bdd98b4 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 13:54:36 +0800 Subject: [PATCH 06/17] fix(kimi-code): wire openUrl and right-click paste in fullscreen Mouse capture in the alternate screen intercepts the terminal's native link activation, leaving OSC 8 hyperlinks (like the footer's PR link) unclickable in fullscreen. Route renderer link clicks to the app's openUrl, and on Windows feed right-clicks to the focused component as a bracketed paste read from the clipboard. --- .changeset/cli-tui-mode-fullscreen.md | 2 +- apps/kimi-code/src/tui/tui-state.ts | 27 ++++++++++++++++++- .../test/tui/create-tui-state.test.ts | 9 +++++++ 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/.changeset/cli-tui-mode-fullscreen.md b/.changeset/cli-tui-mode-fullscreen.md index 7056791a549..82d919918f0 100644 --- a/.changeset/cli-tui-mode-fullscreen.md +++ b/.changeset/cli-tui-mode-fullscreen.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add a `tui_mode` preference for a fullscreen alternate-screen UI: the transcript becomes a scrollable viewport with mouse wheel scrolling, text selection, a scrollbar, and transcript search (Ctrl-Shift-F), while the editor and chrome stay docked at the bottom. Set `tui_mode = "fullscreen"` in ~/.kimi-code/tui.toml and restart. +Add a `tui_mode` preference for a fullscreen alternate-screen UI: the transcript becomes a scrollable viewport with mouse wheel scrolling, text selection, clickable links, a scrollbar, and transcript search (Ctrl-Shift-F), while the editor and chrome stay docked at the bottom. Set `tui_mode = "fullscreen"` in ~/.kimi-code/tui.toml and restart. diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index cf95d5a529d..8ddfa21ea2c 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -8,6 +8,9 @@ import { type TUI, } from '@moonshot-ai/pi-tui'; +import { clipboard } from '#/utils/clipboard/clipboard-native'; +import { openUrl } from '#/utils/open-url'; + import { FooterComponent } from './components/chrome/footer'; import { GutterContainer } from './components/chrome/gutter-container'; import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader'; @@ -78,7 +81,29 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const terminal = new ProcessTerminal(); const tuiMode = initialAppState.tuiMode ?? DEFAULT_TUI_CONFIG.tuiMode ?? 'regular'; - const ui = tuiMode === 'fullscreen' ? new TuiAltScreen(terminal) : new TuiMainScreen(terminal); + const ui = + tuiMode === 'fullscreen' + ? new TuiAltScreen(terminal, undefined, undefined, { + // Mouse capture takes over the terminal's native link activation, so + // route OSC 8 clicks through our own opener. + openUrl, + // Likewise, on Windows the terminal's native right-click paste is + // intercepted; feed the clipboard to the focused component as a + // bracketed paste instead (renderer only calls this on win32). + onRightClickPaste: () => { + const target = ui.getFocusedComponent(); + if (!target?.handleInput || clipboard?.getText === undefined) return; + void clipboard + .getText() + .then((text) => { + if (!text || ui.getFocusedComponent() !== target) return; + target.handleInput?.(`\x1b[200~${text}\x1b[201~`); + ui.requestRender(); + }) + .catch(() => {}); + }, + }) + : new TuiMainScreen(terminal); const transcriptContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 63b4216b990..1a8388ab635 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -131,5 +131,14 @@ describe('createTUIState', () => { // The layout root is mounted and the root children list stays empty. expect((state.ui as TuiAltScreen).getLayoutRoot()).toBeDefined(); expect(state.ui.children).toHaveLength(0); + + // Mouse capture replaces native terminal link activation / right-click + // paste, so both must be routed through renderer callbacks. + const internals = state.ui as unknown as { + openUrl?: (url: string) => void; + onRightClickPaste?: () => void; + }; + expect(typeof internals.openUrl).toBe('function'); + expect(typeof internals.onRightClickPaste).toBe('function'); }); }); From 6ffc65f98cb711f139bd0fcc8dfb6c1b0c62e497 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 14:45:38 +0800 Subject: [PATCH 07/17] feat(kimi-code): fullscreen prompt navigation, exit replay, progress resync - Mark user/assistant transcript messages with OSC 133 zones (start / end / final) so the fullscreen renderer's Ctrl-Shift-Up/Down prompt jumps work; GutterContainer keeps the markers at byte 0 when prefixing its gutter, and message render caches store already-marked lines. - On exit from fullscreen, preserve the frame and replay the transcript through a fresh main-screen renderer so native scrollback gets the regular inline layout (pi's "transcript" exit form). - Re-sync the OSC 9;4 progress indicator after a stop/start cycle: terminal.stop() clears it, and the cached progressActive flag used to suppress the re-send when returning from the external editor mid-turn. --- .changeset/cli-fullscreen-osc133-and-exit.md | 5 +++ .../tui/components/chrome/gutter-container.ts | 5 ++- .../components/messages/assistant-message.ts | 3 +- .../tui/components/messages/user-message.ts | 21 +++++----- apps/kimi-code/src/tui/constant/rendering.ts | 9 +++++ .../src/tui/controllers/editor-keyboard.ts | 6 +++ apps/kimi-code/src/tui/kimi-tui.ts | 30 ++++++++++++++- apps/kimi-code/src/tui/utils/osc133.ts | 34 +++++++++++++++++ .../chrome/gutter-container.test.ts | 11 ++++++ .../messages/assistant-message.test.ts | 16 +++++++- .../components/messages/user-message.test.ts | 16 +++++++- .../test/tui/fullscreen-layout.test.ts | 38 +++++++++++++++++++ 12 files changed, 180 insertions(+), 14 deletions(-) create mode 100644 .changeset/cli-fullscreen-osc133-and-exit.md create mode 100644 apps/kimi-code/src/tui/utils/osc133.ts diff --git a/.changeset/cli-fullscreen-osc133-and-exit.md b/.changeset/cli-fullscreen-osc133-and-exit.md new file mode 100644 index 00000000000..5562a2f7fe3 --- /dev/null +++ b/.changeset/cli-fullscreen-osc133-and-exit.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fullscreen refinements: user and assistant messages now carry OSC 133 zone markers, so Ctrl-Shift-Up/Down jumps between prompts in the transcript viewport; exiting fullscreen replays the transcript into native scrollback in the regular inline layout; the terminal progress indicator is restored when returning from the external editor mid-turn. diff --git a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts index ed19793af55..43e076ec01f 100644 --- a/apps/kimi-code/src/tui/components/chrome/gutter-container.ts +++ b/apps/kimi-code/src/tui/components/chrome/gutter-container.ts @@ -18,6 +18,7 @@ import { Container } from '@moonshot-ai/pi-tui'; import type { Component } from '@moonshot-ai/pi-tui'; +import { prefixPreservingOsc133Zone } from '#/tui/utils/osc133'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; interface TranscriptRenderCache { @@ -68,7 +69,9 @@ export class GutterContainer extends Container { prefixed.push(cache.prefixed[i]!); } else { allReused = false; - prefixed.push(lines.map((line) => lead + line)); + // OSC 133 zone markers must stay at byte 0 for the fullscreen + // renderer's prompt navigation, so the gutter goes after them. + prefixed.push(lines.map((line) => prefixPreservingOsc133Zone(line, lead))); } i++; } diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index fa7e109a768..5e460e2c2cd 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -11,6 +11,7 @@ import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { markOsc133Zone } from '#/tui/utils/osc133'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; type AssistantMarkdownOptions = { @@ -118,7 +119,7 @@ export class AssistantMessageComponent implements Component { i === 0 && this.showBullet ? currentTheme.fg('text', STATUS_BULLET) : MESSAGE_INDENT; lines.push(p + contentLines[i]); } - const rendered = lines.map((line) => truncateToWidth(line, safeWidth, '…')); + const rendered = markOsc133Zone(lines.map((line) => truncateToWidth(line, safeWidth, '…'))); if (isRenderCacheEnabled()) { this.renderCache = { width: safeWidth, lines: rendered }; } diff --git a/apps/kimi-code/src/tui/components/messages/user-message.ts b/apps/kimi-code/src/tui/components/messages/user-message.ts index e7241e963a3..4e61ab15c6a 100644 --- a/apps/kimi-code/src/tui/components/messages/user-message.ts +++ b/apps/kimi-code/src/tui/components/messages/user-message.ts @@ -8,6 +8,7 @@ import { ImageThumbnail } from '#/tui/components/media/image-thumbnail'; import { USER_MESSAGE_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; +import { markOsc133Zone } from '#/tui/utils/osc133'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; export class UserMessageComponent implements Component { @@ -77,15 +78,17 @@ export class UserMessageComponent implements Component { } } - const rendered = lines.map((line) => { - // Inline image sequences (Kitty / iTerm2) carry their own placement - // information and have zero visible width, but pi-tui's truncateToWidth - // treats the embedded base64 payload as visible text and would chop the - // escape sequence in half, leaving garbage like "0m...". Skip truncation - // for those lines; the image itself already respects maxWidthCells. - if (isImageLine(line)) return line; - return truncateToWidth(line, safeWidth, '…'); - }); + const rendered = markOsc133Zone( + lines.map((line) => { + // Inline image sequences (Kitty / iTerm2) carry their own placement + // information and have zero visible width, but pi-tui's truncateToWidth + // treats the embedded base64 payload as visible text and would chop the + // escape sequence in half, leaving garbage like "0m...". Skip truncation + // for those lines; the image itself already respects maxWidthCells. + if (isImageLine(line)) return line; + return truncateToWidth(line, safeWidth, '…'); + }), + ); if (isRenderCacheEnabled()) { this.renderCache = { width: safeWidth, lines: rendered }; } diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index a6a1c6b7d4b..8405c7c0081 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -1,6 +1,15 @@ // Continuation indent for transcript rows that use a two-cell leading marker. export const MESSAGE_INDENT = ' '; +// OSC 133 semantic-zone markers (FinalTerm/shell-integration protocol): +// zero-width escape sequences prefixed onto the first/last rendered line of +// transcript messages. The fullscreen renderer strips them at paint and uses +// the A marker for previous/next-prompt navigation (Ctrl-Shift-Up/Down); in +// regular mode they pass through to native scrollback invisibly. +export const OSC133_ZONE_START = '\x1b]133;A\x07'; +export const OSC133_ZONE_END = '\x1b]133;B\x07'; +export const OSC133_ZONE_FINAL = '\x1b]133;C\x07'; + // Outer left/right padding applied to the transcript, panels, and the // statusline so the chrome's left edge lines up with the input box's // interior (the `>` prompt). The editor itself stays at column 0 — its diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index bc7f3890920..dc86d312a5c 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -57,6 +57,7 @@ export interface EditorKeyboardHost { handleInputModeChange(mode: 'prompt' | 'bash'): void; clearQueuedMessages(): void; setExternalEditorRunning(running: boolean): void; + updateActivityPane(): void; } export class EditorKeyboardController { @@ -547,6 +548,11 @@ export class EditorKeyboardController { state.ui.start(); state.ui.setFocus(state.editor); state.ui.requestRender(true); + // terminal.stop() cleared the OSC 9;4 progress indicator while the + // app-side progressActive flag still reads true; resync so a turn that + // was streaming while the editor was open gets its progress back. + state.terminalState.progressActive = false; + this.host.updateActivityPane(); this.host.setExternalEditorRunning(false); } } diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index b88b60a8f72..fc734e0c04c 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -25,6 +25,7 @@ import { getCapabilities, Spacer, TuiAltScreen, + TuiMainScreen, } from '@moonshot-ai/pi-tui'; import { resolve } from 'pathe'; @@ -988,7 +989,7 @@ export class KimiTUI { // best effort — the terminal may already be dead (SIGHUP / EIO). } try { - this.state.ui.stop(); + this.stopUiForExit(); } catch { // best effort terminal restore. } @@ -1104,6 +1105,33 @@ export class KimiTUI { this.state.ui.addChild(footerWrap); } + // Fullscreen exit: leave the alternate screen with the frame preserved, + // then replay the transcript through a main-screen renderer so native + // scrollback ends up with the same inline layout a regular session would + // have produced (pi's "transcript" exit form). + private stopUiForExit(): void { + const ui = this.state.ui; + if (!(ui instanceof TuiAltScreen)) { + ui.stop(); + return; + } + ui.stop({ preserveScreen: true }); + const main = new TuiMainScreen(ui.terminal); + main.addChild(this.state.transcriptContainer); + main.addChild(this.state.activityContainer); + main.addChild(this.state.todoPanelContainer); + main.addChild(this.state.queueContainer); + main.addChild(this.state.btwPanelContainer); + main.addChild(this.state.editorContainer); + const footerWrap = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + footerWrap.addChild(this.state.footer); + main.addChild(footerWrap); + // First paint of a main-screen renderer writes every line sequentially, + // landing the whole transcript in native scrollback. + main.renderNow(); + main.stop(); + } + // ========================================================================= // Input Dispatch // ========================================================================= diff --git a/apps/kimi-code/src/tui/utils/osc133.ts b/apps/kimi-code/src/tui/utils/osc133.ts new file mode 100644 index 00000000000..3273fe15aa2 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/osc133.ts @@ -0,0 +1,34 @@ +/** + * OSC 133 zone marking for transcript messages. The fullscreen renderer + * anchors previous/next-prompt navigation on lines whose first bytes are an + * OSC 133;A zone marker (and strips the markers at paint), so the marks must + * survive every container between the message component and the ScrollView. + */ + +import { + OSC133_ZONE_END, + OSC133_ZONE_FINAL, + OSC133_ZONE_START, +} from '#/tui/constant/rendering'; + +// One or more consecutive A/B/C zone markers anchored at the line start. +const OSC133_ZONE_PREFIX = /^(?:\x1b\]133;[ABC](?:\x07|\x1b\\))+/; + +/** + * Mark a message's rendered lines as a semantic zone: A on the first line, + * B+C on the last. Mutates and returns the given array — call it on freshly + * built lines before handing them to a render cache (cached lines then + * already carry the marks, so they are never marked twice). + */ +export function markOsc133Zone(lines: string[]): string[] { + if (lines.length === 0) return lines; + lines[0] = OSC133_ZONE_START + lines[0]!; + lines[lines.length - 1] = OSC133_ZONE_END + OSC133_ZONE_FINAL + lines[lines.length - 1]!; + return lines; +} + +/** Prefix a rendered line while keeping any leading OSC 133 zone at byte 0. */ +export function prefixPreservingOsc133Zone(line: string, prefix: string): string { + const zone = OSC133_ZONE_PREFIX.exec(line)?.[0]; + return zone === undefined ? prefix + line : zone + prefix + line.slice(zone.length); +} diff --git a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts index 295363a74d8..e62c4c25adc 100644 --- a/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts +++ b/apps/kimi-code/test/tui/components/chrome/gutter-container.test.ts @@ -54,4 +54,15 @@ describe('GutterContainer', () => { c.addChild(new FakeChild(() => [colored])); expect(c.render(20)).toEqual([` ${colored}`]); }); + + it('keeps a leading OSC 133 zone marker at byte 0, before the gutter', () => { + const c = new GutterContainer(2, 2); + const marked = `\x1b]133;A\x07content`; + const doubleMarked = `\x1b]133;B\x07\x1b]133;C\x07last`; + c.addChild(new FakeChild(() => [marked, doubleMarked])); + expect(c.render(20)).toEqual([ + `\x1b]133;A\x07 content`, + `\x1b]133;B\x07\x1b]133;C\x07 last`, + ]); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index e078e6dd2ab..c64f54ae268 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -17,7 +17,9 @@ vi.mock('cli-highlight', async () => { }); function strip(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(/\u001B\]133;[ABC]\u0007/g, ''); } describe('AssistantMessageComponent', () => { @@ -125,4 +127,16 @@ describe('AssistantMessageComponent', () => { finalTheme.highlightCode?.(code, 'typescript'); expect(highlightSpy).toHaveBeenCalled(); }); + + it('marks the rendered zone with OSC 133 markers, once across cache hits', () => { + const component = new AssistantMessageComponent(); + component.updateContent('hello'); + + const lines = component.render(80); + expect(lines[0]).toMatch(/^\u001B\]133;A\u0007/); + expect(lines[lines.length - 1]).toMatch(/^\u001B\]133;B\u0007\u001B\]133;C\u0007/); + + const cached = component.render(80); + expect(cached[0]).toBe(lines[0]); + }); }); diff --git a/apps/kimi-code/test/tui/components/messages/user-message.test.ts b/apps/kimi-code/test/tui/components/messages/user-message.test.ts index e6a10a05c07..7f8a1d1aec0 100644 --- a/apps/kimi-code/test/tui/components/messages/user-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/user-message.test.ts @@ -5,7 +5,9 @@ import { UserMessageComponent } from '#/tui/components/messages/user-message'; import type { ImageAttachment } from '#/tui/utils/image-attachment-store'; function stripAnsi(text: string): string { - return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); + return text + .replaceAll(/\u001B\[[0-9;]*m/g, '') + .replaceAll(/\u001B\]133;[ABC]\u0007/g, ''); } describe('UserMessageComponent', () => { @@ -104,4 +106,16 @@ describe('UserMessageComponent', () => { // The `$` sits at the leading column where the bullet used to be. expect(contentLine?.startsWith('$ ls')).toBe(true); }); + + it('marks the rendered zone with OSC 133 markers, once across cache hits', () => { + setCapabilities({ images: null, trueColor: true, hyperlinks: true }); + const component = new UserMessageComponent('hello', []); + + const lines = component.render(80); + expect(lines[0]).toMatch(/^\u001B\]133;A\u0007/); + expect(lines[lines.length - 1]).toMatch(/^\u001B\]133;B\u0007\u001B\]133;C\u0007/); + + const cached = component.render(80); + expect(cached[0]).toBe(lines[0]); + }); }); diff --git a/apps/kimi-code/test/tui/fullscreen-layout.test.ts b/apps/kimi-code/test/tui/fullscreen-layout.test.ts index 4e94337b6b4..19045449a75 100644 --- a/apps/kimi-code/test/tui/fullscreen-layout.test.ts +++ b/apps/kimi-code/test/tui/fullscreen-layout.test.ts @@ -126,4 +126,42 @@ describe('fullscreen layout', () => { state.ui.stop(); }); + + it('jumps between prompts with Ctrl-Shift-Up/Down (OSC 133 zones survive the chain)', async () => { + const { state, vt } = await mountFullscreen(); + + state.transcriptContainer.addChild(new UserMessageComponent('第一轮提问')); + const first = new AssistantMessageComponent(); + state.transcriptContainer.addChild(first); + first.updateContent(`回答一\n\n${LONG_MARKDOWN}`); + state.transcriptContainer.addChild(new UserMessageComponent('第二轮提问')); + const second = new AssistantMessageComponent(); + state.transcriptContainer.addChild(second); + second.updateContent(`回答二\n\n${LONG_MARKDOWN}`); + state.ui.requestRender(true); + await vt.waitForRender(); + + const alt = state.ui as TuiAltScreen; + expect(alt.isFollowingOutput).toBe(true); + + const topRows = (): string[] => + Array.from({ length: 6 }, (_, i) => stripAnsi(vt.getViewport()[i] ?? '').trimEnd()); + + // Zones anchor every user/assistant message, so the nearest previous zone + // below the fold is the current turn's assistant message, then the user + // message that started the turn. + vt.sendInput('\x1b[1;6A'); // ctrl+shift+up = previous prompt + await vt.waitForRender(); + expect(topRows()[1]).toContain('回答二'); + + vt.sendInput('\x1b[1;6A'); + await vt.waitForRender(); + expect(topRows()[1]).toContain('第二轮提问'); + + vt.sendInput('\x1b[1;6B'); // ctrl+shift+down = next prompt + await vt.waitForRender(); + expect(topRows()[1]).toContain('回答二'); + + state.ui.stop(); + }); }); From 4b1cae2131c7080dc28f34cc45ed72957bd7e606 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 15:11:17 +0800 Subject: [PATCH 08/17] feat(kimi-code): enable Markdown LaTeX rendering with a render_latex opt-out Align with the upstream pi-tui default: LaTeX math in Markdown messages renders as Unicode text. The explicit renderLatex:false we set during the re-baseline becomes a shared Markdown options helper fed by a new tui.toml preference (render_latex, default true), wired at startup and refreshed on /reload. --- .changeset/cli-markdown-latex.md | 5 +++++ apps/kimi-code/src/tui/commands/plugins.ts | 5 ++--- apps/kimi-code/src/tui/commands/reload.ts | 3 +++ .../components/messages/assistant-message.ts | 14 +++++++++---- .../src/tui/components/messages/plan-box.ts | 3 ++- .../src/tui/components/panes/btw-panel.ts | 5 ++--- apps/kimi-code/src/tui/config.ts | 7 +++++++ apps/kimi-code/src/tui/kimi-tui.ts | 1 + apps/kimi-code/src/tui/tui-state.ts | 5 +++-- apps/kimi-code/src/tui/types.ts | 2 ++ .../src/tui/utils/markdown-options.ts | 21 +++++++++++++++++++ .../messages/assistant-message.test.ts | 16 ++++++++++++++ apps/kimi-code/test/tui/config.test.ts | 13 ++++++++++++ docs/en/configuration/config-files.md | 2 ++ docs/zh/configuration/config-files.md | 2 ++ 15 files changed, 91 insertions(+), 13 deletions(-) create mode 100644 .changeset/cli-markdown-latex.md create mode 100644 apps/kimi-code/src/tui/utils/markdown-options.ts diff --git a/.changeset/cli-markdown-latex.md b/.changeset/cli-markdown-latex.md new file mode 100644 index 00000000000..9872d61d949 --- /dev/null +++ b/.changeset/cli-markdown-latex.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Render LaTeX math expressions in Markdown messages as Unicode text, matching the upstream pi-tui default. Set `render_latex = false` in ~/.kimi-code/tui.toml to keep the raw source. diff --git a/apps/kimi-code/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index e9be7ec9c27..f27652b8674 100644 --- a/apps/kimi-code/src/tui/commands/plugins.ts +++ b/apps/kimi-code/src/tui/commands/plugins.ts @@ -29,6 +29,7 @@ import { import { UsagePanelComponent } from '../components/messages/usage-panel'; import { createMarkdownTheme } from '../theme/pi-tui-theme'; import { formatErrorMessage } from '../utils/event-payload'; +import { createMarkdownOptions } from '../utils/markdown-options'; import { formatPluginSourceLabel, isOfficialPluginInstall, @@ -566,9 +567,7 @@ async function installCapabilityFromPanel( host.showNotice(`${label} is installed.`); host.state.transcriptContainer.addChild(new Spacer(1)); host.state.transcriptContainer.addChild( - new Markdown(WEBBRIDGE_POST_INSTALL_MARKDOWN, 2, 0, createMarkdownTheme(), undefined, { - renderLatex: false, - }), + new Markdown(WEBBRIDGE_POST_INSTALL_MARKDOWN, 2, 0, createMarkdownTheme(), undefined, createMarkdownOptions()), ); host.state.ui.requestRender(); return; diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 482b852ff3f..94029ee058d 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -2,6 +2,7 @@ import type { KimiConfig } from '@moonshot-ai/kimi-code-sdk'; import { currentTheme, lightColors } from '#/tui/theme'; import { loadTuiConfig, type TuiConfig } from '../config'; +import { setMarkdownRenderLatex } from '../utils/markdown-options'; import type { SlashCommandHost } from './dispatch'; import { setExperimentalFeatures } from './experimental-flags'; @@ -63,12 +64,14 @@ export async function applyReloadedTuiConfig( host.setAppState({ editorCommand: config.editorCommand, disablePasteBurst: config.disablePasteBurst, + renderLatex: config.renderLatex, cacheExpiryHint: config.cacheExpiryHint, notifications: config.notifications, upgrade: config.upgrade, statusLine: config.statusLine, }); host.state.editor.setDisablePasteBurst(config.disablePasteBurst); + setMarkdownRenderLatex(config.renderLatex ?? true); } function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void { diff --git a/apps/kimi-code/src/tui/components/messages/assistant-message.ts b/apps/kimi-code/src/tui/components/messages/assistant-message.ts index 5e460e2c2cd..64ed6bbf8d8 100644 --- a/apps/kimi-code/src/tui/components/messages/assistant-message.ts +++ b/apps/kimi-code/src/tui/components/messages/assistant-message.ts @@ -11,6 +11,7 @@ import { MESSAGE_INDENT } from '#/tui/constant/rendering'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { currentTheme } from '#/tui/theme'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; import { markOsc133Zone } from '#/tui/utils/osc133'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; @@ -62,9 +63,14 @@ export class AssistantMessageComponent implements Component { if (this.markdown === undefined || this.markdownTransient !== transient) { this.contentContainer.clear(); - this.markdown = new Markdown(displayText, 0, 0, createMarkdownTheme({ transient }), undefined, { - renderLatex: false, - }); + this.markdown = new Markdown( + displayText, + 0, + 0, + createMarkdownTheme({ transient }), + undefined, + createMarkdownOptions(), + ); this.markdownTransient = transient; this.contentContainer.addChild(this.markdown); return; @@ -88,7 +94,7 @@ export class AssistantMessageComponent implements Component { 0, createMarkdownTheme({ transient: this.lastTransient }), undefined, - { renderLatex: false }, + createMarkdownOptions(), ); this.markdownTransient = this.lastTransient; this.contentContainer.addChild(this.markdown); diff --git a/apps/kimi-code/src/tui/components/messages/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts index 0f25bd3a91c..2b46b31d366 100644 --- a/apps/kimi-code/src/tui/components/messages/plan-box.ts +++ b/apps/kimi-code/src/tui/components/messages/plan-box.ts @@ -10,6 +10,7 @@ import { pathToFileURL } from 'node:url'; import { Markdown, truncateToWidth, visibleWidth, type Component, type MarkdownTheme } from '@moonshot-ai/pi-tui'; import chalk from 'chalk'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; import { toTerminalHyperlink } from '#/utils/terminal-hyperlink'; const LEFT_MARGIN = 2; // two-space indent matching other tool call children @@ -41,7 +42,7 @@ export class PlanBoxComponent implements Component { // parse + wrap output keyed on (text, width), so reusing the same // instance means repeated render() calls from the parent Container // hit the cache instead of re-parsing on every frame. - this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme, undefined, { renderLatex: false }); + this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme, undefined, createMarkdownOptions()); this.status = opts?.status; } diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index d829efea5af..55ad576afd8 100644 --- a/apps/kimi-code/src/tui/components/panes/btw-panel.ts +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -9,6 +9,7 @@ import chalk from 'chalk'; import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; import { currentTheme } from '../../theme'; +import { createMarkdownOptions } from '../../utils/markdown-options'; type BtwPanelPhase = 'running' | 'done' | 'failed'; @@ -196,9 +197,7 @@ export class BtwPanelComponent implements Component { const thinking = turn.thinking.trim(); if (answer.length > 0) { lines.push( - ...new Markdown(answer, 0, 0, this.options.markdownTheme, undefined, { - renderLatex: false, - }).render(width), + ...new Markdown(answer, 0, 0, this.options.markdownTheme, undefined, createMarkdownOptions()).render(width), ); } else if (thinking.length > 0) { const thinkingLines = new Text(chalk.hex(currentTheme.palette.textDim)(thinking), 0, 0).render( diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index 0dc7591ac96..fe06bc45951 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -57,6 +57,7 @@ export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), tui_mode: TuiModeSchema.optional(), + render_latex: z.boolean().optional(), disable_paste_burst: z.boolean().optional(), cache_expiry_hint: z.boolean().optional(), editor: z @@ -83,6 +84,9 @@ export const TuiConfigSchema = z.object({ /** Present in every normalized config; optional only so hand-built test * fixtures from before this field existed still typecheck. */ tuiMode: TuiModeSchema.optional(), + /** LaTeX math rendering in Markdown; optional only so older hand-built test + * fixtures still typecheck. */ + renderLatex: z.boolean().optional(), disablePasteBurst: z.boolean(), /** Present in every normalized config; optional only so hand-built test * fixtures from before this field existed still typecheck. */ @@ -112,6 +116,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', tuiMode: 'regular', + renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, editorCommand: null, @@ -199,6 +204,7 @@ export function normalizeTuiConfig( return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, tuiMode: config.tui_mode ?? DEFAULT_TUI_CONFIG.tuiMode, + renderLatex: config.render_latex ?? DEFAULT_TUI_CONFIG.renderLatex, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, editorCommand: command === undefined || command.length === 0 ? null : command, @@ -249,6 +255,7 @@ export function renderTuiConfig(config: TuiConfig): string { theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name tui_mode = "${config.tuiMode ?? 'regular'}" # "regular" | "fullscreen" (alternate-screen UI; applies on next start) +render_latex = ${String(config.renderLatex !== false)} # false keeps LaTeX math in assistant messages as raw source disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index fc734e0c04c..8d89c6b6d09 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -249,6 +249,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { editorCommand: input.tuiConfig.editorCommand, disablePasteBurst: input.tuiConfig.disablePasteBurst, tuiMode: input.tuiConfig.tuiMode, + renderLatex: input.tuiConfig.renderLatex, cacheExpiryHint: input.tuiConfig.cacheExpiryHint, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 8ddfa21ea2c..fd870df61bf 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -11,8 +11,7 @@ import { import { clipboard } from '#/utils/clipboard/clipboard-native'; import { openUrl } from '#/utils/open-url'; -import { FooterComponent } from './components/chrome/footer'; -import { GutterContainer } from './components/chrome/gutter-container'; +import { FooterComponent } from './components/chrome/footer';import { GutterContainer } from './components/chrome/gutter-container'; import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader'; import { TodoPanelComponent } from './components/chrome/todo-panel'; import type { SessionRow } from './components/dialogs/session-picker'; @@ -21,6 +20,7 @@ import { DEFAULT_TUI_CONFIG } from './config'; import { CHROME_GUTTER } from './constant/rendering'; import type { TasksBrowserState } from './controllers/tasks-browser'; import { currentTheme, type Theme } from './theme'; +import { setMarkdownRenderLatex } from './utils/markdown-options'; import { createTerminalState, type TerminalState } from './utils/terminal-state'; import { INITIAL_LIVE_PANE, @@ -81,6 +81,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const terminal = new ProcessTerminal(); const tuiMode = initialAppState.tuiMode ?? DEFAULT_TUI_CONFIG.tuiMode ?? 'regular'; + setMarkdownRenderLatex(initialAppState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true); const ui = tuiMode === 'fullscreen' ? new TuiAltScreen(terminal, undefined, undefined, { diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index f50a6807101..69d2fdac8b3 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -71,6 +71,8 @@ export interface AppState { disablePasteBurst?: boolean; /** Renderer selection from tui.toml; defaults to 'regular' when absent from older fixtures. */ tuiMode?: TuiMode; + /** LaTeX math rendering in Markdown; defaults to true when absent from older fixtures. */ + renderLatex?: boolean; /** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */ cacheExpiryHint?: boolean; notifications: NotificationsConfig; diff --git a/apps/kimi-code/src/tui/utils/markdown-options.ts b/apps/kimi-code/src/tui/utils/markdown-options.ts new file mode 100644 index 00000000000..d765e599b17 --- /dev/null +++ b/apps/kimi-code/src/tui/utils/markdown-options.ts @@ -0,0 +1,21 @@ +/** + * Shared Markdown behavior options (distinct from the visual theme). + * + * Holds the process-wide LaTeX toggle from tui.toml so transcript components + * don't each need the config threaded through construction. Mirrors the + * render-cache toggle pattern (see utils/render-cache.ts). + */ + +import type { MarkdownOptions } from '@moonshot-ai/pi-tui'; + +// Default on, matching upstream pi-tui; overridden from tui.toml at startup +// and on /reload. +let renderLatex = true; + +export function setMarkdownRenderLatex(value: boolean): void { + renderLatex = value; +} + +export function createMarkdownOptions(): MarkdownOptions { + return { renderLatex }; +} diff --git a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index c64f54ae268..89dec20b98a 100644 --- a/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts +++ b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it, vi } from 'vitest'; import { AssistantMessageComponent } from '#/tui/components/messages/assistant-message'; import { STATUS_BULLET } from '#/tui/constant/symbols'; import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { setMarkdownRenderLatex } from '#/tui/utils/markdown-options'; import { captureProcessWrite } from '../../../helpers/process'; @@ -139,4 +140,19 @@ describe('AssistantMessageComponent', () => { const cached = component.render(80); expect(cached[0]).toBe(lines[0]); }); + + it('renders LaTeX math by default and keeps raw source when disabled', () => { + const component = new AssistantMessageComponent(); + try { + setMarkdownRenderLatex(true); + component.updateContent('能量公式 $E = mc^2$'); + expect(strip(component.render(80).join('\n'))).toContain('E = mc²'); + + setMarkdownRenderLatex(false); + component.invalidate(); + expect(strip(component.render(80).join('\n'))).toContain('$E = mc^2$'); + } finally { + setMarkdownRenderLatex(true); + } + }); }); diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 764fce42302..14936a64419 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -61,6 +61,7 @@ auto_install = false expect(config).toEqual({ theme: 'light', tuiMode: 'regular', + renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, editorCommand: 'code --wait', @@ -89,6 +90,16 @@ tui_mode = "fullscreen" expect(config.tuiMode).toBe('fullscreen'); }); + it('defaults render_latex to true and parses false', () => { + expect(parseTuiConfig('').renderLatex).toBe(true); + + const config = parseTuiConfig(` +render_latex = false +`); + + expect(config.renderLatex).toBe(false); + }); + it('parses cache_expiry_hint', () => { const config = parseTuiConfig(` theme = "dark" @@ -107,6 +118,7 @@ command = " " expect(config).toEqual({ theme: 'auto', tuiMode: 'regular', + renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, editorCommand: null, @@ -154,6 +166,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', tuiMode: 'regular', + renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, editorCommand: 'vim', diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index c468b6dc1b1..fdeaac75931 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -429,6 +429,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | --- | --- | --- | --- | | `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, `light`, or the name of a [custom theme](../customization/themes.md) | | `tui_mode` | `string` | `regular` | UI renderer: `regular` (inline viewport) or `fullscreen` (alternate-screen UI with a scrollable transcript viewport, mouse text selection, a scrollbar, and Ctrl-Shift-F transcript search; applies on next start) | +| `render_latex` | `boolean` | `true` | Render LaTeX math expressions (`$…$`, `$$…$$`) in Markdown messages as Unicode text; `false` keeps the raw source | | `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line | | `cache_expiry_hint` | `boolean` | `true` | Show a dialog when resuming a long-idle session or submitting after a long idle stretch, warning that the context cache has likely expired and offering to compact or start a new session (v2 engine only) | | `[editor].command` | `string` | `""` | External editor command for composing long input; empty falls back to `$VISUAL` / `$EDITOR` | @@ -442,6 +443,7 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | custom theme name tui_mode = "regular" # "regular" | "fullscreen" (alternate-screen UI; applies on next start) +render_latex = true # false keeps LaTeX math in messages as raw source disable_paste_burst = false # true disables non-bracketed paste-burst fallback cache_expiry_hint = true # false disables the "cache expired" dialog on resume / idle submit diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 233951800b3..92370531fee 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -429,6 +429,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | --- | --- | --- | --- | | `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark`、`light`,或[自定义主题](../customization/themes.md)的名字 | | `tui_mode` | `string` | `regular` | 界面渲染模式:`regular`(内联视口)或 `fullscreen`(alternate-screen 全屏界面:transcript 为可滚动视口,支持鼠标选择、滚动条与 Ctrl-Shift-F 搜索;重启后生效) | +| `render_latex` | `boolean` | `true` | 将 Markdown 消息中的 LaTeX 公式(`$…$`、`$$…$$`)渲染为 Unicode 文本;`false` 则保留原始源码 | | `disable_paste_burst` | `boolean` | `false` | 禁用非 bracketed paste 的粘贴突发兜底;默认开启,避免快速多行粘贴被逐行提交 | | `cache_expiry_hint` | `boolean` | `true` | resume 长时间未活动的会话、或长时间空闲后发送消息时,若上下文缓存可能已过期则弹出提醒,可选择先压缩或新建会话(仅 v2 引擎) | | `[editor].command` | `string` | `""` | 编写长输入用的外部编辑器命令;留空则回退到 `$VISUAL` / `$EDITOR` | @@ -442,6 +443,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | 自定义主题名 tui_mode = "regular" # "regular" | "fullscreen"(alternate-screen 全屏界面,重启后生效) +render_latex = true # false 表示消息中的 LaTeX 公式保留原始源码 disable_paste_burst = false # true 表示禁用非 bracketed paste 的粘贴突发兜底 cache_expiry_hint = true # false 表示关闭 resume / 空闲提交时的"缓存已过期"提醒弹窗 From 2c43b31905128f2daed2d9339962014d07af262b Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 15:32:47 +0800 Subject: [PATCH 09/17] refactor(kimi-code): gate fullscreen behind KIMI_CODE_TUI_FULL_SCREEN Drop the public tui_mode preference from tui.toml before release; the fullscreen UI is experimental, so enable it with the KIMI_CODE_TUI_FULL_SCREEN=1 env var instead. Docs move from the config-file reference to the env-vars page. --- .changeset/cli-tui-mode-fullscreen.md | 2 +- apps/kimi-code/src/tui/config.ts | 10 ---------- apps/kimi-code/src/tui/kimi-tui.ts | 1 - apps/kimi-code/src/tui/tui-state.ts | 5 +++-- apps/kimi-code/src/tui/types.ts | 4 +--- apps/kimi-code/test/tui/config.test.ts | 13 ------------- apps/kimi-code/test/tui/create-tui-state.test.ts | 6 ++++-- apps/kimi-code/test/tui/fullscreen-layout.test.ts | 6 ++++-- apps/kimi-code/test/tui/kimi-tui-startup.test.ts | 9 ++++----- docs/en/configuration/config-files.md | 2 -- docs/en/configuration/env-vars.md | 1 + docs/zh/configuration/config-files.md | 2 -- docs/zh/configuration/env-vars.md | 1 + 13 files changed, 19 insertions(+), 43 deletions(-) diff --git a/.changeset/cli-tui-mode-fullscreen.md b/.changeset/cli-tui-mode-fullscreen.md index 82d919918f0..4272b40cebf 100644 --- a/.changeset/cli-tui-mode-fullscreen.md +++ b/.changeset/cli-tui-mode-fullscreen.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add a `tui_mode` preference for a fullscreen alternate-screen UI: the transcript becomes a scrollable viewport with mouse wheel scrolling, text selection, clickable links, a scrollbar, and transcript search (Ctrl-Shift-F), while the editor and chrome stay docked at the bottom. Set `tui_mode = "fullscreen"` in ~/.kimi-code/tui.toml and restart. +Add an experimental fullscreen alternate-screen UI: the transcript becomes a scrollable viewport with mouse wheel scrolling, text selection, clickable links, a scrollbar, and transcript search (Ctrl-Shift-F), while the editor and chrome stay docked at the bottom. Launch with `KIMI_CODE_TUI_FULL_SCREEN=1` to try it. diff --git a/apps/kimi-code/src/tui/config.ts b/apps/kimi-code/src/tui/config.ts index fe06bc45951..5a08af8ff77 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -21,9 +21,6 @@ export const TuiThemeSchema = z.string(); export const NotificationConditionSchema = z.enum(['unfocused', 'always']); -export const TuiModeSchema = z.enum(['regular', 'fullscreen']); -export type TuiMode = z.infer; - export const NotificationsConfigSchema = z.object({ enabled: z.boolean(), condition: NotificationConditionSchema, @@ -56,7 +53,6 @@ export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), - tui_mode: TuiModeSchema.optional(), render_latex: z.boolean().optional(), disable_paste_burst: z.boolean().optional(), cache_expiry_hint: z.boolean().optional(), @@ -81,9 +77,6 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, - /** Present in every normalized config; optional only so hand-built test - * fixtures from before this field existed still typecheck. */ - tuiMode: TuiModeSchema.optional(), /** LaTeX math rendering in Markdown; optional only so older hand-built test * fixtures still typecheck. */ renderLatex: z.boolean().optional(), @@ -115,7 +108,6 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', - tuiMode: 'regular', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, @@ -203,7 +195,6 @@ export function normalizeTuiConfig( .map((item) => item as StatusLineItem) ?? null; return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, - tuiMode: config.tui_mode ?? DEFAULT_TUI_CONFIG.tuiMode, renderLatex: config.render_latex ?? DEFAULT_TUI_CONFIG.renderLatex, disablePasteBurst: config.disable_paste_burst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, cacheExpiryHint: config.cache_expiry_hint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, @@ -254,7 +245,6 @@ export function renderTuiConfig(config: TuiConfig): string { # Agent/runtime settings stay in ~/.kimi-code/config.toml. theme = "${escapeTomlBasicString(config.theme)}" # "auto" | "dark" | "light" | custom theme name -tui_mode = "${config.tuiMode ?? 'regular'}" # "regular" | "fullscreen" (alternate-screen UI; applies on next start) render_latex = ${String(config.renderLatex !== false)} # false keeps LaTeX math in assistant messages as raw source disable_paste_burst = ${String(config.disablePasteBurst)} # true disables non-bracketed paste-burst fallback cache_expiry_hint = ${String(config.cacheExpiryHint !== false)} # false disables the "cache expired" dialog on resume / idle submit diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index 8d89c6b6d09..908356e2de9 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -248,7 +248,6 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { version: input.version, editorCommand: input.tuiConfig.editorCommand, disablePasteBurst: input.tuiConfig.disablePasteBurst, - tuiMode: input.tuiConfig.tuiMode, renderLatex: input.tuiConfig.renderLatex, cacheExpiryHint: input.tuiConfig.cacheExpiryHint, notifications: input.tuiConfig.notifications, diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index fd870df61bf..28716a6e273 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -80,10 +80,11 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const theme = currentTheme; const terminal = new ProcessTerminal(); - const tuiMode = initialAppState.tuiMode ?? DEFAULT_TUI_CONFIG.tuiMode ?? 'regular'; setMarkdownRenderLatex(initialAppState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true); + // Fullscreen is experimental and env-gated for now: KIMI_CODE_TUI_FULL_SCREEN=1. + const fullscreen = process.env['KIMI_CODE_TUI_FULL_SCREEN'] === '1'; const ui = - tuiMode === 'fullscreen' + fullscreen ? new TuiAltScreen(terminal, undefined, undefined, { // Mouse capture takes over the terminal's native link activation, so // route OSC 8 clicks through our own opener. diff --git a/apps/kimi-code/src/tui/types.ts b/apps/kimi-code/src/tui/types.ts index 69d2fdac8b3..2b77c89ea93 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -9,7 +9,7 @@ import type { ToolInputDisplay, } from '@moonshot-ai/kimi-code-sdk'; -import type { NotificationsConfig, StatusLineConfig, TuiMode, UpgradePreferences } from './config'; +import type { NotificationsConfig, StatusLineConfig, UpgradePreferences } from './config'; import type { PendingApproval, PendingQuestion } from './reverse-rpc/types'; import type { ColorToken, ThemeName } from './theme'; @@ -69,8 +69,6 @@ export interface AppState { editorCommand: string | null; /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ disablePasteBurst?: boolean; - /** Renderer selection from tui.toml; defaults to 'regular' when absent from older fixtures. */ - tuiMode?: TuiMode; /** LaTeX math rendering in Markdown; defaults to true when absent from older fixtures. */ renderLatex?: boolean; /** Mirrors the TUI config toggle; defaults to true when absent from older fixtures. */ diff --git a/apps/kimi-code/test/tui/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 14936a64419..48df4730307 100644 --- a/apps/kimi-code/test/tui/config.test.ts +++ b/apps/kimi-code/test/tui/config.test.ts @@ -60,7 +60,6 @@ auto_install = false expect(config).toEqual({ theme: 'light', - tuiMode: 'regular', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, @@ -80,16 +79,6 @@ disable_paste_burst = true expect(config.disablePasteBurst).toBe(true); }); - it('defaults tui_mode to regular and parses fullscreen', () => { - expect(parseTuiConfig('').tuiMode).toBe('regular'); - - const config = parseTuiConfig(` -tui_mode = "fullscreen" -`); - - expect(config.tuiMode).toBe('fullscreen'); - }); - it('defaults render_latex to true and parses false', () => { expect(parseTuiConfig('').renderLatex).toBe(true); @@ -117,7 +106,6 @@ command = " " expect(config).toEqual({ theme: 'auto', - tuiMode: 'regular', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, @@ -165,7 +153,6 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', - tuiMode: 'regular', renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 1a8388ab635..6caf7b27288 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; import { TuiAltScreen, TuiMainScreen } from '@moonshot-ai/pi-tui'; @@ -104,8 +104,9 @@ describe('createTUIState', () => { }); it('builds an alternate-screen renderer with a docked layout in fullscreen mode', () => { + vi.stubEnv('KIMI_CODE_TUI_FULL_SCREEN', '1'); const state = createTUIState({ - initialAppState: { ...fakeInitialAppState(), tuiMode: 'fullscreen' }, + initialAppState: fakeInitialAppState(), startup: { continueLast: false, yolo: false, @@ -113,6 +114,7 @@ describe('createTUIState', () => { plan: false, }, }); + vi.unstubAllEnvs(); expect(state.ui).toBeInstanceOf(TuiAltScreen); expect(state.ui.mode).toBe('fullscreen'); diff --git a/apps/kimi-code/test/tui/fullscreen-layout.test.ts b/apps/kimi-code/test/tui/fullscreen-layout.test.ts index 19045449a75..82778344e6a 100644 --- a/apps/kimi-code/test/tui/fullscreen-layout.test.ts +++ b/apps/kimi-code/test/tui/fullscreen-layout.test.ts @@ -5,7 +5,7 @@ * shrink distribution with no minSize, so a tall transcript crushed it and * the editor's bottom border row was clipped off screen. */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { Spacer, type Terminal, TuiAltScreen } from '@moonshot-ai/pi-tui'; import { VirtualTerminal } from '../../../../packages/pi-tui/test/virtual-terminal'; @@ -68,10 +68,12 @@ async function mountFullscreen(): Promise<{ vt: VirtualTerminal; }> { const opts: KimiTUIOptions = { - initialAppState: { ...fakeInitialAppState(), tuiMode: 'fullscreen' }, + initialAppState: fakeInitialAppState(), startup: { continueLast: false, yolo: false, auto: false, plan: false }, }; + vi.stubEnv('KIMI_CODE_TUI_FULL_SCREEN', '1'); const state = createTUIState(opts); + vi.unstubAllEnvs(); const vt = new VirtualTerminal(WIDTH, HEIGHT); (state.ui as { terminal: Terminal }).terminal = vt; diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 2bbdc75508c..44db137df7b 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -309,12 +309,11 @@ describe('KimiTUI startup', () => { }); }); - it('mounts the docked fullscreen layout when tui_mode is fullscreen', async () => { + it('mounts the docked fullscreen layout when KIMI_CODE_TUI_FULL_SCREEN=1', async () => { const harness = makeHarness(makeSession()); - const driver = makeDriver(harness, { - ...makeStartupInput({}, { tuiMode: 'fullscreen' }), - engineV2: true, - }); + vi.stubEnv('KIMI_CODE_TUI_FULL_SCREEN', '1'); + const driver = makeDriver(harness, { ...makeStartupInput(), engineV2: true }); + vi.unstubAllEnvs(); // buildLayout() runs in the constructor: fullscreen keeps the root // children list empty and mounts the layout root instead. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index fdeaac75931..a3cce551a4c 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -428,7 +428,6 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c | Field | Type | Default | Description | | --- | --- | --- | --- | | `theme` | `string` | `auto` | Color theme: `auto` (follow the terminal), `dark`, `light`, or the name of a [custom theme](../customization/themes.md) | -| `tui_mode` | `string` | `regular` | UI renderer: `regular` (inline viewport) or `fullscreen` (alternate-screen UI with a scrollable transcript viewport, mouse text selection, a scrollbar, and Ctrl-Shift-F transcript search; applies on next start) | | `render_latex` | `boolean` | `true` | Render LaTeX math expressions (`$…$`, `$$…$$`) in Markdown messages as Unicode text; `false` keeps the raw source | | `disable_paste_burst` | `boolean` | `false` | Disable the non-bracketed paste-burst fallback that keeps rapid multi-line pastes from submitting line by line | | `cache_expiry_hint` | `boolean` | `true` | Show a dialog when resuming a long-idle session or submitting after a long idle stretch, warning that the context cache has likely expired and offering to compact or start a new session (v2 engine only) | @@ -442,7 +441,6 @@ Alongside `config.toml`, the CLI keeps terminal-UI and client preferences in a c ```toml # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | custom theme name -tui_mode = "regular" # "regular" | "fullscreen" (alternate-screen UI; applies on next start) render_latex = true # false keeps LaTeX math in messages as raw source disable_paste_burst = false # true disables non-bracketed paste-burst fallback cache_expiry_hint = true # false disables the "cache expired" dialog on resume / idle submit diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 4e519dda47f..835e8be77cc 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -131,6 +131,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_IDENTITY_NAME` | Display name the agent calls itself in the system prompt; takes higher priority than `[identity] name` in `config.toml` and is never written back to it | Any non-empty string; blank values read as unset | | `KIMI_CODE_IDENTITY_SLUG` | Protocol identifier for the `User-Agent` product token sent to third-party providers and the MCP client name; takes higher priority than `[identity] slug`. Derived from the name when unset | Any non-empty string; normalized to lowercase with non-alphanumeric runs folded to `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | Whether the built-in skills documenting Kimi Code itself are offered to the model; takes higher priority than `builtin_product_skills` in `config.toml` (default enabled) | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | +| `KIMI_CODE_TUI_FULL_SCREEN` | Enable the experimental fullscreen alternate-screen UI: scrollable transcript viewport, mouse text selection, clickable links, and Ctrl-Shift-F transcript search | `1` enables it; anything else keeps the regular inline UI | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | Enable the experimental secondary-model feature in every launch mode, including the interactive TUI; the master `KIMI_CODE_EXPERIMENTAL_FLAG=1` also enables it | Truthy: `1`/`true`/`yes`/`on`; falsy: `0`/`false`/`no`/`off` | | `KIMI_SECONDARY_MODEL` | Secondary model; takes higher priority than [`[secondary_model] model`](./config-files.md#secondary-model) in `config.toml`. When the secondary-model experiment is enabled, newly spawned subagents (`Agent` / `AgentSwarm`) bind to it by default instead of inheriting the main agent's model | The alias of a configured `[models]` entry, e.g. `kimi-code/kimi-k2.5`; blank values are ignored | | `KIMI_SECONDARY_EFFORT` | Thinking effort for the secondary model; takes higher priority than `[secondary_model] default_effort` in `config.toml` and applies only when both the model and its experiment are enabled | An effort value, e.g. `low`; blank values are ignored | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 92370531fee..4c29a45e16f 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -428,7 +428,6 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `theme` | `string` | `auto` | 配色主题:`auto`(跟随终端)、`dark`、`light`,或[自定义主题](../customization/themes.md)的名字 | -| `tui_mode` | `string` | `regular` | 界面渲染模式:`regular`(内联视口)或 `fullscreen`(alternate-screen 全屏界面:transcript 为可滚动视口,支持鼠标选择、滚动条与 Ctrl-Shift-F 搜索;重启后生效) | | `render_latex` | `boolean` | `true` | 将 Markdown 消息中的 LaTeX 公式(`$…$`、`$$…$$`)渲染为 Unicode 文本;`false` 则保留原始源码 | | `disable_paste_burst` | `boolean` | `false` | 禁用非 bracketed paste 的粘贴突发兜底;默认开启,避免快速多行粘贴被逐行提交 | | `cache_expiry_hint` | `boolean` | `true` | resume 长时间未活动的会话、或长时间空闲后发送消息时,若上下文缓存可能已过期则弹出提醒,可选择先压缩或新建会话(仅 v2 引擎) | @@ -442,7 +441,6 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod ```toml # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | 自定义主题名 -tui_mode = "regular" # "regular" | "fullscreen"(alternate-screen 全屏界面,重启后生效) render_latex = true # false 表示消息中的 LaTeX 公式保留原始源码 disable_paste_burst = false # true 表示禁用非 bracketed paste 的粘贴突发兜底 cache_expiry_hint = true # false 表示关闭 resume / 空闲提交时的"缓存已过期"提醒弹窗 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 8d44b78734e..74a8885bae4 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -131,6 +131,7 @@ kimi | `KIMI_CODE_IDENTITY_NAME` | Agent 在系统提示词中的自称,优先级高于 `config.toml` 的 `[identity] name`,且不会被写回配置文件 | 任意非空字符串;空值视为未设置 | | `KIMI_CODE_IDENTITY_SLUG` | 协议标识,用于发给第三方 provider 的 `User-Agent` 产品名和 MCP 客户端名,优先级高于 `[identity] slug`。未设置时由名称派生 | 任意非空字符串;会转小写并将连续非字母数字字符折叠为 `-` | | `KIMI_CODE_BUILTIN_PRODUCT_SKILLS` | 是否向模型提供介绍 Kimi Code 自身的内置 Skills,优先级高于 `config.toml` 的 `builtin_product_skills`(默认开启) | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | +| `KIMI_CODE_TUI_FULL_SCREEN` | 启用实验性的 fullscreen alternate-screen 界面:可滚动的 transcript 视口、鼠标选择文本、可点击链接、Ctrl-Shift-F 搜索 | `1` 开启;其他值保持常规内联界面 | | `KIMI_CODE_EXPERIMENTAL_SECONDARY_MODEL` | 在包括交互式 TUI 在内的所有启动方式下启用实验性的次主力模型功能;master `KIMI_CODE_EXPERIMENTAL_FLAG=1` 也会启用本功能 | 真值:`1`/`true`/`yes`/`on`;假值:`0`/`false`/`no`/`off` | | `KIMI_SECONDARY_MODEL` | 次主力模型;优先级高于 `config.toml` 的 [`[secondary_model] model`](./config-files.md#secondary-model)。次主力模型实验功能启用后,新派生的子 Agent 默认绑定该模型,而不再继承主 Agent 的模型 | `[models]` 中已配置条目的别名,如 `kimi-code/kimi-k2.5`;空白值被忽略 | | `KIMI_SECONDARY_EFFORT` | 次主力模型的 thinking effort;优先级高于 `config.toml` 的 `[secondary_model] default_effort`,仅在次主力模型及其实验功能均启用时生效 | effort 取值,如 `low`;空白值被忽略 | From 088c8b9dbde9ff2925904381e13c2c4905d8bb0e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 15:38:11 +0800 Subject: [PATCH 10/17] chore(changesets): clarify fullscreen mode and LaTeX formula entries --- .changeset/cli-markdown-latex.md | 2 +- .changeset/cli-tui-mode-fullscreen.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/cli-markdown-latex.md b/.changeset/cli-markdown-latex.md index 9872d61d949..cea386c7b82 100644 --- a/.changeset/cli-markdown-latex.md +++ b/.changeset/cli-markdown-latex.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Render LaTeX math expressions in Markdown messages as Unicode text, matching the upstream pi-tui default. Set `render_latex = false` in ~/.kimi-code/tui.toml to keep the raw source. +Render LaTeX math formulas (`$…$` / `$$…$$`) in messages as Unicode formulas. Set `render_latex = false` in `~/.kimi-code/tui.toml` to keep the raw source. diff --git a/.changeset/cli-tui-mode-fullscreen.md b/.changeset/cli-tui-mode-fullscreen.md index 4272b40cebf..43d809154dd 100644 --- a/.changeset/cli-tui-mode-fullscreen.md +++ b/.changeset/cli-tui-mode-fullscreen.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add an experimental fullscreen alternate-screen UI: the transcript becomes a scrollable viewport with mouse wheel scrolling, text selection, clickable links, a scrollbar, and transcript search (Ctrl-Shift-F), while the editor and chrome stay docked at the bottom. Launch with `KIMI_CODE_TUI_FULL_SCREEN=1` to try it. +Add an experimental fullscreen TUI mode: scrollable transcript viewport, mouse text selection, clickable links, scrollbar, and transcript search. Set the `KIMI_CODE_TUI_FULL_SCREEN=1` environment variable to enable it. From 001a794d54066bace5d3a1c63594566b11c80a1e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 15:40:39 +0800 Subject: [PATCH 11/17] chore(changesets): trim fullscreen mode entry --- .changeset/cli-tui-mode-fullscreen.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cli-tui-mode-fullscreen.md b/.changeset/cli-tui-mode-fullscreen.md index 43d809154dd..9520216d7c2 100644 --- a/.changeset/cli-tui-mode-fullscreen.md +++ b/.changeset/cli-tui-mode-fullscreen.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add an experimental fullscreen TUI mode: scrollable transcript viewport, mouse text selection, clickable links, scrollbar, and transcript search. Set the `KIMI_CODE_TUI_FULL_SCREEN=1` environment variable to enable it. +Add an experimental fullscreen TUI mode. Set the `KIMI_CODE_TUI_FULL_SCREEN=1` environment variable to enable it. From 17cc72dfdc579f8e714ebd12cdfcf240f63b3f89 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 15:43:24 +0800 Subject: [PATCH 12/17] chore(changesets): trim LaTeX formula entry --- .changeset/cli-markdown-latex.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cli-markdown-latex.md b/.changeset/cli-markdown-latex.md index cea386c7b82..2344678c1f8 100644 --- a/.changeset/cli-markdown-latex.md +++ b/.changeset/cli-markdown-latex.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": patch --- -Render LaTeX math formulas (`$…$` / `$$…$$`) in messages as Unicode formulas. Set `render_latex = false` in `~/.kimi-code/tui.toml` to keep the raw source. +Render LaTeX math formulas (`$…$` / `$$…$$`) in messages as Unicode formulas. From a6948ecbb6259d2d309a7cbdd3dc7715927c5959 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 15:46:05 +0800 Subject: [PATCH 13/17] chore(changesets): drop redundant kimi-code entries --- .changeset/cli-fullscreen-dock-sizing.md | 5 ----- .changeset/cli-fullscreen-osc133-and-exit.md | 5 ----- .changeset/cli-tui-engine-rebaseline.md | 5 ----- 3 files changed, 15 deletions(-) delete mode 100644 .changeset/cli-fullscreen-dock-sizing.md delete mode 100644 .changeset/cli-fullscreen-osc133-and-exit.md delete mode 100644 .changeset/cli-tui-engine-rebaseline.md diff --git a/.changeset/cli-fullscreen-dock-sizing.md b/.changeset/cli-fullscreen-dock-sizing.md deleted file mode 100644 index d3b32896713..00000000000 --- a/.changeset/cli-fullscreen-dock-sizing.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fix the fullscreen layout clipping the editor's bottom border (and squeezing other dock chrome) whenever the transcript grew past the screen height: the dock now keeps its intrinsic height and the editor never shrinks below its 3 rows. diff --git a/.changeset/cli-fullscreen-osc133-and-exit.md b/.changeset/cli-fullscreen-osc133-and-exit.md deleted file mode 100644 index 5562a2f7fe3..00000000000 --- a/.changeset/cli-fullscreen-osc133-and-exit.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Fullscreen refinements: user and assistant messages now carry OSC 133 zone markers, so Ctrl-Shift-Up/Down jumps between prompts in the transcript viewport; exiting fullscreen replays the transcript into native scrollback in the regular inline layout; the terminal progress indicator is restored when returning from the external editor mid-turn. diff --git a/.changeset/cli-tui-engine-rebaseline.md b/.changeset/cli-tui-engine-rebaseline.md deleted file mode 100644 index 73bee5cb598..00000000000 --- a/.changeset/cli-tui-engine-rebaseline.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@moonshot-ai/kimi-code": patch ---- - -Upgrade the terminal UI engine to the latest upstream baseline: fixes paste-marker corruption when deleting or undoing pasted blocks, reduces keyboard input latency on Windows, keeps wide grapheme wrapping from crashing very narrow terminals, and normalizes tab characters in rendered output. From 2146c00e1f92dc5d7364bad5a6b396c6be964792 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 15:56:11 +0800 Subject: [PATCH 14/17] test(kimi-code): add stepRetry to fullscreen layout fixture after main merge --- apps/kimi-code/test/tui/fullscreen-layout.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/kimi-code/test/tui/fullscreen-layout.test.ts b/apps/kimi-code/test/tui/fullscreen-layout.test.ts index 82778344e6a..f002a3a02ec 100644 --- a/apps/kimi-code/test/tui/fullscreen-layout.test.ts +++ b/apps/kimi-code/test/tui/fullscreen-layout.test.ts @@ -41,6 +41,7 @@ function fakeInitialAppState(): AppState { isReplaying: false, streamingPhase: 'idle', streamingStartTime: 0, + stepRetry: null, theme: 'dark', version: '0.0.0-test', editorCommand: null, From f8397ae2612f5a4cc0eb4a223693bf4869e1650e Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 17:10:29 +0800 Subject: [PATCH 15/17] fix(kimi-code): apply render_latex before theme-driven Markdown rebuilds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex review on PR #2830: applyReloadedTuiConfig set the shared LaTeX toggle after applyTheme(), but theme application invalidates transcript components and their rebuilt Markdown children copy the options at construction — so a /reload that only flipped render_latex kept the old value until some later invalidation. Move the setter before applyTheme and pin the ordering with a test. --- apps/kimi-code/src/tui/commands/reload.ts | 5 +++- .../test/tui/commands/reload.test.ts | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/apps/kimi-code/src/tui/commands/reload.ts b/apps/kimi-code/src/tui/commands/reload.ts index 94029ee058d..041ec2d246a 100644 --- a/apps/kimi-code/src/tui/commands/reload.ts +++ b/apps/kimi-code/src/tui/commands/reload.ts @@ -56,6 +56,10 @@ export async function applyReloadedTuiConfig( host: SlashCommandHost, config: TuiConfig, ): Promise { + // Set the LaTeX toggle before applyTheme: theme application invalidates the + // transcript components, which rebuild their Markdown children and copy the + // options at construction — so the new value must be live by then. + setMarkdownRenderLatex(config.renderLatex ?? true); const resolved = config.theme === 'auto' ? (currentTheme.palette === lightColors ? 'light' : 'dark') : undefined; @@ -71,7 +75,6 @@ export async function applyReloadedTuiConfig( statusLine: config.statusLine, }); host.state.editor.setDisablePasteBurst(config.disablePasteBurst); - setMarkdownRenderLatex(config.renderLatex ?? true); } function applyRuntimeConfig(host: SlashCommandHost, config: KimiConfig): void { diff --git a/apps/kimi-code/test/tui/commands/reload.test.ts b/apps/kimi-code/test/tui/commands/reload.test.ts index b36f96213ab..e0d9352401e 100644 --- a/apps/kimi-code/test/tui/commands/reload.test.ts +++ b/apps/kimi-code/test/tui/commands/reload.test.ts @@ -14,6 +14,10 @@ import { isExperimentalFlagEnabled, setExperimentalFeatures, } from '#/tui/commands/experimental-flags'; +import { + createMarkdownOptions, + setMarkdownRenderLatex, +} from '#/tui/utils/markdown-options'; const tempDirs: string[] = []; const originalKimiCodeHome = process.env['KIMI_CODE_HOME']; @@ -116,6 +120,27 @@ auto_install = false expect(themeWhenTracked).toBe('auto'); }); + it('applies the render_latex toggle before theme application rebuilds Markdown', async () => { + await writeTuiConfig('render_latex = false\n'); + const host = makeHost(); + + // applyTheme invalidates transcript components, which rebuild their + // Markdown children by copying the shared options — the reloaded value + // must already be live at that point. + let latexWhenThemeApplied: boolean | undefined; + const mutable = host as unknown as { applyTheme: unknown }; + mutable.applyTheme = vi.fn(() => { + latexWhenThemeApplied = createMarkdownOptions().renderLatex; + }); + + try { + await handleReloadTuiCommand(host); + expect(latexWhenThemeApplied).toBe(false); + } finally { + setMarkdownRenderLatex(true); + } + }); + it('refreshes workspace commands and lazy defaults on a session-less v2 reload', async () => { await writeTuiConfig('theme = "dark"\n'); const host = makeHost(); From c839ae61094acad27e05105784c59ea8f291ac3f Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 17:48:53 +0800 Subject: [PATCH 16/17] fix(kimi-code): carry renderLatex through TUI config saves Codex review on PR #2830: currentTuiConfig omitted renderLatex, so saving an unrelated preference (theme/editor/upgrade/cache-hint) serialized render_latex as the default true and silently reset a user's opt-out. Carry the appState value through the shared save payload. --- apps/kimi-code/src/tui/commands/config.ts | 1 + .../tui/commands/update-preferences.test.ts | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/apps/kimi-code/src/tui/commands/config.ts b/apps/kimi-code/src/tui/commands/config.ts index a3a0f9999db..fac34484e09 100644 --- a/apps/kimi-code/src/tui/commands/config.ts +++ b/apps/kimi-code/src/tui/commands/config.ts @@ -57,6 +57,7 @@ export function currentTuiConfig(host: Pick): TuiConf theme: host.state.appState.theme, editorCommand: host.state.appState.editorCommand, disablePasteBurst: host.state.appState.disablePasteBurst ?? DEFAULT_TUI_CONFIG.disablePasteBurst, + renderLatex: host.state.appState.renderLatex ?? DEFAULT_TUI_CONFIG.renderLatex ?? true, cacheExpiryHint: host.state.appState.cacheExpiryHint ?? DEFAULT_TUI_CONFIG.cacheExpiryHint, notifications: host.state.appState.notifications, upgrade: host.state.appState.upgrade, diff --git a/apps/kimi-code/test/tui/commands/update-preferences.test.ts b/apps/kimi-code/test/tui/commands/update-preferences.test.ts index bf56ba018ec..8e79bfe9102 100644 --- a/apps/kimi-code/test/tui/commands/update-preferences.test.ts +++ b/apps/kimi-code/test/tui/commands/update-preferences.test.ts @@ -43,6 +43,7 @@ describe('update preference commands', () => { theme: 'auto', editorCommand: null, disablePasteBurst: false, + renderLatex: true, cacheExpiryHint: true, notifications: { enabled: true, condition: 'unfocused' }, upgrade: { autoInstall: false }, @@ -52,4 +53,29 @@ describe('update preference commands', () => { expect(track).toHaveBeenCalledWith('upgrade_preference_changed', { auto_install: false }); expect(showStatus).toHaveBeenCalledWith('Automatic updates disabled.'); }); + + it('preserves a render_latex opt-out when saving an unrelated preference', async () => { + mocks.saveTuiConfig.mockClear(); + const host = { + state: { + appState: { + theme: 'auto' as const, + editorCommand: null, + renderLatex: false, + notifications: { enabled: true, condition: 'unfocused' as const }, + upgrade: { autoInstall: true }, + }, + theme: { palette: darkColors }, + }, + setAppState: vi.fn(), + showStatus: vi.fn(), + track: vi.fn(), + }; + + await applyUpdatePreferenceChoice(host, false); + + expect(mocks.saveTuiConfig).toHaveBeenCalledWith( + expect.objectContaining({ renderLatex: false }), + ); + }); }); From d69db9a7b30e7c2d4e688b436b76c90610ac03f6 Mon Sep 17 00:00:00 2001 From: liruifengv Date: Wed, 12 Aug 2026 18:14:01 +0800 Subject: [PATCH 17/17] feat(kimi-code): report tui_mode in lifecycle telemetry Tag startup_perf and exit events with the active renderer mode (regular/fullscreen) so fullscreen adoption is measurable while it is gated behind KIMI_CODE_TUI_FULL_SCREEN. --- apps/kimi-code/src/cli/run-shell.ts | 5 +++-- apps/kimi-code/test/cli/run-shell.test.ts | 10 +++++++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 0dcbaed5adc..ceade4c8135 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -231,7 +231,7 @@ export async function runShell( const sessionId = tui.getCurrentSessionId(); const hasContent = tui.hasSessionContent(); setCrashPhase('shutdown'); - trackLifecycle('exit', { duration_ms: Date.now() - startedAt }); + trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); const gutter = ' '.repeat(CHROME_GUTTER); process.stdout.write(`${gutter}Bye!\n`); @@ -269,11 +269,12 @@ export async function runShell( config_ms: configMs, init_ms: initMs, mcp_ms: mcpMs, + tui_mode: tui.state.ui.mode, }); } catch (error) { removeCrashHandlers(); setCrashPhase('shutdown'); - trackLifecycle('exit', { duration_ms: Date.now() - startedAt }); + trackLifecycle('exit', { duration_ms: Date.now() - startedAt, tui_mode: tui.state.ui.mode }); await shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }); await harness.close(); throw error; diff --git a/apps/kimi-code/test/cli/run-shell.test.ts b/apps/kimi-code/test/cli/run-shell.test.ts index 00631a42a4c..73b7a22222d 100644 --- a/apps/kimi-code/test/cli/run-shell.test.ts +++ b/apps/kimi-code/test/cli/run-shell.test.ts @@ -134,6 +134,8 @@ vi.mock('../../src/tui/index', () => ({ KimiTUI: class { onExit?: () => Promise; + readonly state = { ui: { mode: 'regular' as const } }; + constructor(...args: unknown[]) { mocks.kimiTuiConstructor(this, ...args); } @@ -353,6 +355,7 @@ describe('runShell', () => { config_ms: expect.any(Number), init_ms: expect.any(Number), mcp_ms: 47, + tui_mode: 'regular', }); }); @@ -560,6 +563,7 @@ describe('runShell', () => { config_ms: expect.any(Number), init_ms: expect.any(Number), mcp_ms: 47, + tui_mode: 'regular', }); }); @@ -817,7 +821,10 @@ describe('runShell', () => { ).rejects.toThrow('boom'); expect(mocks.setCrashPhase).toHaveBeenCalledWith('shutdown'); - expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { duration_ms: expect.any(Number) }); + expect(mocks.harnessTrack).toHaveBeenCalledWith('exit', { + duration_ms: expect.any(Number), + tui_mode: 'regular', + }); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce(); expect(mocks.harnessClose).toHaveBeenCalledOnce(); }); @@ -866,6 +873,7 @@ describe('runShell', () => { expect(mocks.withTelemetryContext).toHaveBeenCalledWith({ sessionId: 'ses-1' }); expect(mocks.lifecycleTrack).toHaveBeenCalledWith('exit', { duration_ms: expect.any(Number), + tui_mode: 'regular', }); expect(mocks.harnessTrack).not.toHaveBeenCalledWith('exit', expect.anything()); expect(mocks.shutdownTelemetry).toHaveBeenCalledOnce();