返回源码地图

packages/tui/src/tui.ts

v1.0.0 · a13d35a742c6 · 10:Component 和共享 UI 抽象;非全终端行为审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Minimal TUI implementation with differential rendering
3 */
4
5import { performance } from "node:perf_hooks";
6import { isKeyRelease, matchesKey } from "./keys.ts";
7import type { Terminal } from "./terminal.ts";
8import {
9 parseOscColorResponse,
10 parseTerminalColorSchemeReport,
11 type RgbColor,
12 type TerminalColorScheme,
13 type TerminalColors,
14} from "./terminal-colors.ts";
15import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts";
16import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts";
17
18/**
19 * Component interface - all components must implement this
20 */
21export type TuiMouseEventType = "press" | "release" | "move" | "drag" | "click" | "wheel";
22export type TuiMouseButton = "left" | "middle" | "right" | "none";
23
24/** Normalized cell-based mouse event. Coordinates are zero-based. */
25export interface TuiMouseEvent {
26 type: TuiMouseEventType;
27 button: TuiMouseButton;
28 /** Coordinates local to the receiving component. */
29 x: number;
30 y: number;
31 /** Absolute terminal coordinates. */
32 screenX: number;
33 screenY: number;
34 /** Current component bounds. */
35 width: number;
36 height: number;
37 shift: boolean;
38 alt: boolean;
39 ctrl: boolean;
40 /** Logical lines. Negative values scroll up. */
41 wheelDelta?: number;
42 /** Consecutive click count when type is click. */
43 clickCount?: number;
44}
45
46export interface TuiMouseEventResult {
47 /** Stop propagation and suppress renderer-level fallback behavior. */
48 handled?: boolean;
49 /** Route subsequent drag/release events to this component. Implies handled. */
50 capture?: boolean;
51 /** Give keyboard focus to this component. Implies handled. */
52 focus?: boolean;
53 /**
54 * Explicitly request or suppress a render. Move and release default to false;
55 * press, click, drag, and wheel default to true.
56 */
57 render?: boolean;
58}
59
60/** Internal target metadata used by containers and alternate-screen dispatch. */
61export interface TuiMouseDispatchTarget {
62 component: Component;
63 originX: number;
64 originY: number;
65 width: number;
66 height: number;
67}
68
69/** Result of dispatching to a concrete component. */
70export interface TuiMouseDispatchResult extends TuiMouseEventResult {
71 handled: true;
72 target: TuiMouseDispatchTarget;
73 /** Keyboard focus target, which may be a delegating parent container. */
74 focusTarget?: Component;
75}
76
77/**
78 * Dispatch an event to a component and retain the exact target and coordinate
79 * transform. Containers use this when forwarding events to nested children.
80 */
81export function dispatchMouseEvent(component: Component, event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
82 const result = component.handleMouse?.(event);
83 if (!result) return undefined;
84 if ("target" in result) {
85 // The component forwarded the event to a child it hosts. Like a delegating container, it routes
86 // keys to that child itself, so it keeps keyboard focus. Focusing the child directly would leave
87 // focus on a detached component once the host removes it, e.g. a closed settings submenu.
88 const forwarded = result as TuiMouseDispatchResult;
89 return forwarded.focus && component.handleInput ? { ...forwarded, focusTarget: component } : forwarded;
90 }
91 if (!result.handled && !result.capture && !result.focus) return undefined;
92 return {
93 ...result,
94 handled: true,
95 ...(result.focus ? { focusTarget: component } : {}),
96 target: {
97 component,
98 originX: event.screenX - event.x,
99 originY: event.screenY - event.y,
100 width: event.width,
101 height: event.height,
102 },
103 };
104}
105
106/** Recreate local coordinates for a previously dispatched mouse target. */
107export function retargetMouseEvent(event: TuiMouseEvent, target: TuiMouseDispatchTarget): TuiMouseEvent {
108 return {
109 ...event,
110 x: event.screenX - target.originX,
111 y: event.screenY - target.originY,
112 width: target.width,
113 height: target.height,
114 };
115}
116
117export interface Component {
118 /**
119 * Render the component to lines for the given viewport width
120 * @param width - Current viewport width
121 * @returns Array of strings, each representing a line
122 */
123 render(width: number): string[];
124
125 /** Optional handler for keyboard input when component has focus. */
126 handleInput?(data: string): void;
127
128 /** Optional normalized mouse handler. */
129 handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;
130
131 /**
132 * If true, component receives key release events (Kitty protocol).
133 * Default is false - release events are filtered out.
134 */
135 wantsKeyRelease?: boolean;
136
137 /**
138 * Invalidate any cached rendering state.
139 * Called when theme changes or when component needs to re-render from scratch.
140 */
141 invalidate(): void;
142}
143
144export type TuiInputListenerResult = { consume?: boolean; data?: string } | undefined;
145export type TuiInputListener = (data: string) => TuiInputListenerResult;
146type PendingTerminalColorQuery = {
147 foreground?: RgbColor;
148 background?: RgbColor;
149 palette: Array<RgbColor | undefined>;
150 /** Targets that already replied, so duplicates do not count twice. */
151 replied: Set<string>;
152 /**
153 * Receives the result: the promise's resolve until the timeout, then `onLateReply`. Unset once the
154 * query completed (on the DA1 reply or once every color replied); later replies are ignored.
155 */
156 deliver: ((colors: TerminalColors) => void) | undefined;
157 timer: NodeJS.Timeout | undefined;
158};
159
160const TERMINAL_PALETTE_SIZE = 16;
161/** OSC 10 and 11 plus OSC 4 for every palette color. */
162const TERMINAL_COLOR_REPLY_COUNT = 2 + TERMINAL_PALETTE_SIZE;
163/**
164 * Default colors, palette colors 0-15, and a trailing primary device attributes (DA1) request.
165 * Every terminal answers DA1 and terminals answer in order, so the DA1 reply marks the end of
166 * the color replies, including for terminals that ignore the color queries.
167 */
168const TERMINAL_COLOR_QUERY = `\x1b]10;?\x07\x1b]11;?\x07${Array.from(
169 { length: TERMINAL_PALETTE_SIZE },
170 (_, index) => `\x1b]4;${index};?\x07`,
171).join("")}\x1b[c`;
172const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/;
173
174/**
175 * Interface for components that can receive focus and display a hardware cursor.
176 * When focused, the component should emit CURSOR_MARKER at the cursor position
177 * in its render output. TUI will find this marker and position the hardware
178 * cursor there for proper IME candidate window positioning.
179 */
180export interface Focusable {
181 /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
182 focused: boolean;
183}
184
185/** Type guard to check if a component implements Focusable */
186export function isFocusable(component: Component | null): component is Component & Focusable {
187 return component !== null && "focused" in component;
188}
189
190/**
191 * Cursor position marker - APC (Application Program Command) sequence.
192 * This is a zero-width escape sequence that terminals ignore.
193 * Components emit this at the cursor position when focused.
194 * TUI finds and strips this marker, then positions the hardware cursor there.
195 */
196export const CURSOR_MARKER = "\x1b_pi:c\x07";
197
198export { visibleWidth };
199
200/**
201 * Anchor position for overlays
202 */
203export type OverlayAnchor =
204 | "center"
205 | "top-left"
206 | "top-right"
207 | "bottom-left"
208 | "bottom-right"
209 | "top-center"
210 | "bottom-center"
211 | "left-center"
212 | "right-center";
213
214/**
215 * Margin configuration for overlays
216 */
217export interface OverlayMargin {
218 top?: number;
219 right?: number;
220 bottom?: number;
221 left?: number;
222}
223
224/** Value that can be absolute (number) or percentage (string like "50%") */
225export type SizeValue = number | `${number}%`;
226
227/** Parse a SizeValue into absolute value given a reference size */
228function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {
229 if (value === undefined) return undefined;
230 if (typeof value === "number") return value;
231 // Parse percentage string like "50%"
232 const match = value.match(/^(\d+(?:\.\d+)?)%$/);
233 if (match) {
234 return Math.floor((referenceSize * parseFloat(match[1])) / 100);
235 }
236 return undefined;
237}
238
239/**
240 * Options for overlay positioning and sizing.
241 * Values can be absolute numbers or percentage strings (e.g., "50%").
242 */
243export interface OverlayOptions {
244 // === Sizing ===
245 /** Width in columns, or percentage of terminal width (e.g., "50%") */
246 width?: SizeValue;
247 /** Minimum width in columns */
248 minWidth?: number;
249 /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
250 maxHeight?: SizeValue;
251
252 // === Positioning - anchor-based ===
253 /** Anchor point for positioning (default: 'center') */
254 anchor?: OverlayAnchor;
255 /** Horizontal offset from anchor position (positive = right) */
256 offsetX?: number;
257 /** Vertical offset from anchor position (positive = down) */
258 offsetY?: number;
259
260 // === Positioning - percentage or absolute ===
261 /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
262 row?: SizeValue;
263 /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
264 col?: SizeValue;
265
266 // === Margin from terminal edges ===
267 /** Margin from terminal edges. Number applies to all sides. */
268 margin?: OverlayMargin | number;
269
270 // === Visibility ===
271 /**
272 * Control overlay visibility based on terminal dimensions.
273 * If provided, overlay is only rendered when this returns true.
274 * Called each render cycle with current terminal dimensions.
275 */
276 visible?: (termWidth: number, termHeight: number) => boolean;
277 /** If true, don't capture keyboard focus when shown */
278 nonCapturing?: boolean;
279}
280
281/** Options for {@link OverlayHandle.unfocus}. */
282export interface OverlayUnfocusOptions {
283 /** Explicit target to focus after releasing this overlay. */
284 target: Component | null;
285}
286
287/** Last rendered terminal-relative overlay rectangle. */
288export interface OverlayBounds {
289 row: number;
290 col: number;
291 width: number;
292 height: number;
293}
294
295/**
296 * Handle returned by showOverlay for controlling the overlay
297 */
298export interface OverlayHandle {
299 /** Permanently remove the overlay (cannot be shown again) */
300 hide(): void;
301 /** Temporarily hide or show the overlay */
302 setHidden(hidden: boolean): void;
303 /** Check if overlay is temporarily hidden */
304 isHidden(): boolean;
305 /** Focus this overlay and bring it to the visual front */
306 focus(): void;
307 /** Release focus to the next visible capturing overlay or previous target, or to an explicit target when provided */
308 unfocus(options?: OverlayUnfocusOptions): void;
309 /** Check if this overlay currently has focus */
310 isFocused(): boolean;
311 /** Get the most recent rendered bounds for a visible overlay. */
312 getBounds(): OverlayBounds | undefined;
313}
314
315type OverlayStackEntry = {
316 component: Component;
317 options?: OverlayOptions;
318 preFocus: Component | null;
319 hidden: boolean;
320 focusOrder: number;
321 bounds?: OverlayBounds;
322};
323
324type RenderedOverlayLayout = {
325 entry: OverlayStackEntry;
326 row: number;
327 col: number;
328 width: number;
329 height: number;
330};
331
332type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null };
333type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry };
334type BlockedOverlayFocusRestoreState = {
335 status: "blocked";
336 overlay: OverlayStackEntry;
337 blockedBy: Component;
338 resume: OverlayBlockedFocusResume;
339};
340type ActiveOverlayFocusRestoreState = EligibleOverlayFocusRestoreState | BlockedOverlayFocusRestoreState;
341type OverlayFocusRestoreState = { status: "inactive" } | ActiveOverlayFocusRestoreState;
342type OverlayFocusRestorePolicy = "clear" | "preserve";
343
344/**
345 * Container - a component that contains other components
346 */
347export class Container implements Component {
348 children: Component[] = [];
349 private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };
350
351 addChild(component: Component): void {
352 this.children.push(component);
353 }
354
355 removeChild(component: Component): void {
356 const index = this.children.indexOf(component);
357 if (index !== -1) {
358 this.children.splice(index, 1);
359 }
360 }
361
362 clear(): void {
363 this.children = [];
364 }
365
366 invalidate(): void {
367 for (const child of this.children) {
368 child.invalidate?.();
369 }
370 }
371
372 handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined {
373 if (event.y < 0 || event.y >= event.height) return undefined;
374 const mouseChildren =
375 this.mouseLayout?.width === event.width
376 ? this.mouseLayout.children
377 : this.children.map((component) => ({ component, height: component.render(event.width).length }));
378 let childY = 0;
379 for (const { component: child, height: childHeight } of mouseChildren) {
380 if (event.y >= childY && event.y < childY + childHeight) {
381 const result = dispatchMouseEvent(child, {
382 ...event,
383 y: event.y - childY,
384 height: childHeight,
385 });
386 if (result?.focus && (this as Component).handleInput) return { ...result, focusTarget: this };
387 return result;
388 }
389 childY += childHeight;
390 }
391 return undefined;
392 }
393
394 render(width: number): string[] {
395 const lines: string[] = [];
396 const mouseChildren: Array<{ component: Component; height: number }> = [];
397 for (const child of this.children) {
398 const childLines = child.render(width);
399 mouseChildren.push({ component: child, height: childLines.length });
400 for (const line of childLines) {
401 lines.push(line);
402 }
403 }
404 this.mouseLayout = { width, children: mouseChildren };
405 return lines;
406 }
407}
408
409/**
410 * TUI - Main class for managing terminal UI with differential rendering
411 */
412const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
413
414/** Composite overlay content into a terminal line at a fixed column. */
415export function compositeTuiLine(
416 baseLine: string,
417 overlayLine: string,
418 startCol: number,
419 overlayWidth: number,
420 totalWidth: number,
421): string {
422 if (isImageLine(baseLine)) return baseLine;
423
424 const afterStart = startCol + overlayWidth;
425 const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
426 const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
427 const beforePad = Math.max(0, startCol - base.beforeWidth);
428 const overlayPad = Math.max(0, overlayWidth - overlay.width);
429 const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
430 const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
431 const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
432 const afterPad = Math.max(0, afterTarget - base.afterWidth);
433 const result =
434 base.before +
435 " ".repeat(beforePad) +
436 SEGMENT_RESET +
437 overlay.text +
438 " ".repeat(overlayPad) +
439 SEGMENT_RESET +
440 base.after +
441 " ".repeat(afterPad);
442
443 return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
444}
445
446export type TuiMode = "regular" | "fullscreen";
447
448export interface TuiStopOptions {
449 /** Leave renderer output in place for another TUI taking over the same terminal. */
450 preserveScreen?: boolean;
451}
452
453export interface TUI extends Component {
454 readonly mode: TuiMode;
455 children: Component[];
456 terminal: Terminal;
457 onDebug?: () => void;
458 readonly fullRedraws: number;
459 addChild(component: Component): void;
460 removeChild(component: Component): void;
461 clear(): void;
462 getShowHardwareCursor(): boolean;
463 setShowHardwareCursor(enabled: boolean): void;
464 getClearOnShrink(): boolean;
465 setClearOnShrink(enabled: boolean): void;
466 setFocus(component: Component | null): void;
467 showOverlay(component: Component, options?: OverlayOptions): OverlayHandle;
468 hideOverlay(): void;
469 hasOverlay(): boolean;
470 start(): void;
471 stop(options?: TuiStopOptions): void;
472 renderNow(force?: boolean): void;
473 requestRender(force?: boolean): void;
474 addInputListener(listener: TuiInputListener): () => void;
475 removeInputListener(listener: TuiInputListener): void;
476 onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void;
477 setTerminalColorSchemeNotifications(enabled: boolean): void;
478 queryTerminalColors(options: {
479 timeoutMs: number;
480 onLateReply?: (colors: TerminalColors) => void;
481 }): Promise<TerminalColors>;
482}
483
484export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport");
485
486export interface ViewportTUI extends TUI {
487 readonly [VIEWPORT_TUI]: true;
488 setLayoutRoot(component: Component | undefined): void;
489}
490
491export function isViewportTUI(tui: TUI): tui is ViewportTUI {
492 return (tui as Partial<ViewportTUI>)[VIEWPORT_TUI] === true;
493}
494
495export abstract class TuiBase extends Container implements TUI {
496 abstract readonly mode: TuiMode;
497 public terminal: Terminal;
498 private focusedComponent: Component | null = null;
499 private inputListeners = new Set<TuiInputListener>();
500
501 /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
502 public onDebug?: () => void;
503 private renderRequested = false;
504 private immediateRenderScheduled = false;
505 private renderTimer: NodeJS.Timeout | undefined;
506 private lastRenderAt = 0;
507 private static readonly MIN_RENDER_INTERVAL_MS = 16;
508 private showHardwareCursor = false;
509 private clearOnShrink = false;
510 protected fullRedrawCount = 0;
511 protected stopped = false;
512 /**
513 * Color queries waiting for their DA1 reply, oldest first. Terminals answer in order, so color
514 * replies belong to the oldest one. Queries stay here after a timeout to collect late replies.
515 */
516 private pendingTerminalColorQueries: PendingTerminalColorQuery[] = [];
517 private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>();
518 private terminalColorSchemeNotificationsEnabled = false;
519 /** Directory for debug/crash logs. When undefined, debug logging is disabled and crash dumps fall back to the OS temp directory. */
520 protected readonly logDirectory: string | undefined;
521
522 // Overlay stack for modal components rendered on top of base content
523 private focusOrderCounter = 0;
524 private overlayStack: OverlayStackEntry[] = [];
525 private renderedOverlayLayouts: RenderedOverlayLayout[] = [];
526
527 get hasOverlayEntries(): boolean {
528 return this.overlayStack.length > 0;
529 }
530 private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" };
531
532 constructor(terminal: Terminal, showHardwareCursor?: boolean, logDirectory?: string) {
533 super();
534 this.terminal = terminal;
535 this.logDirectory = logDirectory;
536 if (showHardwareCursor !== undefined) {
537 this.showHardwareCursor = showHardwareCursor;
538 }
539 }
540
541 protected abstract doRender(): void;
542
543 protected resetRenderState(): void {}
544
545 protected beforeTerminalStart(): void {}
546
547 protected afterTerminalStart(): void {}
548
549 protected beforeTerminalStop(_options: TuiStopOptions): void {}
550
551 protected afterTerminalStop(_options: TuiStopOptions): void {}
552
553 get fullRedraws(): number {
554 return this.fullRedrawCount;
555 }
556
557 getShowHardwareCursor(): boolean {
558 return this.showHardwareCursor;
559 }
560
561 setShowHardwareCursor(enabled: boolean): void {
562 if (this.showHardwareCursor === enabled) return;
563 this.showHardwareCursor = enabled;
564 if (!enabled) {
565 this.hideTerminalCursor();
566 }
567 this.requestRender();
568 }
569
570 getClearOnShrink(): boolean {
571 return this.clearOnShrink;
572 }
573
574 /**
575 * Set whether to trigger full re-render when content shrinks.
576 * When true, empty rows are cleared when content shrinks.
577 * When false (default), empty rows remain (reduces redraws on slower terminals).
578 */
579 setClearOnShrink(enabled: boolean): void {
580 this.clearOnShrink = enabled;
581 }
582
583 getFocusedComponent(): Component | null {
584 return this.focusedComponent;
585 }
586
587 setFocus(component: Component | null): void {
588 this.setFocusInternal({ component, overlayFocusRestore: "clear" });
589 }
590
591 private setFocusInternal({
592 component,
593 overlayFocusRestore,
594 }: {
595 component: Component | null;
596 overlayFocusRestore: OverlayFocusRestorePolicy;
597 }): void {
598 const previousFocus = this.focusedComponent;
599 let nextFocus = component;
600 const previousFocusedOverlay = previousFocus
601 ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
602 : undefined;
603 const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false;
604 const restoreState = this.getVisibleOverlayFocusRestore();
605 if (nextFocus && !nextFocusIsOverlay) {
606 if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
607 if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
608 nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
609 } else {
610 this.overlayFocusRestore = {
611 status: "blocked",
612 overlay: restoreState.overlay,
613 blockedBy: nextFocus,
614 resume: restoreState.resume,
615 };
616 }
617 } else if (
618 previousFocusedOverlay &&
619 restoreState.status !== "inactive" &&
620 restoreState.overlay === previousFocusedOverlay &&
621 !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)
622 ) {
623 this.overlayFocusRestore = {
624 status: "blocked",
625 overlay: previousFocusedOverlay,
626 blockedBy: nextFocus,
627 resume: { status: "restore-overlay" },
628 };
629 }
630 } else if (nextFocus === null) {
631 if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
632 nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
633 } else if (overlayFocusRestore === "clear") {
634 this.clearOverlayFocusRestore();
635 }
636 }
637
638 if (isFocusable(this.focusedComponent)) {
639 this.focusedComponent.focused = false;
640 }
641
642 this.focusedComponent = nextFocus;
643
644 if (isFocusable(nextFocus)) {
645 nextFocus.focused = true;
646 }
647
648 const focusedOverlay = nextFocus
649 ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
650 : undefined;
651 if (focusedOverlay) {
652 this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
653 }
654 }
655
656 private clearOverlayFocusRestore(): void {
657 this.overlayFocusRestore = { status: "inactive" };
658 }
659
660 private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void {
661 if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
662 this.clearOverlayFocusRestore();
663 }
664 }
665
666 private resolveBlockedOverlayFocusResume(restoreState: BlockedOverlayFocusRestoreState): Component | null {
667 if (restoreState.resume.status === "restore-overlay") return restoreState.overlay.component;
668 this.clearOverlayFocusRestore();
669 return restoreState.resume.target;
670 }
671
672 private getVisibleOverlayFocusRestore(): OverlayFocusRestoreState {
673 const restoreState = this.overlayFocusRestore;
674 if (restoreState.status === "inactive") return restoreState;
675 if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
676 return { status: "inactive" };
677 }
678 return restoreState;
679 }
680
681 private isOverlayFocusAncestor(entry: OverlayStackEntry, component: Component): boolean {
682 const visited = new Set<Component>();
683 let current = entry.preFocus;
684 while (current && !visited.has(current)) {
685 visited.add(current);
686 if (current === component) return true;
687 current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null;
688 }
689 return false;
690 }
691
692 private retargetOverlayPreFocus(removed: OverlayStackEntry): void {
693 for (const overlay of this.overlayStack) {
694 if (overlay !== removed && overlay.preFocus === removed.component) {
695 overlay.preFocus = removed.preFocus;
696 }
697 }
698 }
699
700 protected getMountedRoots(): readonly Component[] {
701 return this.children;
702 }
703
704 private isComponentMounted(component: Component): boolean {
705 return this.getMountedRoots().some((child) => this.containsComponent(child, component));
706 }
707
708 private containsComponent(root: Component, target: Component): boolean {
709 if (root === target) return true;
710 if (!(root instanceof Container)) return false;
711 return root.children.some((child) => this.containsComponent(child, target));
712 }
713
714 /**
715 * Show an overlay component with configurable positioning and sizing.
716 * Returns a handle to control the overlay's visibility.
717 */
718 showOverlay(component: Component, options?: OverlayOptions): OverlayHandle {
719 const entry: OverlayStackEntry = {
720 component,
721 ...(options === undefined ? {} : { options }),
722 preFocus: this.focusedComponent,
723 hidden: false,
724 focusOrder: ++this.focusOrderCounter,
725 };
726 this.overlayStack.push(entry);
727 // Only focus if overlay is actually visible
728 if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
729 this.setFocus(component);
730 }
731 this.hideTerminalCursor();
732 this.requestRender();
733
734 // Return handle for controlling this overlay
735 return {
736 hide: () => {
737 const index = this.overlayStack.indexOf(entry);
738 if (index !== -1) {
739 this.clearOverlayFocusRestoreFor(entry);
740 this.retargetOverlayPreFocus(entry);
741 this.overlayStack.splice(index, 1);
742 // Restore focus if this overlay had focus
743 if (this.focusedComponent === component) {
744 const topVisible = this.getTopmostVisibleOverlay();
745 this.setFocus(topVisible?.component ?? entry.preFocus);
746 }
747 if (this.overlayStack.length === 0) this.hideTerminalCursor();
748 this.requestRender();
749 }
750 },
751 setHidden: (hidden: boolean) => {
752 if (entry.hidden === hidden) return;
753 entry.hidden = hidden;
754 // Update focus when hiding/showing
755 if (hidden) {
756 this.clearOverlayFocusRestoreFor(entry);
757 // If this overlay had focus, move focus to next visible or preFocus
758 if (this.focusedComponent === component) {
759 const topVisible = this.getTopmostVisibleOverlay();
760 this.setFocus(topVisible?.component ?? entry.preFocus);
761 }
762 } else {
763 // Restore focus to this overlay when showing (if it's actually visible)
764 if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
765 entry.focusOrder = ++this.focusOrderCounter;
766 this.setFocus(component);
767 }
768 }
769 this.requestRender();
770 },
771 isHidden: () => entry.hidden,
772 focus: () => {
773 if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return;
774 entry.focusOrder = ++this.focusOrderCounter;
775 this.setFocus(component);
776 this.requestRender();
777 },
778 unfocus: (unfocusOptions) => {
779 const isFocused = this.focusedComponent === component;
780 const restoreState = this.overlayFocusRestore;
781 const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
782 if (!isFocused && !hasPendingRestore) return;
783 if (
784 restoreState.status === "blocked" &&
785 restoreState.overlay === entry &&
786 this.focusedComponent === restoreState.blockedBy
787 ) {
788 if (unfocusOptions) {
789 this.overlayFocusRestore = {
790 status: "blocked",
791 overlay: entry,
792 blockedBy: restoreState.blockedBy,
793 resume: { status: "focus-target", target: unfocusOptions.target },
794 };
795 } else {
796 this.clearOverlayFocusRestore();
797 }
798 this.requestRender();
799 return;
800 }
801 this.clearOverlayFocusRestoreFor(entry);
802 if (isFocused || unfocusOptions) {
803 const topVisible = this.getTopmostVisibleOverlay();
804 const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
805 this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
806 }
807 this.requestRender();
808 },
809 isFocused: () => this.focusedComponent === component,
810 getBounds: () => {
811 if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry) || !entry.bounds) return undefined;
812 return { ...entry.bounds };
813 },
814 };
815 }
816
817 /** Hide the topmost overlay and restore previous focus. */
818 hideOverlay(): void {
819 const overlay = this.overlayStack[this.overlayStack.length - 1];
820 if (!overlay) return;
821 this.clearOverlayFocusRestoreFor(overlay);
822 this.retargetOverlayPreFocus(overlay);
823 this.overlayStack.pop();
824 if (this.focusedComponent === overlay.component) {
825 // Find topmost visible overlay, or fall back to preFocus
826 const topVisible = this.getTopmostVisibleOverlay();
827 this.setFocus(topVisible?.component ?? overlay.preFocus);
828 }
829 if (this.overlayStack.length === 0) this.hideTerminalCursor();
830 this.requestRender();
831 }
832
833 /** Hide the cursor while running. After stop(), the shell owns the cursor and it must stay visible. */
834 private hideTerminalCursor(): void {
835 if (!this.stopped) this.terminal.hideCursor();
836 }
837
838 /** Check if there are any visible overlays */
839 hasOverlay(): boolean {
840 return this.overlayStack.some((o) => this.isOverlayVisible(o));
841 }
842
843 /** Check if the focused component is a visible overlay */
844 protected isOverlayFocused(): boolean {
845 return this.overlayStack.some(
846 (entry) => entry.component === this.focusedComponent && this.isOverlayVisible(entry),
847 );
848 }
849
850 /** Keep overlay containers as keyboard focus owners when a nested control is clicked. */
851 protected resolveMouseFocusTarget(component: Component): Component {
852 for (let index = this.overlayStack.length - 1; index >= 0; index--) {
853 const overlay = this.overlayStack[index]!;
854 if (this.isOverlayVisible(overlay) && this.containsComponent(overlay.component, component)) {
855 return overlay.component;
856 }
857 }
858 return component;
859 }
860
861 /** Dispatch to the visually topmost overlay under the pointer. */
862 protected dispatchMouseToOverlay(event: TuiMouseEvent): { hit: boolean; result?: TuiMouseDispatchResult } {
863 for (let index = this.renderedOverlayLayouts.length - 1; index >= 0; index--) {
864 const layout = this.renderedOverlayLayouts[index]!;
865 if (
866 event.screenX < layout.col ||
867 event.screenX >= layout.col + layout.width ||
868 event.screenY < layout.row ||
869 event.screenY >= layout.row + layout.height
870 ) {
871 continue;
872 }
873 const result = dispatchMouseEvent(layout.entry.component, {
874 ...event,
875 x: event.screenX - layout.col,
876 y: event.screenY - layout.row,
877 width: layout.width,
878 height: layout.height,
879 });
880 return result
881 ? {
882 hit: true,
883 result: result.focus ? { ...result, focusTarget: layout.entry.component } : result,
884 }
885 : { hit: true };
886 }
887 return { hit: false };
888 }
889
890 /** Check if an overlay entry is currently visible */
891 private isOverlayVisible(entry: OverlayStackEntry): boolean {
892 if (entry.hidden) return false;
893 if (entry.options?.visible) {
894 return entry.options.visible(this.terminal.columns, this.terminal.rows);
895 }
896 return true;
897 }
898
899 /** Find the visual-frontmost visible capturing overlay, if any */
900 private getTopmostVisibleOverlay(): OverlayStackEntry | undefined {
901 let topmost: OverlayStackEntry | undefined;
902 for (const overlay of this.overlayStack) {
903 if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay)) continue;
904 if (!topmost || overlay.focusOrder > topmost.focusOrder) {
905 topmost = overlay;
906 }
907 }
908 return topmost;
909 }
910
911 override invalidate(): void {
912 for (const root of this.getMountedRoots()) root.invalidate();
913 for (const overlay of this.overlayStack) overlay.component.invalidate();
914 }
915
916 start(): void {
917 this.stopped = false;
918 this.beforeTerminalStart();
919 this.terminal.start(
920 (data) => this.handleTerminalInput(data),
921 () => this.requestRender(),
922 );
923 this.afterTerminalStart();
924 this.terminal.hideCursor();
925 if (this.terminalColorSchemeNotificationsEnabled) {
926 this.terminal.write("\x1b[?2031h");
927 }
928 this.queryCellSize();
929 this.requestRender();
930 }
931
932 addInputListener(listener: TuiInputListener): () => void {
933 this.inputListeners.add(listener);
934 return () => {
935 this.inputListeners.delete(listener);
936 };
937 }
938
939 removeInputListener(listener: TuiInputListener): void {
940 this.inputListeners.delete(listener);
941 }
942
943 onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void {
944 this.terminalColorSchemeListeners.add(listener);
945 return () => {
946 this.terminalColorSchemeListeners.delete(listener);
947 };
948 }
949
950 setTerminalColorSchemeNotifications(enabled: boolean): void {
951 if (this.terminalColorSchemeNotificationsEnabled === enabled) {
952 return;
953 }
954 this.terminalColorSchemeNotificationsEnabled = enabled;
955 if (!this.stopped) {
956 this.terminal.write(enabled ? "\x1b[?2031h" : "\x1b[?2031l");
957 }
958 }
959
960 private queryCellSize(): void {
961 // Only query if terminal supports images (cell size is only used for image rendering)
962 if (!getCapabilities().images) {
963 return;
964 }
965 // Query terminal for cell size in pixels: CSI 16 t
966 // Response format: CSI 6 ; height ; width t
967 this.terminal.write("\x1b[16t");
968 }
969
970 stop(options: TuiStopOptions = {}): void {
971 this.stopped = true;
972 this.cancelRenderTimer();
973 if (this.terminalColorSchemeNotificationsEnabled) {
974 this.terminal.write("\x1b[?2031l");
975 }
976 this.beforeTerminalStop(options);
977 this.terminal.showCursor();
978 this.terminal.stop();
979 this.afterTerminalStop(options);
980 }
981
982 renderNow(force = false): void {
983 if (force) this.resetRenderState();
984 this.renderRequested = false;
985 this.cancelRenderTimer();
986 this.lastRenderAt = performance.now();
987 this.doRender();
988 }
989
990 requestRender(force = false): void {
991 if (force) {
992 this.resetRenderState();
993 this.requestImmediateRender();
994 return;
995 }
996 if (this.renderRequested) return;
997 this.renderRequested = true;
998 process.nextTick(() => this.scheduleRender());
999 }
1000
1001 private requestImmediateRender(): void {
1002 this.cancelRenderTimer();
1003 this.renderRequested = true;
1004 if (this.immediateRenderScheduled) return;
1005 this.immediateRenderScheduled = true;
1006 process.nextTick(() => {
1007 this.immediateRenderScheduled = false;
1008 if (this.stopped || !this.renderRequested) return;
1009 // A previously queued scheduleRender() can create a timer before this
1010 // callback runs. User input must preempt that throttled frame.
1011 this.cancelRenderTimer();
1012 this.renderRequested = false;
1013 this.lastRenderAt = performance.now();
1014 this.doRender();
1015 });
1016 }
1017
1018 private cancelRenderTimer(): void {
1019 if (!this.renderTimer) return;
1020 clearTimeout(this.renderTimer);
1021 this.renderTimer = undefined;
1022 }
1023
1024 private scheduleRender(): void {
1025 if (this.stopped || this.renderTimer || !this.renderRequested) {
1026 return;
1027 }
1028 const elapsed = performance.now() - this.lastRenderAt;
1029 const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed);
1030 this.renderTimer = setTimeout(() => {
1031 this.renderTimer = undefined;
1032 if (this.stopped || !this.renderRequested) {
1033 return;
1034 }
1035 this.renderRequested = false;
1036 this.lastRenderAt = performance.now();
1037 this.doRender();
1038 if (this.renderRequested) {
1039 this.scheduleRender();
1040 }
1041 }, delay);
1042 }
1043
1044 private handleTerminalInput(data: string): void {
1045 if (this.consumeTerminalColorResponse(data)) {
1046 return;
1047 }
1048 if (this.consumeTerminalColorSchemeReport(data)) {
1049 return;
1050 }
1051
1052 if (this.inputListeners.size > 0) {
1053 let current = data;
1054 for (const listener of this.inputListeners) {
1055 const result = listener(current);
1056 if (result?.consume) {
1057 return;
1058 }
1059 if (result?.data !== undefined) {
1060 current = result.data;
1061 }
1062 }
1063 if (current.length === 0) {
1064 return;
1065 }
1066 data = current;
1067 }
1068
1069 // Consume terminal cell size responses without blocking unrelated input.
1070 if (this.consumeCellSizeResponse(data)) {
1071 return;
1072 }
1073
1074 // Global debug key handler (Shift+Ctrl+D)
1075 if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
1076 this.onDebug();
1077 return;
1078 }
1079
1080 // If focused component is an overlay, verify it's still visible
1081 // (visibility can change due to terminal resize or visible() callback)
1082 const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);
1083 if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
1084 // Focused overlay is no longer visible, redirect to topmost visible overlay
1085 const topVisible = this.getTopmostVisibleOverlay();
1086 if (topVisible) {
1087 this.setFocus(topVisible.component);
1088 } else {
1089 this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
1090 }
1091 }
1092
1093 const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent);
1094 if (!focusIsOverlay) {
1095 const restoreState = this.getVisibleOverlayFocusRestore();
1096 if (restoreState.status === "eligible") {
1097 this.setFocus(restoreState.overlay.component);
1098 } else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) {
1099 if (restoreState.resume.status === "restore-overlay") {
1100 this.setFocus(restoreState.overlay.component);
1101 } else {
1102 this.clearOverlayFocusRestore();
1103 this.setFocus(restoreState.resume.target);
1104 }
1105 }
1106 }
1107
1108 // Pass input to focused component (including Ctrl+C)
1109 // The focused component can decide how to handle Ctrl+C
1110 if (this.focusedComponent?.handleInput) {
1111 // Filter out key release events unless component opts in
1112 if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
1113 return;
1114 }
1115 this.focusedComponent.handleInput(data);
1116 // Keyboard input is latency-sensitive. Avoid the throttled timer path,
1117 // where even setTimeout(0) can take a full 16 ms tick on Windows.
1118 this.requestImmediateRender();
1119 }
1120 }
1121
1122 private consumeTerminalColorResponse(data: string): boolean {
1123 const query = this.pendingTerminalColorQueries[0];
1124 if (!query) {
1125 return false;
1126 }
1127 if (DEVICE_ATTRIBUTES_RESPONSE_PATTERN.test(data)) {
1128 this.pendingTerminalColorQueries.shift();
1129 this.completeTerminalColorQuery(query);
1130 return true;
1131 }
1132
1133 const response = parseOscColorResponse(data);
1134 if (!response) {
1135 return false;
1136 }
1137 const { target, rgb } = response;
1138 const key = String(target);
1139 if (!query.deliver || query.replied.has(key)) {
1140 return true;
1141 }
1142 query.replied.add(key);
1143 if (target === "foreground") {
1144 query.foreground = rgb;
1145 } else if (target === "background") {
1146 query.background = rgb;
1147 } else if (target < TERMINAL_PALETTE_SIZE) {
1148 query.palette[target] = rgb;
1149 }
1150 if (query.replied.size === TERMINAL_COLOR_REPLY_COUNT) {
1151 this.completeTerminalColorQuery(query);
1152 }
1153 return true;
1154 }
1155
1156 private terminalColorQueryResult(query: PendingTerminalColorQuery): TerminalColors {
1157 const palette = query.palette.every((color) => color !== undefined) ? (query.palette as RgbColor[]) : undefined;
1158 return { foreground: query.foreground, background: query.background, palette };
1159 }
1160
1161 private completeTerminalColorQuery(query: PendingTerminalColorQuery): void {
1162 const deliver = query.deliver;
1163 query.deliver = undefined;
1164 clearTimeout(query.timer);
1165 deliver?.(this.terminalColorQueryResult(query));
1166 }
1167
1168 private consumeTerminalColorSchemeReport(data: string): boolean {
1169 const scheme = parseTerminalColorSchemeReport(data);
1170 if (!scheme) {
1171 return false;
1172 }
1173
1174 for (const listener of this.terminalColorSchemeListeners) {
1175 listener(scheme);
1176 }
1177 return true;
1178 }
1179
1180 private consumeCellSizeResponse(data: string): boolean {
1181 // Response format: ESC [ 6 ; height ; width t
1182 const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
1183 if (!match) {
1184 return false;
1185 }
1186
1187 const heightPx = parseInt(match[1], 10);
1188 const widthPx = parseInt(match[2], 10);
1189 if (heightPx <= 0 || widthPx <= 0) {
1190 return true;
1191 }
1192
1193 setCellDimensions({ widthPx, heightPx });
1194 // Invalidate all components so images re-render with correct dimensions.
1195 this.invalidate();
1196 this.requestRender();
1197 return true;
1198 }
1199
1200 /**
1201 * Resolve overlay layout from options.
1202 * Returns { width, row, col, maxHeight } for rendering.
1203 */
1204 private resolveOverlayLayout(
1205 options: OverlayOptions | undefined,
1206 overlayHeight: number,
1207 termWidth: number,
1208 termHeight: number,
1209 ): { width: number; row: number; col: number; maxHeight: number | undefined } {
1210 const opt = options ?? {};
1211
1212 // Parse margin (clamp to non-negative)
1213 const margin =
1214 typeof opt.margin === "number"
1215 ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
1216 : (opt.margin ?? {});
1217 const marginTop = Math.max(0, margin.top ?? 0);
1218 const marginRight = Math.max(0, margin.right ?? 0);
1219 const marginBottom = Math.max(0, margin.bottom ?? 0);
1220 const marginLeft = Math.max(0, margin.left ?? 0);
1221
1222 // Available space after margins
1223 const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
1224 const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
1225
1226 // === Resolve width ===
1227 let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
1228 // Apply minWidth
1229 if (opt.minWidth !== undefined) {
1230 width = Math.max(width, opt.minWidth);
1231 }
1232 // Clamp to available space
1233 width = Math.max(1, Math.min(width, availWidth));
1234
1235 // === Resolve maxHeight ===
1236 let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
1237 // Clamp to available space
1238 if (maxHeight !== undefined) {
1239 maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
1240 }
1241
1242 // Effective overlay height (may be clamped by maxHeight)
1243 const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
1244
1245 // === Resolve position ===
1246 let row: number;
1247 let col: number;
1248
1249 if (opt.row !== undefined) {
1250 if (typeof opt.row === "string") {
1251 // Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
1252 const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
1253 if (match) {
1254 const maxRow = Math.max(0, availHeight - effectiveHeight);
1255 const percent = parseFloat(match[1]) / 100;
1256 row = marginTop + Math.floor(maxRow * percent);
1257 } else {
1258 // Invalid format, fall back to center
1259 row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
1260 }
1261 } else {
1262 // Absolute row position
1263 row = opt.row;
1264 }
1265 } else {
1266 // Anchor-based (default: center)
1267 const anchor = opt.anchor ?? "center";
1268 row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);
1269 }
1270
1271 if (opt.col !== undefined) {
1272 if (typeof opt.col === "string") {
1273 // Percentage: 0% = left, 100% = right (overlay stays within bounds)
1274 const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
1275 if (match) {
1276 const maxCol = Math.max(0, availWidth - width);
1277 const percent = parseFloat(match[1]) / 100;
1278 col = marginLeft + Math.floor(maxCol * percent);
1279 } else {
1280 // Invalid format, fall back to center
1281 col = this.resolveAnchorCol("center", width, availWidth, marginLeft);
1282 }
1283 } else {
1284 // Absolute column position
1285 col = opt.col;
1286 }
1287 } else {
1288 // Anchor-based (default: center)
1289 const anchor = opt.anchor ?? "center";
1290 col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);
1291 }
1292
1293 // Apply offsets
1294 if (opt.offsetY !== undefined) row += opt.offsetY;
1295 if (opt.offsetX !== undefined) col += opt.offsetX;
1296
1297 // Clamp to terminal bounds (respecting margins)
1298 row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
1299 col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
1300
1301 return { width, row, col, maxHeight };
1302 }
1303
1304 private resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number {
1305 switch (anchor) {
1306 case "top-left":
1307 case "top-center":
1308 case "top-right":
1309 return marginTop;
1310 case "bottom-left":
1311 case "bottom-center":
1312 case "bottom-right":
1313 return marginTop + availHeight - height;
1314 case "left-center":
1315 case "center":
1316 case "right-center":
1317 return marginTop + Math.floor((availHeight - height) / 2);
1318 }
1319 }
1320
1321 private resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number {
1322 switch (anchor) {
1323 case "top-left":
1324 case "left-center":
1325 case "bottom-left":
1326 return marginLeft;
1327 case "top-right":
1328 case "right-center":
1329 case "bottom-right":
1330 return marginLeft + availWidth - width;
1331 case "top-center":
1332 case "center":
1333 case "bottom-center":
1334 return marginLeft + Math.floor((availWidth - width) / 2);
1335 }
1336 }
1337
1338 /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
1339 protected compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {
1340 if (this.overlayStack.length === 0) {
1341 this.renderedOverlayLayouts = [];
1342 return lines;
1343 }
1344 const result = [...lines];
1345
1346 for (const entry of this.overlayStack) entry.bounds = undefined;
1347
1348 // Pre-render all visible overlays and calculate positions
1349 const rendered: { entry: OverlayStackEntry; overlayLines: string[]; row: number; col: number; w: number }[] = [];
1350 let minLinesNeeded = result.length;
1351
1352 const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
1353 visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
1354 for (const entry of visibleEntries) {
1355 const { component, options } = entry;
1356
1357 // Get layout with height=0 first to determine width and maxHeight
1358 // (width and maxHeight don't depend on overlay height)
1359 const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
1360
1361 // Render component at calculated width
1362 let overlayLines = component.render(width);
1363
1364 // Apply maxHeight if specified
1365 if (maxHeight !== undefined && overlayLines.length > maxHeight) {
1366 overlayLines = overlayLines.slice(0, maxHeight);
1367 }
1368
1369 // Get final row/col with actual overlay height
1370 const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
1371 entry.bounds = { row, col, width, height: overlayLines.length };
1372
1373 rendered.push({ entry, overlayLines, row, col, w: width });
1374 minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
1375 }
1376 this.renderedOverlayLayouts = rendered.map(({ entry, row, col, w, overlayLines }) => ({
1377 entry,
1378 row,
1379 col,
1380 width: w,
1381 height: overlayLines.length,
1382 }));
1383
1384 // Pad to at least terminal height so overlays have screen-relative positions.
1385 // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
1386 // inflation that pushed content into scrollback on terminal widen.
1387 const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
1388
1389 // Extend result with empty lines if content is too short for overlay placement or working area
1390 while (result.length < workingHeight) {
1391 result.push("");
1392 }
1393
1394 const viewportStart = Math.max(0, workingHeight - termHeight);
1395
1396 // Composite each overlay
1397 for (const { overlayLines, row, col, w } of rendered) {
1398 for (let i = 0; i < overlayLines.length; i++) {
1399 const idx = viewportStart + row + i;
1400 if (idx >= 0 && idx < result.length) {
1401 // Defensive: truncate overlay line to declared width before compositing
1402 // (components should already respect width, but this ensures it)
1403 const truncatedOverlayLine =
1404 visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
1405 result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
1406 }
1407 }
1408 }
1409
1410 return result;
1411 }
1412
1413 protected applyLineResets(lines: string[]): string[] {
1414 const reset = SEGMENT_RESET;
1415 for (let i = 0; i < lines.length; i++) {
1416 const line = lines[i];
1417 if (!isImageLine(line)) {
1418 lines[i] = normalizeTerminalOutput(line) + reset;
1419 }
1420 }
1421 return lines;
1422 }
1423
1424 private compositeLineAt(
1425 baseLine: string,
1426 overlayLine: string,
1427 startCol: number,
1428 overlayWidth: number,
1429 totalWidth: number,
1430 ): string {
1431 return compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth);
1432 }
1433
1434 /**
1435 * Find and extract cursor position from rendered lines.
1436 * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
1437 * Only scans the bottom terminal height lines (visible viewport).
1438 * @param lines - Rendered lines to search
1439 * @param height - Terminal height (visible viewport size)
1440 * @returns Cursor position { row, col } or null if no marker found
1441 */
1442 protected extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null {
1443 // Only scan the bottom `height` lines (visible viewport)
1444 const viewportTop = Math.max(0, lines.length - height);
1445 for (let row = lines.length - 1; row >= viewportTop; row--) {
1446 const line = lines[row];
1447 const markerIndex = line.indexOf(CURSOR_MARKER);
1448 if (markerIndex !== -1) {
1449 // Calculate visual column (width of text before marker)
1450 const beforeMarker = line.slice(0, markerIndex);
1451 const col = visibleWidth(beforeMarker);
1452
1453 // Strip marker from the line
1454 lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
1455
1456 return { row, col };
1457 }
1458 }
1459 return null;
1460 }
1461
1462 /**
1463 * Query the terminal's theme colors: the default foreground (OSC 10), the default background
1464 * (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the
1465 * replies. Resolves when the DA1 reply or all color replies arrive, or when the timeout expires.
1466 * Colors the terminal did not report are undefined; the palette is only set when all 16 arrived.
1467 * @param timeoutMs Query timeout in milliseconds, for terminals that do not answer DA1 either.
1468 * @param onLateReply Receives the replies if the query completes after the timeout, e.g. over slow links.
1469 */
1470 queryTerminalColors({
1471 timeoutMs,
1472 onLateReply,
1473 }: {
1474 timeoutMs: number;
1475 onLateReply?: (colors: TerminalColors) => void;
1476 }): Promise<TerminalColors> {
1477 return new Promise((resolve) => {
1478 const query: PendingTerminalColorQuery = {
1479 palette: Array.from({ length: TERMINAL_PALETTE_SIZE }, () => undefined),
1480 replied: new Set(),
1481 deliver: resolve,
1482 timer: undefined,
1483 };
1484 // Resolve with the replies so far, and keep collecting late replies for `onLateReply`.
1485 query.timer = setTimeout(() => {
1486 query.deliver = onLateReply;
1487 resolve(this.terminalColorQueryResult(query));
1488 }, timeoutMs);
1489 this.pendingTerminalColorQueries.push(query);
1490 this.terminal.write(TERMINAL_COLOR_QUERY);
1491 });
1492 }
1493}