/** * 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 { isKeyRelease, matchesKey } from "./keys.js"; import type { Terminal } from "./terminal.js"; import { getCapabilities, setCellDimensions } from "./terminal-image.js"; import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js"; /** * Component interface - all components must implement this */ export interface Component { /** * Render the component to lines for the given viewport width * @param width - Current viewport width * @returns Array of strings, each representing a line */ render(width: number): string[]; /** * Optional handler for keyboard input when component has focus */ handleInput?(data: string): void; /** * If true, component receives key release events (Kitty protocol). * Default is false - release events are filtered out. */ wantsKeyRelease?: boolean; /** * Invalidate any cached rendering state. * Called when theme changes or when component needs to re-render from scratch. */ invalidate(): void; } export { visibleWidth }; /** * Anchor position for overlays */ export type OverlayAnchor = | "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "top-center" | "bottom-center" | "left-center" | "right-center"; /** * Margin configuration for overlays */ export interface OverlayMargin { top?: number; right?: number; bottom?: number; left?: number; } /** Value that can be absolute (number) or percentage (string like "50%") */ export type SizeValue = number | `${number}%`; /** Parse a SizeValue into absolute value given a reference size */ function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined { if (value === undefined) return undefined; if (typeof value === "number") return value; // Parse percentage string like "50%" const match = value.match(/^(\d+(?:\.\d+)?)%$/); if (match) { return Math.floor((referenceSize * parseFloat(match[1])) / 100); } return undefined; } /** * Options for overlay positioning and sizing. * Values can be absolute numbers or percentage strings (e.g., "50%"). */ export interface OverlayOptions { // === Sizing === /** Width in columns, or percentage of terminal width (e.g., "50%") */ width?: SizeValue; /** Minimum width in columns */ minWidth?: number; /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */ maxHeight?: SizeValue; // === Positioning - anchor-based === /** Anchor point for positioning (default: 'center') */ anchor?: OverlayAnchor; /** Horizontal offset from anchor position (positive = right) */ offsetX?: number; /** Vertical offset from anchor position (positive = down) */ offsetY?: number; // === Positioning - percentage or absolute === /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */ row?: SizeValue; /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */ col?: SizeValue; // === Margin from terminal edges === /** Margin from terminal edges. Number applies to all sides. */ margin?: OverlayMargin | number; // === Visibility === /** * Control overlay visibility based on terminal dimensions. * If provided, overlay is only rendered when this returns true. * Called each render cycle with current terminal dimensions. */ visible?: (termWidth: number, termHeight: number) => boolean; } /** * Handle returned by showOverlay for controlling the overlay */ export interface OverlayHandle { /** Permanently remove the overlay (cannot be shown again) */ hide(): void; /** Temporarily hide or show the overlay */ setHidden(hidden: boolean): void; /** Check if overlay is temporarily hidden */ isHidden(): boolean; } /** * Container - a component that contains other components */ export class Container implements Component { children: Component[] = []; addChild(component: Component): void { this.children.push(component); } removeChild(component: Component): void { const index = this.children.indexOf(component); if (index !== -1) { this.children.splice(index, 1); } } clear(): void { this.children = []; } invalidate(): void { for (const child of this.children) { child.invalidate?.(); } } render(width: number): string[] { const lines: string[] = []; for (const child of this.children) { lines.push(...child.render(width)); } return lines; } } /** * TUI - Main class for managing terminal UI with differential rendering */ export class TUI extends Container { public terminal: Terminal; private previousLines: string[] = []; private previousWidth = 0; private focusedComponent: Component | null = null; /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ public onDebug?: () => void; private renderRequested = false; private cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line) private inputBuffer = ""; // Buffer for parsing terminal responses private cellSizeQueryPending = false; // Overlay stack for modal components rendered on top of base content private overlayStack: { component: Component; options?: OverlayOptions; preFocus: Component | null; hidden: boolean; }[] = []; constructor(terminal: Terminal) { super(); this.terminal = terminal; } setFocus(component: Component | null): void { this.focusedComponent = component; } /** * Show an overlay component with configurable positioning and sizing. * Returns a handle to control the overlay's visibility. */ showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { const entry = { component, options, preFocus: this.focusedComponent, hidden: false }; this.overlayStack.push(entry); // Only focus if overlay is actually visible if (this.isOverlayVisible(entry)) { this.setFocus(component); } this.terminal.hideCursor(); this.requestRender(); // Return handle for controlling this overlay return { hide: () => { const index = this.overlayStack.indexOf(entry); if (index !== -1) { this.overlayStack.splice(index, 1); // Restore focus if this overlay had focus if (this.focusedComponent === component) { const topVisible = this.getTopmostVisibleOverlay(); this.setFocus(topVisible?.component ?? entry.preFocus); } if (this.overlayStack.length === 0) this.terminal.hideCursor(); this.requestRender(); } }, setHidden: (hidden: boolean) => { if (entry.hidden === hidden) return; entry.hidden = hidden; // Update focus when hiding/showing if (hidden) { // If this overlay had focus, move focus to next visible or preFocus if (this.focusedComponent === component) { const topVisible = this.getTopmostVisibleOverlay(); this.setFocus(topVisible?.component ?? entry.preFocus); } } else { // Restore focus to this overlay when showing (if it's actually visible) if (this.isOverlayVisible(entry)) { this.setFocus(component); } } this.requestRender(); }, isHidden: () => entry.hidden, }; } /** Hide the topmost overlay and restore previous focus. */ hideOverlay(): void { const overlay = this.overlayStack.pop(); if (!overlay) return; // Find topmost visible overlay, or fall back to preFocus const topVisible = this.getTopmostVisibleOverlay(); this.setFocus(topVisible?.component ?? overlay.preFocus); if (this.overlayStack.length === 0) this.terminal.hideCursor(); this.requestRender(); } /** Check if there are any visible overlays */ hasOverlay(): boolean { return this.overlayStack.some((o) => this.isOverlayVisible(o)); } /** Check if an overlay entry is currently visible */ private isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean { if (entry.hidden) return false; if (entry.options?.visible) { return entry.options.visible(this.terminal.columns, this.terminal.rows); } return true; } /** Find the topmost visible overlay, if any */ private getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined { for (let i = this.overlayStack.length - 1; i >= 0; i--) { if (this.isOverlayVisible(this.overlayStack[i])) { return this.overlayStack[i]; } } return undefined; } override invalidate(): void { super.invalidate(); for (const overlay of this.overlayStack) overlay.component.invalidate?.(); } start(): void { this.terminal.start( (data) => this.handleInput(data), () => this.requestRender(), ); this.terminal.hideCursor(); this.queryCellSize(); this.requestRender(); } private queryCellSize(): void { // Only query if terminal supports images (cell size is only used for image rendering) if (!getCapabilities().images) { return; } // Query terminal for cell size in pixels: CSI 16 t // Response format: CSI 6 ; height ; width t this.cellSizeQueryPending = true; this.terminal.write("\x1b[16t"); } stop(): void { // 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.cursorRow; 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.terminal.showCursor(); this.terminal.stop(); } requestRender(force = false): void { if (force) { this.previousLines = []; this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear this.cursorRow = 0; } if (this.renderRequested) return; this.renderRequested = true; process.nextTick(() => { this.renderRequested = false; this.doRender(); }); } private handleInput(data: string): void { // If we're waiting for cell size response, buffer input and parse if (this.cellSizeQueryPending) { this.inputBuffer += data; const filtered = this.parseCellSizeResponse(); if (filtered.length === 0) return; data = filtered; } // Global debug key handler (Shift+Ctrl+D) if (matchesKey(data, "shift+ctrl+d") && this.onDebug) { this.onDebug(); return; } // If focused component is an overlay, verify it's still visible // (visibility can change due to terminal resize or visible() callback) const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent); if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) { // Focused overlay is no longer visible, redirect to topmost visible overlay const topVisible = this.getTopmostVisibleOverlay(); if (topVisible) { this.setFocus(topVisible.component); } else { // No visible overlays, restore to preFocus this.setFocus(focusedOverlay.preFocus); } } // Pass input to focused component (including Ctrl+C) // The focused component can decide how to handle Ctrl+C if (this.focusedComponent?.handleInput) { // Filter out key release events unless component opts in if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) { return; } this.focusedComponent.handleInput(data); this.requestRender(); } } private parseCellSizeResponse(): string { // Response format: ESC [ 6 ; height ; width t // Match the response pattern const responsePattern = /\x1b\[6;(\d+);(\d+)t/; const match = this.inputBuffer.match(responsePattern); if (match) { const heightPx = parseInt(match[1], 10); const widthPx = parseInt(match[2], 10); if (heightPx > 0 && widthPx > 0) { setCellDimensions({ widthPx, heightPx }); // Invalidate all components so images re-render with correct dimensions this.invalidate(); this.requestRender(); } // Remove the response from buffer this.inputBuffer = this.inputBuffer.replace(responsePattern, ""); this.cellSizeQueryPending = false; } // Check if we have a partial cell size response starting (wait for more data) // Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet) const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/; if (partialCellSizePattern.test(this.inputBuffer)) { // Check if it's actually a complete different escape sequence (ends with a letter) // Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc. const lastChar = this.inputBuffer[this.inputBuffer.length - 1]; if (!/[a-zA-Z~]/.test(lastChar)) { // Doesn't end with a terminator, might be incomplete - wait for more return ""; } } // No cell size response found, return buffered data as user input const result = this.inputBuffer; this.inputBuffer = ""; this.cellSizeQueryPending = false; // Give up waiting return result; } private containsImage(line: string): boolean { return line.includes("\x1b_G") || line.includes("\x1b]1337;File="); } /** * Resolve overlay layout from options. * Returns { width, row, col, maxHeight } for rendering. */ private resolveOverlayLayout( options: OverlayOptions | undefined, overlayHeight: number, termWidth: number, termHeight: number, ): { width: number; row: number; col: number; maxHeight: number | undefined } { const opt = options ?? {}; // Parse margin (clamp to non-negative) const margin = typeof opt.margin === "number" ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin } : (opt.margin ?? {}); const marginTop = Math.max(0, margin.top ?? 0); const marginRight = Math.max(0, margin.right ?? 0); const marginBottom = Math.max(0, margin.bottom ?? 0); const marginLeft = Math.max(0, margin.left ?? 0); // Available space after margins const availWidth = Math.max(1, termWidth - marginLeft - marginRight); const availHeight = Math.max(1, termHeight - marginTop - marginBottom); // === Resolve width === let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth); // Apply minWidth if (opt.minWidth !== undefined) { width = Math.max(width, opt.minWidth); } // Clamp to available space width = Math.max(1, Math.min(width, availWidth)); // === Resolve maxHeight === let maxHeight = parseSizeValue(opt.maxHeight, termHeight); // Clamp to available space if (maxHeight !== undefined) { maxHeight = Math.max(1, Math.min(maxHeight, availHeight)); } // Effective overlay height (may be clamped by maxHeight) const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight; // === Resolve position === let row: number; let col: number; if (opt.row !== undefined) { if (typeof opt.row === "string") { // Percentage: 0% = top, 100% = bottom (overlay stays within bounds) const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/); if (match) { const maxRow = Math.max(0, availHeight - effectiveHeight); const percent = parseFloat(match[1]) / 100; row = marginTop + Math.floor(maxRow * percent); } else { // Invalid format, fall back to center row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop); } } else { // Absolute row position row = opt.row; } } else { // Anchor-based (default: center) const anchor = opt.anchor ?? "center"; row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop); } if (opt.col !== undefined) { if (typeof opt.col === "string") { // Percentage: 0% = left, 100% = right (overlay stays within bounds) const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/); if (match) { const maxCol = Math.max(0, availWidth - width); const percent = parseFloat(match[1]) / 100; col = marginLeft + Math.floor(maxCol * percent); } else { // Invalid format, fall back to center col = this.resolveAnchorCol("center", width, availWidth, marginLeft); } } else { // Absolute column position col = opt.col; } } else { // Anchor-based (default: center) const anchor = opt.anchor ?? "center"; col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft); } // Apply offsets if (opt.offsetY !== undefined) row += opt.offsetY; if (opt.offsetX !== undefined) col += opt.offsetX; // Clamp to terminal bounds (respecting margins) row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight)); col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width)); return { width, row, col, maxHeight }; } private resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number { switch (anchor) { case "top-left": case "top-center": case "top-right": return marginTop; case "bottom-left": case "bottom-center": case "bottom-right": return marginTop + availHeight - height; case "left-center": case "center": case "right-center": return marginTop + Math.floor((availHeight - height) / 2); } } private resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number { switch (anchor) { case "top-left": case "left-center": case "bottom-left": return marginLeft; case "top-right": case "right-center": case "bottom-right": return marginLeft + availWidth - width; case "top-center": case "center": case "bottom-center": return marginLeft + Math.floor((availWidth - width) / 2); } } /** Composite all overlays into content lines (in stack order, later = on top). */ private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { if (this.overlayStack.length === 0) return lines; const result = [...lines]; // Pre-render all visible overlays and calculate positions const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = []; let minLinesNeeded = result.length; for (const entry of this.overlayStack) { // Skip invisible overlays (hidden or visible() returns false) if (!this.isOverlayVisible(entry)) continue; const { component, options } = entry; // Get layout with height=0 first to determine width and maxHeight // (width and maxHeight don't depend on overlay height) const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight); // Render component at calculated width let overlayLines = component.render(width); // Apply maxHeight if specified if (maxHeight !== undefined && overlayLines.length > maxHeight) { overlayLines = overlayLines.slice(0, maxHeight); } // Get final row/col with actual overlay height const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); rendered.push({ overlayLines, row, col, w: width }); minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length); } // Extend result with empty lines if content is too short for overlay placement while (result.length < minLinesNeeded) { result.push(""); } const viewportStart = Math.max(0, result.length - termHeight); // Track which lines were modified for final verification const modifiedLines = new Set(); // Composite each overlay for (const { overlayLines, row, col, w } of rendered) { for (let i = 0; i < overlayLines.length; i++) { const idx = viewportStart + row + i; if (idx >= 0 && idx < result.length) { // Defensive: truncate overlay line to declared width before compositing // (components should already respect width, but this ensures it) const truncatedOverlayLine = visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i]; result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth); modifiedLines.add(idx); } } } // Final verification: ensure no composited line exceeds terminal width // This is a belt-and-suspenders safeguard - compositeLineAt should already // guarantee this, but we verify here to prevent crashes from any edge cases // Only check lines that were actually modified (optimization) for (const idx of modifiedLines) { const lineWidth = visibleWidth(result[idx]); if (lineWidth > termWidth) { result[idx] = sliceByColumn(result[idx], 0, termWidth, true); } } return result; } private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; private applyLineResets(lines: string[]): string[] { const reset = TUI.SEGMENT_RESET; return lines.map((line) => (this.containsImage(line) ? line : line + reset)); } /** Splice overlay content into a base line at a specific column. Single-pass optimized. */ private compositeLineAt( baseLine: string, overlayLine: string, startCol: number, overlayWidth: number, totalWidth: number, ): string { if (this.containsImage(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); } private doRender(): void { const width = this.terminal.columns; const height = this.terminal.rows; // 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); } newLines = this.applyLineResets(newLines); // Width changed - need full re-render const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; // First render - just output everything without clearing (assumes clean screen) if (this.previousLines.length === 0 && !widthChanged) { let buffer = "\x1b[?2026h"; // Begin synchronized output for (let i = 0; i < newLines.length; i++) { if (i > 0) buffer += "\r\n"; buffer += newLines[i]; } buffer += "\x1b[?2026l"; // End synchronized output this.terminal.write(buffer); // After rendering N lines, cursor is at end of last line (clamp to 0 for empty) this.cursorRow = Math.max(0, newLines.length - 1); this.previousLines = newLines; this.previousWidth = width; return; } // Width changed - full re-render if (widthChanged) { let buffer = "\x1b[?2026h"; // Begin synchronized output buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home for (let i = 0; i < newLines.length; i++) { if (i > 0) buffer += "\r\n"; buffer += newLines[i]; } buffer += "\x1b[?2026l"; // End synchronized output this.terminal.write(buffer); this.cursorRow = Math.max(0, newLines.length - 1); this.previousLines = newLines; this.previousWidth = width; 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; } } // No changes if (firstChanged === -1) { 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"; // Move to end of new content (clamp to 0 for empty content) const targetRow = Math.max(0, newLines.length - 1); const lineDiff = targetRow - this.cursorRow; if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`; else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`; buffer += "\r"; // Clear extra lines const extraLines = this.previousLines.length - newLines.length; for (let i = 0; i < extraLines; i++) { buffer += "\r\n\x1b[2K"; } buffer += `\x1b[${extraLines}A`; buffer += "\x1b[?2026l"; this.terminal.write(buffer); this.cursorRow = targetRow; } this.previousLines = newLines; this.previousWidth = width; return; } // Check if firstChanged is outside the viewport // cursorRow is the line where cursor is (0-indexed) // Viewport shows lines from (cursorRow - height + 1) to cursorRow // If firstChanged < viewportTop, we need full re-render const viewportTop = this.cursorRow - height + 1; if (firstChanged < viewportTop) { // First change is above viewport - need full re-render let buffer = "\x1b[?2026h"; // Begin synchronized output buffer += "\x1b[3J\x1b[2J\x1b[H"; // Clear scrollback, screen, and home for (let i = 0; i < newLines.length; i++) { if (i > 0) buffer += "\r\n"; buffer += newLines[i]; } buffer += "\x1b[?2026l"; // End synchronized output this.terminal.write(buffer); this.cursorRow = Math.max(0, newLines.length - 1); this.previousLines = newLines; this.previousWidth = width; return; } // Render from first changed line to end // Build buffer with all updates wrapped in synchronized output let buffer = "\x1b[?2026h"; // Begin synchronized output // Move cursor to first changed line const lineDiff = firstChanged - this.cursorRow; if (lineDiff > 0) { buffer += `\x1b[${lineDiff}B`; // Move down } else if (lineDiff < 0) { buffer += `\x1b[${-lineDiff}A`; // Move up } buffer += "\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"; buffer += "\x1b[2K"; // Clear current line const line = newLines[i]; const isImageLine = this.containsImage(line); if (!isImageLine && visibleWidth(line) > width) { // Log all lines to crash file for debugging const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log"); const crashData = [ `Crash at ${new Date().toISOString()}`, `Terminal width: ${width}`, `Line ${i} visible width: ${visibleWidth(line)}`, "", "=== All rendered lines ===", ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`), "", ].join("\n"); fs.mkdirSync(path.dirname(crashLogPath), { recursive: true }); fs.writeFileSync(crashLogPath, crashData); // Clean up terminal state before throwing this.stop(); const errorMsg = [ `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`, "", "This is likely caused by a custom TUI component not truncating its output.", "Use visibleWidth() to measure and truncateToWidth() to truncate lines.", "", `Debug log written to: ${crashLogPath}`, ].join("\n"); throw new Error(errorMsg); } buffer += line; } // Track where cursor ended up after rendering let finalCursorRow = renderEnd; // If we had more lines before, clear them and move cursor back if (this.previousLines.length > newLines.length) { // Move to end of new content first if we stopped before it if (renderEnd < newLines.length - 1) { const moveDown = newLines.length - 1 - renderEnd; buffer += `\x1b[${moveDown}B`; finalCursorRow = newLines.length - 1; } const extraLines = this.previousLines.length - newLines.length; for (let i = newLines.length; i < this.previousLines.length; i++) { buffer += "\r\n\x1b[2K"; } // Move cursor back to end of new content buffer += `\x1b[${extraLines}A`; } buffer += "\x1b[?2026l"; // End synchronized output // Write entire buffer at once this.terminal.write(buffer); // Track cursor position for next render this.cursorRow = finalCursorRow; this.previousLines = newLines; this.previousWidth = width; } }