diff --git a/.changeset/cli-markdown-latex.md b/.changeset/cli-markdown-latex.md new file mode 100644 index 00000000000..2344678c1f8 --- /dev/null +++ b/.changeset/cli-markdown-latex.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Render LaTeX math formulas (`$…$` / `$$…$$`) in messages as Unicode formulas. diff --git a/.changeset/cli-tui-mode-fullscreen.md b/.changeset/cli-tui-mode-fullscreen.md new file mode 100644 index 00000000000..9520216d7c2 --- /dev/null +++ b/.changeset/cli-tui-mode-fullscreen.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add an experimental fullscreen TUI mode. Set the `KIMI_CODE_TUI_FULL_SCREEN=1` environment variable to enable it. diff --git a/.changeset/pi-tui-alt-screen-groundwork.md b/.changeset/pi-tui-alt-screen-groundwork.md new file mode 100644 index 00000000000..2a98bff37d1 --- /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. 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/.changeset/pi-tui-upstream-rebaseline.md b/.changeset/pi-tui-upstream-rebaseline.md new file mode 100644 index 00000000000..9be1036307a --- /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 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/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/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/src/tui/commands/plugins.ts b/apps/kimi-code/src/tui/commands/plugins.ts index 9244c7d2527..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,7 +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()), + 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..041ec2d246a 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'; @@ -55,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; @@ -63,6 +68,7 @@ export async function applyReloadedTuiConfig( host.setAppState({ editorCommand: config.editorCommand, disablePasteBurst: config.disablePasteBurst, + renderLatex: config.renderLatex, cacheExpiryHint: config.cacheExpiryHint, notifications: config.notifications, upgrade: config.upgrade, 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/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..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,8 @@ 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'; type AssistantMarkdownOptions = { @@ -61,7 +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 })); + this.markdown = new Markdown( + displayText, + 0, + 0, + createMarkdownTheme({ transient }), + undefined, + createMarkdownOptions(), + ); this.markdownTransient = transient; this.contentContainer.addChild(this.markdown); return; @@ -84,6 +93,8 @@ export class AssistantMessageComponent implements Component { 0, 0, createMarkdownTheme({ transient: this.lastTransient }), + undefined, + createMarkdownOptions(), ); this.markdownTransient = this.lastTransient; this.contentContainer.addChild(this.markdown); @@ -114,7 +125,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/plan-box.ts b/apps/kimi-code/src/tui/components/messages/plan-box.ts index d1eeec03cba..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); + this.markdown = new Markdown(plan.trim(), 0, 0, markdownTheme, undefined, createMarkdownOptions()); this.status = opts?.status; } 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/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts index f32aa9321db..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'; @@ -195,7 +196,9 @@ 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, createMarkdownOptions()).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..5a08af8ff77 100644 --- a/apps/kimi-code/src/tui/config.ts +++ b/apps/kimi-code/src/tui/config.ts @@ -53,6 +53,7 @@ export const DEFAULT_STATUS_LINE_CONFIG: StatusLineConfig = { export const TuiConfigFileSchema = z.object({ theme: TuiThemeSchema.optional(), + render_latex: z.boolean().optional(), disable_paste_burst: z.boolean().optional(), cache_expiry_hint: z.boolean().optional(), editor: z @@ -76,6 +77,9 @@ export const TuiConfigFileSchema = z.object({ export const TuiConfigSchema = z.object({ theme: TuiThemeSchema, + /** 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. */ @@ -104,6 +108,7 @@ export const DEFAULT_UPGRADE_PREFERENCES: UpgradePreferences = { export const DEFAULT_TUI_CONFIG: TuiConfig = TuiConfigSchema.parse({ theme: 'auto', + renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, editorCommand: null, @@ -190,6 +195,7 @@ export function normalizeTuiConfig( .map((item) => item as StatusLineItem) ?? null; return TuiConfigSchema.parse({ theme: config.theme ?? DEFAULT_TUI_CONFIG.theme, + 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, @@ -239,6 +245,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 +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/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index d3de252f3dd..d3a9e45d14a 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 55df80609b9..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 { @@ -525,7 +526,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); }); @@ -544,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/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 488b426cdb7..614b6946683 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -24,6 +24,8 @@ import { type Focusable, getCapabilities, Spacer, + TuiAltScreen, + TuiMainScreen, } from '@moonshot-ai/pi-tui'; import { resolve } from 'pathe'; @@ -153,6 +155,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 { formatStepRetryDetail, formatStepRetryLabel } from './utils/step-retry'; import { formatBashOutputForDisplay } from './utils/shell-output'; @@ -253,6 +256,7 @@ function createInitialAppState(input: KimiTUIStartupInput): AppState { version: input.version, editorCommand: input.tuiConfig.editorCommand, disablePasteBurst: input.tuiConfig.disablePasteBurst, + renderLatex: input.tuiConfig.renderLatex, cacheExpiryHint: input.tuiConfig.cacheExpiryHint, notifications: input.tuiConfig.notifications, upgrade: input.tuiConfig.upgrade, @@ -383,12 +387,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; @@ -995,7 +1000,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. } @@ -1080,6 +1085,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); @@ -1098,9 +1106,43 @@ 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 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); } + // 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 // ========================================================================= @@ -3509,12 +3551,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, @@ -3524,21 +3566,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 589d79c579f..c749cb633d6 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -1,11 +1,17 @@ import { Container, ProcessTerminal, - TUI, + ScrollView, + TuiAltScreen, + TuiMainScreen, + VStack, + type TUI, } from '@moonshot-ai/pi-tui'; -import { FooterComponent } from './components/chrome/footer'; -import { GutterContainer } from './components/chrome/gutter-container'; +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'; import { TodoPanelComponent } from './components/chrome/todo-panel'; import type { SessionRow } from './components/dialogs/session-picker'; @@ -14,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, @@ -35,6 +42,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; @@ -71,7 +84,32 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const theme = currentTheme; const terminal = new ProcessTerminal(); - const ui = new TUI(terminal); + 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 = + 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); @@ -87,6 +125,33 @@ 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(). + // 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, + overscroll: 'chain', + scrollbar: 'auto', + }); + dockContainer = new VStack(); + 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, { basis: 0, grow: 1, shrink: 1, minSize: 1 }); + root.addChild(dockContainer, { basis: 'auto', grow: 0, shrink: 1, minSize: 1 }); + ui.setLayoutRoot(root); + } + return { ui, terminal, @@ -97,6 +162,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 d1e3341d87d..275ac35d487 100644 --- a/apps/kimi-code/src/tui/types.ts +++ b/apps/kimi-code/src/tui/types.ts @@ -71,6 +71,8 @@ export interface AppState { editorCommand: string | null; /** Mirrors the TUI config toggle; defaults to false when absent from older fixtures. */ disablePasteBurst?: boolean; + /** 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/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/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/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(); 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(); 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 }), + ); + }); }); 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/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/components/messages/assistant-message.test.ts b/apps/kimi-code/test/tui/components/messages/assistant-message.test.ts index e078e6dd2ab..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'; @@ -17,7 +18,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 +128,31 @@ 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]); + }); + + 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/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/config.test.ts b/apps/kimi-code/test/tui/config.test.ts index 9ae144a2b4e..48df4730307 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', + renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, editorCommand: 'code --wait', @@ -78,6 +79,16 @@ disable_paste_burst = true expect(config.disablePasteBurst).toBe(true); }); + 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" @@ -95,6 +106,7 @@ command = " " expect(config).toEqual({ theme: 'auto', + renderLatex: true, disablePasteBurst: false, cacheExpiryHint: true, editorCommand: null, @@ -141,6 +153,7 @@ command = " " expect(await loadTuiConfig(filePath)).toEqual({ theme: 'light', + renderLatex: true, 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 8e17cc8f6b3..be9bded9997 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,7 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect, vi } from 'vitest'; + +import { TuiAltScreen, TuiMainScreen } from '@moonshot-ai/pi-tui'; import { createTUIState, type KimiTUIOptions } from '#/tui/kimi-tui'; import type { AppState } from '#/tui/types'; @@ -85,4 +87,61 @@ 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', () => { + vi.stubEnv('KIMI_CODE_TUI_FULL_SCREEN', '1'); + const state = createTUIState({ + initialAppState: fakeInitialAppState(), + startup: { + continueLast: false, + yolo: false, + auto: false, + plan: false, + }, + }); + vi.unstubAllEnvs(); + + 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); + + // 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'); + }); }); 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..f002a3a02ec --- /dev/null +++ b/apps/kimi-code/test/tui/fullscreen-layout.test.ts @@ -0,0 +1,170 @@ +/** + * 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, vi } 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, + stepRetry: null, + 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(), + 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; + + // 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(); + }); + + 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(); + }); +}); 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 7f463e9a36f..1d3132a352e 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -326,6 +326,24 @@ describe('KimiTUI startup', () => { }); }); + it('mounts the docked fullscreen layout when KIMI_CODE_TUI_FULL_SCREEN=1', async () => { + const harness = makeHarness(makeSession()); + 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. + 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/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index 27f5f03f366..a3cce551a4c 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) | +| `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` | @@ -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 +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 af917ec881b..67a6371f3dd 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -132,6 +132,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 f102efba0f8..4c29a45e16f 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)的名字 | +| `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` | @@ -440,6 +441,7 @@ MCP server 的声明配置写在 `~/.kimi-code/mcp.json` 或项目内 `.kimi-cod ```toml # ~/.kimi-code/tui.toml theme = "auto" # "auto" | "dark" | "light" | 自定义主题名 +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 e846aa093d0..fef8e3867bc 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -132,6 +132,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`;空白值被忽略 | diff --git a/packages/pi-tui/CHANGELOG.md b/packages/pi-tui/CHANGELOG.md index 595fabd73db..dc057767b28 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 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 ### 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 42b2c77ccad..3b408f0ea29 100644 Binary files a/packages/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node and b/packages/pi-tui/native/win32/prebuilds/win32-arm64/win32-console-mode.node differ diff --git a/packages/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node b/packages/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node index 2c6d86d8725..911e55d4f97 100644 Binary files a/packages/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node and b/packages/pi-tui/native/win32/prebuilds/win32-x64/win32-console-mode.node differ diff --git a/packages/pi-tui/native/win32/src/win32-console-mode.c b/packages/pi-tui/native/win32/src/win32-console-mode.c index d68810c7046..9dbc58e9f21 100644 --- a/packages/pi-tui/native/win32/src/win32-console-mode.c +++ b/packages/pi-tui/native/win32/src/win32-console-mode.c @@ -5,6 +5,7 @@ #endif #define NAPI_AUTO_LENGTH ((unsigned long long)-1) +#define KEY_PRESSED_MASK 0x8000 typedef void* napi_env; typedef void* napi_value; @@ -13,6 +14,9 @@ typedef napi_value (__cdecl *napi_callback)(napi_env, napi_callback_info); typedef int (__cdecl *napi_create_function_fn)(napi_env, const char*, unsigned long long, napi_callback, void*, napi_value*); typedef int (__cdecl *napi_set_named_property_fn)(napi_env, napi_value, const char*, napi_value); typedef int (__cdecl *napi_get_boolean_fn)(napi_env, int, napi_value*); +typedef int (__cdecl *napi_get_cb_info_fn)(napi_env, napi_callback_info, unsigned long long*, napi_value*, napi_value*, void**); +typedef int (__cdecl *napi_get_value_string_utf8_fn)(napi_env, napi_value, char*, unsigned long long, unsigned long long*); +typedef SHORT (WINAPI *get_async_key_state_fn)(int); static void* node_symbol(const char* name) { HMODULE module = GetModuleHandleA(0); @@ -23,6 +27,41 @@ static void* node_symbol(const char* name) { return module ? (void*)GetProcAddress(module, name) : 0; } +static int string_equals(const char* left, const char* right) { + while (*left && *right && *left == *right) { + left++; + right++; + } + return *left == 0 && *right == 0; +} + +static get_async_key_state_fn get_async_key_state_symbol(void) { + static int loaded = 0; + static get_async_key_state_fn get_async_key_state = 0; + + if (!loaded) { + HMODULE module = GetModuleHandleA("user32.dll"); + if (!module) module = LoadLibraryA("user32.dll"); + get_async_key_state = module ? (get_async_key_state_fn)GetProcAddress(module, "GetAsyncKeyState") : 0; + loaded = 1; + } + + return get_async_key_state; +} + +static int is_key_pressed(int virtual_key) { + get_async_key_state_fn get_async_key_state = get_async_key_state_symbol(); + return get_async_key_state && (((unsigned short)get_async_key_state(virtual_key)) & KEY_PRESSED_MASK) != 0; +} + +static int is_modifier_name_pressed(const char* name) { + if (string_equals(name, "shift")) return is_key_pressed(VK_SHIFT) || is_key_pressed(VK_LSHIFT) || is_key_pressed(VK_RSHIFT); + if (string_equals(name, "control")) return is_key_pressed(VK_CONTROL) || is_key_pressed(VK_LCONTROL) || is_key_pressed(VK_RCONTROL); + if (string_equals(name, "option") || string_equals(name, "alt")) return is_key_pressed(VK_MENU) || is_key_pressed(VK_LMENU) || is_key_pressed(VK_RMENU); + if (string_equals(name, "command") || string_equals(name, "super") || string_equals(name, "win")) return is_key_pressed(VK_LWIN) || is_key_pressed(VK_RWIN); + return 0; +} + static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callback_info info) { (void)info; @@ -38,16 +77,43 @@ static napi_value __cdecl enable_virtual_terminal_input(napi_env env, napi_callb return result; } -__declspec(dllexport) napi_value __cdecl napi_register_module_v1(napi_env env, napi_value exports) { +static napi_value __cdecl is_modifier_pressed(napi_env env, napi_callback_info info) { + napi_get_cb_info_fn napi_get_cb_info = (napi_get_cb_info_fn)node_symbol("napi_get_cb_info"); + napi_get_value_string_utf8_fn napi_get_value_string_utf8 = (napi_get_value_string_utf8_fn)node_symbol("napi_get_value_string_utf8"); + napi_get_boolean_fn napi_get_boolean = (napi_get_boolean_fn)node_symbol("napi_get_boolean"); + + int pressed = 0; + if (napi_get_cb_info && napi_get_value_string_utf8) { + unsigned long long argc = 1; + napi_value args[1] = {0}; + if (napi_get_cb_info(env, info, &argc, args, 0, 0) == 0 && argc >= 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/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/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..f279a862da5 --- /dev/null +++ b/packages/pi-tui/src/components/scroll-view.ts @@ -0,0 +1,221 @@ +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 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; + 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 followSuppressedAtEnd = false; + 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, 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)); + 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 = nextFollowingEnd; + this.followSuppressedAtEnd = nextFollowSuppressedAtEnd; + if (moved) 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; + const wasFollowingEnd = this.followingEnd; + this.currentScrollTop = next; + this.followingEnd = this.followEnd && next === maxScrollTop; + this.followSuppressedAtEnd = false; + if (moved !== 0) this.markScrollbarActivity(); + if (moved !== 0 || this.followingEnd !== wasFollowingEnd) 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; + this.followSuppressedAtEnd = false; + 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; + this.followSuppressedAtEnd = false; + 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.currentScrollTop < maxScrollTop) this.followSuppressedAtEnd = false; + if (this.followEnd && this.currentScrollTop === maxScrollTop && !this.followSuppressedAtEnd) { + 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..0d5a4a1093b 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,17 @@ 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, + type ScrollViewScrollToOptions, +} from "./components/scroll-view.ts"; export { type SelectItem, SelectList, @@ -27,6 +35,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 +72,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 +117,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..1930e8cde54 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,21 @@ 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.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; } export type Keybinding = keyof Keybindings; @@ -54,6 +71,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 +96,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 +111,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 +156,57 @@ 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.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", + }, + "tui.altScreen.nextPrompt": { + 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; 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..841f9f9611a --- /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 && /\s/.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..51f738ecded --- /dev/null +++ b/packages/pi-tui/src/layout.ts @@ -0,0 +1,410 @@ +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); + } + // 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); + + 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/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-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..fd01434c522 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 */ @@ -93,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 */ @@ -175,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) => { @@ -308,11 +335,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..4acaf77c323 --- /dev/null +++ b/packages/pi-tui/src/tui-alt-screen.ts @@ -0,0 +1,1302 @@ +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"; +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, + type OverlayHandle, + 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; +} + +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. */ + 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 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; + + 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.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)); + } + + 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.closeSearch(); + 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; + } + } + + 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); + } + + 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(); + this.stopScrollbarDrag(); + this.pressedUrl = undefined; + this.selectionDragged = false; + if (hadActiveSelection) { + this.selectionAnchor = undefined; + this.selectionFocus = undefined; + this.selectionGranularity = "character"; + this.selectionInitialRange = undefined; + if (hadNonEmptyActiveSelection) this.requestRender(); + } + this.lastClick = undefined; + // 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 undefined; + + 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 (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)); + } + 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.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 }; + } + 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 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; + 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; + 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); + 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..9a4ecb8817e 100644 --- a/packages/pi-tui/test/keybindings.test.ts +++ b/packages/pi-tui/test/keybindings.test.ts @@ -11,6 +11,41 @@ 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.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"]); + }); + 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..a9aca930b62 --- /dev/null +++ b/packages/pi-tui/test/latex.test.ts @@ -0,0 +1,484 @@ +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"); + assert.strictEqual(renderLatex("\\frac{1}\n{2}", { display: true }), "1\n─\n2"); + }); + + 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/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/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/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/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..80eadd6264c 100644 --- a/packages/pi-tui/test/terminal.test.ts +++ b/packages/pi-tui/test/terminal.test.ts @@ -1,7 +1,54 @@ 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, + 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", () => { + 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", () => { @@ -176,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); @@ -190,6 +237,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..69c494e4a53 --- /dev/null +++ b/packages/pi-tui/test/tui-alt-screen.test.ts @@ -0,0 +1,1323 @@ +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"; +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("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); + 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("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); + 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); + 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("\x1b[1mal\x1b[0mpha\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("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!"))); + + 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("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"); + terminal.sendInput("\x1b[<32;4;2M"); + terminal.sendInput("\x1b[<0;4;2m"); + await terminal.waitForRender(); + assert.strictEqual(clipboardWriteCount(), 0); + + // 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(); + 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("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); + 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);