tui.ts
57 KB1720 lines
tui.ts
1/**2 * Minimal TUI implementation with differential rendering3 */45import * as fs from "node:fs";6import * as os from "node:os";7import * as path from "node:path";8import { performance } from "node:perf_hooks";9import { isKeyRelease, matchesKey } from "./keys.ts";10import type { Terminal } from "./terminal.ts";11import {12 isOsc11BackgroundColorResponse,13 parseOsc11BackgroundColor,14 parseTerminalColorSchemeReport,15 type RgbColor,16 type TerminalColorScheme,17} from "./terminal-colors.ts";18import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts";19import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts";2021const KITTY_SEQUENCE_PREFIX = "\x1b_G";2223interface KittyImageHeader {24 ids: number[];25 rows: number;26}2728function parseKittyImageHeader(line: string): KittyImageHeader | undefined {29 const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);30 if (sequenceStart === -1) return undefined;3132 const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;33 const paramsEnd = line.indexOf(";", paramsStart);34 if (paramsEnd === -1) return undefined;3536 const ids: number[] = [];37 let rows = 1;38 const params = line.slice(paramsStart, paramsEnd);39 for (const param of params.split(",")) {40 const [key, value] = param.split("=", 2);41 if (value === undefined) continue;42 const numberValue = Number(value);43 if (!Number.isInteger(numberValue) || numberValue <= 0 || numberValue > 0xffffffff) continue;44 if (key === "i") {45 ids.push(numberValue);46 } else if (key === "r") {47 rows = numberValue;48 }49 }50 return { ids, rows };51}5253function extractKittyImageIds(line: string): number[] {54 return parseKittyImageHeader(line)?.ids ?? [];55}5657function extractKittyImageRows(line: string): number {58 return parseKittyImageHeader(line)?.rows ?? 1;59}6061/**62 * Component interface - all components must implement this63 */64export interface Component {65 /**66 * Render the component to lines for the given viewport width67 * @param width - Current viewport width68 * @returns Array of strings, each representing a line69 */70 render(width: number): string[];7172 /**73 * Optional handler for keyboard input when component has focus74 */75 handleInput?(data: string): void;7677 /**78 * If true, component receives key release events (Kitty protocol).79 * Default is false - release events are filtered out.80 */81 wantsKeyRelease?: boolean;8283 /**84 * Invalidate any cached rendering state.85 * Called when theme changes or when component needs to re-render from scratch.86 */87 invalidate(): void;88}8990type InputListenerResult = { consume?: boolean; data?: string } | undefined;91type InputListener = (data: string) => InputListenerResult;92type PendingOsc11BackgroundQuery = {93 settled: boolean;94 resolve: ((rgb: RgbColor | undefined) => void) | undefined;95 timer: NodeJS.Timeout | undefined;96};9798/**99 * Interface for components that can receive focus and display a hardware cursor.100 * When focused, the component should emit CURSOR_MARKER at the cursor position101 * in its render output. TUI will find this marker and position the hardware102 * cursor there for proper IME candidate window positioning.103 */104export interface Focusable {105 /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */106 focused: boolean;107}108109/** Type guard to check if a component implements Focusable */110export function isFocusable(component: Component | null): component is Component & Focusable {111 return component !== null && "focused" in component;112}113114/**115 * Cursor position marker - APC (Application Program Command) sequence.116 * This is a zero-width escape sequence that terminals ignore.117 * Components emit this at the cursor position when focused.118 * TUI finds and strips this marker, then positions the hardware cursor there.119 */120export const CURSOR_MARKER = "\x1b_pi:c\x07";121122export { visibleWidth };123124/**125 * Anchor position for overlays126 */127export type OverlayAnchor =128 | "center"129 | "top-left"130 | "top-right"131 | "bottom-left"132 | "bottom-right"133 | "top-center"134 | "bottom-center"135 | "left-center"136 | "right-center";137138/**139 * Margin configuration for overlays140 */141export interface OverlayMargin {142 top?: number;143 right?: number;144 bottom?: number;145 left?: number;146}147148/** Value that can be absolute (number) or percentage (string like "50%") */149export type SizeValue = number | `${number}%`;150151/** Parse a SizeValue into absolute value given a reference size */152function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {153 if (value === undefined) return undefined;154 if (typeof value === "number") return value;155 // Parse percentage string like "50%"156 const match = value.match(/^(\d+(?:\.\d+)?)%$/);157 if (match) {158 return Math.floor((referenceSize * parseFloat(match[1])) / 100);159 }160 return undefined;161}162163function isTermuxSession(): boolean {164 return Boolean(process.env.TERMUX_VERSION);165}166167/**168 * Options for overlay positioning and sizing.169 * Values can be absolute numbers or percentage strings (e.g., "50%").170 */171export interface OverlayOptions {172 // === Sizing ===173 /** Width in columns, or percentage of terminal width (e.g., "50%") */174 width?: SizeValue;175 /** Minimum width in columns */176 minWidth?: number;177 /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */178 maxHeight?: SizeValue;179180 // === Positioning - anchor-based ===181 /** Anchor point for positioning (default: 'center') */182 anchor?: OverlayAnchor;183 /** Horizontal offset from anchor position (positive = right) */184 offsetX?: number;185 /** Vertical offset from anchor position (positive = down) */186 offsetY?: number;187188 // === Positioning - percentage or absolute ===189 /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */190 row?: SizeValue;191 /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */192 col?: SizeValue;193194 // === Margin from terminal edges ===195 /** Margin from terminal edges. Number applies to all sides. */196 margin?: OverlayMargin | number;197198 // === Visibility ===199 /**200 * Control overlay visibility based on terminal dimensions.201 * If provided, overlay is only rendered when this returns true.202 * Called each render cycle with current terminal dimensions.203 */204 visible?: (termWidth: number, termHeight: number) => boolean;205 /** If true, don't capture keyboard focus when shown */206 nonCapturing?: boolean;207}208209/** Options for {@link OverlayHandle.unfocus}. */210export interface OverlayUnfocusOptions {211 /** Explicit target to focus after releasing this overlay. */212 target: Component | null;213}214215/**216 * Handle returned by showOverlay for controlling the overlay217 */218export interface OverlayHandle {219 /** Permanently remove the overlay (cannot be shown again) */220 hide(): void;221 /** Temporarily hide or show the overlay */222 setHidden(hidden: boolean): void;223 /** Check if overlay is temporarily hidden */224 isHidden(): boolean;225 /** Focus this overlay and bring it to the visual front */226 focus(): void;227 /** Release focus to the next visible capturing overlay or previous target, or to an explicit target when provided */228 unfocus(options?: OverlayUnfocusOptions): void;229 /** Check if this overlay currently has focus */230 isFocused(): boolean;231}232233type OverlayStackEntry = {234 component: Component;235 options?: OverlayOptions;236 preFocus: Component | null;237 hidden: boolean;238 focusOrder: number;239};240241type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null };242type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry };243type BlockedOverlayFocusRestoreState = {244 status: "blocked";245 overlay: OverlayStackEntry;246 blockedBy: Component;247 resume: OverlayBlockedFocusResume;248};249type ActiveOverlayFocusRestoreState = EligibleOverlayFocusRestoreState | BlockedOverlayFocusRestoreState;250type OverlayFocusRestoreState = { status: "inactive" } | ActiveOverlayFocusRestoreState;251type OverlayFocusRestorePolicy = "clear" | "preserve";252253/**254 * Container - a component that contains other components255 */256export class Container implements Component {257 children: Component[] = [];258259 addChild(component: Component): void {260 this.children.push(component);261 }262263 removeChild(component: Component): void {264 const index = this.children.indexOf(component);265 if (index !== -1) {266 this.children.splice(index, 1);267 }268 }269270 clear(): void {271 this.children = [];272 }273274 invalidate(): void {275 for (const child of this.children) {276 child.invalidate?.();277 }278 }279280 render(width: number): string[] {281 const lines: string[] = [];282 for (const child of this.children) {283 const childLines = child.render(width);284 for (const line of childLines) {285 lines.push(line);286 }287 }288 return lines;289 }290}291292/**293 * TUI - Main class for managing terminal UI with differential rendering294 */295export class TUI extends Container {296 public terminal: Terminal;297 private previousLines: string[] = [];298 private previousKittyImageIds = new Set<number>();299 private previousWidth = 0;300 private previousHeight = 0;301 private focusedComponent: Component | null = null;302 private inputListeners = new Set<InputListener>();303304 /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */305 public onDebug?: () => void;306 private renderRequested = false;307 private renderTimer: NodeJS.Timeout | undefined;308 private lastRenderAt = 0;309 private static readonly MIN_RENDER_INTERVAL_MS = 16;310 private cursorRow = 0; // Logical cursor row (end of rendered content)311 private hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)312 private showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";313 private clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)314 private maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)315 private previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves316 private fullRedrawCount = 0;317 private stopped = false;318 private pendingOsc11BackgroundReplies = 0;319 private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = [];320 private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>();321 private terminalColorSchemeNotificationsEnabled = false;322 private readonly logDirectory: string;323324 // Overlay stack for modal components rendered on top of base content325 private focusOrderCounter = 0;326 private overlayStack: OverlayStackEntry[] = [];327 private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" };328329 constructor(terminal: Terminal, showHardwareCursor?: boolean, logDirectory?: string) {330 super();331 this.terminal = terminal;332 this.logDirectory = logDirectory ?? process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");333 if (showHardwareCursor !== undefined) {334 this.showHardwareCursor = showHardwareCursor;335 }336 }337338 get fullRedraws(): number {339 return this.fullRedrawCount;340 }341342 getShowHardwareCursor(): boolean {343 return this.showHardwareCursor;344 }345346 setShowHardwareCursor(enabled: boolean): void {347 if (this.showHardwareCursor === enabled) return;348 this.showHardwareCursor = enabled;349 if (!enabled) {350 this.terminal.hideCursor();351 }352 this.requestRender();353 }354355 getClearOnShrink(): boolean {356 return this.clearOnShrink;357 }358359 /**360 * Set whether to trigger full re-render when content shrinks.361 * When true (default), empty rows are cleared when content shrinks.362 * When false, empty rows remain (reduces redraws on slower terminals).363 */364 setClearOnShrink(enabled: boolean): void {365 this.clearOnShrink = enabled;366 }367368 setFocus(component: Component | null): void {369 this.setFocusInternal({ component, overlayFocusRestore: "clear" });370 }371372 private setFocusInternal({373 component,374 overlayFocusRestore,375 }: {376 component: Component | null;377 overlayFocusRestore: OverlayFocusRestorePolicy;378 }): void {379 const previousFocus = this.focusedComponent;380 let nextFocus = component;381 const previousFocusedOverlay = previousFocus382 ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))383 : undefined;384 const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false;385 const restoreState = this.getVisibleOverlayFocusRestore();386 if (nextFocus && !nextFocusIsOverlay) {387 if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {388 if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {389 nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);390 } else {391 this.overlayFocusRestore = {392 status: "blocked",393 overlay: restoreState.overlay,394 blockedBy: nextFocus,395 resume: restoreState.resume,396 };397 }398 } else if (399 previousFocusedOverlay &&400 restoreState.status !== "inactive" &&401 restoreState.overlay === previousFocusedOverlay &&402 !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)403 ) {404 this.overlayFocusRestore = {405 status: "blocked",406 overlay: previousFocusedOverlay,407 blockedBy: nextFocus,408 resume: { status: "restore-overlay" },409 };410 }411 } else if (nextFocus === null) {412 if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {413 nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);414 } else if (overlayFocusRestore === "clear") {415 this.clearOverlayFocusRestore();416 }417 }418419 if (isFocusable(this.focusedComponent)) {420 this.focusedComponent.focused = false;421 }422423 this.focusedComponent = nextFocus;424425 if (isFocusable(nextFocus)) {426 nextFocus.focused = true;427 }428429 const focusedOverlay = nextFocus430 ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))431 : undefined;432 if (focusedOverlay) {433 this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };434 }435 }436437 private clearOverlayFocusRestore(): void {438 this.overlayFocusRestore = { status: "inactive" };439 }440441 private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void {442 if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {443 this.clearOverlayFocusRestore();444 }445 }446447 private resolveBlockedOverlayFocusResume(restoreState: BlockedOverlayFocusRestoreState): Component | null {448 if (restoreState.resume.status === "restore-overlay") return restoreState.overlay.component;449 this.clearOverlayFocusRestore();450 return restoreState.resume.target;451 }452453 private getVisibleOverlayFocusRestore(): OverlayFocusRestoreState {454 const restoreState = this.overlayFocusRestore;455 if (restoreState.status === "inactive") return restoreState;456 if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {457 return { status: "inactive" };458 }459 return restoreState;460 }461462 private isOverlayFocusAncestor(entry: OverlayStackEntry, component: Component): boolean {463 const visited = new Set<Component>();464 let current = entry.preFocus;465 while (current && !visited.has(current)) {466 visited.add(current);467 if (current === component) return true;468 current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null;469 }470 return false;471 }472473 private retargetOverlayPreFocus(removed: OverlayStackEntry): void {474 for (const overlay of this.overlayStack) {475 if (overlay !== removed && overlay.preFocus === removed.component) {476 overlay.preFocus = removed.preFocus;477 }478 }479 }480481 private isComponentMounted(component: Component): boolean {482 return this.children.some((child) => this.containsComponent(child, component));483 }484485 private containsComponent(root: Component, target: Component): boolean {486 if (root === target) return true;487 if (!(root instanceof Container)) return false;488 return root.children.some((child) => this.containsComponent(child, target));489 }490491 /**492 * Show an overlay component with configurable positioning and sizing.493 * Returns a handle to control the overlay's visibility.494 */495 showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {496 const entry: OverlayStackEntry = {497 component,498 ...(options === undefined ? {} : { options }),499 preFocus: this.focusedComponent,500 hidden: false,501 focusOrder: ++this.focusOrderCounter,502 };503 this.overlayStack.push(entry);504 // Only focus if overlay is actually visible505 if (!options?.nonCapturing && this.isOverlayVisible(entry)) {506 this.setFocus(component);507 }508 this.terminal.hideCursor();509 this.requestRender();510511 // Return handle for controlling this overlay512 return {513 hide: () => {514 const index = this.overlayStack.indexOf(entry);515 if (index !== -1) {516 this.clearOverlayFocusRestoreFor(entry);517 this.retargetOverlayPreFocus(entry);518 this.overlayStack.splice(index, 1);519 // Restore focus if this overlay had focus520 if (this.focusedComponent === component) {521 const topVisible = this.getTopmostVisibleOverlay();522 this.setFocus(topVisible?.component ?? entry.preFocus);523 }524 if (this.overlayStack.length === 0) this.terminal.hideCursor();525 this.requestRender();526 }527 },528 setHidden: (hidden: boolean) => {529 if (entry.hidden === hidden) return;530 entry.hidden = hidden;531 // Update focus when hiding/showing532 if (hidden) {533 this.clearOverlayFocusRestoreFor(entry);534 // If this overlay had focus, move focus to next visible or preFocus535 if (this.focusedComponent === component) {536 const topVisible = this.getTopmostVisibleOverlay();537 this.setFocus(topVisible?.component ?? entry.preFocus);538 }539 } else {540 // Restore focus to this overlay when showing (if it's actually visible)541 if (!options?.nonCapturing && this.isOverlayVisible(entry)) {542 entry.focusOrder = ++this.focusOrderCounter;543 this.setFocus(component);544 }545 }546 this.requestRender();547 },548 isHidden: () => entry.hidden,549 focus: () => {550 if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return;551 entry.focusOrder = ++this.focusOrderCounter;552 this.setFocus(component);553 this.requestRender();554 },555 unfocus: (unfocusOptions) => {556 const isFocused = this.focusedComponent === component;557 const restoreState = this.overlayFocusRestore;558 const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;559 if (!isFocused && !hasPendingRestore) return;560 if (561 restoreState.status === "blocked" &&562 restoreState.overlay === entry &&563 this.focusedComponent === restoreState.blockedBy564 ) {565 if (unfocusOptions) {566 this.overlayFocusRestore = {567 status: "blocked",568 overlay: entry,569 blockedBy: restoreState.blockedBy,570 resume: { status: "focus-target", target: unfocusOptions.target },571 };572 } else {573 this.clearOverlayFocusRestore();574 }575 this.requestRender();576 return;577 }578 this.clearOverlayFocusRestoreFor(entry);579 if (isFocused || unfocusOptions) {580 const topVisible = this.getTopmostVisibleOverlay();581 const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;582 this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);583 }584 this.requestRender();585 },586 isFocused: () => this.focusedComponent === component,587 };588 }589590 /** Hide the topmost overlay and restore previous focus. */591 hideOverlay(): void {592 const overlay = this.overlayStack[this.overlayStack.length - 1];593 if (!overlay) return;594 this.clearOverlayFocusRestoreFor(overlay);595 this.retargetOverlayPreFocus(overlay);596 this.overlayStack.pop();597 if (this.focusedComponent === overlay.component) {598 // Find topmost visible overlay, or fall back to preFocus599 const topVisible = this.getTopmostVisibleOverlay();600 this.setFocus(topVisible?.component ?? overlay.preFocus);601 }602 if (this.overlayStack.length === 0) this.terminal.hideCursor();603 this.requestRender();604 }605606 /** Check if there are any visible overlays */607 hasOverlay(): boolean {608 return this.overlayStack.some((o) => this.isOverlayVisible(o));609 }610611 /** Check if an overlay entry is currently visible */612 private isOverlayVisible(entry: OverlayStackEntry): boolean {613 if (entry.hidden) return false;614 if (entry.options?.visible) {615 return entry.options.visible(this.terminal.columns, this.terminal.rows);616 }617 return true;618 }619620 /** Find the visual-frontmost visible capturing overlay, if any */621 private getTopmostVisibleOverlay(): OverlayStackEntry | undefined {622 let topmost: OverlayStackEntry | undefined;623 for (const overlay of this.overlayStack) {624 if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay)) continue;625 if (!topmost || overlay.focusOrder > topmost.focusOrder) {626 topmost = overlay;627 }628 }629 return topmost;630 }631632 override invalidate(): void {633 super.invalidate();634 for (const overlay of this.overlayStack) overlay.component.invalidate?.();635 }636637 start(): void {638 this.stopped = false;639 this.terminal.start(640 (data) => this.handleInput(data),641 () => this.requestRender(),642 );643 this.terminal.hideCursor();644 if (this.terminalColorSchemeNotificationsEnabled) {645 this.terminal.write("\x1b[?2031h");646 }647 this.queryCellSize();648 this.requestRender();649 }650651 addInputListener(listener: InputListener): () => void {652 this.inputListeners.add(listener);653 return () => {654 this.inputListeners.delete(listener);655 };656 }657658 removeInputListener(listener: InputListener): void {659 this.inputListeners.delete(listener);660 }661662 onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void {663 this.terminalColorSchemeListeners.add(listener);664 return () => {665 this.terminalColorSchemeListeners.delete(listener);666 };667 }668669 setTerminalColorSchemeNotifications(enabled: boolean): void {670 if (this.terminalColorSchemeNotificationsEnabled === enabled) {671 return;672 }673 this.terminalColorSchemeNotificationsEnabled = enabled;674 if (!this.stopped) {675 this.terminal.write(enabled ? "\x1b[?2031h" : "\x1b[?2031l");676 }677 }678679 private queryCellSize(): void {680 // Only query if terminal supports images (cell size is only used for image rendering)681 if (!getCapabilities().images) {682 return;683 }684 // Query terminal for cell size in pixels: CSI 16 t685 // Response format: CSI 6 ; height ; width t686 this.terminal.write("\x1b[16t");687 }688689 stop(): void {690 this.stopped = true;691 if (this.renderTimer) {692 clearTimeout(this.renderTimer);693 this.renderTimer = undefined;694 }695 if (this.terminalColorSchemeNotificationsEnabled) {696 this.terminal.write("\x1b[?2031l");697 }698 // Move cursor to the end of the content to prevent overwriting/artifacts on exit699 if (this.previousLines.length > 0) {700 // Overwrite the inverted cursor with a normal space to clear the artifact701 this.terminal.write(" ");702 const targetRow = this.previousLines.length; // Line after the last content703 const lineDiff = targetRow - this.hardwareCursorRow;704 if (lineDiff > 0) {705 this.terminal.write(`\x1b[${lineDiff}B`);706 } else if (lineDiff < 0) {707 this.terminal.write(`\x1b[${-lineDiff}A`);708 }709 this.terminal.write("\r\n");710 }711712 this.terminal.showCursor();713 this.terminal.stop();714 }715716 requestRender(force = false): void {717 if (force) {718 this.previousLines = [];719 this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear720 this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear721 this.cursorRow = 0;722 this.hardwareCursorRow = 0;723 this.maxLinesRendered = 0;724 this.previousViewportTop = 0;725 if (this.renderTimer) {726 clearTimeout(this.renderTimer);727 this.renderTimer = undefined;728 }729 this.renderRequested = true;730 process.nextTick(() => {731 if (this.stopped || !this.renderRequested) {732 return;733 }734 this.renderRequested = false;735 this.lastRenderAt = performance.now();736 this.doRender();737 });738 return;739 }740 if (this.renderRequested) return;741 this.renderRequested = true;742 process.nextTick(() => this.scheduleRender());743 }744745 private scheduleRender(): void {746 if (this.stopped || this.renderTimer || !this.renderRequested) {747 return;748 }749 const elapsed = performance.now() - this.lastRenderAt;750 const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);751 this.renderTimer = setTimeout(() => {752 this.renderTimer = undefined;753 if (this.stopped || !this.renderRequested) {754 return;755 }756 this.renderRequested = false;757 this.lastRenderAt = performance.now();758 this.doRender();759 if (this.renderRequested) {760 this.scheduleRender();761 }762 }, delay);763 }764765 private handleInput(data: string): void {766 if (this.consumeOsc11BackgroundResponse(data)) {767 return;768 }769 if (this.consumeTerminalColorSchemeReport(data)) {770 return;771 }772773 if (this.inputListeners.size > 0) {774 let current = data;775 for (const listener of this.inputListeners) {776 const result = listener(current);777 if (result?.consume) {778 return;779 }780 if (result?.data !== undefined) {781 current = result.data;782 }783 }784 if (current.length === 0) {785 return;786 }787 data = current;788 }789790 // Consume terminal cell size responses without blocking unrelated input.791 if (this.consumeCellSizeResponse(data)) {792 return;793 }794795 // Global debug key handler (Shift+Ctrl+D)796 if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {797 this.onDebug();798 return;799 }800801 // If focused component is an overlay, verify it's still visible802 // (visibility can change due to terminal resize or visible() callback)803 const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);804 if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {805 // Focused overlay is no longer visible, redirect to topmost visible overlay806 const topVisible = this.getTopmostVisibleOverlay();807 if (topVisible) {808 this.setFocus(topVisible.component);809 } else {810 this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });811 }812 }813814 const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent);815 if (!focusIsOverlay) {816 const restoreState = this.getVisibleOverlayFocusRestore();817 if (restoreState.status === "eligible") {818 this.setFocus(restoreState.overlay.component);819 } else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) {820 if (restoreState.resume.status === "restore-overlay") {821 this.setFocus(restoreState.overlay.component);822 } else {823 this.clearOverlayFocusRestore();824 this.setFocus(restoreState.resume.target);825 }826 }827 }828829 // Pass input to focused component (including Ctrl+C)830 // The focused component can decide how to handle Ctrl+C831 if (this.focusedComponent?.handleInput) {832 // Filter out key release events unless component opts in833 if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {834 return;835 }836 this.focusedComponent.handleInput(data);837 this.requestRender();838 }839 }840841 private consumeOsc11BackgroundResponse(data: string): boolean {842 if (this.pendingOsc11BackgroundReplies <= 0) {843 return false;844 }845846 if (!isOsc11BackgroundColorResponse(data)) {847 return false;848 }849850 const rgb = parseOsc11BackgroundColor(data);851 this.pendingOsc11BackgroundReplies -= 1;852 const query = this.pendingOsc11BackgroundQueries.shift();853 if (query && !query.settled) {854 query.settled = true;855 if (query.timer) {856 clearTimeout(query.timer);857 query.timer = undefined;858 }859 query.resolve?.(rgb);860 query.resolve = undefined;861 }862 return true;863 }864865 private consumeTerminalColorSchemeReport(data: string): boolean {866 const scheme = parseTerminalColorSchemeReport(data);867 if (!scheme) {868 return false;869 }870871 for (const listener of this.terminalColorSchemeListeners) {872 listener(scheme);873 }874 return true;875 }876877 private consumeCellSizeResponse(data: string): boolean {878 // Response format: ESC [ 6 ; height ; width t879 const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);880 if (!match) {881 return false;882 }883884 const heightPx = parseInt(match[1], 10);885 const widthPx = parseInt(match[2], 10);886 if (heightPx <= 0 || widthPx <= 0) {887 return true;888 }889890 setCellDimensions({ widthPx, heightPx });891 // Invalidate all components so images re-render with correct dimensions.892 this.invalidate();893 this.requestRender();894 return true;895 }896897 /**898 * Resolve overlay layout from options.899 * Returns { width, row, col, maxHeight } for rendering.900 */901 private resolveOverlayLayout(902 options: OverlayOptions | undefined,903 overlayHeight: number,904 termWidth: number,905 termHeight: number,906 ): { width: number; row: number; col: number; maxHeight: number | undefined } {907 const opt = options ?? {};908909 // Parse margin (clamp to non-negative)910 const margin =911 typeof opt.margin === "number"912 ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }913 : (opt.margin ?? {});914 const marginTop = Math.max(0, margin.top ?? 0);915 const marginRight = Math.max(0, margin.right ?? 0);916 const marginBottom = Math.max(0, margin.bottom ?? 0);917 const marginLeft = Math.max(0, margin.left ?? 0);918919 // Available space after margins920 const availWidth = Math.max(1, termWidth - marginLeft - marginRight);921 const availHeight = Math.max(1, termHeight - marginTop - marginBottom);922923 // === Resolve width ===924 let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);925 // Apply minWidth926 if (opt.minWidth !== undefined) {927 width = Math.max(width, opt.minWidth);928 }929 // Clamp to available space930 width = Math.max(1, Math.min(width, availWidth));931932 // === Resolve maxHeight ===933 let maxHeight = parseSizeValue(opt.maxHeight, termHeight);934 // Clamp to available space935 if (maxHeight !== undefined) {936 maxHeight = Math.max(1, Math.min(maxHeight, availHeight));937 }938939 // Effective overlay height (may be clamped by maxHeight)940 const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;941942 // === Resolve position ===943 let row: number;944 let col: number;945946 if (opt.row !== undefined) {947 if (typeof opt.row === "string") {948 // Percentage: 0% = top, 100% = bottom (overlay stays within bounds)949 const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);950 if (match) {951 const maxRow = Math.max(0, availHeight - effectiveHeight);952 const percent = parseFloat(match[1]) / 100;953 row = marginTop + Math.floor(maxRow * percent);954 } else {955 // Invalid format, fall back to center956 row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);957 }958 } else {959 // Absolute row position960 row = opt.row;961 }962 } else {963 // Anchor-based (default: center)964 const anchor = opt.anchor ?? "center";965 row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);966 }967968 if (opt.col !== undefined) {969 if (typeof opt.col === "string") {970 // Percentage: 0% = left, 100% = right (overlay stays within bounds)971 const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);972 if (match) {973 const maxCol = Math.max(0, availWidth - width);974 const percent = parseFloat(match[1]) / 100;975 col = marginLeft + Math.floor(maxCol * percent);976 } else {977 // Invalid format, fall back to center978 col = this.resolveAnchorCol("center", width, availWidth, marginLeft);979 }980 } else {981 // Absolute column position982 col = opt.col;983 }984 } else {985 // Anchor-based (default: center)986 const anchor = opt.anchor ?? "center";987 col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);988 }989990 // Apply offsets991 if (opt.offsetY !== undefined) row += opt.offsetY;992 if (opt.offsetX !== undefined) col += opt.offsetX;993994 // Clamp to terminal bounds (respecting margins)995 row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));996 col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));997998 return { width, row, col, maxHeight };999 }10001001 private resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number {1002 switch (anchor) {1003 case "top-left":1004 case "top-center":1005 case "top-right":1006 return marginTop;1007 case "bottom-left":1008 case "bottom-center":1009 case "bottom-right":1010 return marginTop + availHeight - height;1011 case "left-center":1012 case "center":1013 case "right-center":1014 return marginTop + Math.floor((availHeight - height) / 2);1015 }1016 }10171018 private resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number {1019 switch (anchor) {1020 case "top-left":1021 case "left-center":1022 case "bottom-left":1023 return marginLeft;1024 case "top-right":1025 case "right-center":1026 case "bottom-right":1027 return marginLeft + availWidth - width;1028 case "top-center":1029 case "center":1030 case "bottom-center":1031 return marginLeft + Math.floor((availWidth - width) / 2);1032 }1033 }10341035 /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */1036 private compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {1037 if (this.overlayStack.length === 0) return lines;1038 const result = [...lines];10391040 // Pre-render all visible overlays and calculate positions1041 const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];1042 let minLinesNeeded = result.length;10431044 const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));1045 visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);1046 for (const entry of visibleEntries) {1047 const { component, options } = entry;10481049 // Get layout with height=0 first to determine width and maxHeight1050 // (width and maxHeight don't depend on overlay height)1051 const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);10521053 // Render component at calculated width1054 let overlayLines = component.render(width);10551056 // Apply maxHeight if specified1057 if (maxHeight !== undefined && overlayLines.length > maxHeight) {1058 overlayLines = overlayLines.slice(0, maxHeight);1059 }10601061 // Get final row/col with actual overlay height1062 const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);10631064 rendered.push({ overlayLines, row, col, w: width });1065 minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);1066 }10671068 // Pad to at least terminal height so overlays have screen-relative positions.1069 // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing1070 // inflation that pushed content into scrollback on terminal widen.1071 const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);10721073 // Extend result with empty lines if content is too short for overlay placement or working area1074 while (result.length < workingHeight) {1075 result.push("");1076 }10771078 const viewportStart = Math.max(0, workingHeight - termHeight);10791080 // Composite each overlay1081 for (const { overlayLines, row, col, w } of rendered) {1082 for (let i = 0; i < overlayLines.length; i++) {1083 const idx = viewportStart + row + i;1084 if (idx >= 0 && idx < result.length) {1085 // Defensive: truncate overlay line to declared width before compositing1086 // (components should already respect width, but this ensures it)1087 const truncatedOverlayLine =1088 visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];1089 result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);1090 }1091 }1092 }10931094 return result;1095 }10961097 private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";10981099 private applyLineResets(lines: string[]): string[] {1100 const reset = TUI.SEGMENT_RESET;1101 for (let i = 0; i < lines.length; i++) {1102 const line = lines[i];1103 if (!isImageLine(line)) {1104 lines[i] = normalizeTerminalOutput(line) + reset;1105 }1106 }1107 return lines;1108 }11091110 private collectKittyImageIds(lines: string[]): Set<number> {1111 const ids = new Set<number>();1112 for (const line of lines) {1113 for (const id of extractKittyImageIds(line)) {1114 ids.add(id);1115 }1116 }1117 return ids;1118 }11191120 private deleteKittyImages(ids: Iterable<number>): string {1121 let buffer = "";1122 for (const id of ids) {1123 buffer += deleteKittyImage(id);1124 }1125 return buffer;1126 }11271128 private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number {1129 const rows = extractKittyImageRows(lines[index] ?? "");1130 if (rows <= 1) return 1;11311132 const maxRows = Math.min(rows, maxIndex - index + 1, lines.length - index);1133 let reservedRows = 1;1134 while (reservedRows < maxRows) {1135 const line = lines[index + reservedRows] ?? "";1136 if (isImageLine(line) || visibleWidth(line) > 0) break;1137 reservedRows++;1138 }1139 return reservedRows;1140 }11411142 private expandChangedRangeForKittyImages(1143 firstChanged: number,1144 lastChanged: number,1145 newLines: string[],1146 ): { firstChanged: number; lastChanged: number } {1147 let expandedFirstChanged = firstChanged;1148 let expandedLastChanged = lastChanged;1149 const expandForLines = (lines: string[]): void => {1150 for (let i = 0; i < lines.length; i++) {1151 if (extractKittyImageIds(lines[i]).length === 0) continue;1152 const blockEnd = i + this.getKittyImageReservedRows(lines, i) - 1;1153 if (i >= firstChanged || (i <= lastChanged && blockEnd >= firstChanged)) {1154 expandedFirstChanged = Math.min(expandedFirstChanged, i);1155 expandedLastChanged = Math.max(expandedLastChanged, blockEnd);1156 }1157 }1158 };11591160 expandForLines(this.previousLines);1161 expandForLines(newLines);1162 return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged };1163 }11641165 private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string {1166 if (firstChanged < 0 || lastChanged < firstChanged) return "";11671168 const ids = new Set<number>();1169 const maxLine = Math.min(lastChanged, this.previousLines.length - 1);1170 for (let i = firstChanged; i <= maxLine; i++) {1171 for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {1172 ids.add(id);1173 }1174 }11751176 return this.deleteKittyImages(ids);1177 }11781179 /** Splice overlay content into a base line at a specific column. Single-pass optimized. */1180 private compositeLineAt(1181 baseLine: string,1182 overlayLine: string,1183 startCol: number,1184 overlayWidth: number,1185 totalWidth: number,1186 ): string {1187 if (isImageLine(baseLine)) return baseLine;11881189 // Single pass through baseLine extracts both before and after segments1190 const afterStart = startCol + overlayWidth;1191 const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);11921193 // Extract overlay with width tracking (strict=true to exclude wide chars at boundary)1194 const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);11951196 // Pad segments to target widths1197 const beforePad = Math.max(0, startCol - base.beforeWidth);1198 const overlayPad = Math.max(0, overlayWidth - overlay.width);1199 const actualBeforeWidth = Math.max(startCol, base.beforeWidth);1200 const actualOverlayWidth = Math.max(overlayWidth, overlay.width);1201 const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);1202 const afterPad = Math.max(0, afterTarget - base.afterWidth);12031204 // Compose result1205 const r = TUI.SEGMENT_RESET;1206 const result =1207 base.before +1208 " ".repeat(beforePad) +1209 r +1210 overlay.text +1211 " ".repeat(overlayPad) +1212 r +1213 base.after +1214 " ".repeat(afterPad);12151216 // CRITICAL: Always verify and truncate to terminal width.1217 // This is the final safeguard against width overflow which would crash the TUI.1218 // Width tracking can drift from actual visible width due to:1219 // - Complex ANSI/OSC sequences (hyperlinks, colors)1220 // - Wide characters at segment boundaries1221 // - Edge cases in segment extraction1222 const resultWidth = visibleWidth(result);1223 if (resultWidth <= totalWidth) {1224 return result;1225 }1226 // Truncate with strict=true to ensure we don't exceed totalWidth1227 return sliceByColumn(result, 0, totalWidth, true);1228 }12291230 /**1231 * Find and extract cursor position from rendered lines.1232 * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.1233 * Only scans the bottom terminal height lines (visible viewport).1234 * @param lines - Rendered lines to search1235 * @param height - Terminal height (visible viewport size)1236 * @returns Cursor position { row, col } or null if no marker found1237 */1238 private extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null {1239 // Only scan the bottom `height` lines (visible viewport)1240 const viewportTop = Math.max(0, lines.length - height);1241 for (let row = lines.length - 1; row >= viewportTop; row--) {1242 const line = lines[row];1243 const markerIndex = line.indexOf(CURSOR_MARKER);1244 if (markerIndex !== -1) {1245 // Calculate visual column (width of text before marker)1246 const beforeMarker = line.slice(0, markerIndex);1247 const col = visibleWidth(beforeMarker);12481249 // Strip marker from the line1250 lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);12511252 return { row, col };1253 }1254 }1255 return null;1256 }12571258 private doRender(): void {1259 if (this.stopped) return;1260 const width = this.terminal.columns;1261 const height = this.terminal.rows;1262 const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;1263 const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;1264 const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;1265 let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;1266 let viewportTop = prevViewportTop;1267 let hardwareCursorRow = this.hardwareCursorRow;1268 const computeLineDiff = (targetRow: number): number => {1269 const currentScreenRow = hardwareCursorRow - prevViewportTop;1270 const targetScreenRow = targetRow - viewportTop;1271 return targetScreenRow - currentScreenRow;1272 };12731274 // Render all components to get new lines1275 let newLines = this.render(width);12761277 // Composite overlays into the rendered lines (before differential compare)1278 if (this.overlayStack.length > 0) {1279 newLines = this.compositeOverlays(newLines, width, height);1280 }12811282 // Extract cursor position before applying line resets (marker must be found first)1283 const cursorPos = this.extractCursorPosition(newLines, height);12841285 newLines = this.applyLineResets(newLines);12861287 // Helper to clear scrollback and viewport and render all new lines1288 const fullRender = (clear: boolean): void => {1289 this.fullRedrawCount += 1;1290 let buffer = "\x1b[?2026h"; // Begin synchronized output1291 if (clear) {1292 buffer += this.deleteKittyImages(this.previousKittyImageIds);1293 buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback1294 }1295 for (let i = 0; i < newLines.length; i++) {1296 if (i > 0) buffer += "\r\n";1297 const line = newLines[i];1298 const isImage = isImageLine(line);1299 const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;1300 if (imageReservedRows > 1 && imageReservedRows <= height) {1301 for (let row = 1; row < imageReservedRows; row++) {1302 buffer += "\r\n";1303 }1304 buffer += `\x1b[${imageReservedRows - 1}A`;1305 buffer += line;1306 buffer += `\x1b[${imageReservedRows - 1}B`;1307 i += imageReservedRows - 1;1308 continue;1309 }1310 buffer += line;1311 }1312 buffer += "\x1b[?2026l"; // End synchronized output1313 this.terminal.write(buffer);1314 this.cursorRow = Math.max(0, newLines.length - 1);1315 this.hardwareCursorRow = this.cursorRow;1316 // Reset max lines when clearing, otherwise track growth1317 if (clear) {1318 this.maxLinesRendered = newLines.length;1319 } else {1320 this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);1321 }1322 const bufferLength = Math.max(height, newLines.length);1323 this.previousViewportTop = Math.max(0, bufferLength - height);1324 this.positionHardwareCursor(cursorPos, newLines.length);1325 this.previousLines = newLines;1326 this.previousKittyImageIds = this.collectKittyImageIds(newLines);1327 this.previousWidth = width;1328 this.previousHeight = height;1329 };13301331 const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";1332 const logRedraw = (reason: string): void => {1333 if (!debugRedraw) return;1334 const logPath = path.join(this.logDirectory, "pi-debug.log");1335 const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;1336 fs.mkdirSync(path.dirname(logPath), { recursive: true });1337 fs.appendFileSync(logPath, msg);1338 };13391340 // First render - just output everything without clearing (assumes clean screen)1341 if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {1342 logRedraw("first render");1343 fullRender(false);1344 return;1345 }13461347 // Width changes always need a full re-render because wrapping changes.1348 if (widthChanged) {1349 logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);1350 fullRender(true);1351 return;1352 }13531354 // Height changes normally need a full re-render to keep the visible viewport aligned,1355 // but Termux changes height when the software keyboard shows or hides.1356 // In that environment, a full redraw causes the entire history to replay on every toggle.1357 if (heightChanged && !isTermuxSession()) {1358 logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);1359 fullRender(true);1360 return;1361 }13621363 // Content shrunk below the working area and no overlays - re-render to clear empty rows1364 // (overlays need the padding, so only do this when no overlays are active)1365 // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var1366 if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {1367 logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);1368 fullRender(true);1369 return;1370 }13711372 // Find first and last changed lines1373 let firstChanged = -1;1374 let lastChanged = -1;1375 const maxLines = Math.max(newLines.length, this.previousLines.length);1376 for (let i = 0; i < maxLines; i++) {1377 const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";1378 const newLine = i < newLines.length ? newLines[i] : "";13791380 if (oldLine !== newLine) {1381 if (firstChanged === -1) {1382 firstChanged = i;1383 }1384 lastChanged = i;1385 }1386 }1387 const appendedLines = newLines.length > this.previousLines.length;1388 if (appendedLines) {1389 if (firstChanged === -1) {1390 firstChanged = this.previousLines.length;1391 }1392 lastChanged = newLines.length - 1;1393 }1394 if (firstChanged !== -1) {1395 const expandedRange = this.expandChangedRangeForKittyImages(firstChanged, lastChanged, newLines);1396 firstChanged = expandedRange.firstChanged;1397 lastChanged = expandedRange.lastChanged;1398 }1399 const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;14001401 // No changes - but still need to update hardware cursor position if it moved1402 if (firstChanged === -1) {1403 this.positionHardwareCursor(cursorPos, newLines.length);1404 this.previousViewportTop = prevViewportTop;1405 this.previousHeight = height;1406 return;1407 }14081409 // All changes are in deleted lines (nothing to render, just clear)1410 if (firstChanged >= newLines.length) {1411 if (this.previousLines.length > newLines.length) {1412 let buffer = "\x1b[?2026h";1413 buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);1414 // Move to end of new content (clamp to 0 for empty content)1415 const targetRow = Math.max(0, newLines.length - 1);1416 if (targetRow < prevViewportTop) {1417 logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);1418 fullRender(true);1419 return;1420 }1421 const lineDiff = computeLineDiff(targetRow);1422 if (lineDiff > 0) buffer += `\x1b[${lineDiff}B`;1423 else if (lineDiff < 0) buffer += `\x1b[${-lineDiff}A`;1424 buffer += "\r";1425 // Clear extra lines without scrolling1426 const extraLines = this.previousLines.length - newLines.length;1427 if (extraLines > height) {1428 logRedraw(`extraLines > height (${extraLines} > ${height})`);1429 fullRender(true);1430 return;1431 }1432 const clearStartOffset = newLines.length === 0 ? 0 : 1;1433 if (extraLines > 0 && clearStartOffset > 0) {1434 buffer += `\x1b[${clearStartOffset}B`;1435 }1436 for (let i = 0; i < extraLines; i++) {1437 buffer += "\r\x1b[2K";1438 if (i < extraLines - 1) buffer += "\x1b[1B";1439 }1440 const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);1441 if (moveBack > 0) {1442 buffer += `\x1b[${moveBack}A`;1443 }1444 buffer += "\x1b[?2026l";1445 this.terminal.write(buffer);1446 this.cursorRow = targetRow;1447 this.hardwareCursorRow = targetRow;1448 }1449 this.positionHardwareCursor(cursorPos, newLines.length);1450 this.previousLines = newLines;1451 this.previousKittyImageIds = this.collectKittyImageIds(newLines);1452 this.previousWidth = width;1453 this.previousHeight = height;1454 this.previousViewportTop = prevViewportTop;1455 return;1456 }14571458 // Differential rendering can only touch what was actually visible.1459 // If the first changed line is above the previous viewport, we need a full redraw.1460 if (firstChanged < prevViewportTop) {1461 logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);1462 fullRender(true);1463 return;1464 }14651466 // Render from first changed line to end1467 // Build buffer with all updates wrapped in synchronized output1468 let buffer = "\x1b[?2026h"; // Begin synchronized output1469 buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);1470 const prevViewportBottom = prevViewportTop + height - 1;1471 const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;1472 if (moveTargetRow > prevViewportBottom) {1473 const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));1474 const moveToBottom = height - 1 - currentScreenRow;1475 if (moveToBottom > 0) {1476 buffer += `\x1b[${moveToBottom}B`;1477 }1478 const scroll = moveTargetRow - prevViewportBottom;1479 buffer += "\r\n".repeat(scroll);1480 prevViewportTop += scroll;1481 viewportTop += scroll;1482 hardwareCursorRow = moveTargetRow;1483 }14841485 // Move cursor to first changed line (use hardwareCursorRow for actual position)1486 const lineDiff = computeLineDiff(moveTargetRow);1487 if (lineDiff > 0) {1488 buffer += `\x1b[${lineDiff}B`; // Move down1489 } else if (lineDiff < 0) {1490 buffer += `\x1b[${-lineDiff}A`; // Move up1491 }14921493 buffer += appendStart ? "\r\n" : "\r"; // Move to column 014941495 // Only render changed lines (firstChanged to lastChanged), not all lines to end1496 // This reduces flicker when only a single line changes (e.g., spinner animation)1497 const renderEnd = Math.min(lastChanged, newLines.length - 1);1498 for (let i = firstChanged; i <= renderEnd; i++) {1499 if (i > firstChanged) buffer += "\r\n";1500 const line = newLines[i];1501 const isImage = isImageLine(line);1502 const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1;1503 if (imageReservedRows > 1) {1504 const imageStartScreenRow = i - viewportTop;1505 if (imageStartScreenRow < 0 || imageStartScreenRow + imageReservedRows > height) {1506 logRedraw(1507 `kitty image pre-clear would scroll (${imageStartScreenRow} + ${imageReservedRows} > ${height})`,1508 );1509 fullRender(true);1510 return;1511 }15121513 buffer += "\x1b[2K";1514 for (let row = 1; row < imageReservedRows; row++) {1515 buffer += "\r\n\x1b[2K";1516 }1517 buffer += `\x1b[${imageReservedRows - 1}A`;1518 buffer += line;1519 buffer += `\x1b[${imageReservedRows - 1}B`;1520 i += imageReservedRows - 1;1521 continue;1522 }15231524 buffer += "\x1b[2K"; // Clear current line1525 if (!isImage && visibleWidth(line) > width) {1526 // Log all lines to crash file for debugging1527 const crashLogPath = path.join(this.logDirectory, "pi-crash.log");1528 const crashData = [1529 `Crash at ${new Date().toISOString()}`,1530 `Terminal width: ${width}`,1531 `Line ${i} visible width: ${visibleWidth(line)}`,1532 "",1533 "=== All rendered lines ===",1534 ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),1535 "",1536 ].join("\n");1537 fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });1538 fs.writeFileSync(crashLogPath, crashData);15391540 // Clean up terminal state before throwing1541 this.stop();15421543 const errorMsg = [1544 `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,1545 "",1546 "This is likely caused by a custom TUI component not truncating its output.",1547 "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",1548 "",1549 `Debug log written to: ${crashLogPath}`,1550 ].join("\n");1551 throw new Error(errorMsg);1552 }1553 buffer += line;1554 }15551556 // Track where cursor ended up after rendering1557 let finalCursorRow = renderEnd;15581559 // If we had more lines before, clear them and move cursor back1560 if (this.previousLines.length > newLines.length) {1561 // Move to end of new content first if we stopped before it1562 if (renderEnd < newLines.length - 1) {1563 const moveDown = newLines.length - 1 - renderEnd;1564 buffer += `\x1b[${moveDown}B`;1565 finalCursorRow = newLines.length - 1;1566 }1567 const extraLines = this.previousLines.length - newLines.length;1568 for (let i = newLines.length; i < this.previousLines.length; i++) {1569 buffer += "\r\n\x1b[2K";1570 }1571 // Move cursor back to end of new content1572 buffer += `\x1b[${extraLines}A`;1573 }15741575 buffer += "\x1b[?2026l"; // End synchronized output15761577 if (process.env.PI_TUI_DEBUG === "1") {1578 const debugDir = "/tmp/tui";1579 fs.mkdirSync(debugDir, { recursive: true });1580 const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);1581 const debugData = [1582 `firstChanged: ${firstChanged}`,1583 `viewportTop: ${viewportTop}`,1584 `cursorRow: ${this.cursorRow}`,1585 `height: ${height}`,1586 `lineDiff: ${lineDiff}`,1587 `hardwareCursorRow: ${hardwareCursorRow}`,1588 `renderEnd: ${renderEnd}`,1589 `finalCursorRow: ${finalCursorRow}`,1590 `cursorPos: ${JSON.stringify(cursorPos)}`,1591 `newLines.length: ${newLines.length}`,1592 `previousLines.length: ${this.previousLines.length}`,1593 "",1594 "=== newLines ===",1595 JSON.stringify(newLines, null, 2),1596 "",1597 "=== previousLines ===",1598 JSON.stringify(this.previousLines, null, 2),1599 "",1600 "=== buffer ===",1601 JSON.stringify(buffer),1602 ].join("\n");1603 fs.writeFileSync(debugPath, debugData);1604 }16051606 // Write entire buffer at once1607 this.terminal.write(buffer);16081609 // Track cursor position for next render1610 // cursorRow tracks end of content (for viewport calculation)1611 // hardwareCursorRow tracks actual terminal cursor position (for movement)1612 this.cursorRow = Math.max(0, newLines.length - 1);1613 this.hardwareCursorRow = finalCursorRow;1614 // Track terminal's working area (grows but doesn't shrink unless cleared)1615 this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);1616 this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);16171618 // Position hardware cursor for IME1619 this.positionHardwareCursor(cursorPos, newLines.length);16201621 this.previousLines = newLines;1622 this.previousKittyImageIds = this.collectKittyImageIds(newLines);1623 this.previousWidth = width;1624 this.previousHeight = height;1625 }16261627 /**1628 * Position the hardware cursor for IME candidate window.1629 * @param cursorPos The cursor position extracted from rendered output, or null1630 * @param totalLines Total number of rendered lines1631 */1632 private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {1633 if (!cursorPos || totalLines <= 0) {1634 this.terminal.hideCursor();1635 return;1636 }16371638 // Clamp cursor position to valid range1639 const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));1640 const targetCol = Math.max(0, cursorPos.col);16411642 // Move cursor from current position to target1643 const rowDelta = targetRow - this.hardwareCursorRow;1644 let buffer = "";1645 if (rowDelta > 0) {1646 buffer += `\x1b[${rowDelta}B`; // Move down1647 } else if (rowDelta < 0) {1648 buffer += `\x1b[${-rowDelta}A`; // Move up1649 }1650 // Move to absolute column (1-indexed)1651 buffer += `\x1b[${targetCol + 1}G`;16521653 if (buffer) {1654 this.terminal.write(buffer);1655 }16561657 this.hardwareCursorRow = targetRow;1658 if (this.showHardwareCursor) {1659 this.terminal.showCursor();1660 } else {1661 this.terminal.hideCursor();1662 }1663 }16641665 /**1666 * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`).1667 * @param timeoutMs Query timeout in milliseconds.1668 * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse.1669 */1670 queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined> {1671 return new Promise((resolve) => {1672 const query: PendingOsc11BackgroundQuery = {1673 settled: false,1674 resolve,1675 timer: undefined,1676 };16771678 query.timer = setTimeout(() => {1679 if (query.settled) {1680 return;1681 }1682 query.settled = true;1683 query.timer = undefined;1684 query.resolve?.(undefined);1685 query.resolve = undefined;1686 }, timeoutMs);1687 this.pendingOsc11BackgroundQueries.push(query);1688 this.pendingOsc11BackgroundReplies += 1;1689 this.terminal.write("\x1b]11;?\x07");1690 });1691 }16921693 /**1694 * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`).1695 * Terminals that support the color palette notification protocol reply with1696 * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light.1697 */1698 queryTerminalColorScheme({ timeoutMs }: { timeoutMs: number }): Promise<TerminalColorScheme | undefined> {1699 return new Promise((resolve) => {1700 let settled = false;1701 let timer: NodeJS.Timeout | undefined;1702 let unsubscribe: () => void = () => {};1703 const settle = (scheme: TerminalColorScheme | undefined) => {1704 if (settled) return;1705 settled = true;1706 if (timer) {1707 clearTimeout(timer);1708 timer = undefined;1709 }1710 unsubscribe();1711 resolve(scheme);1712 };17131714 unsubscribe = this.onTerminalColorSchemeChange(settle);1715 timer = setTimeout(() => settle(undefined), timeoutMs);1716 this.terminal.write("\x1b[?996n");1717 });1718 }1719}1720