1
/**2
* Minimal TUI implementation with differential rendering3
*/5
import { performance } from "node:perf_hooks";6
import { isKeyRelease, matchesKey } from "./keys.ts";7
import type { Terminal } from "./terminal.ts";8
import {9
parseOscColorResponse,10
parseTerminalColorSchemeReport,11
type RgbColor,12
type TerminalColorScheme,13
type TerminalColors,14
} from "./terminal-colors.ts";15
import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.ts";16
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.ts";18
/**19
* Component interface - all components must implement this20
*/21
export type TuiMouseEventType = "press" | "release" | "move" | "drag" | "click" | "wheel";22
export type TuiMouseButton = "left" | "middle" | "right" | "none";24
/** Normalized cell-based mouse event. Coordinates are zero-based. */25
export 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
}46
export 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
}60
/** Internal target metadata used by containers and alternate-screen dispatch. */61
export interface TuiMouseDispatchTarget {62
component: Component;63
originX: number;64
originY: number;65
width: number;66
height: number;67
}69
/** Result of dispatching to a concrete component. */70
export 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
}77
/**78
* Dispatch an event to a component and retain the exact target and coordinate79
* transform. Containers use this when forwarding events to nested children.80
*/81
export 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 routes86
// keys to that child itself, so it keeps keyboard focus. Focusing the child directly would leave87
// 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
}106
/** Recreate local coordinates for a previously dispatched mouse target. */107
export 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
}117
export interface Component {118
/**119
* Render the component to lines for the given viewport width120
* @param width - Current viewport width121
* @returns Array of strings, each representing a line122
*/123
render(width: number): string[];125
/** Optional handler for keyboard input when component has focus. */126
handleInput?(data: string): void;128
/** Optional normalized mouse handler. */129
handleMouse?(event: TuiMouseEvent): TuiMouseEventResult | undefined;131
/**132
* If true, component receives key release events (Kitty protocol).133
* Default is false - release events are filtered out.134
*/135
wantsKeyRelease?: boolean;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
}144
export type TuiInputListenerResult = { consume?: boolean; data?: string } | undefined;145
export type TuiInputListener = (data: string) => TuiInputListenerResult;146
type 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 the154
* 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
};160
const TERMINAL_PALETTE_SIZE = 16;161
/** OSC 10 and 11 plus OSC 4 for every palette color. */162
const 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 of166
* the color replies, including for terminals that ignore the color queries.167
*/168
const 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`;172
const DEVICE_ATTRIBUTES_RESPONSE_PATTERN = /^\x1b\[\?[\d;]*c$/;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 position177
* in its render output. TUI will find this marker and position the hardware178
* cursor there for proper IME candidate window positioning.179
*/180
export interface Focusable {181
/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */182
focused: boolean;183
}185
/** Type guard to check if a component implements Focusable */186
export function isFocusable(component: Component | null): component is Component & Focusable {187
return component !== null && "focused" in component;188
}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
*/196
export const CURSOR_MARKER = "\x1b_pi:c\x07";198
export { visibleWidth };200
/**201
* Anchor position for overlays202
*/203
export 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";214
/**215
* Margin configuration for overlays216
*/217
export interface OverlayMargin {218
top?: number;219
right?: number;220
bottom?: number;221
left?: number;222
}224
/** Value that can be absolute (number) or percentage (string like "50%") */225
export type SizeValue = number | `${number}%`;227
/** Parse a SizeValue into absolute value given a reference size */228
function 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
}239
/**240
* Options for overlay positioning and sizing.241
* Values can be absolute numbers or percentage strings (e.g., "50%").242
*/243
export 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;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;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;266
// === Margin from terminal edges ===267
/** Margin from terminal edges. Number applies to all sides. */268
margin?: OverlayMargin | number;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
}281
/** Options for {@link OverlayHandle.unfocus}. */282
export interface OverlayUnfocusOptions {283
/** Explicit target to focus after releasing this overlay. */284
target: Component | null;285
}287
/** Last rendered terminal-relative overlay rectangle. */288
export interface OverlayBounds {289
row: number;290
col: number;291
width: number;292
height: number;293
}295
/**296
* Handle returned by showOverlay for controlling the overlay297
*/298
export 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
}315
type OverlayStackEntry = {316
component: Component;317
options?: OverlayOptions;318
preFocus: Component | null;319
hidden: boolean;320
focusOrder: number;321
bounds?: OverlayBounds;322
};324
type RenderedOverlayLayout = {325
entry: OverlayStackEntry;326
row: number;327
col: number;328
width: number;329
height: number;330
};332
type OverlayBlockedFocusResume = { status: "restore-overlay" } | { status: "focus-target"; target: Component | null };333
type EligibleOverlayFocusRestoreState = { status: "eligible"; overlay: OverlayStackEntry };334
type BlockedOverlayFocusRestoreState = {335
status: "blocked";336
overlay: OverlayStackEntry;337
blockedBy: Component;338
resume: OverlayBlockedFocusResume;339
};340
type ActiveOverlayFocusRestoreState = EligibleOverlayFocusRestoreState | BlockedOverlayFocusRestoreState;341
type OverlayFocusRestoreState = { status: "inactive" } | ActiveOverlayFocusRestoreState;342
type OverlayFocusRestorePolicy = "clear" | "preserve";344
/**345
* Container - a component that contains other components346
*/347
export class Container implements Component {348
children: Component[] = [];349
private mouseLayout?: { width: number; children: Array<{ component: Component; height: number }> };351
addChild(component: Component): void {352
this.children.push(component);353
}355
removeChild(component: Component): void {356
const index = this.children.indexOf(component);357
if (index !== -1) {358
this.children.splice(index, 1);359
}360
}362
clear(): void {363
this.children = [];364
}366
invalidate(): void {367
for (const child of this.children) {368
child.invalidate?.();369
}370
}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.width376
? this.mouseLayout.children377
: 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
}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
}409
/**410
* TUI - Main class for managing terminal UI with differential rendering411
*/412
const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";414
/** Composite overlay content into a terminal line at a fixed column. */415
export 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;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);443
return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);444
}446
export type TuiMode = "regular" | "fullscreen";448
export interface TuiStopOptions {449
/** Leave renderer output in place for another TUI taking over the same terminal. */450
preserveScreen?: boolean;451
}453
export 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
}484
export const VIEWPORT_TUI = Symbol.for("@earendil-works/pi-tui/viewport");486
export interface ViewportTUI extends TUI {487
readonly [VIEWPORT_TUI]: true;488
setLayoutRoot(component: Component | undefined): void;489
}491
export function isViewportTUI(tui: TUI): tui is ViewportTUI {492
return (tui as Partial<ViewportTUI>)[VIEWPORT_TUI] === true;493
}495
export 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>();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 color514
* 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;522
// Overlay stack for modal components rendered on top of base content523
private focusOrderCounter = 0;524
private overlayStack: OverlayStackEntry[] = [];525
private renderedOverlayLayouts: RenderedOverlayLayout[] = [];527
get hasOverlayEntries(): boolean {528
return this.overlayStack.length > 0;529
}530
private overlayFocusRestore: OverlayFocusRestoreState = { status: "inactive" };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
}541
protected abstract doRender(): void;543
protected resetRenderState(): void {}545
protected beforeTerminalStart(): void {}547
protected afterTerminalStart(): void {}549
protected beforeTerminalStop(_options: TuiStopOptions): void {}551
protected afterTerminalStop(_options: TuiStopOptions): void {}553
get fullRedraws(): number {554
return this.fullRedrawCount;555
}557
getShowHardwareCursor(): boolean {558
return this.showHardwareCursor;559
}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
}570
getClearOnShrink(): boolean {571
return this.clearOnShrink;572
}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
}583
getFocusedComponent(): Component | null {584
return this.focusedComponent;585
}587
setFocus(component: Component | null): void {588
this.setFocusInternal({ component, overlayFocusRestore: "clear" });589
}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 = previousFocus601
? 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
}638
if (isFocusable(this.focusedComponent)) {639
this.focusedComponent.focused = false;640
}642
this.focusedComponent = nextFocus;644
if (isFocusable(nextFocus)) {645
nextFocus.focused = true;646
}648
const focusedOverlay = nextFocus649
? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))650
: undefined;651
if (focusedOverlay) {652
this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };653
}654
}656
private clearOverlayFocusRestore(): void {657
this.overlayFocusRestore = { status: "inactive" };658
}660
private clearOverlayFocusRestoreFor(overlay: OverlayStackEntry): void {661
if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {662
this.clearOverlayFocusRestore();663
}664
}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
}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
}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
}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
}700
protected getMountedRoots(): readonly Component[] {701
return this.children;702
}704
private isComponentMounted(component: Component): boolean {705
return this.getMountedRoots().some((child) => this.containsComponent(child, component));706
}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
}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 visible728
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {729
this.setFocus(component);730
}731
this.hideTerminalCursor();732
this.requestRender();734
// Return handle for controlling this overlay735
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 focus743
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/showing755
if (hidden) {756
this.clearOverlayFocusRestoreFor(entry);757
// If this overlay had focus, move focus to next visible or preFocus758
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.blockedBy787
) {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
}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 preFocus826
const topVisible = this.getTopmostVisibleOverlay();827
this.setFocus(topVisible?.component ?? overlay.preFocus);828
}829
if (this.overlayStack.length === 0) this.hideTerminalCursor();830
this.requestRender();831
}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
}838
/** Check if there are any visible overlays */839
hasOverlay(): boolean {840
return this.overlayStack.some((o) => this.isOverlayVisible(o));841
}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
}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
}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.height870
) {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 result881
? {882
hit: true,883
result: result.focus ? { ...result, focusTarget: layout.entry.component } : result,884
}885
: { hit: true };886
}887
return { hit: false };888
}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
}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
}911
override invalidate(): void {912
for (const root of this.getMountedRoots()) root.invalidate();913
for (const overlay of this.overlayStack) overlay.component.invalidate();914
}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
}932
addInputListener(listener: TuiInputListener): () => void {933
this.inputListeners.add(listener);934
return () => {935
this.inputListeners.delete(listener);936
};937
}939
removeInputListener(listener: TuiInputListener): void {940
this.inputListeners.delete(listener);941
}943
onTerminalColorSchemeChange(listener: (scheme: TerminalColorScheme) => void): () => void {944
this.terminalColorSchemeListeners.add(listener);945
return () => {946
this.terminalColorSchemeListeners.delete(listener);947
};948
}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
}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 t966
// Response format: CSI 6 ; height ; width t967
this.terminal.write("\x1b[16t");968
}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
}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
}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
}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 this1010
// 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
}1018
private cancelRenderTimer(): void {1019
if (!this.renderTimer) return;1020
clearTimeout(this.renderTimer);1021
this.renderTimer = undefined;1022
}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
}1044
private handleTerminalInput(data: string): void {1045
if (this.consumeTerminalColorResponse(data)) {1046
return;1047
}1048
if (this.consumeTerminalColorSchemeReport(data)) {1049
return;1050
}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
}1069
// Consume terminal cell size responses without blocking unrelated input.1070
if (this.consumeCellSizeResponse(data)) {1071
return;1072
}1074
// Global debug key handler (Shift+Ctrl+D)1075
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {1076
this.onDebug();1077
return;1078
}1080
// If focused component is an overlay, verify it's still visible1081
// (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 overlay1085
const topVisible = this.getTopmostVisibleOverlay();1086
if (topVisible) {1087
this.setFocus(topVisible.component);1088
} else {1089
this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });1090
}1091
}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
}1108
// Pass input to focused component (including Ctrl+C)1109
// The focused component can decide how to handle Ctrl+C1110
if (this.focusedComponent?.handleInput) {1111
// Filter out key release events unless component opts in1112
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
}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
}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
}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
}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
}1168
private consumeTerminalColorSchemeReport(data: string): boolean {1169
const scheme = parseTerminalColorSchemeReport(data);1170
if (!scheme) {1171
return false;1172
}1174
for (const listener of this.terminalColorSchemeListeners) {1175
listener(scheme);1176
}1177
return true;1178
}1180
private consumeCellSizeResponse(data: string): boolean {1181
// Response format: ESC [ 6 ; height ; width t1182
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);1183
if (!match) {1184
return false;1185
}1187
const heightPx = parseInt(match[1], 10);1188
const widthPx = parseInt(match[2], 10);1189
if (heightPx <= 0 || widthPx <= 0) {1190
return true;1191
}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
}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 ?? {};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);1222
// Available space after margins1223
const availWidth = Math.max(1, termWidth - marginLeft - marginRight);1224
const availHeight = Math.max(1, termHeight - marginTop - marginBottom);1226
// === Resolve width ===1227
let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);1228
// Apply minWidth1229
if (opt.minWidth !== undefined) {1230
width = Math.max(width, opt.minWidth);1231
}1232
// Clamp to available space1233
width = Math.max(1, Math.min(width, availWidth));1235
// === Resolve maxHeight ===1236
let maxHeight = parseSizeValue(opt.maxHeight, termHeight);1237
// Clamp to available space1238
if (maxHeight !== undefined) {1239
maxHeight = Math.max(1, Math.min(maxHeight, availHeight));1240
}1242
// Effective overlay height (may be clamped by maxHeight)1243
const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;1245
// === Resolve position ===1246
let row: number;1247
let col: number;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 center1259
row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);1260
}1261
} else {1262
// Absolute row position1263
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
}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 center1281
col = this.resolveAnchorCol("center", width, availWidth, marginLeft);1282
}1283
} else {1284
// Absolute column position1285
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
}1293
// Apply offsets1294
if (opt.offsetY !== undefined) row += opt.offsetY;1295
if (opt.offsetX !== undefined) col += opt.offsetX;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));1301
return { width, row, col, maxHeight };1302
}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
}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
}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];1346
for (const entry of this.overlayStack) entry.bounds = undefined;1348
// Pre-render all visible overlays and calculate positions1349
const rendered: { entry: OverlayStackEntry; overlayLines: string[]; row: number; col: number; w: number }[] = [];1350
let minLinesNeeded = result.length;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;1357
// Get layout with height=0 first to determine width and maxHeight1358
// (width and maxHeight don't depend on overlay height)1359
const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);1361
// Render component at calculated width1362
let overlayLines = component.render(width);1364
// Apply maxHeight if specified1365
if (maxHeight !== undefined && overlayLines.length > maxHeight) {1366
overlayLines = overlayLines.slice(0, maxHeight);1367
}1369
// Get final row/col with actual overlay height1370
const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);1371
entry.bounds = { row, col, width, height: overlayLines.length };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
}));1384
// Pad to at least terminal height so overlays have screen-relative positions.1385
// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing1386
// inflation that pushed content into scrollback on terminal widen.1387
const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);1389
// Extend result with empty lines if content is too short for overlay placement or working area1390
while (result.length < workingHeight) {1391
result.push("");1392
}1394
const viewportStart = Math.max(0, workingHeight - termHeight);1396
// Composite each overlay1397
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 compositing1402
// (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
}1410
return result;1411
}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
}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
}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 search1439
* @param height - Terminal height (visible viewport size)1440
* @returns Cursor position { row, col } or null if no marker found1441
*/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);1453
// Strip marker from the line1454
lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);1456
return { row, col };1457
}1458
}1459
return null;1460
}1462
/**1463
* Query the terminal's theme colors: the default foreground (OSC 10), the default background1464
* (OSC 11), and ANSI colors 0-15 (OSC 4), followed by a DA1 request that marks the end of the1465
* 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
}