ππ Agent

tui.ts

57 KB1720 lines
tui.ts
1/**
2 * Minimal TUI implementation with differential rendering
3 */
4
5import * 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";
20
21const KITTY_SEQUENCE_PREFIX = "\x1b_G";
22
23interface KittyImageHeader {
24 ids: number[];
25 rows: number;
26}
27
28function parseKittyImageHeader(line: string): KittyImageHeader | undefined {
29 const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
30 if (sequenceStart === -1) return undefined;
31
32 const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
33 const paramsEnd = line.indexOf(";", paramsStart);
34 if (paramsEnd === -1) return undefined;
35
36 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}
52
53function extractKittyImageIds(line: string): number[] {
54 return parseKittyImageHeader(line)?.ids ?? [];
55}
56
57function extractKittyImageRows(line: string): number {
58 return parseKittyImageHeader(line)?.rows ?? 1;
59}
60
61/**
62 * Component interface - all components must implement this
63 */
64export interface Component {
65 /**
66 * Render the component to lines for the given viewport width
67 * @param width - Current viewport width
68 * @returns Array of strings, each representing a line
69 */
70 render(width: number): string[];
71
72 /**
73 * Optional handler for keyboard input when component has focus
74 */
75 handleInput?(data: string): void;
76
77 /**
78 * If true, component receives key release events (Kitty protocol).
79 * Default is false - release events are filtered out.
80 */
81 wantsKeyRelease?: boolean;
82
83 /**
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}
89
90type 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};
97
98/**
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 position
101 * in its render output. TUI will find this marker and position the hardware
102 * 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}
108
109/** 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}
113
114/**
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";
121
122export { visibleWidth };
123
124/**
125 * Anchor position for overlays
126 */
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";
137
138/**
139 * Margin configuration for overlays
140 */
141export interface OverlayMargin {
142 top?: number;
143 right?: number;
144 bottom?: number;
145 left?: number;
146}
147
148/** Value that can be absolute (number) or percentage (string like "50%") */
149export type SizeValue = number | `${number}%`;
150
151/** 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}
162
163function isTermuxSession(): boolean {
164 return Boolean(process.env.TERMUX_VERSION);
165}
166
167/**
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;
179
180 // === 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;
187
188 // === 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;
193
194 // === Margin from terminal edges ===
195 /** Margin from terminal edges. Number applies to all sides. */
196 margin?: OverlayMargin | number;
197
198 // === 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}
208
209/** Options for {@link OverlayHandle.unfocus}. */
210export interface OverlayUnfocusOptions {
211 /** Explicit target to focus after releasing this overlay. */
212 target: Component | null;
213}
214
215/**
216 * Handle returned by showOverlay for controlling the overlay
217 */
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}
232
233type OverlayStackEntry = {
234 component: Component;
235 options?: OverlayOptions;
236 preFocus: Component | null;
237 hidden: boolean;
238 focusOrder: number;
239};
240
241type 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";
252
253/**
254 * Container - a component that contains other components
255 */
256export class Container implements Component {
257 children: Component[] = [];
258
259 addChild(component: Component): void {
260 this.children.push(component);
261 }
262
263 removeChild(component: Component): void {
264 const index = this.children.indexOf(component);
265 if (index !== -1) {
266 this.children.splice(index, 1);
267 }
268 }
269
270 clear(): void {
271 this.children = [];
272 }
273
274 invalidate(): void {
275 for (const child of this.children) {
276 child.invalidate?.();
277 }
278 }
279
280 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}
291
292/**
293 * TUI - Main class for managing terminal UI with differential rendering
294 */
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>();
303
304 /** 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 moves
316 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;
323
324 // Overlay stack for modal components rendered on top of base content
325 private focusOrderCounter = 0;
326 private overlayStack: OverlayStackEntry[] = [];
327 private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" };
328
329 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 }
337
338 get fullRedraws(): number {
339 return this.fullRedrawCount;
340 }
341
342 getShowHardwareCursor(): boolean {
343 return this.showHardwareCursor;
344 }
345
346 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 }
354
355 getClearOnShrink(): boolean {
356 return this.clearOnShrink;
357 }
358
359 /**
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 }
367
368 setFocus(component: Component | null): void {
369 this.setFocusInternal({ component, overlayFocusRestore: "clear" });
370 }
371
372 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 = previousFocus
382 ? 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 }
418
419 if (isFocusable(this.focusedComponent)) {
420 this.focusedComponent.focused = false;
421 }
422
423 this.focusedComponent = nextFocus;
424
425 if (isFocusable(nextFocus)) {
426 nextFocus.focused = true;
427 }
428
429 const focusedOverlay = nextFocus
430 ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
431 : undefined;
432 if (focusedOverlay) {
433 this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
434 }
435 }
436
437 private clearOverlayFocusRestore(): void {
438 this.overlayFocusRestore = { status: "inactive" };
439 }
440
441 private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void {
442 if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
443 this.clearOverlayFocusRestore();
444 }
445 }
446
447 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 }
452
453 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 }
461
462 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 }
472
473 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 }
480
481 private isComponentMounted(component: Component): boolean {
482 return this.children.some((child) => this.containsComponent(child, component));
483 }
484
485 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 }
490
491 /**
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 visible
505 if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
506 this.setFocus(component);
507 }
508 this.terminal.hideCursor();
509 this.requestRender();
510
511 // Return handle for controlling this overlay
512 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 focus
520 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/showing
532 if (hidden) {
533 this.clearOverlayFocusRestoreFor(entry);
534 // If this overlay had focus, move focus to next visible or preFocus
535 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.blockedBy
564 ) {
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 }
589
590 /** 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 preFocus
599 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 }
605
606 /** Check if there are any visible overlays */
607 hasOverlay(): boolean {
608 return this.overlayStack.some((o) => this.isOverlayVisible(o));
609 }
610
611 /** 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 }
619
620 /** 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 }
631
632 override invalidate(): void {
633 super.invalidate();
634 for (const overlay of this.overlayStack) overlay.component.invalidate?.();
635 }
636
637 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 }
650
651 addInputListener(listener: InputListener): () => void {
652 this.inputListeners.add(listener);
653 return () => {
654 this.inputListeners.delete(listener);
655 };
656 }
657
658 removeInputListener(listener: InputListener): void {
659 this.inputListeners.delete(listener);
660 }
661
662 onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void {
663 this.terminalColorSchemeListeners.add(listener);
664 return () => {
665 this.terminalColorSchemeListeners.delete(listener);
666 };
667 }
668
669 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 }
678
679 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 t
685 // Response format: CSI 6 ; height ; width t
686 this.terminal.write("\x1b[16t");
687 }
688
689 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 exit
699 if (this.previousLines.length > 0) {
700 // Overwrite the inverted cursor with a normal space to clear the artifact
701 this.terminal.write(" ");
702 const targetRow = this.previousLines.length; // Line after the last content
703 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 }
711
712 this.terminal.showCursor();
713 this.terminal.stop();
714 }
715
716 requestRender(force = false): void {
717 if (force) {
718 this.previousLines = [];
719 this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
720 this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
721 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 }
744
745 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 }
764
765 private handleInput(data: string): void {
766 if (this.consumeOsc11BackgroundResponse(data)) {
767 return;
768 }
769 if (this.consumeTerminalColorSchemeReport(data)) {
770 return;
771 }
772
773 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 }
789
790 // Consume terminal cell size responses without blocking unrelated input.
791 if (this.consumeCellSizeResponse(data)) {
792 return;
793 }
794
795 // Global debug key handler (Shift+Ctrl+D)
796 if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
797 this.onDebug();
798 return;
799 }
800
801 // If focused component is an overlay, verify it's still visible
802 // (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 overlay
806 const topVisible = this.getTopmostVisibleOverlay();
807 if (topVisible) {
808 this.setFocus(topVisible.component);
809 } else {
810 this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
811 }
812 }
813
814 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 }
828
829 // Pass input to focused component (including Ctrl+C)
830 // The focused component can decide how to handle Ctrl+C
831 if (this.focusedComponent?.handleInput) {
832 // Filter out key release events unless component opts in
833 if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
834 return;
835 }
836 this.focusedComponent.handleInput(data);
837 this.requestRender();
838 }
839 }
840
841 private consumeOsc11BackgroundResponse(data: string): boolean {
842 if (this.pendingOsc11BackgroundReplies <= 0) {
843 return false;
844 }
845
846 if (!isOsc11BackgroundColorResponse(data)) {
847 return false;
848 }
849
850 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 }
864
865 private consumeTerminalColorSchemeReport(data: string): boolean {
866 const scheme = parseTerminalColorSchemeReport(data);
867 if (!scheme) {
868 return false;
869 }
870
871 for (const listener of this.terminalColorSchemeListeners) {
872 listener(scheme);
873 }
874 return true;
875 }
876
877 private consumeCellSizeResponse(data: string): boolean {
878 // Response format: ESC [ 6 ; height ; width t
879 const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
880 if (!match) {
881 return false;
882 }
883
884 const heightPx = parseInt(match[1], 10);
885 const widthPx = parseInt(match[2], 10);
886 if (heightPx <= 0 || widthPx <= 0) {
887 return true;
888 }
889
890 setCellDimensions({ widthPx, heightPx });
891 // Invalidate all components so images re-render with correct dimensions.
892 this.invalidate();
893 this.requestRender();
894 return true;
895 }
896
897 /**
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 ?? {};
908
909 // 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);
918
919 // Available space after margins
920 const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
921 const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
922
923 // === Resolve width ===
924 let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
925 // Apply minWidth
926 if (opt.minWidth !== undefined) {
927 width = Math.max(width, opt.minWidth);
928 }
929 // Clamp to available space
930 width = Math.max(1, Math.min(width, availWidth));
931
932 // === Resolve maxHeight ===
933 let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
934 // Clamp to available space
935 if (maxHeight !== undefined) {
936 maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
937 }
938
939 // Effective overlay height (may be clamped by maxHeight)
940 const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
941
942 // === Resolve position ===
943 let row: number;
944 let col: number;
945
946 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 center
956 row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
957 }
958 } else {
959 // Absolute row position
960 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 }
967
968 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 center
978 col = this.resolveAnchorCol("center", width, availWidth, marginLeft);
979 }
980 } else {
981 // Absolute column position
982 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 }
989
990 // Apply offsets
991 if (opt.offsetY !== undefined) row += opt.offsetY;
992 if (opt.offsetX !== undefined) col += opt.offsetX;
993
994 // 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));
997
998 return { width, row, col, maxHeight };
999 }
1000
1001 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 }
1017
1018 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 }
1034
1035 /** 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];
1039
1040 // Pre-render all visible overlays and calculate positions
1041 const rendered: { overlayLines: string[]; row: number; col: number; w: number }[] = [];
1042 let minLinesNeeded = result.length;
1043
1044 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;
1048
1049 // Get layout with height=0 first to determine width and maxHeight
1050 // (width and maxHeight don't depend on overlay height)
1051 const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
1052
1053 // Render component at calculated width
1054 let overlayLines = component.render(width);
1055
1056 // Apply maxHeight if specified
1057 if (maxHeight !== undefined && overlayLines.length > maxHeight) {
1058 overlayLines = overlayLines.slice(0, maxHeight);
1059 }
1060
1061 // Get final row/col with actual overlay height
1062 const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
1063
1064 rendered.push({ overlayLines, row, col, w: width });
1065 minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
1066 }
1067
1068 // Pad to at least terminal height so overlays have screen-relative positions.
1069 // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
1070 // inflation that pushed content into scrollback on terminal widen.
1071 const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
1072
1073 // Extend result with empty lines if content is too short for overlay placement or working area
1074 while (result.length < workingHeight) {
1075 result.push("");
1076 }
1077
1078 const viewportStart = Math.max(0, workingHeight - termHeight);
1079
1080 // Composite each overlay
1081 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 compositing
1086 // (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 }
1093
1094 return result;
1095 }
1096
1097 private static readonly SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
1098
1099 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 }
1109
1110 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 }
1119
1120 private deleteKittyImages(ids: Iterable<number>): string {
1121 let buffer = "";
1122 for (const id of ids) {
1123 buffer += deleteKittyImage(id);
1124 }
1125 return buffer;
1126 }
1127
1128 private getKittyImageReservedRows(lines: string[], index: number, maxIndex = lines.length - 1): number {
1129 const rows = extractKittyImageRows(lines[index] ?? "");
1130 if (rows <= 1) return 1;
1131
1132 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 }
1141
1142 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 };
1159
1160 expandForLines(this.previousLines);
1161 expandForLines(newLines);
1162 return { firstChanged: expandedFirstChanged, lastChanged: expandedLastChanged };
1163 }
1164
1165 private deleteChangedKittyImages(firstChanged: number, lastChanged: number): string {
1166 if (firstChanged < 0 || lastChanged < firstChanged) return "";
1167
1168 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 }
1175
1176 return this.deleteKittyImages(ids);
1177 }
1178
1179 /** 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;
1188
1189 // Single pass through baseLine extracts both before and after segments
1190 const afterStart = startCol + overlayWidth;
1191 const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
1192
1193 // Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
1194 const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
1195
1196 // Pad segments to target widths
1197 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);
1203
1204 // Compose result
1205 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);
1215
1216 // 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 boundaries
1221 // - Edge cases in segment extraction
1222 const resultWidth = visibleWidth(result);
1223 if (resultWidth <= totalWidth) {
1224 return result;
1225 }
1226 // Truncate with strict=true to ensure we don't exceed totalWidth
1227 return sliceByColumn(result, 0, totalWidth, true);
1228 }
1229
1230 /**
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 search
1235 * @param height - Terminal height (visible viewport size)
1236 * @returns Cursor position { row, col } or null if no marker found
1237 */
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);
1248
1249 // Strip marker from the line
1250 lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
1251
1252 return { row, col };
1253 }
1254 }
1255 return null;
1256 }
1257
1258 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 };
1273
1274 // Render all components to get new lines
1275 let newLines = this.render(width);
1276
1277 // Composite overlays into the rendered lines (before differential compare)
1278 if (this.overlayStack.length > 0) {
1279 newLines = this.compositeOverlays(newLines, width, height);
1280 }
1281
1282 // Extract cursor position before applying line resets (marker must be found first)
1283 const cursorPos = this.extractCursorPosition(newLines, height);
1284
1285 newLines = this.applyLineResets(newLines);
1286
1287 // Helper to clear scrollback and viewport and render all new lines
1288 const fullRender = (clear: boolean): void => {
1289 this.fullRedrawCount += 1;
1290 let buffer = "\x1b[?2026h"; // Begin synchronized output
1291 if (clear) {
1292 buffer += this.deleteKittyImages(this.previousKittyImageIds);
1293 buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
1294 }
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 output
1313 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 growth
1317 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 };
1330
1331 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 };
1339
1340 // 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 }
1346
1347 // 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 }
1353
1354 // 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 }
1362
1363 // Content shrunk below the working area and no overlays - re-render to clear empty rows
1364 // (overlays need the padding, so only do this when no overlays are active)
1365 // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
1366 if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
1367 logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
1368 fullRender(true);
1369 return;
1370 }
1371
1372 // Find first and last changed lines
1373 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] : "";
1379
1380 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;
1400
1401 // No changes - but still need to update hardware cursor position if it moved
1402 if (firstChanged === -1) {
1403 this.positionHardwareCursor(cursorPos, newLines.length);
1404 this.previousViewportTop = prevViewportTop;
1405 this.previousHeight = height;
1406 return;
1407 }
1408
1409 // 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 scrolling
1426 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 }
1457
1458 // 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 }
1465
1466 // Render from first changed line to end
1467 // Build buffer with all updates wrapped in synchronized output
1468 let buffer = "\x1b[?2026h"; // Begin synchronized output
1469 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 }
1484
1485 // 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 down
1489 } else if (lineDiff < 0) {
1490 buffer += `\x1b[${-lineDiff}A`; // Move up
1491 }
1492
1493 buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
1494
1495 // Only render changed lines (firstChanged to lastChanged), not all lines to end
1496 // 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 }
1512
1513 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 }
1523
1524 buffer += "\x1b[2K"; // Clear current line
1525 if (!isImage && visibleWidth(line) > width) {
1526 // Log all lines to crash file for debugging
1527 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);
1539
1540 // Clean up terminal state before throwing
1541 this.stop();
1542
1543 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 }
1555
1556 // Track where cursor ended up after rendering
1557 let finalCursorRow = renderEnd;
1558
1559 // If we had more lines before, clear them and move cursor back
1560 if (this.previousLines.length > newLines.length) {
1561 // Move to end of new content first if we stopped before it
1562 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 content
1572 buffer += `\x1b[${extraLines}A`;
1573 }
1574
1575 buffer += "\x1b[?2026l"; // End synchronized output
1576
1577 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 }
1605
1606 // Write entire buffer at once
1607 this.terminal.write(buffer);
1608
1609 // Track cursor position for next render
1610 // 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);
1617
1618 // Position hardware cursor for IME
1619 this.positionHardwareCursor(cursorPos, newLines.length);
1620
1621 this.previousLines = newLines;
1622 this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1623 this.previousWidth = width;
1624 this.previousHeight = height;
1625 }
1626
1627 /**
1628 * Position the hardware cursor for IME candidate window.
1629 * @param cursorPos The cursor position extracted from rendered output, or null
1630 * @param totalLines Total number of rendered lines
1631 */
1632 private positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {
1633 if (!cursorPos || totalLines <= 0) {
1634 this.terminal.hideCursor();
1635 return;
1636 }
1637
1638 // Clamp cursor position to valid range
1639 const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
1640 const targetCol = Math.max(0, cursorPos.col);
1641
1642 // Move cursor from current position to target
1643 const rowDelta = targetRow - this.hardwareCursorRow;
1644 let buffer = "";
1645 if (rowDelta > 0) {
1646 buffer += `\x1b[${rowDelta}B`; // Move down
1647 } else if (rowDelta < 0) {
1648 buffer += `\x1b[${-rowDelta}A`; // Move up
1649 }
1650 // Move to absolute column (1-indexed)
1651 buffer += `\x1b[${targetCol + 1}G`;
1652
1653 if (buffer) {
1654 this.terminal.write(buffer);
1655 }
1656
1657 this.hardwareCursorRow = targetRow;
1658 if (this.showHardwareCursor) {
1659 this.terminal.showCursor();
1660 } else {
1661 this.terminal.hideCursor();
1662 }
1663 }
1664
1665 /**
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 };
1677
1678 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 }
1692
1693 /**
1694 * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`).
1695 * Terminals that support the color palette notification protocol reply with
1696 * `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 };
1713
1714 unsubscribe = this.onTerminalColorSchemeChange(settle);
1715 timer = setTimeout(() => settle(undefined), timeoutMs);
1716 this.terminal.write("\x1b[?996n");
1717 });
1718 }
1719}
1720