import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; import { deleteKittyImage, isImageLine } from "./terminal-image.ts"; import { type TUI, TuiBase, type TuiStopOptions } from "./tui.ts"; import { visibleWidth } from "./utils.ts"; const KITTY_SEQUENCE_PREFIX = "\x1b_G"; const MAX_RENDER_WRITE_CHARS = 1024 * 1024; /** * Streams terminal output in 1 MiB chunks so a full render never forms one string large enough to exceed V8's limit. * * `append()` fills the current chunk and flushes it when full. Oversized input is split at chunk boundaries, preserving * surrogate pairs so each write remains valid UTF-16. Callers append synchronized-output begin/end sequences themselves; * the final `flush()` writes any remainder, including the end sequence. */ class BoundedTerminalWriter { private buffer = ""; private writtenChars = 0; private readonly write: (data: string) => void; constructor(write: (data: string) => void) { this.write = write; } /** * Append terminal data, flushing full chunks as needed. Callers must call `flush()` after the final append. * @param value Terminal data to write in order; oversized values are split without splitting surrogate pairs. */ append(value: string): void { let offset = 0; while (offset < value.length) { const capacity = MAX_RENDER_WRITE_CHARS - this.buffer.length; if (capacity === 0) { this.flush(); continue; } let end = Math.min(value.length, offset + capacity); if ( end < value.length && value.charCodeAt(end - 1) >= 0xd800 && value.charCodeAt(end - 1) <= 0xdbff && value.charCodeAt(end) >= 0xdc00 && value.charCodeAt(end) <= 0xdfff ) { end--; } if (end === offset) { this.flush(); continue; } this.buffer += value.slice(offset, end); offset = end; if (this.buffer.length === MAX_RENDER_WRITE_CHARS) { this.flush(); } } } /** Write the current chunk, if any, and retain only its character count for debug output. */ flush(): void { if (!this.buffer) return; this.write(this.buffer); this.writtenChars += this.buffer.length; this.buffer = ""; } get length(): number { return this.writtenChars + this.buffer.length; } } 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[] = []; 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.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.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 collectKittyImageIds(lines: string[]): Set { const ids = new Set(); for (const line of lines) { for (const id of extractKittyImageIds(line)) { 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[], ): { firstChanged: number; lastChanged: number } { let expandedFirstChanged = firstChanged; let expandedLastChanged = lastChanged; const expandForLines = (lines: string[]): void => { for (let i = 0; i < lines.length; i++) { if (extractKittyImageIds(lines[i]).length === 0) continue; const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1; if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) { expandedFirstChanged = Math.min(expandedFirstChanged, i); expandedLastChanged = Math.max(expandedLastChanged, blockEnd); } } }; expandForLines(this.previousLines); expandForLines(newLines); return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged }; } private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string { if (firstChanged < 0 || lastChanged < firstChanged) return ""; const ids = new Set(); const maxLine = Math.min(lastChanged, this.previousLines.length - 1); for (let i = firstChanged; i <= maxLine; i++) { for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) { ids.add(id); } } return this.deleteKittyImages(ids); } protected doRender(): void { if (this.stopped) return; const width = this.terminal.columns; const height = this.terminal.rows; const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width; const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height; const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height; let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop; let viewportTop = prevViewportTop; let hardwareCursorRow = this.hardwareCursorRow; const computeLineDiff = (targetRow: number): number => { const currentScreenRow = hardwareCursorRow - prevViewportTop; const targetScreenRow = targetRow - viewportTop; return targetScreenRow - currentScreenRow; }; // Render all components to get new lines let newLines = this.render(width); // Composite overlays into the rendered lines (before differential compare) if (this.hasOverlayEntries) { newLines = this.compositeOverlays(newLines, width, height); } // Extract cursor position before applying line resets (marker must be found first) const cursorPos = this.extractCursorPosition(newLines, height); newLines = this.applyLineResets(newLines); // Helper to clear scrollback and viewport and render all new lines const fullRender = (clear: boolean): void => { this.fullRedrawCount += 1; const output = new BoundedTerminalWriter((data) => this.terminal.write(data)); output.append("\x1b[?2026h"); // Begin synchronized output if (clear) { output.append(this.deleteKittyImages(this.previousKittyImageIds)); output.append("\x1b[2J\x1b[H\x1b[3J"); // Clear screen, home, then clear scrollback } for (let i = 0; i < newLines.length; i++) { if (i > 0) output.append("\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++) { output.append("\r\n"); } output.append(`\x1b[${imageReservedRows - 1}A`); output.append(line); output.append(`\x1b[${imageReservedRows - 1}B`); i += imageReservedRows - 1; continue; } output.append(line); } output.append("\x1b[?2026l"); // End synchronized output output.flush(); this.cursorRow = Math.max(0, newLines.length - 1); this.hardwareCursorRow = this.cursorRow; // Reset max lines when clearing, otherwise track growth if (clear) { this.maxLinesRendered = newLines.length; } else { this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); } const bufferLength = Math.max(height, newLines.length); this.previousViewportTop = Math.max(0, bufferLength - height); this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousKittyImageIds = this.collectKittyImageIds(newLines); this.previousWidth = width; this.previousHeight = height; }; const redrawLogDirectory = process.env.PI_TUI_DEBUG_REDRAW === "1" ? this.logDirectory : undefined; const logRedraw = (reason: string): void => { if (redrawLogDirectory === undefined) return; const logPath = path.join(redrawLogDirectory, "pi-tui-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() if (this.getClearOnShrink() && newLines.length < this.maxLinesRendered && !this.hasOverlayEntries) { logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`); fullRender(true); return; } // Find first and last changed lines let firstChanged = -1; let lastChanged = -1; const maxLines = Math.max(newLines.length, this.previousLines.length); for (let i = 0; i < maxLines; i++) { const oldLine = i < this.previousLines.length ? this.previousLines[i] : ""; const newLine = i < newLines.length ? newLines[i] : ""; if (oldLine !== newLine) { if (firstChanged === -1) { firstChanged = i; } lastChanged = i; } } const appendedLines = newLines.length > this.previousLines.length; if (appendedLines) { if (firstChanged === -1) { firstChanged = this.previousLines.length; } lastChanged = newLines.length - 1; } if (firstChanged !== -1) { const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines); firstChanged = expandedRange.firstChanged; lastChanged = expandedRange.lastChanged; } const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0; // No changes - but still need to update hardware cursor position if it moved if (firstChanged === -1) { this.positionHardwareCursor(cursorPos, newLines.length); this.previousViewportTop = prevViewportTop; this.previousHeight = height; return; } // All changes are in deleted lines (nothing to render, just clear) if (firstChanged >= newLines.length) { if (this.previousLines.length > newLines.length) { const output = new BoundedTerminalWriter((data) => this.terminal.write(data)); output.append("\x1b[?2026h"); output.append(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) output.append(`\x1b[${lineDiff}B`); else if (lineDiff < 0) output.append(`\x1b[${-lineDiff}A`); output.append("\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) { output.append(`\x1b[${clearStartOffset}B`); } for (let i = 0; i < extraLines; i++) { output.append("\r\x1b[2K"); if (i < extraLines - 1) output.append("\x1b[1B"); } const moveBack = Math.max(0, extraLines - 1 + clearStartOffset); if (moveBack > 0) { output.append(`\x1b[${moveBack}A`); } output.append("\x1b[?2026l"); output.flush(); this.cursorRow = targetRow; this.hardwareCursorRow = targetRow; } this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousKittyImageIds = this.collectKittyImageIds(newLines); this.previousWidth = width; this.previousHeight = height; this.previousViewportTop = prevViewportTop; return; } // Differential rendering can only touch what was actually visible. // If the first changed line is above the previous viewport, we need a full redraw. if (firstChanged < prevViewportTop) { logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`); fullRender(true); return; } // Render from first changed line to end // Keep updates wrapped in synchronized output while writing bounded chunks. const output = new BoundedTerminalWriter((data) => this.terminal.write(data)); output.append("\x1b[?2026h"); // Begin synchronized output output.append(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) { output.append(`\x1b[${moveToBottom}B`); } const scroll = moveTargetRow - prevViewportBottom; output.append("\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) { output.append(`\x1b[${lineDiff}B`); // Move down } else if (lineDiff < 0) { output.append(`\x1b[${-lineDiff}A`); // Move up } output.append(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) output.append("\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; } output.append("\x1b[2K"); for (let row = 1; row < imageReservedRows; row++) { output.append("\r\n\x1b[2K"); } output.append(`\x1b[${imageReservedRows - 1}A`); output.append(line); output.append(`\x1b[${imageReservedRows - 1}B`); i += imageReservedRows - 1; continue; } output.append("\x1b[2K"); // Clear current line if (!isImage && visibleWidth(line) > width) { // Log all lines to crash file for debugging const crashLogPath = path.join(this.logDirectory ?? os.tmpdir(), "pi-tui-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); } output.append(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; output.append(`\x1b[${moveDown}B`); finalCursorRow = newLines.length - 1; } const extraLines = this.previousLines.length - newLines.length; for (let i = newLines.length; i < this.previousLines.length; i++) { output.append("\r\n\x1b[2K"); } // Move cursor back to end of new content output.append(`\x1b[${extraLines}A`); } output.append("\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 ===", `[${output.length} chars written in bounded chunks]`, ].join("\n"); fs.writeFileSync(debugPath, debugData); } output.flush(); // Track cursor position for next render // cursorRow tracks end of content (for viewport calculation) // hardwareCursorRow tracks actual terminal cursor position (for movement) this.cursorRow = Math.max(0, newLines.length - 1); this.hardwareCursorRow = finalCursorRow; // Track terminal's working area (grows but doesn't shrink unless cleared) this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length); this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1); // Position hardware cursor for IME this.positionHardwareCursor(cursorPos, newLines.length); this.previousLines = newLines; this.previousKittyImageIds = this.collectKittyImageIds(newLines); this.previousWidth = width; this.previousHeight = height; } /** * Position the hardware cursor for IME candidate window. * @param cursorPos The cursor position extracted from rendered output, or null * @param totalLines Total number of rendered lines */ private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void { if (!cursorPos || totalLines <= 0) { this.terminal.hideCursor(); return; } // Clamp cursor position to valid range const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1)); const targetCol = Math.max(0, cursorPos.col); // Move cursor from current position to target const rowDelta = targetRow - this.hardwareCursorRow; let buffer = ""; if (rowDelta > 0) { buffer += `\x1b[${rowDelta}B`; // Move down } else if (rowDelta < 0) { buffer += `\x1b[${-rowDelta}A`; // Move up } // Move to absolute column (1-indexed) buffer += `\x1b[${targetCol + 1}G`; if (buffer) { this.terminal.write(buffer); } this.hardwareCursorRow = targetRow; if (this.getShowHardwareCursor()) { this.terminal.showCursor(); } else { this.terminal.hideCursor(); } } }