Download packages/pi-tui/src/tui.ts from SaylorTwift/kimi-code: direct link, hf CLI and curl.
- Browser
- Download file 47.6 kB
-
https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/pi-tui/src/tui.ts
- Command line
-
hf download hf://SaylorTwift/kimi-code/packages/pi-tui/src/tui.ts
-
curl -L -o tui.ts https://huggingface.co/SaylorTwift/kimi-code/resolve/main/packages/pi-tui/src/tui.ts
47.6 kB
| /** | |
| * Minimal TUI implementation with differential rendering | |
| */ | |
| import { performance } from "node:perf_hooks"; | |
| import { isKeyRelease, matchesKey } from "./keys.ts"; | |
| import type { Terminal } from "./terminal.ts"; | |
| import { | |
| isOsc11BackgroundColorResponse, | |
| parseOsc11BackgroundColor, | |
| parseTerminalColorSchemeReport, | |
| type RgbColor, | |
| type TerminalColorScheme, | |
| } from "./terminal-colors.ts"; | |
| import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts"; | |
| import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts"; | |
| /** | |
| * Component interface - all components must implement this | |
| */ | |
| export type TuiMouseEventType = "press" | "release" | "move" | "drag" | "click" | "wheel"; | |
| export type TuiMouseButton = "left" | "middle" | "right" | "none"; | |
| /** Normalized cell-based mouse event. Coordinates are zero-based. */ | |
| export interface TuiMouseEvent { | |
| type: TuiMouseEventType; | |
| button: TuiMouseButton; | |
| /** Coordinates local to the receiving component. */ | |
| x: number; | |
| y: number; | |
| /** Absolute terminal coordinates. */ | |
| screenX: number; | |
| screenY: number; | |
| /** Current component bounds. */ | |
| width: number; | |
| height: number; | |
| shift: boolean; | |
| alt: boolean; | |
| ctrl: boolean; | |
| /** Logical lines. Negative values scroll up. */ | |
| wheelDelta?: number; | |
| /** Consecutive click count when type is click. */ | |
| clickCount?: number; | |
| } | |
| export interface TuiMouseEventResult { | |
| /** Stop propagation and suppress renderer-level fallback behavior. */ | |
| handled?: boolean; | |
| /** Route subsequent drag/release events to this component. Implies handled. */ | |
| capture?: boolean; | |
| /** Give keyboard focus to this component. Implies handled. */ | |
| focus?: boolean; | |
| /** | |
| * Explicitly request or suppress a render. Move and release default to false; | |
| * press, click, drag, and wheel default to true. | |
| */ | |
| render?: boolean; | |
| } | |
| /** Internal target metadata used by containers and alternate-screen dispatch. */ | |
| export interface TuiMouseDispatchTarget { | |
| component: Component; | |
| originX: number; | |
| originY: number; | |
| width: number; | |
| height: number; | |
| } | |
| /** Result of dispatching to a concrete component. */ | |
| export interface TuiMouseDispatchResult extends TuiMouseEventResult { | |
| handled: true; | |
| target: TuiMouseDispatchTarget; | |
| /** Keyboard focus target, which may be a delegating parent container. */ | |
| focusTarget?: Component; | |
| } | |
| /** | |
| * Dispatch an event to a component and retain the exact target and coordinate | |
| * transform. Containers use this when forwarding events to nested children. | |
| */ | |
| export function dispatchMouseEvent(component: Component, event: TuiMouseEvent): TuiMouseDispatchResult | undefined { | |
| const result = component.handleMouse?.(event); | |
| if (!result) return undefined; | |
| if ("target" in result) return result as TuiMouseDispatchResult; | |
| if (!result.handled && !result.capture && !result.focus) return undefined; | |
| return { | |
| ...result, | |
| handled: true, | |
| ...(result.focus ? { focusTarget: component } : {}), | |
| target: { | |
| component, | |
| originX: event.screenX - event.x, | |
| originY: event.screenY - event.y, | |
| width: event.width, | |
| height: event.height, | |
| }, | |
| }; | |
| } | |
| /** Recreate local coordinates for a previously dispatched mouse target. */ | |
| export function retargetMouseEvent(event: TuiMouseEvent, target: TuiMouseDispatchTarget): TuiMouseEvent { | |
| return { | |
| ...event, | |
| x: event.screenX - target.originX, | |
| y: event.screenY - target.originY, | |
| width: target.width, | |
| height: target.height, | |
| }; | |
| } | |
| export interface Component { | |
| /** | |
| * Render the component to lines for the given viewport width | |
| * @param width - Current viewport width | |
| * @returns Array of strings, each representing a line | |
| */ | |
| render(width: number): string[]; | |
| /** Optional handler for keyboard input when component has focus. */ | |
| handleInput?(data: string): void; | |
| /** Optional normalized mouse handler. */ | |
| handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined; | |
| /** | |
| * If true, component receives key release events (Kitty protocol). | |
| * Default is false - release events are filtered out. | |
| */ | |
| wantsKeyRelease?: boolean; | |
| /** | |
| * Invalidate any cached rendering state. | |
| * Called when theme changes or when component needs to re-render from scratch. | |
| */ | |
| invalidate(): void; | |
| } | |
| export type TuiInputListenerResult = { consume?: boolean; data?: string } | undefined; | |
| export type TuiInputListener = (data: string) => TuiInputListenerResult; | |
| type PendingOsc11BackgroundQuery = { | |
| settled: boolean; | |
| resolve: ((rgb: RgbColor | undefined) => void) | undefined; | |
| timer: NodeJS.Timeout | undefined; | |
| }; | |
| /** | |
| * Interface for components that can receive focus and display a hardware cursor. | |
| * When focused, the component should emit CURSOR_MARKER at the cursor position | |
| * in its render output. TUI will find this marker and position the hardware | |
| * cursor there for proper IME candidate window positioning. | |
| */ | |
| export interface Focusable { | |
| /** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */ | |
| focused: boolean; | |
| } | |
| /** Type guard to check if a component implements Focusable */ | |
| export function isFocusable(component: Component | null): component is Component & Focusable { | |
| return component !== null && "focused" in component; | |
| } | |
| /** | |
| * Cursor position marker - APC (Application Program Command) sequence. | |
| * This is a zero-width escape sequence that terminals ignore. | |
| * Components emit this at the cursor position when focused. | |
| * TUI finds and strips this marker, then positions the hardware cursor there. | |
| */ | |
| export const CURSOR_MARKER = "\x1b_pi:c\x07"; | |
| export { visibleWidth }; | |
| /** | |
| * Anchor position for overlays | |
| */ | |
| export type OverlayAnchor = | |
| | "center" | |
| | "top-left" | |
| | "top-right" | |
| | "bottom-left" | |
| | "bottom-right" | |
| | "top-center" | |
| | "bottom-center" | |
| | "left-center" | |
| | "right-center"; | |
| /** | |
| * Margin configuration for overlays | |
| */ | |
| export interface OverlayMargin { | |
| top?: number; | |
| right?: number; | |
| bottom?: number; | |
| left?: number; | |
| } | |
| /** Value that can be absolute (number) or percentage (string like "50%") */ | |
| export type SizeValue = number | `${number}%`; | |
| /** Parse a SizeValue into absolute value given a reference size */ | |
| function parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined { | |
| if (value === undefined) return undefined; | |
| if (typeof value === "number") return value; | |
| // Parse percentage string like "50%" | |
| const match = value.match(/^(\d+(?:\.\d+)?)%$/); | |
| if (match) { | |
| return Math.floor((referenceSize * parseFloat(match[1]!)) / 100); | |
| } | |
| return undefined; | |
| } | |
| /** | |
| * Options for overlay positioning and sizing. | |
| * Values can be absolute numbers or percentage strings (e.g., "50%"). | |
| */ | |
| export interface OverlayOptions { | |
| // === Sizing === | |
| /** Width in columns, or percentage of terminal width (e.g., "50%") */ | |
| width?: SizeValue; | |
| /** Minimum width in columns */ | |
| minWidth?: number; | |
| /** Maximum height in rows, or percentage of terminal height (e.g., "50%") */ | |
| maxHeight?: SizeValue; | |
| // === Positioning - anchor-based === | |
| /** Anchor point for positioning (default: 'center') */ | |
| anchor?: OverlayAnchor; | |
| /** Horizontal offset from anchor position (positive = right) */ | |
| offsetX?: number; | |
| /** Vertical offset from anchor position (positive = down) */ | |
| offsetY?: number; | |
| // === Positioning - percentage or absolute === | |
| /** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */ | |
| row?: SizeValue; | |
| /** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */ | |
| col?: SizeValue; | |
| // === Margin from terminal edges === | |
| /** Margin from terminal edges. Number applies to all sides. */ | |
| margin?: OverlayMargin | number; | |
| // === Visibility === | |
| /** | |
| * Control overlay visibility based on terminal dimensions. | |
| * If provided, overlay is only rendered when this returns true. | |
| * Called each render cycle with current terminal dimensions. | |
| */ | |
| visible?: (termWidth: number, termHeight: number) => boolean; | |
| /** If true, don't capture keyboard focus when shown */ | |
| nonCapturing?: boolean; | |
| } | |
| /** Options for {@link OverlayHandle.unfocus}. */ | |
| export interface OverlayUnfocusOptions { | |
| /** Explicit target to focus after releasing this overlay. */ | |
| target: Component | null; | |
| } | |
| /** Last rendered terminal-relative overlay rectangle. */ | |
| export interface OverlayBounds { | |
| row: number; | |
| col: number; | |
| width: number; | |
| height: number; | |
| } | |
| /** | |
| * Handle returned by showOverlay for controlling the overlay | |
| */ | |
| export interface OverlayHandle { | |
| /** Permanently remove the overlay (cannot be shown again) */ | |
| hide(): void; | |
| /** Temporarily hide or show the overlay */ | |
| setHidden(hidden: boolean): void; | |
| /** Check if overlay is temporarily hidden */ | |
| isHidden(): boolean; | |
| /** Focus this overlay and bring it to the visual front */ | |
| focus(): void; | |
| /** Release focus to the next visible capturing overlay or previous target, or to an explicit target when provided */ | |
| unfocus(options?: OverlayUnfocusOptions): void; | |
| /** Check if this overlay currently has focus */ | |
| isFocused(): boolean; | |
| /** Get the most recent rendered bounds for a visible overlay. */ | |
| getBounds(): OverlayBounds | undefined; | |
| } | |
| type OverlayStackEntry = { | |
| component: Component; | |
| options?: OverlayOptions; | |
| preFocus: Component | null; | |
| hidden: boolean; | |
| focusOrder: number; | |
| bounds?: OverlayBounds; | |
| }; | |
| type RenderedOverlayLayout = { | |
| entry: OverlayStackEntry; | |
| row: number; | |
| col: number; | |
| width: number; | |
| height: number; | |
| }; | |
| type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null }; | |
| type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry }; | |
| type BlockedOverlayFocusRestoreState = { | |
| status: "blocked"; | |
| overlay: OverlayStackEntry; | |
| blockedBy: Component; | |
| resume: OverlayBlockedFocusResume; | |
| }; | |
| type ActiveOverlayFocusRestoreState = EligibleOverlayFocusRestoreState | BlockedOverlayFocusRestoreState; | |
| type OverlayFocusRestoreState = { status: "inactive" } | ActiveOverlayFocusRestoreState; | |
| type OverlayFocusRestorePolicy = "clear" | "preserve"; | |
| /** | |
| * Container - a component that contains other components | |
| */ | |
| export class Container implements Component { | |
| children: Component[] = []; | |
| private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> }; | |
| addChild(component: Component): void { | |
| this.children.push(component); | |
| } | |
| removeChild(component: Component): void { | |
| const index = this.children.indexOf(component); | |
| if (index !== -1) { | |
| this.children.splice(index, 1); | |
| } | |
| } | |
| clear(): void { | |
| this.children = []; | |
| } | |
| invalidate(): void { | |
| for (const child of this.children) { | |
| child.invalidate?.(); | |
| } | |
| } | |
| handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined { | |
| if (event.y < 0 || event.y >= event.height) return undefined; | |
| const mouseChildren = | |
| this.mouseLayout?.width === event.width | |
| ? this.mouseLayout.children | |
| : this.children.map((component) => ({ component, height: component.render(event.width).length })); | |
| let childY = 0; | |
| for (const { component: child, height: childHeight } of mouseChildren) { | |
| if (event.y >= childY && event.y < childY + childHeight) { | |
| const result = dispatchMouseEvent(child, { | |
| ...event, | |
| y: event.y - childY, | |
| height: childHeight, | |
| }); | |
| if (result?.focus && (this as Component).handleInput) return { ...result, focusTarget: this }; | |
| return result; | |
| } | |
| childY += childHeight; | |
| } | |
| return undefined; | |
| } | |
| render(width: number): string[] { | |
| // Extremely narrow terminals can report tiny or even non-positive | |
| // column counts; never propagate a width below 1 into components. | |
| width = Math.max(1, width); | |
| const lines: string[] = []; | |
| const mouseChildren: Array<{ component: Component; height: number }> = []; | |
| for (const child of this.children) { | |
| const childLines = child.render(width); | |
| mouseChildren.push({ component: child, height: childLines.length }); | |
| for (const line of childLines) { | |
| lines.push(line); | |
| } | |
| } | |
| this.mouseLayout = { width, children: mouseChildren }; | |
| return lines; | |
| } | |
| } | |
| /** | |
| * TUI - Main class for managing terminal UI with differential rendering | |
| */ | |
| export const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; | |
| /** Composite overlay content into a terminal line at a fixed column. */ | |
| export function compositeTuiLine( | |
| baseLine: string, | |
| overlayLine: string, | |
| startCol: number, | |
| overlayWidth: number, | |
| totalWidth: number, | |
| ): string { | |
| if (isImageLine(baseLine)) return baseLine; | |
| const afterStart = startCol + overlayWidth; | |
| const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true); | |
| const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true); | |
| const beforePad = Math.max(0, startCol - base.beforeWidth); | |
| const overlayPad = Math.max(0, overlayWidth - overlay.width); | |
| const actualBeforeWidth = Math.max(startCol, base.beforeWidth); | |
| const actualOverlayWidth = Math.max(overlayWidth, overlay.width); | |
| const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth); | |
| const afterPad = Math.max(0, afterTarget - base.afterWidth); | |
| const result = | |
| base.before + | |
| " ".repeat(beforePad) + | |
| SEGMENT_RESET + | |
| overlay.text + | |
| " ".repeat(overlayPad) + | |
| SEGMENT_RESET + | |
| base.after + | |
| " ".repeat(afterPad); | |
| return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true); | |
| } | |
| export type TuiMode = "regular" | "fullscreen"; | |
| export interface TuiStopOptions { | |
| /** Leave renderer output in place for another TUI taking over the same terminal. */ | |
| preserveScreen?: boolean; | |
| } | |
| export interface TUI extends Component { | |
| readonly mode: TuiMode; | |
| children: Component[]; | |
| terminal: Terminal; | |
| onDebug?: () => void; | |
| readonly fullRedraws: number; | |
| addChild(component: Component): void; | |
| removeChild(component: Component): void; | |
| clear(): void; | |
| getShowHardwareCursor(): boolean; | |
| setShowHardwareCursor(enabled: boolean): void; | |
| getClearOnShrink(): boolean; | |
| setClearOnShrink(enabled: boolean): void; | |
| setFocus(component: Component | null): void; | |
| showOverlay(component: Component, options?: OverlayOptions): OverlayHandle; | |
| hideOverlay(): void; | |
| hasOverlay(): boolean; | |
| start(): void; | |
| stop(options?: TuiStopOptions): void; | |
| renderNow(force?: boolean): void; | |
| requestRender(force?: boolean): void; | |
| addInputListener(listener: TuiInputListener): () => void; | |
| removeInputListener(listener: TuiInputListener): void; | |
| onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void; | |
| setTerminalColorSchemeNotifications(enabled: boolean): void; | |
| queryTerminalBackgroundColor(options: { timeoutMs: number }): Promise<RgbColor | undefined>; | |
| queryTerminalColorScheme(options: { timeoutMs: number }): Promise<TerminalColorScheme | undefined>; | |
| } | |
| export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport"); | |
| export interface ViewportTUI extends TUI { | |
| readonly [VIEWPORT_TUI]: true; | |
| setLayoutRoot(component: Component | undefined): void; | |
| } | |
| export function isViewportTUI(tui: TUI): tui is ViewportTUI { | |
| return (tui as Partial<ViewportTUI>)[VIEWPORT_TUI] === true; | |
| } | |
| export abstract class TuiBase extends Container implements TUI { | |
| abstract readonly mode: TuiMode; | |
| public terminal: Terminal; | |
| private focusedComponent: Component | null = null; | |
| private inputListeners = new Set<TuiInputListener>(); | |
| /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */ | |
| public onDebug?: () => void; | |
| private renderRequested = false; | |
| private immediateRenderScheduled = false; | |
| private renderTimer: NodeJS.Timeout | undefined; | |
| private lastRenderAt = 0; | |
| private static readonly MIN_RENDER_INTERVAL_MS = 16; | |
| private showHardwareCursor = false; | |
| private clearOnShrink = false; | |
| protected fullRedrawCount = 0; | |
| protected stopped = false; | |
| private pendingOsc11BackgroundReplies = 0; | |
| private pendingOsc11BackgroundQueries: PendingOsc11BackgroundQuery[] = []; | |
| private terminalColorSchemeListeners = new Set<(scheme: TerminalColorScheme) => void>(); | |
| private terminalColorSchemeNotificationsEnabled = false; | |
| /** Directory for debug/crash logs. When undefined, debug logging is disabled and crash dumps fall back to the OS temp directory. */ | |
| protected readonly logDirectory: string | undefined; | |
| // Overlay stack for modal components rendered on top of base content | |
| private focusOrderCounter = 0; | |
| private overlayStack: OverlayStackEntry[] = []; | |
| private renderedOverlayLayouts: RenderedOverlayLayout[] = []; | |
| get hasOverlayEntries(): boolean { | |
| return this.overlayStack.length > 0; | |
| } | |
| private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" }; | |
| constructor(terminal: Terminal, showHardwareCursor?: boolean, logDirectory?: string) { | |
| super(); | |
| this.terminal = terminal; | |
| this.logDirectory = logDirectory; | |
| if (showHardwareCursor !== undefined) { | |
| this.showHardwareCursor = showHardwareCursor; | |
| } | |
| } | |
| protected abstract doRender(): void; | |
| protected resetRenderState(): void {} | |
| protected beforeTerminalStart(): void {} | |
| protected afterTerminalStart(): void {} | |
| protected beforeTerminalStop(_options: TuiStopOptions): void {} | |
| protected afterTerminalStop(_options: TuiStopOptions): void {} | |
| get fullRedraws(): number { | |
| return this.fullRedrawCount; | |
| } | |
| getShowHardwareCursor(): boolean { | |
| return this.showHardwareCursor; | |
| } | |
| setShowHardwareCursor(enabled: boolean): void { | |
| if (this.showHardwareCursor === enabled) return; | |
| this.showHardwareCursor = enabled; | |
| if (!enabled) { | |
| this.terminal.hideCursor(); | |
| } | |
| this.requestRender(); | |
| } | |
| getClearOnShrink(): boolean { | |
| return this.clearOnShrink; | |
| } | |
| /** | |
| * Set whether to trigger full re-render when content shrinks. | |
| * When true, empty rows are cleared when content shrinks. | |
| * When false (default), empty rows remain (reduces redraws on slower terminals). | |
| */ | |
| setClearOnShrink(enabled: boolean): void { | |
| this.clearOnShrink = enabled; | |
| } | |
| getFocusedComponent(): Component | null { | |
| return this.focusedComponent; | |
| } | |
| setFocus(component: Component | null): void { | |
| this.setFocusInternal({ component, overlayFocusRestore: "clear" }); | |
| } | |
| private setFocusInternal({ | |
| component, | |
| overlayFocusRestore, | |
| }: { | |
| component: Component | null; | |
| overlayFocusRestore: OverlayFocusRestorePolicy; | |
| }): void { | |
| const previousFocus = this.focusedComponent; | |
| let nextFocus = component; | |
| const previousFocusedOverlay = previousFocus | |
| ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry)) | |
| : undefined; | |
| const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false; | |
| const restoreState = this.getVisibleOverlayFocusRestore(); | |
| if (nextFocus && !nextFocusIsOverlay) { | |
| if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { | |
| if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) { | |
| nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); | |
| } else { | |
| this.overlayFocusRestore = { | |
| status: "blocked", | |
| overlay: restoreState.overlay, | |
| blockedBy: nextFocus, | |
| resume: restoreState.resume, | |
| }; | |
| } | |
| } else if ( | |
| previousFocusedOverlay && | |
| restoreState.status !== "inactive" && | |
| restoreState.overlay === previousFocusedOverlay && | |
| !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus) | |
| ) { | |
| this.overlayFocusRestore = { | |
| status: "blocked", | |
| overlay: previousFocusedOverlay, | |
| blockedBy: nextFocus, | |
| resume: { status: "restore-overlay" }, | |
| }; | |
| } | |
| } else if (nextFocus === null) { | |
| if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) { | |
| nextFocus = this.resolveBlockedOverlayFocusResume(restoreState); | |
| } else if (overlayFocusRestore === "clear") { | |
| this.clearOverlayFocusRestore(); | |
| } | |
| } | |
| if (isFocusable(this.focusedComponent)) { | |
| this.focusedComponent.focused = false; | |
| } | |
| this.focusedComponent = nextFocus; | |
| if (isFocusable(nextFocus)) { | |
| nextFocus.focused = true; | |
| } | |
| const focusedOverlay = nextFocus | |
| ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry)) | |
| : undefined; | |
| if (focusedOverlay) { | |
| this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay }; | |
| } | |
| } | |
| private clearOverlayFocusRestore(): void { | |
| this.overlayFocusRestore = { status: "inactive" }; | |
| } | |
| private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void { | |
| if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) { | |
| this.clearOverlayFocusRestore(); | |
| } | |
| } | |
| private resolveBlockedOverlayFocusResume(restoreState: BlockedOverlayFocusRestoreState): Component | null { | |
| if (restoreState.resume.status === "restore-overlay") return restoreState.overlay.component; | |
| this.clearOverlayFocusRestore(); | |
| return restoreState.resume.target; | |
| } | |
| private getVisibleOverlayFocusRestore(): OverlayFocusRestoreState { | |
| const restoreState = this.overlayFocusRestore; | |
| if (restoreState.status === "inactive") return restoreState; | |
| if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) { | |
| return { status: "inactive" }; | |
| } | |
| return restoreState; | |
| } | |
| private isOverlayFocusAncestor(entry: OverlayStackEntry, component: Component): boolean { | |
| const visited = new Set<Component>(); | |
| let current = entry.preFocus; | |
| while (current && !visited.has(current)) { | |
| visited.add(current); | |
| if (current === component) return true; | |
| current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null; | |
| } | |
| return false; | |
| } | |
| private retargetOverlayPreFocus(removed: OverlayStackEntry): void { | |
| for (const overlay of this.overlayStack) { | |
| if (overlay !== removed && overlay.preFocus === removed.component) { | |
| overlay.preFocus = removed.preFocus; | |
| } | |
| } | |
| } | |
| protected getMountedRoots(): readonly Component[] { | |
| return this.children; | |
| } | |
| private isComponentMounted(component: Component): boolean { | |
| return this.getMountedRoots().some((child) => this.containsComponent(child, component)); | |
| } | |
| private containsComponent(root: Component, target: Component): boolean { | |
| if (root === target) return true; | |
| if (!(root instanceof Container)) return false; | |
| return root.children.some((child) => this.containsComponent(child, target)); | |
| } | |
| /** | |
| * Show an overlay component with configurable positioning and sizing. | |
| * Returns a handle to control the overlay's visibility. | |
| */ | |
| showOverlay(component: Component, options?: OverlayOptions): OverlayHandle { | |
| const entry: OverlayStackEntry = { | |
| component, | |
| ...(options === undefined ? {} : { options }), | |
| preFocus: this.focusedComponent, | |
| hidden: false, | |
| focusOrder: ++this.focusOrderCounter, | |
| }; | |
| this.overlayStack.push(entry); | |
| // Only focus if overlay is actually visible | |
| if (!options?.nonCapturing && this.isOverlayVisible(entry)) { | |
| this.setFocus(component); | |
| } | |
| this.terminal.hideCursor(); | |
| this.requestRender(); | |
| // Return handle for controlling this overlay | |
| return { | |
| hide: () => { | |
| const index = this.overlayStack.indexOf(entry); | |
| if (index !== -1) { | |
| this.clearOverlayFocusRestoreFor(entry); | |
| this.retargetOverlayPreFocus(entry); | |
| this.overlayStack.splice(index, 1); | |
| // Restore focus if this overlay had focus | |
| if (this.focusedComponent === component) { | |
| const topVisible = this.getTopmostVisibleOverlay(); | |
| this.setFocus(topVisible?.component ?? entry.preFocus); | |
| } | |
| if (this.overlayStack.length === 0) this.terminal.hideCursor(); | |
| this.requestRender(); | |
| } | |
| }, | |
| setHidden: (hidden: boolean) => { | |
| if (entry.hidden === hidden) return; | |
| entry.hidden = hidden; | |
| // Update focus when hiding/showing | |
| if (hidden) { | |
| this.clearOverlayFocusRestoreFor(entry); | |
| // If this overlay had focus, move focus to next visible or preFocus | |
| if (this.focusedComponent === component) { | |
| const topVisible = this.getTopmostVisibleOverlay(); | |
| this.setFocus(topVisible?.component ?? entry.preFocus); | |
| } | |
| } else { | |
| // Restore focus to this overlay when showing (if it's actually visible) | |
| if (!options?.nonCapturing && this.isOverlayVisible(entry)) { | |
| entry.focusOrder = ++this.focusOrderCounter; | |
| this.setFocus(component); | |
| } | |
| } | |
| this.requestRender(); | |
| }, | |
| isHidden: () => entry.hidden, | |
| focus: () => { | |
| if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return; | |
| entry.focusOrder = ++this.focusOrderCounter; | |
| this.setFocus(component); | |
| this.requestRender(); | |
| }, | |
| unfocus: (unfocusOptions) => { | |
| const isFocused = this.focusedComponent === component; | |
| const restoreState = this.overlayFocusRestore; | |
| const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry; | |
| if (!isFocused && !hasPendingRestore) return; | |
| if ( | |
| restoreState.status === "blocked" && | |
| restoreState.overlay === entry && | |
| this.focusedComponent === restoreState.blockedBy | |
| ) { | |
| if (unfocusOptions) { | |
| this.overlayFocusRestore = { | |
| status: "blocked", | |
| overlay: entry, | |
| blockedBy: restoreState.blockedBy, | |
| resume: { status: "focus-target", target: unfocusOptions.target }, | |
| }; | |
| } else { | |
| this.clearOverlayFocusRestore(); | |
| } | |
| this.requestRender(); | |
| return; | |
| } | |
| this.clearOverlayFocusRestoreFor(entry); | |
| if (isFocused || unfocusOptions) { | |
| const topVisible = this.getTopmostVisibleOverlay(); | |
| const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus; | |
| this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget); | |
| } | |
| this.requestRender(); | |
| }, | |
| isFocused: () => this.focusedComponent === component, | |
| getBounds: () => { | |
| if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry) || !entry.bounds) return undefined; | |
| return { ...entry.bounds }; | |
| }, | |
| }; | |
| } | |
| /** Hide the topmost overlay and restore previous focus. */ | |
| hideOverlay(): void { | |
| const overlay = this.overlayStack[this.overlayStack.length - 1]; | |
| if (!overlay) return; | |
| this.clearOverlayFocusRestoreFor(overlay); | |
| this.retargetOverlayPreFocus(overlay); | |
| this.overlayStack.pop(); | |
| if (this.focusedComponent === overlay.component) { | |
| // Find topmost visible overlay, or fall back to preFocus | |
| const topVisible = this.getTopmostVisibleOverlay(); | |
| this.setFocus(topVisible?.component ?? overlay.preFocus); | |
| } | |
| if (this.overlayStack.length === 0) this.terminal.hideCursor(); | |
| this.requestRender(); | |
| } | |
| /** Check if there are any visible overlays */ | |
| hasOverlay(): boolean { | |
| return this.overlayStack.some((o) => this.isOverlayVisible(o)); | |
| } | |
| /** Check if the focused component is a visible overlay */ | |
| protected isOverlayFocused(): boolean { | |
| return this.overlayStack.some( | |
| (entry) => entry.component === this.focusedComponent && this.isOverlayVisible(entry), | |
| ); | |
| } | |
| /** Keep overlay containers as keyboard focus owners when a nested control is clicked. */ | |
| protected resolveMouseFocusTarget(component: Component): Component { | |
| for (let index = this.overlayStack.length - 1; index >= 0; index--) { | |
| const overlay = this.overlayStack[index]!; | |
| if (this.isOverlayVisible(overlay) && this.containsComponent(overlay.component, component)) { | |
| return overlay.component; | |
| } | |
| } | |
| return component; | |
| } | |
| /** Dispatch to the visually topmost overlay under the pointer. */ | |
| protected dispatchMouseToOverlay(event: TuiMouseEvent): { hit: boolean; result?: TuiMouseDispatchResult } { | |
| for (let index = this.renderedOverlayLayouts.length - 1; index >= 0; index--) { | |
| const layout = this.renderedOverlayLayouts[index]!; | |
| if ( | |
| event.screenX < layout.col || | |
| event.screenX >= layout.col + layout.width || | |
| event.screenY < layout.row || | |
| event.screenY >= layout.row + layout.height | |
| ) { | |
| continue; | |
| } | |
| const result = dispatchMouseEvent(layout.entry.component, { | |
| ...event, | |
| x: event.screenX - layout.col, | |
| y: event.screenY - layout.row, | |
| width: layout.width, | |
| height: layout.height, | |
| }); | |
| return result | |
| ? { | |
| hit: true, | |
| result: result.focus ? { ...result, focusTarget: layout.entry.component } : result, | |
| } | |
| : { hit: true }; | |
| } | |
| return { hit: false }; | |
| } | |
| /** Check if an overlay entry is currently visible */ | |
| private isOverlayVisible(entry: OverlayStackEntry): boolean { | |
| if (entry.hidden) return false; | |
| if (entry.options?.visible) { | |
| return entry.options.visible(this.terminal.columns, this.terminal.rows); | |
| } | |
| return true; | |
| } | |
| /** Find the visual-frontmost visible capturing overlay, if any */ | |
| private getTopmostVisibleOverlay(): OverlayStackEntry | undefined { | |
| let topmost: OverlayStackEntry | undefined; | |
| for (const overlay of this.overlayStack) { | |
| if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay)) continue; | |
| if (!topmost || overlay.focusOrder > topmost.focusOrder) { | |
| topmost = overlay; | |
| } | |
| } | |
| return topmost; | |
| } | |
| override invalidate(): void { | |
| for (const root of this.getMountedRoots()) root.invalidate(); | |
| for (const overlay of this.overlayStack) overlay.component.invalidate(); | |
| } | |
| start(): void { | |
| this.stopped = false; | |
| this.beforeTerminalStart(); | |
| this.terminal.start( | |
| (data) => this.handleTerminalInput(data), | |
| () => this.requestRender(), | |
| ); | |
| this.afterTerminalStart(); | |
| this.terminal.hideCursor(); | |
| if (this.terminalColorSchemeNotificationsEnabled) { | |
| this.terminal.write("\x1b[?2031h"); | |
| } | |
| this.queryCellSize(); | |
| this.requestRender(); | |
| } | |
| addInputListener(listener: TuiInputListener): () => void { | |
| this.inputListeners.add(listener); | |
| return () => { | |
| this.inputListeners.delete(listener); | |
| }; | |
| } | |
| removeInputListener(listener: TuiInputListener): void { | |
| this.inputListeners.delete(listener); | |
| } | |
| onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void { | |
| this.terminalColorSchemeListeners.add(listener); | |
| return () => { | |
| this.terminalColorSchemeListeners.delete(listener); | |
| }; | |
| } | |
| setTerminalColorSchemeNotifications(enabled: boolean): void { | |
| if (this.terminalColorSchemeNotificationsEnabled === enabled) { | |
| return; | |
| } | |
| this.terminalColorSchemeNotificationsEnabled = enabled; | |
| if (!this.stopped) { | |
| this.terminal.write(enabled ? "\x1b[?2031h" : "\x1b[?2031l"); | |
| } | |
| } | |
| private queryCellSize(): void { | |
| // Only query if terminal supports images (cell size is only used for image rendering) | |
| if (!getCapabilities().images) { | |
| return; | |
| } | |
| // Query terminal for cell size in pixels: CSI 16 t | |
| // Response format: CSI 6 ; height ; width t | |
| this.terminal.write("\x1b[16t"); | |
| } | |
| stop(options: TuiStopOptions = {}): void { | |
| this.stopped = true; | |
| this.cancelRenderTimer(); | |
| if (this.terminalColorSchemeNotificationsEnabled) { | |
| this.terminal.write("\x1b[?2031l"); | |
| } | |
| this.beforeTerminalStop(options); | |
| this.terminal.showCursor(); | |
| this.terminal.stop(); | |
| this.afterTerminalStop(options); | |
| } | |
| renderNow(force = false): void { | |
| if (force) this.resetRenderState(); | |
| this.renderRequested = false; | |
| this.cancelRenderTimer(); | |
| this.lastRenderAt = performance.now(); | |
| this.doRender(); | |
| } | |
| requestRender(force = false): void { | |
| if (force) { | |
| this.resetRenderState(); | |
| this.requestImmediateRender(); | |
| return; | |
| } | |
| if (this.renderRequested) return; | |
| this.renderRequested = true; | |
| process.nextTick(() => this.scheduleRender()); | |
| } | |
| private requestImmediateRender(): void { | |
| this.cancelRenderTimer(); | |
| this.renderRequested = true; | |
| if (this.immediateRenderScheduled) return; | |
| this.immediateRenderScheduled = true; | |
| process.nextTick(() => { | |
| this.immediateRenderScheduled = false; | |
| if (this.stopped || !this.renderRequested) return; | |
| // A previously queued scheduleRender() can create a timer before this | |
| // callback runs. User input must preempt that throttled frame. | |
| this.cancelRenderTimer(); | |
| this.renderRequested = false; | |
| this.lastRenderAt = performance.now(); | |
| this.doRender(); | |
| }); | |
| } | |
| private cancelRenderTimer(): void { | |
| if (!this.renderTimer) return; | |
| clearTimeout(this.renderTimer); | |
| this.renderTimer = undefined; | |
| } | |
| private scheduleRender(): void { | |
| if (this.stopped || this.renderTimer || !this.renderRequested) { | |
| return; | |
| } | |
| const elapsed = performance.now() - this.lastRenderAt; | |
| const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed); | |
| this.renderTimer = setTimeout(() => { | |
| this.renderTimer = undefined; | |
| if (this.stopped || !this.renderRequested) { | |
| return; | |
| } | |
| this.renderRequested = false; | |
| this.lastRenderAt = performance.now(); | |
| this.doRender(); | |
| if (this.renderRequested) { | |
| this.scheduleRender(); | |
| } | |
| }, delay); | |
| } | |
| private handleTerminalInput(data: string): void { | |
| if (this.consumeOsc11BackgroundResponse(data)) { | |
| return; | |
| } | |
| if (this.consumeTerminalColorSchemeReport(data)) { | |
| return; | |
| } | |
| if (this.inputListeners.size > 0) { | |
| let current = data; | |
| for (const listener of this.inputListeners) { | |
| const result = listener(current); | |
| if (result?.consume) { | |
| return; | |
| } | |
| if (result?.data !== undefined) { | |
| current = result.data; | |
| } | |
| } | |
| if (current.length === 0) { | |
| return; | |
| } | |
| data = current; | |
| } | |
| // Consume terminal cell size responses without blocking unrelated input. | |
| if (this.consumeCellSizeResponse(data)) { | |
| return; | |
| } | |
| // Global debug key handler (Shift+Ctrl+D) | |
| if (matchesKey(data, "shift+ctrl+d") && this.onDebug) { | |
| this.onDebug(); | |
| return; | |
| } | |
| // If focused component is an overlay, verify it's still visible | |
| // (visibility can change due to terminal resize or visible() callback) | |
| const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent); | |
| if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) { | |
| // Focused overlay is no longer visible, redirect to topmost visible overlay | |
| const topVisible = this.getTopmostVisibleOverlay(); | |
| if (topVisible) { | |
| this.setFocus(topVisible.component); | |
| } else { | |
| this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" }); | |
| } | |
| } | |
| const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent); | |
| if (!focusIsOverlay) { | |
| const restoreState = this.getVisibleOverlayFocusRestore(); | |
| if (restoreState.status === "eligible") { | |
| this.setFocus(restoreState.overlay.component); | |
| } else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) { | |
| if (restoreState.resume.status === "restore-overlay") { | |
| this.setFocus(restoreState.overlay.component); | |
| } else { | |
| this.clearOverlayFocusRestore(); | |
| this.setFocus(restoreState.resume.target); | |
| } | |
| } | |
| } | |
| // Pass input to focused component (including Ctrl+C) | |
| // The focused component can decide how to handle Ctrl+C | |
| if (this.focusedComponent?.handleInput) { | |
| // Filter out key release events unless component opts in | |
| if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) { | |
| return; | |
| } | |
| this.focusedComponent.handleInput(data); | |
| // Keyboard input is latency-sensitive. Avoid the throttled timer path, | |
| // where even setTimeout(0) can take a full 16 ms tick on Windows. | |
| this.requestImmediateRender(); | |
| } | |
| } | |
| private consumeOsc11BackgroundResponse(data: string): boolean { | |
| if (this.pendingOsc11BackgroundReplies <= 0) { | |
| return false; | |
| } | |
| if (!isOsc11BackgroundColorResponse(data)) { | |
| return false; | |
| } | |
| const rgb = parseOsc11BackgroundColor(data); | |
| this.pendingOsc11BackgroundReplies -= 1; | |
| const query = this.pendingOsc11BackgroundQueries.shift(); | |
| if (query && !query.settled) { | |
| query.settled = true; | |
| if (query.timer) { | |
| clearTimeout(query.timer); | |
| query.timer = undefined; | |
| } | |
| query.resolve?.(rgb); | |
| query.resolve = undefined; | |
| } | |
| return true; | |
| } | |
| private consumeTerminalColorSchemeReport(data: string): boolean { | |
| const scheme = parseTerminalColorSchemeReport(data); | |
| if (!scheme) { | |
| return false; | |
| } | |
| for (const listener of this.terminalColorSchemeListeners) { | |
| listener(scheme); | |
| } | |
| return true; | |
| } | |
| private consumeCellSizeResponse(data: string): boolean { | |
| // Response format: ESC [ 6 ; height ; width t | |
| const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/); | |
| if (!match) { | |
| return false; | |
| } | |
| const heightPx = parseInt(match[1]!, 10); | |
| const widthPx = parseInt(match[2]!, 10); | |
| if (heightPx <= 0 || widthPx <= 0) { | |
| return true; | |
| } | |
| setCellDimensions({ widthPx, heightPx }); | |
| // Invalidate all components so images re-render with correct dimensions. | |
| this.invalidate(); | |
| this.requestRender(); | |
| return true; | |
| } | |
| /** | |
| * Resolve overlay layout from options. | |
| * Returns { width, row, col, maxHeight } for rendering. | |
| */ | |
| private resolveOverlayLayout( | |
| options: OverlayOptions | undefined, | |
| overlayHeight: number, | |
| termWidth: number, | |
| termHeight: number, | |
| ): { width: number; row: number; col: number; maxHeight: number | undefined } { | |
| const opt = options ?? {}; | |
| // Parse margin (clamp to non-negative) | |
| const margin = | |
| typeof opt.margin === "number" | |
| ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin } | |
| : (opt.margin ?? {}); | |
| const marginTop = Math.max(0, margin.top ?? 0); | |
| const marginRight = Math.max(0, margin.right ?? 0); | |
| const marginBottom = Math.max(0, margin.bottom ?? 0); | |
| const marginLeft = Math.max(0, margin.left ?? 0); | |
| // Available space after margins | |
| const availWidth = Math.max(1, termWidth - marginLeft - marginRight); | |
| const availHeight = Math.max(1, termHeight - marginTop - marginBottom); | |
| // === Resolve width === | |
| let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth); | |
| // Apply minWidth | |
| if (opt.minWidth !== undefined) { | |
| width = Math.max(width, opt.minWidth); | |
| } | |
| // Clamp to available space | |
| width = Math.max(1, Math.min(width, availWidth)); | |
| // === Resolve maxHeight === | |
| let maxHeight = parseSizeValue(opt.maxHeight, termHeight); | |
| // Clamp to available space | |
| if (maxHeight !== undefined) { | |
| maxHeight = Math.max(1, Math.min(maxHeight, availHeight)); | |
| } | |
| // Effective overlay height (may be clamped by maxHeight) | |
| const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight; | |
| // === Resolve position === | |
| let row: number; | |
| let col: number; | |
| if (opt.row !== undefined) { | |
| if (typeof opt.row === "string") { | |
| // Percentage: 0% = top, 100% = bottom (overlay stays within bounds) | |
| const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/); | |
| if (match) { | |
| const maxRow = Math.max(0, availHeight - effectiveHeight); | |
| const percent = parseFloat(match[1]!) / 100; | |
| row = marginTop + Math.floor(maxRow * percent); | |
| } else { | |
| // Invalid format, fall back to center | |
| row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop); | |
| } | |
| } else { | |
| // Absolute row position | |
| row = opt.row; | |
| } | |
| } else { | |
| // Anchor-based (default: center) | |
| const anchor = opt.anchor ?? "center"; | |
| row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop); | |
| } | |
| if (opt.col !== undefined) { | |
| if (typeof opt.col === "string") { | |
| // Percentage: 0% = left, 100% = right (overlay stays within bounds) | |
| const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/); | |
| if (match) { | |
| const maxCol = Math.max(0, availWidth - width); | |
| const percent = parseFloat(match[1]!) / 100; | |
| col = marginLeft + Math.floor(maxCol * percent); | |
| } else { | |
| // Invalid format, fall back to center | |
| col = this.resolveAnchorCol("center", width, availWidth, marginLeft); | |
| } | |
| } else { | |
| // Absolute column position | |
| col = opt.col; | |
| } | |
| } else { | |
| // Anchor-based (default: center) | |
| const anchor = opt.anchor ?? "center"; | |
| col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft); | |
| } | |
| // Apply offsets | |
| if (opt.offsetY !== undefined) row += opt.offsetY; | |
| if (opt.offsetX !== undefined) col += opt.offsetX; | |
| // Clamp to terminal bounds (respecting margins) | |
| row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight)); | |
| col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width)); | |
| return { width, row, col, maxHeight }; | |
| } | |
| private resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number { | |
| switch (anchor) { | |
| case "top-left": | |
| case "top-center": | |
| case "top-right": | |
| return marginTop; | |
| case "bottom-left": | |
| case "bottom-center": | |
| case "bottom-right": | |
| return marginTop + availHeight - height; | |
| case "left-center": | |
| case "center": | |
| case "right-center": | |
| return marginTop + Math.floor((availHeight - height) / 2); | |
| } | |
| } | |
| private resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number { | |
| switch (anchor) { | |
| case "top-left": | |
| case "left-center": | |
| case "bottom-left": | |
| return marginLeft; | |
| case "top-right": | |
| case "right-center": | |
| case "bottom-right": | |
| return marginLeft + availWidth - width; | |
| case "top-center": | |
| case "center": | |
| case "bottom-center": | |
| return marginLeft + Math.floor((availWidth - width) / 2); | |
| } | |
| } | |
| /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */ | |
| protected compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] { | |
| if (this.overlayStack.length === 0) { | |
| this.renderedOverlayLayouts = []; | |
| return lines; | |
| } | |
| const result = [...lines]; | |
| for (const entry of this.overlayStack) entry.bounds = undefined; | |
| // Pre-render all visible overlays and calculate positions | |
| const rendered: { entry: OverlayStackEntry; overlayLines: string[]; row: number; col: number; w: number }[] = []; | |
| let minLinesNeeded = result.length; | |
| const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e)); | |
| visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder); | |
| for (const entry of visibleEntries) { | |
| const { component, options } = entry; | |
| // Get layout with height=0 first to determine width and maxHeight | |
| // (width and maxHeight don't depend on overlay height) | |
| const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight); | |
| // Render component at calculated width | |
| let overlayLines = component.render(width); | |
| // Apply maxHeight if specified | |
| if (maxHeight !== undefined && overlayLines.length > maxHeight) { | |
| overlayLines = overlayLines.slice(0, maxHeight); | |
| } | |
| // Get final row/col with actual overlay height | |
| const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight); | |
| entry.bounds = { row, col, width, height: overlayLines.length }; | |
| rendered.push({ entry, overlayLines, row, col, w: width }); | |
| minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length); | |
| } | |
| this.renderedOverlayLayouts = rendered.map(({ entry, row, col, w, overlayLines }) => ({ | |
| entry, | |
| row, | |
| col, | |
| width: w, | |
| height: overlayLines.length, | |
| })); | |
| // Pad to at least terminal height so overlays have screen-relative positions. | |
| // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing | |
| // inflation that pushed content into scrollback on terminal widen. | |
| const workingHeight = Math.max(result.length, termHeight, minLinesNeeded); | |
| // Extend result with empty lines if content is too short for overlay placement or working area | |
| while (result.length < workingHeight) { | |
| result.push(""); | |
| } | |
| const viewportStart = Math.max(0, workingHeight - termHeight); | |
| // Composite each overlay | |
| for (const { overlayLines, row, col, w } of rendered) { | |
| for (let i = 0; i < overlayLines.length; i++) { | |
| const idx = viewportStart + row + i; | |
| if (idx >= 0 && idx < result.length) { | |
| // Defensive: truncate overlay line to declared width before compositing | |
| // (components should already respect width, but this ensures it) | |
| const truncatedOverlayLine = | |
| visibleWidth(overlayLines[i]!) > w ? sliceByColumn(overlayLines[i]!, 0, w, true) : overlayLines[i]!; | |
| result[idx] = this.compositeLineAt(result[idx]!, truncatedOverlayLine, col, w, termWidth); | |
| } | |
| } | |
| } | |
| return result; | |
| } | |
| protected applyLineResets(lines: string[]): string[] { | |
| const reset = SEGMENT_RESET; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]!; | |
| if (!isImageLine(line)) { | |
| lines[i] = normalizeTerminalOutput(line) + reset; | |
| } | |
| } | |
| return lines; | |
| } | |
| private compositeLineAt( | |
| baseLine: string, | |
| overlayLine: string, | |
| startCol: number, | |
| overlayWidth: number, | |
| totalWidth: number, | |
| ): string { | |
| return compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth); | |
| } | |
| /** | |
| * Find and extract cursor position from rendered lines. | |
| * Searches for CURSOR_MARKER, calculates its position, and strips it from the output. | |
| * Only scans the bottom terminal height lines (visible viewport). | |
| * @param lines - Rendered lines to search | |
| * @param height - Terminal height (visible viewport size) | |
| * @returns Cursor position { row, col } or null if no marker found | |
| */ | |
| protected extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null { | |
| // Only scan the bottom `height` lines (visible viewport) | |
| const viewportTop = Math.max(0, lines.length - height); | |
| for (let row = lines.length - 1; row >= viewportTop; row--) { | |
| const line = lines[row]!; | |
| const markerIndex = line.indexOf(CURSOR_MARKER); | |
| if (markerIndex !== -1) { | |
| // Calculate visual column (width of text before marker) | |
| const beforeMarker = line.slice(0, markerIndex); | |
| const col = visibleWidth(beforeMarker); | |
| // Strip marker from the line | |
| lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length); | |
| return { row, col }; | |
| } | |
| } | |
| return null; | |
| } | |
| /** | |
| * Query the terminal's default background color with OSC 11 (`ESC ] 11 ; ? BEL`). | |
| * @param timeoutMs Query timeout in milliseconds. | |
| * @returns Promise containing the parsed RGB color, or undefined if it times out or fails to parse. | |
| */ | |
| queryTerminalBackgroundColor({ timeoutMs }: { timeoutMs: number }): Promise<RgbColor | undefined> { | |
| return new Promise((resolve) => { | |
| const query: PendingOsc11BackgroundQuery = { | |
| settled: false, | |
| resolve, | |
| timer: undefined, | |
| }; | |
| query.timer = setTimeout(() => { | |
| if (query.settled) { | |
| return; | |
| } | |
| query.settled = true; | |
| query.timer = undefined; | |
| query.resolve?.(undefined); | |
| query.resolve = undefined; | |
| }, timeoutMs); | |
| this.pendingOsc11BackgroundQueries.push(query); | |
| this.pendingOsc11BackgroundReplies += 1; | |
| this.terminal.write("\x1b]11;?\x07"); | |
| }); | |
| } | |
| /** | |
| * Query the terminal's color-scheme preference with DSR (`CSI ? 996 n`). | |
| * Terminals that support the color palette notification protocol reply with | |
| * `CSI ? 997 ; 1 n` for dark or `CSI ? 997 ; 2 n` for light. | |
| */ | |
| queryTerminalColorScheme({ timeoutMs }: { timeoutMs: number }): Promise<TerminalColorScheme | undefined> { | |
| return new Promise((resolve) => { | |
| let settled = false; | |
| let timer: NodeJS.Timeout | undefined; | |
| let unsubscribe: () => void = () => {}; | |
| const settle = (scheme: TerminalColorScheme | undefined) => { | |
| if (settled) return; | |
| settled = true; | |
| if (timer) { | |
| clearTimeout(timer); | |
| timer = undefined; | |
| } | |
| unsubscribe(); | |
| resolve(scheme); | |
| }; | |
| unsubscribe = this.onTerminalColorSchemeChange(settle); | |
| timer = setTimeout(() => settle(undefined), timeoutMs); | |
| this.terminal.write("\x1b[?996n"); | |
| }); | |
| } | |
| } | |