@asm-agent/tui 0.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +795 -0
- package/dist/autocomplete.d.ts +61 -0
- package/dist/autocomplete.d.ts.map +1 -0
- package/dist/autocomplete.js +601 -0
- package/dist/autocomplete.js.map +1 -0
- package/dist/components/box.d.ts +24 -0
- package/dist/components/box.d.ts.map +1 -0
- package/dist/components/box.js +114 -0
- package/dist/components/box.js.map +1 -0
- package/dist/components/cancellable-loader.d.ts +22 -0
- package/dist/components/cancellable-loader.d.ts.map +1 -0
- package/dist/components/cancellable-loader.js +35 -0
- package/dist/components/cancellable-loader.js.map +1 -0
- package/dist/components/editor.d.ts +275 -0
- package/dist/components/editor.d.ts.map +1 -0
- package/dist/components/editor.js +1968 -0
- package/dist/components/editor.js.map +1 -0
- package/dist/components/image.d.ts +34 -0
- package/dist/components/image.d.ts.map +1 -0
- package/dist/components/image.js +96 -0
- package/dist/components/image.js.map +1 -0
- package/dist/components/input.d.ts +37 -0
- package/dist/components/input.d.ts.map +1 -0
- package/dist/components/input.js +385 -0
- package/dist/components/input.js.map +1 -0
- package/dist/components/loader.d.ts +29 -0
- package/dist/components/loader.d.ts.map +1 -0
- package/dist/components/loader.js +69 -0
- package/dist/components/loader.js.map +1 -0
- package/dist/components/markdown.d.ts +100 -0
- package/dist/components/markdown.d.ts.map +1 -0
- package/dist/components/markdown.js +773 -0
- package/dist/components/markdown.js.map +1 -0
- package/dist/components/select-list.d.ts +61 -0
- package/dist/components/select-list.d.ts.map +1 -0
- package/dist/components/select-list.js +206 -0
- package/dist/components/select-list.js.map +1 -0
- package/dist/components/settings-list.d.ts +49 -0
- package/dist/components/settings-list.d.ts.map +1 -0
- package/dist/components/settings-list.js +170 -0
- package/dist/components/settings-list.js.map +1 -0
- package/dist/components/spacer.d.ts +12 -0
- package/dist/components/spacer.d.ts.map +1 -0
- package/dist/components/spacer.js +21 -0
- package/dist/components/spacer.js.map +1 -0
- package/dist/components/text.d.ts +19 -0
- package/dist/components/text.d.ts.map +1 -0
- package/dist/components/text.js +77 -0
- package/dist/components/text.js.map +1 -0
- package/dist/components/truncated-text.d.ts +14 -0
- package/dist/components/truncated-text.d.ts.map +1 -0
- package/dist/components/truncated-text.js +44 -0
- package/dist/components/truncated-text.js.map +1 -0
- package/dist/editor-component.d.ts +51 -0
- package/dist/editor-component.d.ts.map +1 -0
- package/dist/editor-component.js +2 -0
- package/dist/editor-component.js.map +1 -0
- package/dist/fullscreen.d.ts +105 -0
- package/dist/fullscreen.d.ts.map +1 -0
- package/dist/fullscreen.js +597 -0
- package/dist/fullscreen.js.map +1 -0
- package/dist/fuzzy.d.ts +26 -0
- package/dist/fuzzy.d.ts.map +1 -0
- package/dist/fuzzy.js +114 -0
- package/dist/fuzzy.js.map +1 -0
- package/dist/index.d.ts +29 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +27 -0
- package/dist/index.js.map +1 -0
- package/dist/keybindings.d.ts +239 -0
- package/dist/keybindings.d.ts.map +1 -0
- package/dist/keybindings.js +222 -0
- package/dist/keybindings.js.map +1 -0
- package/dist/keys.d.ts +184 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +1163 -0
- package/dist/keys.js.map +1 -0
- package/dist/kill-ring.d.ts +26 -0
- package/dist/kill-ring.d.ts.map +1 -0
- package/dist/kill-ring.js +42 -0
- package/dist/kill-ring.js.map +1 -0
- package/dist/latex.d.ts +21 -0
- package/dist/latex.d.ts.map +1 -0
- package/dist/latex.js +797 -0
- package/dist/latex.js.map +1 -0
- package/dist/mouse.d.ts +24 -0
- package/dist/mouse.d.ts.map +1 -0
- package/dist/mouse.js +34 -0
- package/dist/mouse.js.map +1 -0
- package/dist/render-cache.d.ts +9 -0
- package/dist/render-cache.d.ts.map +1 -0
- package/dist/render-cache.js +23 -0
- package/dist/render-cache.js.map +1 -0
- package/dist/selection-metadata.d.ts +22 -0
- package/dist/selection-metadata.d.ts.map +1 -0
- package/dist/selection-metadata.js +141 -0
- package/dist/selection-metadata.js.map +1 -0
- package/dist/slash-command-context.d.ts +12 -0
- package/dist/slash-command-context.d.ts.map +1 -0
- package/dist/slash-command-context.js +32 -0
- package/dist/slash-command-context.js.map +1 -0
- package/dist/stdin-buffer.d.ts +50 -0
- package/dist/stdin-buffer.d.ts.map +1 -0
- package/dist/stdin-buffer.js +313 -0
- package/dist/stdin-buffer.js.map +1 -0
- package/dist/terminal-colors.d.ts +31 -0
- package/dist/terminal-colors.d.ts.map +1 -0
- package/dist/terminal-colors.js +161 -0
- package/dist/terminal-colors.js.map +1 -0
- package/dist/terminal-image.d.ts +85 -0
- package/dist/terminal-image.d.ts.map +1 -0
- package/dist/terminal-image.js +324 -0
- package/dist/terminal-image.js.map +1 -0
- package/dist/terminal.d.ts +113 -0
- package/dist/terminal.d.ts.map +1 -0
- package/dist/terminal.js +486 -0
- package/dist/terminal.js.map +1 -0
- package/dist/tui.d.ts +310 -0
- package/dist/tui.d.ts.map +1 -0
- package/dist/tui.js +1673 -0
- package/dist/tui.js.map +1 -0
- package/dist/undo-stack.d.ts +16 -0
- package/dist/undo-stack.d.ts.map +1 -0
- package/dist/undo-stack.js +26 -0
- package/dist/undo-stack.js.map +1 -0
- package/dist/utils.d.ts +98 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +1233 -0
- package/dist/utils.js.map +1 -0
- package/package.json +52 -0
package/dist/tui.d.ts
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal TUI implementation with differential rendering
|
|
3
|
+
*/
|
|
4
|
+
import { type ScrollInfo } from "./fullscreen.js";
|
|
5
|
+
import type { TableCellSelectionRegion } from "./selection-metadata.js";
|
|
6
|
+
import type { Terminal } from "./terminal.js";
|
|
7
|
+
import { visibleWidth } from "./utils.js";
|
|
8
|
+
/**
|
|
9
|
+
* Component interface - all components must implement this
|
|
10
|
+
*/
|
|
11
|
+
export interface Component {
|
|
12
|
+
/**
|
|
13
|
+
* Render the component to lines for the given viewport width
|
|
14
|
+
* @param width - Current viewport width
|
|
15
|
+
* @returns Array of strings, each representing a line
|
|
16
|
+
*/
|
|
17
|
+
render(width: number): string[];
|
|
18
|
+
getSelectionRegions?(): ReadonlyArray<TableCellSelectionRegion>;
|
|
19
|
+
/**
|
|
20
|
+
* Optional handler for keyboard input when component has focus
|
|
21
|
+
*/
|
|
22
|
+
handleInput?(data: string): void;
|
|
23
|
+
/**
|
|
24
|
+
* If true, component receives key release events (Kitty protocol).
|
|
25
|
+
* Default is false - release events are filtered out.
|
|
26
|
+
*/
|
|
27
|
+
wantsKeyRelease?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Invalidate any cached rendering state.
|
|
30
|
+
* Called when theme changes or when component needs to re-render from scratch.
|
|
31
|
+
*/
|
|
32
|
+
invalidate(): void;
|
|
33
|
+
}
|
|
34
|
+
export interface TuiStopOptions {
|
|
35
|
+
preserveAltScreen?: boolean;
|
|
36
|
+
flushFullscreen?: boolean;
|
|
37
|
+
}
|
|
38
|
+
export interface FullscreenOptions {
|
|
39
|
+
scroll: Component[];
|
|
40
|
+
dock: Component;
|
|
41
|
+
mouse?: boolean;
|
|
42
|
+
viewportControls?: boolean;
|
|
43
|
+
}
|
|
44
|
+
interface ExitFullscreenOptions {
|
|
45
|
+
flush?: boolean;
|
|
46
|
+
leaveAltScreen?: boolean;
|
|
47
|
+
}
|
|
48
|
+
type InputListenerResult = {
|
|
49
|
+
consume?: boolean;
|
|
50
|
+
data?: string;
|
|
51
|
+
} | undefined;
|
|
52
|
+
type InputListener = (data: string) => InputListenerResult;
|
|
53
|
+
/**
|
|
54
|
+
* Interface for components that can receive focus and display a hardware cursor.
|
|
55
|
+
* When focused, the component should emit CURSOR_MARKER at the cursor position
|
|
56
|
+
* in its render output. TUI will find this marker and position the hardware
|
|
57
|
+
* cursor there for proper IME candidate window positioning.
|
|
58
|
+
*/
|
|
59
|
+
export interface Focusable {
|
|
60
|
+
/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */
|
|
61
|
+
focused: boolean;
|
|
62
|
+
}
|
|
63
|
+
/** Type guard to check if a component implements Focusable */
|
|
64
|
+
export declare function isFocusable(component: Component | null): component is Component & Focusable;
|
|
65
|
+
/**
|
|
66
|
+
* Cursor position marker - APC (Application Program Command) sequence.
|
|
67
|
+
* This is a zero-width escape sequence that terminals ignore.
|
|
68
|
+
* Components emit this at the cursor position when focused.
|
|
69
|
+
* TUI finds and strips this marker, then positions the hardware cursor there.
|
|
70
|
+
*/
|
|
71
|
+
export declare const CURSOR_MARKER = "\u001B_pi:c\u0007";
|
|
72
|
+
export { visibleWidth };
|
|
73
|
+
/**
|
|
74
|
+
* Anchor position for overlays
|
|
75
|
+
*/
|
|
76
|
+
export type OverlayAnchor = "center" | "top-left" | "top-right" | "bottom-left" | "bottom-right" | "top-center" | "bottom-center" | "left-center" | "right-center";
|
|
77
|
+
/**
|
|
78
|
+
* Margin configuration for overlays
|
|
79
|
+
*/
|
|
80
|
+
export interface OverlayMargin {
|
|
81
|
+
top?: number;
|
|
82
|
+
right?: number;
|
|
83
|
+
bottom?: number;
|
|
84
|
+
left?: number;
|
|
85
|
+
}
|
|
86
|
+
/** Value that can be absolute (number) or percentage (string like "50%") */
|
|
87
|
+
export type SizeValue = number | `${number}%`;
|
|
88
|
+
/**
|
|
89
|
+
* Options for overlay positioning and sizing.
|
|
90
|
+
* Values can be absolute numbers or percentage strings (e.g., "50%").
|
|
91
|
+
*/
|
|
92
|
+
export interface OverlayOptions {
|
|
93
|
+
/** Width in columns, or percentage of terminal width (e.g., "50%") */
|
|
94
|
+
width?: SizeValue;
|
|
95
|
+
/** Minimum width in columns */
|
|
96
|
+
minWidth?: number;
|
|
97
|
+
/** Maximum height in rows, or percentage of terminal height (e.g., "50%") */
|
|
98
|
+
maxHeight?: SizeValue;
|
|
99
|
+
/** Render overlay content into terminal scrollback instead of clipping it to the visible viewport. */
|
|
100
|
+
scrollback?: boolean;
|
|
101
|
+
/** Anchor point for positioning (default: 'center') */
|
|
102
|
+
anchor?: OverlayAnchor;
|
|
103
|
+
/** Horizontal offset from anchor position (positive = right) */
|
|
104
|
+
offsetX?: number;
|
|
105
|
+
/** Vertical offset from anchor position (positive = down) */
|
|
106
|
+
offsetY?: number;
|
|
107
|
+
/** Row position: absolute number, or percentage (e.g., "25%" = 25% from top) */
|
|
108
|
+
row?: SizeValue;
|
|
109
|
+
/** Column position: absolute number, or percentage (e.g., "50%" = centered horizontally) */
|
|
110
|
+
col?: SizeValue;
|
|
111
|
+
/** Zero-width marker in base content; positions the overlay immediately above its row. */
|
|
112
|
+
aboveMarker?: string;
|
|
113
|
+
/** Margin from terminal edges. Number applies to all sides. */
|
|
114
|
+
margin?: OverlayMargin | number;
|
|
115
|
+
/**
|
|
116
|
+
* Control overlay visibility based on terminal dimensions.
|
|
117
|
+
* If provided, overlay is only rendered when this returns true.
|
|
118
|
+
* Called each render cycle with current terminal dimensions.
|
|
119
|
+
*/
|
|
120
|
+
visible?: (termWidth: number, termHeight: number) => boolean;
|
|
121
|
+
/** If true, don't capture keyboard focus when shown */
|
|
122
|
+
nonCapturing?: boolean;
|
|
123
|
+
/** If true, temporarily disable fullscreen mouse tracking while the overlay is visible. */
|
|
124
|
+
suspendFullscreenMouse?: boolean;
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Handle returned by showOverlay for controlling the overlay
|
|
128
|
+
*/
|
|
129
|
+
export interface OverlayHandle {
|
|
130
|
+
/** Permanently remove the overlay (cannot be shown again) */
|
|
131
|
+
hide(): void;
|
|
132
|
+
/** Temporarily hide or show the overlay */
|
|
133
|
+
setHidden(hidden: boolean): void;
|
|
134
|
+
/** Check if overlay is temporarily hidden */
|
|
135
|
+
isHidden(): boolean;
|
|
136
|
+
/** Focus this overlay and bring it to the visual front */
|
|
137
|
+
focus(): void;
|
|
138
|
+
/** Release focus to the previous target */
|
|
139
|
+
unfocus(): void;
|
|
140
|
+
/** Check if this overlay currently has focus */
|
|
141
|
+
isFocused(): boolean;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Container - a component that contains other components
|
|
145
|
+
*/
|
|
146
|
+
export declare class Container implements Component {
|
|
147
|
+
children: Component[];
|
|
148
|
+
private selectionRegions;
|
|
149
|
+
addChild(component: Component): void;
|
|
150
|
+
removeChild(component: Component): void;
|
|
151
|
+
clear(): void;
|
|
152
|
+
invalidate(): void;
|
|
153
|
+
render(width: number): string[];
|
|
154
|
+
getSelectionRegions(): ReadonlyArray<TableCellSelectionRegion>;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* TUI - Main class for managing terminal UI with differential rendering
|
|
158
|
+
*/
|
|
159
|
+
export declare class TUI extends Container {
|
|
160
|
+
terminal: Terminal;
|
|
161
|
+
private previousLines;
|
|
162
|
+
private previousKittyImageIds;
|
|
163
|
+
private previousWidth;
|
|
164
|
+
private previousHeight;
|
|
165
|
+
private focusedComponent;
|
|
166
|
+
private inputListeners;
|
|
167
|
+
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
168
|
+
onDebug?: () => void;
|
|
169
|
+
/** Copies fullscreen mouse selections; when unset, OSC 52 is written directly. */
|
|
170
|
+
onCopy?: (text: string) => void;
|
|
171
|
+
/** Opens hyperlinks clicked in the fullscreen viewport; when unset, the platform opener is used. */
|
|
172
|
+
onOpenUrl?: (url: string) => void;
|
|
173
|
+
private renderRequested;
|
|
174
|
+
private renderTimer;
|
|
175
|
+
private lastRenderAt;
|
|
176
|
+
private static readonly MIN_RENDER_INTERVAL_MS;
|
|
177
|
+
private cursorRow;
|
|
178
|
+
private hardwareCursorRow;
|
|
179
|
+
private showHardwareCursor;
|
|
180
|
+
private clearOnShrink;
|
|
181
|
+
private maxLinesRendered;
|
|
182
|
+
private previousViewportTop;
|
|
183
|
+
private fullRedrawCount;
|
|
184
|
+
private preserveViewportOnNextRender;
|
|
185
|
+
private stopped;
|
|
186
|
+
private fullscreenLeftMouseDragged;
|
|
187
|
+
private fullscreenPressedHyperlink;
|
|
188
|
+
private overlaySelectionRegions;
|
|
189
|
+
private fullscreen;
|
|
190
|
+
private static readonly WHEEL_SCROLL_LINES;
|
|
191
|
+
private static readonly SELECTION_AUTO_SCROLL_DELAY_MS;
|
|
192
|
+
private static readonly SELECTION_AUTO_SCROLL_INTERVAL_MS;
|
|
193
|
+
private selectionAutoScrollTimer;
|
|
194
|
+
private selectionAutoScrollDirection;
|
|
195
|
+
private selectionAutoScrollRow;
|
|
196
|
+
private selectionAutoScrollColumn;
|
|
197
|
+
private focusOrderCounter;
|
|
198
|
+
private overlayStack;
|
|
199
|
+
constructor(terminal: Terminal, showHardwareCursor?: boolean);
|
|
200
|
+
get fullRedraws(): number;
|
|
201
|
+
getShowHardwareCursor(): boolean;
|
|
202
|
+
setShowHardwareCursor(enabled: boolean): void;
|
|
203
|
+
getClearOnShrink(): boolean;
|
|
204
|
+
/**
|
|
205
|
+
* Set whether to trigger full re-render when content shrinks.
|
|
206
|
+
* When true (default), empty rows are cleared when content shrinks.
|
|
207
|
+
* When false, empty rows remain (reduces redraws on slower terminals).
|
|
208
|
+
*/
|
|
209
|
+
setClearOnShrink(enabled: boolean): void;
|
|
210
|
+
setFocus(component: Component | null): void;
|
|
211
|
+
/**
|
|
212
|
+
* Show an overlay component with configurable positioning and sizing.
|
|
213
|
+
* Returns a handle to control the overlay's visibility.
|
|
214
|
+
*/
|
|
215
|
+
showOverlay(component: Component, options?: OverlayOptions): OverlayHandle;
|
|
216
|
+
/** Hide the topmost overlay and restore previous focus. */
|
|
217
|
+
hideOverlay(): void;
|
|
218
|
+
/** Check if there are any visible overlays */
|
|
219
|
+
hasOverlay(): boolean;
|
|
220
|
+
/** Check if an overlay entry is currently visible */
|
|
221
|
+
private isOverlayVisible;
|
|
222
|
+
/** Find the topmost visible capturing overlay, if any */
|
|
223
|
+
private getTopmostVisibleOverlay;
|
|
224
|
+
private shouldEnableFullscreenMouseTracking;
|
|
225
|
+
private isFullscreenOverlayFocused;
|
|
226
|
+
private syncFullscreenMouseTracking;
|
|
227
|
+
invalidate(): void;
|
|
228
|
+
start(): void;
|
|
229
|
+
addInputListener(listener: InputListener): () => void;
|
|
230
|
+
removeInputListener(listener: InputListener): void;
|
|
231
|
+
private queryCellSize;
|
|
232
|
+
stop(options?: TuiStopOptions): void;
|
|
233
|
+
requestRender(force?: boolean): void;
|
|
234
|
+
/**
|
|
235
|
+
* Request a render that keeps the user anchored at their current scroll
|
|
236
|
+
* position. Normally, when content above the visible viewport changes, the
|
|
237
|
+
* renderer may fall back to a full screen redraw that replays the entire
|
|
238
|
+
* transcript from the top. For deliberate toggles (e.g. expanding all tool
|
|
239
|
+
* output) that is jarring: it scrolls to the top and reprints everything.
|
|
240
|
+
* This instead repaints only the visible viewport in place, leaving
|
|
241
|
+
* scrollback untouched.
|
|
242
|
+
*/
|
|
243
|
+
requestRenderPreservingViewport(): void;
|
|
244
|
+
/**
|
|
245
|
+
* Render a scrollable transcript window on the alternate screen with `dock`
|
|
246
|
+
* pinned to the bottom rows; the primary screen stays untouched until exit.
|
|
247
|
+
* Wheel tracking is enabled blind — probing is not viable (tmux never
|
|
248
|
+
* answers DECRQM) and unsupporting terminals ignore the mode-sets.
|
|
249
|
+
*/
|
|
250
|
+
enterFullscreen(options: FullscreenOptions): void;
|
|
251
|
+
/**
|
|
252
|
+
* Leave fullscreen. The inline differ resumes against the entry snapshot,
|
|
253
|
+
* so content produced while fullscreen flows into native scrollback.
|
|
254
|
+
*/
|
|
255
|
+
exitFullscreen(options?: ExitFullscreenOptions): void;
|
|
256
|
+
isFullscreen(): boolean;
|
|
257
|
+
/** Scroll the fullscreen transcript window (negative = up). */
|
|
258
|
+
scrollBy(lines: number): void;
|
|
259
|
+
scrollToTop(): void;
|
|
260
|
+
scrollToBottom(): void;
|
|
261
|
+
/** Scroll state of the fullscreen window, or null when not fullscreen. */
|
|
262
|
+
getScrollInfo(): ScrollInfo | null;
|
|
263
|
+
private openHyperlink;
|
|
264
|
+
private copySelection;
|
|
265
|
+
private updateSelectionAutoScroll;
|
|
266
|
+
private scheduleSelectionAutoScroll;
|
|
267
|
+
private stopSelectionAutoScroll;
|
|
268
|
+
private scheduleRender;
|
|
269
|
+
private handleInput;
|
|
270
|
+
private handleFullscreenInput;
|
|
271
|
+
private consumeCellSizeResponse;
|
|
272
|
+
/**
|
|
273
|
+
* Resolve overlay layout from options.
|
|
274
|
+
* Returns { width, row, col, maxHeight } for rendering.
|
|
275
|
+
*/
|
|
276
|
+
private resolveOverlayLayout;
|
|
277
|
+
private resolveAnchorRow;
|
|
278
|
+
private resolveAnchorCol;
|
|
279
|
+
/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
|
|
280
|
+
private compositeOverlays;
|
|
281
|
+
private selectableSpan;
|
|
282
|
+
private createDockSelectionRegions;
|
|
283
|
+
private subtractSelectionCoverage;
|
|
284
|
+
private static readonly SEGMENT_RESET;
|
|
285
|
+
private applyLineResets;
|
|
286
|
+
private collectKittyImageIds;
|
|
287
|
+
private deleteKittyImages;
|
|
288
|
+
private expandLastChangedForKittyImages;
|
|
289
|
+
private deleteChangedKittyImages;
|
|
290
|
+
/** Splice overlay content into a base line at a specific column. Single-pass optimized. */
|
|
291
|
+
private compositeLineAt;
|
|
292
|
+
/**
|
|
293
|
+
* Find and extract cursor position from rendered lines.
|
|
294
|
+
* Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
|
|
295
|
+
* Only scans the bottom terminal height lines (visible viewport).
|
|
296
|
+
* @param lines - Rendered lines to search
|
|
297
|
+
* @param height - Terminal height (visible viewport size)
|
|
298
|
+
* @returns Cursor position { row, col } or null if no marker found
|
|
299
|
+
*/
|
|
300
|
+
private extractCursorPosition;
|
|
301
|
+
private renderFullscreen;
|
|
302
|
+
private doRender;
|
|
303
|
+
/**
|
|
304
|
+
* Position the hardware cursor for IME candidate window.
|
|
305
|
+
* @param cursorPos The cursor position extracted from rendered output, or null
|
|
306
|
+
* @param totalLines Total number of rendered lines
|
|
307
|
+
*/
|
|
308
|
+
private positionHardwareCursor;
|
|
309
|
+
}
|
|
310
|
+
//# sourceMappingURL=tui.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../src/tui.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Minimal TUI implementation with differential rendering\n */\n\nimport { execFile } from \"node:child_process\";\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { performance } from \"node:perf_hooks\";\nimport { withFullscreenImageFallback } from \"./components/image.js\";\nimport { FullscreenViewport, type ScrollInfo, type SelectionScrollDirection } from \"./fullscreen.js\";\nimport { getKeybindings } from \"./keybindings.js\";\nimport { isKeyRelease, matchesKey } from \"./keys.js\";\nimport { isMouseSequence, isWheelDown, isWheelUp, MOUSE_BUTTON_LEFT, parseSgrMouseEvent } from \"./mouse.js\";\nimport type { TableCellSelectionRegion } from \"./selection-metadata.js\";\nimport type { Terminal } from \"./terminal.js\";\nimport { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from \"./terminal-image.js\";\nimport {\n\textractSegments,\n\tnormalizeTerminalOutput,\n\tsliceByColumn,\n\tsliceWithWidth,\n\tstripAnsi,\n\tvisibleContentSpan,\n\tvisibleWidth,\n} from \"./utils.js\";\n\nconst KITTY_SEQUENCE_PREFIX = \"\\x1b_G\";\n\nfunction extractKittyImageIds(line: string): number[] {\n\tconst sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);\n\tif (sequenceStart === -1) return [];\n\n\tconst paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;\n\tconst paramsEnd = line.indexOf(\";\", paramsStart);\n\tif (paramsEnd === -1) return [];\n\n\tconst params = line.slice(paramsStart, paramsEnd);\n\tfor (const param of params.split(\",\")) {\n\t\tconst [key, value] = param.split(\"=\", 2);\n\t\tif (key !== \"i\" || value === undefined) continue;\n\t\tconst id = Number(value);\n\t\tif (Number.isInteger(id) && id > 0 && id <= 0xffffffff) {\n\t\t\treturn [id];\n\t\t}\n\t}\n\treturn [];\n}\n\n/**\n * Component interface - all components must implement this\n */\nexport interface Component {\n\t/**\n\t * Render the component to lines for the given viewport width\n\t * @param width - Current viewport width\n\t * @returns Array of strings, each representing a line\n\t */\n\trender(width: number): string[];\n\n\tgetSelectionRegions?(): ReadonlyArray<TableCellSelectionRegion>;\n\n\t/**\n\t * Optional handler for keyboard input when component has focus\n\t */\n\thandleInput?(data: string): void;\n\n\t/**\n\t * If true, component receives key release events (Kitty protocol).\n\t * Default is false - release events are filtered out.\n\t */\n\twantsKeyRelease?: boolean;\n\n\t/**\n\t * Invalidate any cached rendering state.\n\t * Called when theme changes or when component needs to re-render from scratch.\n\t */\n\tinvalidate(): void;\n}\n\nexport interface TuiStopOptions {\n\tpreserveAltScreen?: boolean;\n\tflushFullscreen?: boolean;\n}\n\nexport interface FullscreenOptions {\n\tscroll: Component[];\n\tdock: Component;\n\tmouse?: boolean;\n\tviewportControls?: boolean;\n}\n\ninterface ExitFullscreenOptions {\n\tflush?: boolean;\n\tleaveAltScreen?: boolean;\n}\n\ntype InputListenerResult = { consume?: boolean; data?: string } | undefined;\ntype InputListener = (data: string) => InputListenerResult;\n\ninterface FrameSelectionRegion {\n\tline: number;\n\tcol: number;\n\twidth: number;\n}\n\n/**\n * Interface for components that can receive focus and display a hardware cursor.\n * When focused, the component should emit CURSOR_MARKER at the cursor position\n * in its render output. TUI will find this marker and position the hardware\n * cursor there for proper IME candidate window positioning.\n */\nexport interface Focusable {\n\t/** Set by TUI when focus changes. Component should emit CURSOR_MARKER when true. */\n\tfocused: boolean;\n}\n\n/** Type guard to check if a component implements Focusable */\nexport function isFocusable(component: Component | null): component is Component & Focusable {\n\treturn component !== null && \"focused\" in component;\n}\n\n/**\n * Cursor position marker - APC (Application Program Command) sequence.\n * This is a zero-width escape sequence that terminals ignore.\n * Components emit this at the cursor position when focused.\n * TUI finds and strips this marker, then positions the hardware cursor there.\n */\nexport const CURSOR_MARKER = \"\\x1b_pi:c\\x07\";\n\nexport { visibleWidth };\n\n/**\n * Anchor position for overlays\n */\nexport type OverlayAnchor =\n\t| \"center\"\n\t| \"top-left\"\n\t| \"top-right\"\n\t| \"bottom-left\"\n\t| \"bottom-right\"\n\t| \"top-center\"\n\t| \"bottom-center\"\n\t| \"left-center\"\n\t| \"right-center\";\n\n/**\n * Margin configuration for overlays\n */\nexport interface OverlayMargin {\n\ttop?: number;\n\tright?: number;\n\tbottom?: number;\n\tleft?: number;\n}\n\n/** Value that can be absolute (number) or percentage (string like \"50%\") */\nexport type SizeValue = number | `${number}%`;\n\n/** Parse a SizeValue into absolute value given a reference size */\nfunction parseSizeValue(value: SizeValue | undefined, referenceSize: number): number | undefined {\n\tif (value === undefined) return undefined;\n\tif (typeof value === \"number\") return value;\n\t// Parse percentage string like \"50%\"\n\tconst match = value.match(/^(\\d+(?:\\.\\d+)?)%$/);\n\tif (match) {\n\t\treturn Math.floor((referenceSize * parseFloat(match[1])) / 100);\n\t}\n\treturn undefined;\n}\n\nfunction isTermuxSession(): boolean {\n\treturn Boolean(process.env.TERMUX_VERSION);\n}\n\n/**\n * Options for overlay positioning and sizing.\n * Values can be absolute numbers or percentage strings (e.g., \"50%\").\n */\nexport interface OverlayOptions {\n\t// === Sizing ===\n\t/** Width in columns, or percentage of terminal width (e.g., \"50%\") */\n\twidth?: SizeValue;\n\t/** Minimum width in columns */\n\tminWidth?: number;\n\t/** Maximum height in rows, or percentage of terminal height (e.g., \"50%\") */\n\tmaxHeight?: SizeValue;\n\t/** Render overlay content into terminal scrollback instead of clipping it to the visible viewport. */\n\tscrollback?: boolean;\n\n\t// === Positioning - anchor-based ===\n\t/** Anchor point for positioning (default: 'center') */\n\tanchor?: OverlayAnchor;\n\t/** Horizontal offset from anchor position (positive = right) */\n\toffsetX?: number;\n\t/** Vertical offset from anchor position (positive = down) */\n\toffsetY?: number;\n\n\t// === Positioning - percentage or absolute ===\n\t/** Row position: absolute number, or percentage (e.g., \"25%\" = 25% from top) */\n\trow?: SizeValue;\n\t/** Column position: absolute number, or percentage (e.g., \"50%\" = centered horizontally) */\n\tcol?: SizeValue;\n\t/** Zero-width marker in base content; positions the overlay immediately above its row. */\n\taboveMarker?: string;\n\n\t// === Margin from terminal edges ===\n\t/** Margin from terminal edges. Number applies to all sides. */\n\tmargin?: OverlayMargin | number;\n\n\t// === Visibility ===\n\t/**\n\t * Control overlay visibility based on terminal dimensions.\n\t * If provided, overlay is only rendered when this returns true.\n\t * Called each render cycle with current terminal dimensions.\n\t */\n\tvisible?: (termWidth: number, termHeight: number) => boolean;\n\t/** If true, don't capture keyboard focus when shown */\n\tnonCapturing?: boolean;\n\t/** If true, temporarily disable fullscreen mouse tracking while the overlay is visible. */\n\tsuspendFullscreenMouse?: boolean;\n}\n\n/**\n * Handle returned by showOverlay for controlling the overlay\n */\nexport interface OverlayHandle {\n\t/** Permanently remove the overlay (cannot be shown again) */\n\thide(): void;\n\t/** Temporarily hide or show the overlay */\n\tsetHidden(hidden: boolean): void;\n\t/** Check if overlay is temporarily hidden */\n\tisHidden(): boolean;\n\t/** Focus this overlay and bring it to the visual front */\n\tfocus(): void;\n\t/** Release focus to the previous target */\n\tunfocus(): void;\n\t/** Check if this overlay currently has focus */\n\tisFocused(): boolean;\n}\n\n/**\n * Container - a component that contains other components\n */\nexport class Container implements Component {\n\tchildren: Component[] = [];\n\tprivate selectionRegions: TableCellSelectionRegion[] = [];\n\n\taddChild(component: Component): void {\n\t\tthis.children.push(component);\n\t\tthis.selectionRegions = [];\n\t}\n\n\tremoveChild(component: Component): void {\n\t\tconst index = this.children.indexOf(component);\n\t\tif (index !== -1) {\n\t\t\tthis.children.splice(index, 1);\n\t\t\tthis.selectionRegions = [];\n\t\t}\n\t}\n\n\tclear(): void {\n\t\tthis.children = [];\n\t\tthis.selectionRegions = [];\n\t}\n\n\tinvalidate(): void {\n\t\tthis.selectionRegions = [];\n\t\tfor (const child of this.children) {\n\t\t\tchild.invalidate?.();\n\t\t}\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\t\tconst selectionRegions: TableCellSelectionRegion[] = [];\n\t\tfor (const child of this.children) {\n\t\t\tconst lineOffset = lines.length;\n\t\t\tconst childLines = child.render(width);\n\t\t\tfor (const region of child.getSelectionRegions?.() ?? []) {\n\t\t\t\tselectionRegions.push({\n\t\t\t\t\t...region,\n\t\t\t\t\tline: region.line + lineOffset,\n\t\t\t\t\ttableTop: region.tableTop + lineOffset,\n\t\t\t\t\ttableBottom: region.tableBottom + lineOffset,\n\t\t\t\t});\n\t\t\t}\n\t\t\tfor (const line of childLines) {\n\t\t\t\tlines.push(line);\n\t\t\t}\n\t\t}\n\t\tthis.selectionRegions = selectionRegions;\n\t\treturn lines;\n\t}\n\n\tgetSelectionRegions(): ReadonlyArray<TableCellSelectionRegion> {\n\t\treturn this.selectionRegions;\n\t}\n}\n\n/**\n * TUI - Main class for managing terminal UI with differential rendering\n */\nexport class TUI extends Container {\n\tpublic terminal: Terminal;\n\tprivate previousLines: string[] = [];\n\tprivate previousKittyImageIds = new Set<number>();\n\tprivate previousWidth = 0;\n\tprivate previousHeight = 0;\n\tprivate focusedComponent: Component | null = null;\n\tprivate inputListeners = new Set<InputListener>();\n\n\t/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */\n\tpublic onDebug?: () => void;\n\t/** Copies fullscreen mouse selections; when unset, OSC 52 is written directly. */\n\tpublic onCopy?: (text: string) => void;\n\t/** Opens hyperlinks clicked in the fullscreen viewport; when unset, the platform opener is used. */\n\tpublic onOpenUrl?: (url: string) => void;\n\tprivate renderRequested = false;\n\tprivate renderTimer: NodeJS.Timeout | undefined;\n\tprivate lastRenderAt = 0;\n\tprivate static readonly MIN_RENDER_INTERVAL_MS = 16;\n\tprivate cursorRow = 0; // Logical cursor row (end of rendered content)\n\tprivate hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)\n\tprivate showHardwareCursor = process.env.PI_HARDWARE_CURSOR === \"1\";\n\tprivate clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === \"1\"; // Clear empty rows when content shrinks (default: off)\n\tprivate maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)\n\tprivate previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves\n\tprivate fullRedrawCount = 0;\n\tprivate preserveViewportOnNextRender = false; // One-shot: repaint visible viewport in place instead of replaying scrollback\n\tprivate stopped = false;\n\tprivate fullscreenLeftMouseDragged = false;\n\tprivate fullscreenPressedHyperlink: string | null = null;\n\tprivate overlaySelectionRegions: FrameSelectionRegion[] = [];\n\n\t// While set, doRender paints fixed frames via the viewport; the inline\n\t// differ's bookkeeping stays frozen in `inlineState` until exit.\n\tprivate fullscreen: {\n\t\tviewport: FullscreenViewport;\n\t\tscroll: Component[];\n\t\tdock: Component;\n\t\tmouse: boolean;\n\t\tviewportControls: boolean;\n\t\tinlineState: {\n\t\t\tpreviousLines: string[];\n\t\t\tpreviousKittyImageIds: Set<number>;\n\t\t\tpreviousWidth: number;\n\t\t\tpreviousHeight: number;\n\t\t\tcursorRow: number;\n\t\t\thardwareCursorRow: number;\n\t\t\tmaxLinesRendered: number;\n\t\t\tpreviousViewportTop: number;\n\t\t};\n\t} | null = null;\n\tprivate static readonly WHEEL_SCROLL_LINES = 3;\n\tprivate static readonly SELECTION_AUTO_SCROLL_DELAY_MS = 150;\n\tprivate static readonly SELECTION_AUTO_SCROLL_INTERVAL_MS = 50;\n\tprivate selectionAutoScrollTimer: NodeJS.Timeout | undefined;\n\tprivate selectionAutoScrollDirection: SelectionScrollDirection | null = null;\n\tprivate selectionAutoScrollRow = 0;\n\tprivate selectionAutoScrollColumn = 0;\n\n\t// Overlay stack for modal components rendered on top of base content\n\tprivate focusOrderCounter = 0;\n\tprivate overlayStack: {\n\t\tcomponent: Component;\n\t\toptions?: OverlayOptions;\n\t\tpreFocus: Component | null;\n\t\thidden: boolean;\n\t\tfocusOrder: number;\n\t}[] = [];\n\n\tconstructor(terminal: Terminal, showHardwareCursor?: boolean) {\n\t\tsuper();\n\t\tthis.terminal = terminal;\n\t\tif (showHardwareCursor !== undefined) {\n\t\t\tthis.showHardwareCursor = showHardwareCursor;\n\t\t}\n\t}\n\n\tget fullRedraws(): number {\n\t\treturn this.fullRedrawCount;\n\t}\n\n\tgetShowHardwareCursor(): boolean {\n\t\treturn this.showHardwareCursor;\n\t}\n\n\tsetShowHardwareCursor(enabled: boolean): void {\n\t\tif (this.showHardwareCursor === enabled) return;\n\t\tthis.showHardwareCursor = enabled;\n\t\tif (!enabled) {\n\t\t\tthis.terminal.hideCursor();\n\t\t}\n\t\tthis.requestRender();\n\t}\n\n\tgetClearOnShrink(): boolean {\n\t\treturn this.clearOnShrink;\n\t}\n\n\t/**\n\t * Set whether to trigger full re-render when content shrinks.\n\t * When true (default), empty rows are cleared when content shrinks.\n\t * When false, empty rows remain (reduces redraws on slower terminals).\n\t */\n\tsetClearOnShrink(enabled: boolean): void {\n\t\tthis.clearOnShrink = enabled;\n\t}\n\n\tsetFocus(component: Component | null): void {\n\t\t// Clear focused flag on old component\n\t\tif (isFocusable(this.focusedComponent)) {\n\t\t\tthis.focusedComponent.focused = false;\n\t\t}\n\n\t\tthis.focusedComponent = component;\n\n\t\t// Set focused flag on new component\n\t\tif (isFocusable(component)) {\n\t\t\tcomponent.focused = true;\n\t\t}\n\t}\n\n\t/**\n\t * Show an overlay component with configurable positioning and sizing.\n\t * Returns a handle to control the overlay's visibility.\n\t */\n\tshowOverlay(component: Component, options?: OverlayOptions): OverlayHandle {\n\t\tconst entry = {\n\t\t\tcomponent,\n\t\t\toptions,\n\t\t\tpreFocus: this.focusedComponent,\n\t\t\thidden: false,\n\t\t\tfocusOrder: ++this.focusOrderCounter,\n\t\t};\n\t\tthis.overlayStack.push(entry);\n\t\t// Only focus if overlay is actually visible\n\t\tif (!options?.nonCapturing && this.isOverlayVisible(entry)) {\n\t\t\tthis.setFocus(component);\n\t\t}\n\t\tthis.syncFullscreenMouseTracking();\n\t\tthis.terminal.hideCursor();\n\t\tthis.requestRender();\n\n\t\t// Return handle for controlling this overlay\n\t\treturn {\n\t\t\thide: () => {\n\t\t\t\tconst index = this.overlayStack.indexOf(entry);\n\t\t\t\tif (index !== -1) {\n\t\t\t\t\tthis.overlayStack.splice(index, 1);\n\t\t\t\t\t// Restore focus if this overlay had focus\n\t\t\t\t\tif (this.focusedComponent === component) {\n\t\t\t\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\t\t\t\tthis.setFocus(topVisible?.component ?? entry.preFocus);\n\t\t\t\t\t}\n\t\t\t\t\tif (this.overlayStack.length === 0) this.terminal.hideCursor();\n\t\t\t\t\tthis.syncFullscreenMouseTracking();\n\t\t\t\t\tthis.requestRender();\n\t\t\t\t}\n\t\t\t},\n\t\t\tsetHidden: (hidden: boolean) => {\n\t\t\t\tif (entry.hidden === hidden) return;\n\t\t\t\tentry.hidden = hidden;\n\t\t\t\t// Update focus when hiding/showing\n\t\t\t\tif (hidden) {\n\t\t\t\t\t// If this overlay had focus, move focus to next visible or preFocus\n\t\t\t\t\tif (this.focusedComponent === component) {\n\t\t\t\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\t\t\t\tthis.setFocus(topVisible?.component ?? entry.preFocus);\n\t\t\t\t\t}\n\t\t\t\t} else {\n\t\t\t\t\t// Restore focus to this overlay when showing (if it's actually visible)\n\t\t\t\t\tif (!options?.nonCapturing && this.isOverlayVisible(entry)) {\n\t\t\t\t\t\tentry.focusOrder = ++this.focusOrderCounter;\n\t\t\t\t\t\tthis.setFocus(component);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t\tthis.syncFullscreenMouseTracking();\n\t\t\t\tthis.requestRender();\n\t\t\t},\n\t\t\tisHidden: () => entry.hidden,\n\t\t\tfocus: () => {\n\t\t\t\tif (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry)) return;\n\t\t\t\tif (this.focusedComponent !== component) {\n\t\t\t\t\tthis.setFocus(component);\n\t\t\t\t}\n\t\t\t\tentry.focusOrder = ++this.focusOrderCounter;\n\t\t\t\tthis.syncFullscreenMouseTracking();\n\t\t\t\tthis.requestRender();\n\t\t\t},\n\t\t\tunfocus: () => {\n\t\t\t\tif (this.focusedComponent !== component) return;\n\t\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\t\tthis.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);\n\t\t\t\tthis.syncFullscreenMouseTracking();\n\t\t\t\tthis.requestRender();\n\t\t\t},\n\t\t\tisFocused: () => this.focusedComponent === component,\n\t\t};\n\t}\n\n\t/** Hide the topmost overlay and restore previous focus. */\n\thideOverlay(): void {\n\t\tconst overlay = this.overlayStack.pop();\n\t\tif (!overlay) return;\n\t\tif (this.focusedComponent === overlay.component) {\n\t\t\t// Find topmost visible overlay, or fall back to preFocus\n\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\tthis.setFocus(topVisible?.component ?? overlay.preFocus);\n\t\t}\n\t\tif (this.overlayStack.length === 0) this.terminal.hideCursor();\n\t\tthis.syncFullscreenMouseTracking();\n\t\tthis.requestRender();\n\t}\n\n\t/** Check if there are any visible overlays */\n\thasOverlay(): boolean {\n\t\treturn this.overlayStack.some((o) => this.isOverlayVisible(o));\n\t}\n\n\t/** Check if an overlay entry is currently visible */\n\tprivate isOverlayVisible(entry: (typeof this.overlayStack)[number]): boolean {\n\t\tif (entry.hidden) return false;\n\t\tif (entry.options?.visible) {\n\t\t\treturn entry.options.visible(this.terminal.columns, this.terminal.rows);\n\t\t}\n\t\treturn true;\n\t}\n\n\t/** Find the topmost visible capturing overlay, if any */\n\tprivate getTopmostVisibleOverlay(): (typeof this.overlayStack)[number] | undefined {\n\t\tfor (let i = this.overlayStack.length - 1; i >= 0; i--) {\n\t\t\tif (this.overlayStack[i].options?.nonCapturing) continue;\n\t\t\tif (this.isOverlayVisible(this.overlayStack[i])) {\n\t\t\t\treturn this.overlayStack[i];\n\t\t\t}\n\t\t}\n\t\treturn undefined;\n\t}\n\n\tprivate shouldEnableFullscreenMouseTracking(): boolean {\n\t\tif (!this.fullscreen?.mouse) return false;\n\t\treturn !this.overlayStack.some(\n\t\t\t(entry) => entry.options?.suspendFullscreenMouse === true && this.isOverlayVisible(entry),\n\t\t);\n\t}\n\n\tprivate isFullscreenOverlayFocused(): boolean {\n\t\treturn this.overlayStack.some((entry) => entry.component === this.focusedComponent);\n\t}\n\n\tprivate syncFullscreenMouseTracking(): void {\n\t\tconst enabled = this.shouldEnableFullscreenMouseTracking();\n\t\tif (!enabled) {\n\t\t\tthis.stopSelectionAutoScroll();\n\t\t\tthis.fullscreenLeftMouseDragged = false;\n\t\t\tthis.fullscreenPressedHyperlink = null;\n\t\t\tthis.fullscreen?.viewport.clearSelection();\n\t\t} else if (this.isFullscreenOverlayFocused()) {\n\t\t\tthis.stopSelectionAutoScroll();\n\t\t}\n\t\tthis.terminal.setMouseTracking(enabled);\n\t}\n\n\toverride invalidate(): void {\n\t\tsuper.invalidate();\n\t\tfor (const overlay of this.overlayStack) overlay.component.invalidate?.();\n\t}\n\n\tstart(): void {\n\t\tthis.stopped = false;\n\t\tthis.terminal.start(\n\t\t\t(data) => this.handleInput(data),\n\t\t\t() => this.requestRender(),\n\t\t);\n\t\tthis.terminal.hideCursor();\n\t\tthis.queryCellSize();\n\t\tthis.requestRender();\n\t}\n\n\taddInputListener(listener: InputListener): () => void {\n\t\tthis.inputListeners.add(listener);\n\t\treturn () => {\n\t\t\tthis.inputListeners.delete(listener);\n\t\t};\n\t}\n\n\tremoveInputListener(listener: InputListener): void {\n\t\tthis.inputListeners.delete(listener);\n\t}\n\n\tprivate queryCellSize(): void {\n\t\t// Only query if terminal supports images (cell size is only used for image rendering)\n\t\tif (!getCapabilities().images) {\n\t\t\treturn;\n\t\t}\n\t\t// Query terminal for cell size in pixels: CSI 16 t\n\t\t// Response format: CSI 6 ; height ; width t\n\t\tthis.terminal.write(\"\\x1b[16t\");\n\t}\n\n\tstop(options: TuiStopOptions = {}): void {\n\t\tconst preserveAltScreen = options.preserveAltScreen === true && this.terminal.altScreenActive;\n\t\tconst flushFullscreen = options.flushFullscreen ?? !preserveAltScreen;\n\t\tthis.exitFullscreen({ flush: flushFullscreen, leaveAltScreen: !preserveAltScreen });\n\t\tthis.stopped = true;\n\t\tif (this.renderTimer) {\n\t\t\tclearTimeout(this.renderTimer);\n\t\t\tthis.renderTimer = undefined;\n\t\t}\n\t\t// Move cursor to the end of the content to prevent overwriting/artifacts on exit\n\t\tif (!preserveAltScreen && this.previousLines.length > 0) {\n\t\t\tconst targetRow = this.previousLines.length; // Line after the last content\n\t\t\tconst lineDiff = targetRow - this.hardwareCursorRow;\n\t\t\tif (lineDiff > 0) {\n\t\t\t\tthis.terminal.write(`\\x1b[${lineDiff}B`);\n\t\t\t} else if (lineDiff < 0) {\n\t\t\t\tthis.terminal.write(`\\x1b[${-lineDiff}A`);\n\t\t\t}\n\t\t\tthis.terminal.write(\"\\r\\n\");\n\t\t}\n\n\t\tif (preserveAltScreen) {\n\t\t\tthis.terminal.hideCursor();\n\t\t} else {\n\t\t\tthis.terminal.showCursor();\n\t\t}\n\t\tthis.terminal.stop({ preserveAltScreen });\n\t}\n\n\trequestRender(force = false): void {\n\t\tif (force) {\n\t\t\tthis.fullscreen?.viewport.reset();\n\t\t\t// Keep the previous frame metadata so the forced full repaint can\n\t\t\t// clean up only the visible viewport and avoid touching scrollback.\n\t\t\tthis.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear\n\t\t\tthis.cursorRow = 0;\n\t\t\tthis.hardwareCursorRow = 0;\n\t\t\tthis.maxLinesRendered = 0;\n\t\t\tif (this.renderTimer) {\n\t\t\t\tclearTimeout(this.renderTimer);\n\t\t\t\tthis.renderTimer = undefined;\n\t\t\t}\n\t\t\tthis.renderRequested = true;\n\t\t\tprocess.nextTick(() => {\n\t\t\t\tif (this.stopped || !this.renderRequested) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tthis.renderRequested = false;\n\t\t\t\tthis.lastRenderAt = performance.now();\n\t\t\t\tthis.doRender();\n\t\t\t});\n\t\t\treturn;\n\t\t}\n\t\tif (this.renderRequested) return;\n\t\tthis.renderRequested = true;\n\t\tprocess.nextTick(() => this.scheduleRender());\n\t}\n\n\t/**\n\t * Request a render that keeps the user anchored at their current scroll\n\t * position. Normally, when content above the visible viewport changes, the\n\t * renderer may fall back to a full screen redraw that replays the entire\n\t * transcript from the top. For deliberate toggles (e.g. expanding all tool\n\t * output) that is jarring: it scrolls to the top and reprints everything.\n\t * This instead repaints only the visible viewport in place, leaving\n\t * scrollback untouched.\n\t */\n\trequestRenderPreservingViewport(): void {\n\t\tthis.preserveViewportOnNextRender = true;\n\t\tthis.requestRender();\n\t}\n\n\t/**\n\t * Render a scrollable transcript window on the alternate screen with `dock`\n\t * pinned to the bottom rows; the primary screen stays untouched until exit.\n\t * Wheel tracking is enabled blind — probing is not viable (tmux never\n\t * answers DECRQM) and unsupporting terminals ignore the mode-sets.\n\t */\n\tenterFullscreen(options: FullscreenOptions): void {\n\t\tif (this.fullscreen) return;\n\t\tthis.fullscreenLeftMouseDragged = false;\n\t\tthis.fullscreenPressedHyperlink = null;\n\t\tthis.fullscreen = {\n\t\t\tviewport: new FullscreenViewport(),\n\t\t\tscroll: options.scroll,\n\t\t\tdock: options.dock,\n\t\t\tmouse: options.mouse !== false,\n\t\t\tviewportControls: options.viewportControls !== false,\n\t\t\tinlineState: {\n\t\t\t\tpreviousLines: this.previousLines,\n\t\t\t\tpreviousKittyImageIds: this.previousKittyImageIds,\n\t\t\t\tpreviousWidth: this.previousWidth,\n\t\t\t\tpreviousHeight: this.previousHeight,\n\t\t\t\tcursorRow: this.cursorRow,\n\t\t\t\thardwareCursorRow: this.hardwareCursorRow,\n\t\t\t\tmaxLinesRendered: this.maxLinesRendered,\n\t\t\t\tpreviousViewportTop: this.previousViewportTop,\n\t\t\t},\n\t\t};\n\t\tthis.terminal.enterAltScreen();\n\t\tthis.terminal.hideCursor();\n\t\tthis.syncFullscreenMouseTracking();\n\t\tthis.requestRender();\n\t}\n\n\t/**\n\t * Leave fullscreen. The inline differ resumes against the entry snapshot,\n\t * so content produced while fullscreen flows into native scrollback.\n\t */\n\texitFullscreen(options: ExitFullscreenOptions = {}): void {\n\t\tthis.stopSelectionAutoScroll();\n\t\tif (!this.fullscreen) return;\n\t\tconst { inlineState } = this.fullscreen;\n\t\tthis.fullscreen = null;\n\t\tthis.syncFullscreenMouseTracking();\n\t\tif (options.leaveAltScreen !== false) {\n\t\t\tthis.terminal.leaveAltScreen();\n\t\t}\n\t\tthis.previousLines = inlineState.previousLines;\n\t\tthis.previousKittyImageIds = inlineState.previousKittyImageIds;\n\t\tthis.previousWidth = inlineState.previousWidth;\n\t\tthis.previousHeight = inlineState.previousHeight;\n\t\tthis.cursorRow = inlineState.cursorRow;\n\t\tthis.hardwareCursorRow = inlineState.hardwareCursorRow;\n\t\tthis.maxLinesRendered = inlineState.maxLinesRendered;\n\t\tthis.previousViewportTop = inlineState.previousViewportTop;\n\t\t// synchronous so the flush also happens on shutdown, where a scheduled render never fires\n\t\tif (options.flush !== false && !this.stopped) {\n\t\t\tthis.doRender();\n\t\t}\n\t}\n\n\tisFullscreen(): boolean {\n\t\treturn this.fullscreen !== null;\n\t}\n\n\t/** Scroll the fullscreen transcript window (negative = up). */\n\tscrollBy(lines: number): void {\n\t\tif (!this.fullscreen) return;\n\t\tthis.fullscreen.viewport.scrollBy(lines);\n\t\tthis.requestRender();\n\t}\n\n\tscrollToTop(): void {\n\t\tif (!this.fullscreen) return;\n\t\tthis.fullscreen.viewport.scrollToTop();\n\t\tthis.requestRender();\n\t}\n\n\tscrollToBottom(): void {\n\t\tif (!this.fullscreen) return;\n\t\tthis.fullscreen.viewport.scrollToBottom();\n\t\tthis.requestRender();\n\t}\n\n\t/** Scroll state of the fullscreen window, or null when not fullscreen. */\n\tgetScrollInfo(): ScrollInfo | null {\n\t\treturn this.fullscreen?.viewport.scrollInfo() ?? null;\n\t}\n\n\t// Terminals gate their native link handling while mouse reporting is active\n\t// (Ghostty only refreshes link hover when reporting is off or shift is held),\n\t// so clicks the TUI consumes must open OSC 8 hyperlinks itself.\n\tprivate openHyperlink(url: string): void {\n\t\tif (/\\p{Cc}/u.test(url)) return;\n\t\tlet href: string;\n\t\ttry {\n\t\t\tconst parsed = new URL(url);\n\t\t\tif (parsed.protocol !== \"http:\" && parsed.protocol !== \"https:\") return;\n\t\t\thref = parsed.href;\n\t\t} catch {\n\t\t\treturn;\n\t\t}\n\t\tif (this.onOpenUrl) {\n\t\t\tthis.onOpenUrl(href);\n\t\t\treturn;\n\t\t}\n\t\tconst [command, ...args] =\n\t\t\tprocess.platform === \"darwin\"\n\t\t\t\t? [\"open\", href]\n\t\t\t\t: process.platform === \"win32\"\n\t\t\t\t\t? [\n\t\t\t\t\t\t\tpath.win32.join(process.env.SystemRoot ?? \"C:\\\\Windows\", \"System32\", \"rundll32.exe\"),\n\t\t\t\t\t\t\t\"url.dll,FileProtocolHandler\",\n\t\t\t\t\t\t\thref,\n\t\t\t\t\t\t]\n\t\t\t\t\t: [\"xdg-open\", href];\n\t\texecFile(command, args, () => {});\n\t}\n\n\tprivate copySelection(text: string): void {\n\t\tif (this.onCopy) {\n\t\t\tthis.onCopy(text);\n\t\t\treturn;\n\t\t}\n\t\t// fallback: OSC 52 works locally, over SSH, and through tmux (set-clipboard)\n\t\tconst base64 = Buffer.from(text, \"utf8\").toString(\"base64\");\n\t\tthis.terminal.write(`\\x1b]52;c;${base64}\\x07`);\n\t}\n\n\tprivate updateSelectionAutoScroll(viewport: FullscreenViewport, screenRow: number, screenColumn: number): void {\n\t\tconst direction = viewport.selectionAutoScrollDirection(screenRow);\n\t\tthis.selectionAutoScrollRow = screenRow;\n\t\tthis.selectionAutoScrollColumn = screenColumn;\n\t\tif (direction === this.selectionAutoScrollDirection && this.selectionAutoScrollTimer) return;\n\t\tthis.stopSelectionAutoScroll();\n\t\tif (direction === null) return;\n\t\tthis.selectionAutoScrollDirection = direction;\n\t\tthis.scheduleSelectionAutoScroll(TUI.SELECTION_AUTO_SCROLL_DELAY_MS);\n\t}\n\n\tprivate scheduleSelectionAutoScroll(delay: number): void {\n\t\tthis.selectionAutoScrollTimer = setTimeout(() => {\n\t\t\tthis.selectionAutoScrollTimer = undefined;\n\t\t\tconst fullscreen = this.fullscreen;\n\t\t\tconst direction = this.selectionAutoScrollDirection;\n\t\t\tif (\n\t\t\t\t!fullscreen ||\n\t\t\t\tthis.isFullscreenOverlayFocused() ||\n\t\t\t\tdirection === null ||\n\t\t\t\tfullscreen.viewport.selectionAutoScrollDirection(this.selectionAutoScrollRow) !== direction ||\n\t\t\t\t!fullscreen.viewport.scrollSelection(direction, this.selectionAutoScrollColumn)\n\t\t\t) {\n\t\t\t\tthis.stopSelectionAutoScroll();\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.requestRender();\n\t\t\tthis.scheduleSelectionAutoScroll(TUI.SELECTION_AUTO_SCROLL_INTERVAL_MS);\n\t\t}, delay);\n\t\tthis.selectionAutoScrollTimer.unref();\n\t}\n\n\tprivate stopSelectionAutoScroll(): void {\n\t\tif (this.selectionAutoScrollTimer) {\n\t\t\tclearTimeout(this.selectionAutoScrollTimer);\n\t\t\tthis.selectionAutoScrollTimer = undefined;\n\t\t}\n\t\tthis.selectionAutoScrollDirection = null;\n\t}\n\n\tprivate scheduleRender(): void {\n\t\tif (this.stopped || this.renderTimer || !this.renderRequested) {\n\t\t\treturn;\n\t\t}\n\t\tconst elapsed = performance.now() - this.lastRenderAt;\n\t\tconst delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);\n\t\tthis.renderTimer = setTimeout(() => {\n\t\t\tthis.renderTimer = undefined;\n\t\t\tif (this.stopped || !this.renderRequested) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.renderRequested = false;\n\t\t\tthis.lastRenderAt = performance.now();\n\t\t\tthis.doRender();\n\t\t\tif (this.renderRequested) {\n\t\t\t\tthis.scheduleRender();\n\t\t\t}\n\t\t}, delay);\n\t}\n\n\tprivate handleInput(data: string): void {\n\t\tif (this.inputListeners.size > 0) {\n\t\t\tlet current = data;\n\t\t\tfor (const listener of this.inputListeners) {\n\t\t\t\tconst result = listener(current);\n\t\t\t\tif (result?.consume) {\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (result?.data !== undefined) {\n\t\t\t\t\tcurrent = result.data;\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (current.length === 0) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tdata = current;\n\t\t}\n\n\t\t// Consume terminal cell size responses without blocking unrelated input.\n\t\tif (this.consumeCellSizeResponse(data)) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Global debug key handler (Shift+Ctrl+D)\n\t\tif (matchesKey(data, \"shift+ctrl+d\") && this.onDebug) {\n\t\t\tthis.onDebug();\n\t\t\treturn;\n\t\t}\n\n\t\tif (this.fullscreen && this.handleFullscreenInput(data)) {\n\t\t\treturn;\n\t\t}\n\n\t\t// If focused component is an overlay, verify it's still visible\n\t\t// (visibility can change due to terminal resize or visible() callback)\n\t\tconst focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);\n\t\tif (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {\n\t\t\t// Focused overlay is no longer visible, redirect to topmost visible overlay\n\t\t\tconst topVisible = this.getTopmostVisibleOverlay();\n\t\t\tif (topVisible) {\n\t\t\t\tthis.setFocus(topVisible.component);\n\t\t\t} else {\n\t\t\t\t// No visible overlays, restore to preFocus\n\t\t\t\tthis.setFocus(focusedOverlay.preFocus);\n\t\t\t}\n\t\t}\n\n\t\t// Pass input to focused component (including Ctrl+C)\n\t\t// The focused component can decide how to handle Ctrl+C\n\t\tif (this.focusedComponent?.handleInput) {\n\t\t\t// Filter out key release events unless component opts in\n\t\t\tif (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.focusedComponent.handleInput(data);\n\t\t\tthis.requestRender();\n\t\t}\n\t}\n\n\t// Mouse reports are always consumed (nothing downstream understands them);\n\t// viewport keys are skipped while an overlay has focus so selectors keep\n\t// their own pageUp/pageDown.\n\tprivate handleFullscreenInput(data: string): boolean {\n\t\tconst fullscreen = this.fullscreen;\n\t\tif (!fullscreen) return false;\n\n\t\tconst overlayFocused = this.isFullscreenOverlayFocused();\n\n\t\tif (isMouseSequence(data)) {\n\t\t\t// consumed even when disabled — mouse reports are garbage downstream\n\t\t\tconst event = this.terminal.mouseTrackingActive ? parseSgrMouseEvent(data) : null;\n\t\t\tconst leftReleaseWasDrag =\n\t\t\t\tevent?.button === MOUSE_BUTTON_LEFT && !event.press ? this.fullscreenLeftMouseDragged : false;\n\t\t\tif (event?.button === MOUSE_BUTTON_LEFT && event.press) {\n\t\t\t\tthis.fullscreenLeftMouseDragged = event.motion;\n\t\t\t\tif (!event.motion) {\n\t\t\t\t\tthis.fullscreenPressedHyperlink = fullscreen.viewport.hyperlinkAt(event.y - 1, event.x - 1);\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (event && !overlayFocused) {\n\t\t\t\tconst viewport = fullscreen.viewport;\n\t\t\t\tif (isWheelUp(event)) {\n\t\t\t\t\tthis.stopSelectionAutoScroll();\n\t\t\t\t\tthis.scrollBy(-TUI.WHEEL_SCROLL_LINES);\n\t\t\t\t} else if (isWheelDown(event)) {\n\t\t\t\t\tthis.stopSelectionAutoScroll();\n\t\t\t\t\tthis.scrollBy(TUI.WHEEL_SCROLL_LINES);\n\t\t\t\t} else if (event.button === MOUSE_BUTTON_LEFT && event.press && !event.motion) {\n\t\t\t\t\tthis.stopSelectionAutoScroll();\n\t\t\t\t\tif (!viewport.beginSelection(event.y - 1, event.x - 1)) {\n\t\t\t\t\t\tviewport.beginFrameSelection(event.y - 1, event.x - 1);\n\t\t\t\t\t}\n\t\t\t\t\tthis.requestRender();\n\t\t\t\t} else if (event.button === MOUSE_BUTTON_LEFT && event.press && event.motion) {\n\t\t\t\t\tviewport.extendActiveSelection(event.y - 1, event.x - 1);\n\t\t\t\t\tthis.updateSelectionAutoScroll(viewport, event.y - 1, event.x - 1);\n\t\t\t\t\tthis.requestRender();\n\t\t\t\t} else if (!event.press && viewport.hasSelection()) {\n\t\t\t\t\tthis.stopSelectionAutoScroll();\n\t\t\t\t\tconst text = viewport.endActiveSelection();\n\t\t\t\t\tif (text) this.copySelection(text);\n\t\t\t\t\tthis.requestRender();\n\t\t\t\t} else if (!event.press) {\n\t\t\t\t\tthis.stopSelectionAutoScroll();\n\t\t\t\t\tviewport.clearSelection();\n\t\t\t\t\tif (event.button === MOUSE_BUTTON_LEFT && !event.motion && !leftReleaseWasDrag) {\n\t\t\t\t\t\tconst url = this.fullscreenPressedHyperlink ?? viewport.hyperlinkAt(event.y - 1, event.x - 1);\n\t\t\t\t\t\tif (url) this.openHyperlink(url);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t} else if (event && overlayFocused) {\n\t\t\t\tthis.stopSelectionAutoScroll();\n\t\t\t\tconst viewport = fullscreen.viewport;\n\t\t\t\tif (event.button === MOUSE_BUTTON_LEFT && event.press && !event.motion) {\n\t\t\t\t\tif (!viewport.beginFrameSelection(event.y - 1, event.x - 1)) {\n\t\t\t\t\t\tviewport.beginSelection(event.y - 1, event.x - 1);\n\t\t\t\t\t}\n\t\t\t\t\tthis.requestRender();\n\t\t\t\t} else if (event.button === MOUSE_BUTTON_LEFT && event.press && event.motion) {\n\t\t\t\t\tviewport.extendActiveSelection(event.y - 1, event.x - 1);\n\t\t\t\t\tthis.requestRender();\n\t\t\t\t} else if (!event.press && viewport.hasSelection()) {\n\t\t\t\t\tconst text = viewport.endActiveSelection();\n\t\t\t\t\tif (text) this.copySelection(text);\n\t\t\t\t\tthis.requestRender();\n\t\t\t\t} else if (!event.press) {\n\t\t\t\t\tviewport.clearSelection();\n\t\t\t\t\tif (event.button === MOUSE_BUTTON_LEFT && !event.motion && !leftReleaseWasDrag) {\n\t\t\t\t\t\tconst url = this.fullscreenPressedHyperlink ?? viewport.hyperlinkAt(event.y - 1, event.x - 1);\n\t\t\t\t\t\tif (url) this.openHyperlink(url);\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t\tif (event?.button === MOUSE_BUTTON_LEFT && !event.press) {\n\t\t\t\tthis.fullscreenLeftMouseDragged = false;\n\t\t\t\tthis.fullscreenPressedHyperlink = null;\n\t\t\t}\n\t\t\treturn true;\n\t\t}\n\t\tthis.stopSelectionAutoScroll();\n\n\t\tif (overlayFocused || !fullscreen.viewportControls) return false;\n\n\t\tconst keybindings = getKeybindings();\n\t\tif (keybindings.matches(data, \"tui.viewport.pageUp\")) {\n\t\t\tthis.scrollBy(-fullscreen.viewport.pageSize());\n\t\t\treturn true;\n\t\t}\n\t\tif (keybindings.matches(data, \"tui.viewport.pageDown\")) {\n\t\t\tthis.scrollBy(fullscreen.viewport.pageSize());\n\t\t\treturn true;\n\t\t}\n\t\tif (keybindings.matches(data, \"tui.viewport.top\")) {\n\t\t\tthis.scrollToTop();\n\t\t\treturn true;\n\t\t}\n\t\tif (keybindings.matches(data, \"tui.viewport.follow\")) {\n\t\t\tthis.scrollToBottom();\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate consumeCellSizeResponse(data: string): boolean {\n\t\t// Response format: ESC [ 6 ; height ; width t\n\t\tconst match = data.match(/^\\x1b\\[6;(\\d+);(\\d+)t$/);\n\t\tif (!match) {\n\t\t\treturn false;\n\t\t}\n\n\t\tconst heightPx = parseInt(match[1], 10);\n\t\tconst widthPx = parseInt(match[2], 10);\n\t\tif (heightPx <= 0 || widthPx <= 0) {\n\t\t\treturn true;\n\t\t}\n\n\t\tsetCellDimensions({ widthPx, heightPx });\n\t\t// Invalidate all components so images re-render with correct dimensions.\n\t\tthis.invalidate();\n\t\tthis.requestRender();\n\t\treturn true;\n\t}\n\n\t/**\n\t * Resolve overlay layout from options.\n\t * Returns { width, row, col, maxHeight } for rendering.\n\t */\n\tprivate resolveOverlayLayout(\n\t\toptions: OverlayOptions | undefined,\n\t\toverlayHeight: number,\n\t\ttermWidth: number,\n\t\ttermHeight: number,\n\t): { width: number; row: number; col: number; maxHeight: number | undefined } {\n\t\tconst opt = options ?? {};\n\n\t\t// Parse margin (clamp to non-negative)\n\t\tconst margin =\n\t\t\ttypeof opt.margin === \"number\"\n\t\t\t\t? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }\n\t\t\t\t: (opt.margin ?? {});\n\t\tconst marginTop = Math.max(0, margin.top ?? 0);\n\t\tconst marginRight = Math.max(0, margin.right ?? 0);\n\t\tconst marginBottom = Math.max(0, margin.bottom ?? 0);\n\t\tconst marginLeft = Math.max(0, margin.left ?? 0);\n\n\t\t// Available space after margins\n\t\tconst availWidth = Math.max(1, termWidth - marginLeft - marginRight);\n\t\tconst availHeight = Math.max(1, termHeight - marginTop - marginBottom);\n\n\t\t// === Resolve width ===\n\t\tlet width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);\n\t\t// Apply minWidth\n\t\tif (opt.minWidth !== undefined) {\n\t\t\twidth = Math.max(width, opt.minWidth);\n\t\t}\n\t\t// Clamp to available space\n\t\twidth = Math.max(1, Math.min(width, availWidth));\n\n\t\t// === Resolve maxHeight ===\n\t\tlet maxHeight = parseSizeValue(opt.maxHeight, termHeight);\n\t\t// Clamp to available space\n\t\tif (maxHeight !== undefined) {\n\t\t\tmaxHeight = Math.max(1, Math.min(maxHeight, availHeight));\n\t\t}\n\n\t\t// Effective overlay height (may be clamped by maxHeight)\n\t\tconst effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;\n\n\t\t// === Resolve position ===\n\t\tlet row: number;\n\t\tlet col: number;\n\n\t\tif (opt.row !== undefined) {\n\t\t\tif (typeof opt.row === \"string\") {\n\t\t\t\t// Percentage: 0% = top, 100% = bottom (overlay stays within bounds)\n\t\t\t\tconst match = opt.row.match(/^(\\d+(?:\\.\\d+)?)%$/);\n\t\t\t\tif (match) {\n\t\t\t\t\tconst maxRow = Math.max(0, availHeight - effectiveHeight);\n\t\t\t\t\tconst percent = parseFloat(match[1]) / 100;\n\t\t\t\t\trow = marginTop + Math.floor(maxRow * percent);\n\t\t\t\t} else {\n\t\t\t\t\t// Invalid format, fall back to center\n\t\t\t\t\trow = this.resolveAnchorRow(\"center\", effectiveHeight, availHeight, marginTop);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Absolute row position\n\t\t\t\trow = opt.row;\n\t\t\t}\n\t\t} else {\n\t\t\t// Anchor-based (default: center)\n\t\t\tconst anchor = opt.anchor ?? \"center\";\n\t\t\trow = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);\n\t\t}\n\n\t\tif (opt.col !== undefined) {\n\t\t\tif (typeof opt.col === \"string\") {\n\t\t\t\t// Percentage: 0% = left, 100% = right (overlay stays within bounds)\n\t\t\t\tconst match = opt.col.match(/^(\\d+(?:\\.\\d+)?)%$/);\n\t\t\t\tif (match) {\n\t\t\t\t\tconst maxCol = Math.max(0, availWidth - width);\n\t\t\t\t\tconst percent = parseFloat(match[1]) / 100;\n\t\t\t\t\tcol = marginLeft + Math.floor(maxCol * percent);\n\t\t\t\t} else {\n\t\t\t\t\t// Invalid format, fall back to center\n\t\t\t\t\tcol = this.resolveAnchorCol(\"center\", width, availWidth, marginLeft);\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\t// Absolute column position\n\t\t\t\tcol = opt.col;\n\t\t\t}\n\t\t} else {\n\t\t\t// Anchor-based (default: center)\n\t\t\tconst anchor = opt.anchor ?? \"center\";\n\t\t\tcol = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);\n\t\t}\n\n\t\t// Apply offsets\n\t\tif (opt.offsetY !== undefined) row += opt.offsetY;\n\t\tif (opt.offsetX !== undefined) col += opt.offsetX;\n\n\t\t// Clamp to terminal bounds (respecting margins)\n\t\trow = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));\n\t\tcol = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));\n\n\t\treturn { width, row, col, maxHeight };\n\t}\n\n\tprivate resolveAnchorRow(anchor: OverlayAnchor, height: number, availHeight: number, marginTop: number): number {\n\t\tswitch (anchor) {\n\t\t\tcase \"top-left\":\n\t\t\tcase \"top-center\":\n\t\t\tcase \"top-right\":\n\t\t\t\treturn marginTop;\n\t\t\tcase \"bottom-left\":\n\t\t\tcase \"bottom-center\":\n\t\t\tcase \"bottom-right\":\n\t\t\t\treturn marginTop + availHeight - height;\n\t\t\tcase \"left-center\":\n\t\t\tcase \"center\":\n\t\t\tcase \"right-center\":\n\t\t\t\treturn marginTop + Math.floor((availHeight - height) / 2);\n\t\t}\n\t}\n\n\tprivate resolveAnchorCol(anchor: OverlayAnchor, width: number, availWidth: number, marginLeft: number): number {\n\t\tswitch (anchor) {\n\t\t\tcase \"top-left\":\n\t\t\tcase \"left-center\":\n\t\t\tcase \"bottom-left\":\n\t\t\t\treturn marginLeft;\n\t\t\tcase \"top-right\":\n\t\t\tcase \"right-center\":\n\t\t\tcase \"bottom-right\":\n\t\t\t\treturn marginLeft + availWidth - width;\n\t\t\tcase \"top-center\":\n\t\t\tcase \"center\":\n\t\t\tcase \"bottom-center\":\n\t\t\t\treturn marginLeft + Math.floor((availWidth - width) / 2);\n\t\t}\n\t}\n\n\t/** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */\n\tprivate compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {\n\t\tif (this.overlayStack.length === 0) return lines;\n\t\tconst result = [...lines];\n\t\tconst overlaySelectionRegions: FrameSelectionRegion[] = [...this.overlaySelectionRegions];\n\n\t\t// Pre-render all visible overlays and calculate positions\n\t\tconst rendered: {\n\t\t\tcomponent: Component;\n\t\t\toverlayLines: string[];\n\t\t\trow: number;\n\t\t\tcol: number;\n\t\t\tw: number;\n\t\t\tscrollback: boolean;\n\t\t\taboveMarker?: { line: number; col: number; offsetY: number };\n\t\t}[] = [];\n\t\tlet minLinesNeeded = result.length;\n\n\t\tconst visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));\n\t\tvisibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);\n\t\tfor (const entry of visibleEntries) {\n\t\t\tconst { component, options } = entry;\n\t\t\tconst scrollback = options?.scrollback === true;\n\t\t\tlet aboveMarker: { line: number; col: number; offsetY: number } | undefined;\n\t\t\tif (options?.aboveMarker) {\n\t\t\t\tfor (let line = result.length - 1; line >= 0; line--) {\n\t\t\t\t\tconst markerIndex = result[line].indexOf(options.aboveMarker);\n\t\t\t\t\tif (markerIndex === -1) continue;\n\t\t\t\t\taboveMarker = {\n\t\t\t\t\t\tline,\n\t\t\t\t\t\tcol: visibleWidth(result[line].slice(0, markerIndex)),\n\t\t\t\t\t\toffsetY: options.offsetY ?? 0,\n\t\t\t\t\t};\n\t\t\t\t\tresult[line] =\n\t\t\t\t\t\tresult[line].slice(0, markerIndex) + result[line].slice(markerIndex + options.aboveMarker.length);\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t\tif (!aboveMarker) continue;\n\t\t\t}\n\n\t\t\t// Get layout with height=0 first to determine width and maxHeight\n\t\t\t// (width and maxHeight don't depend on overlay height)\n\t\t\tconst { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);\n\n\t\t\t// Render component at calculated width\n\t\t\tlet overlayLines = component.render(width);\n\n\t\t\t// Apply maxHeight if specified\n\t\t\tif (maxHeight !== undefined && overlayLines.length > maxHeight) {\n\t\t\t\toverlayLines = overlayLines.slice(0, maxHeight);\n\t\t\t}\n\n\t\t\t// Get final row/col with actual overlay height\n\t\t\tconst { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);\n\n\t\t\trendered.push({ component, overlayLines, row, col, w: width, scrollback, aboveMarker });\n\t\t\tif (!aboveMarker) {\n\t\t\t\tminLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);\n\t\t\t}\n\t\t}\n\n\t\t// Pad to at least terminal height so overlays have screen-relative positions.\n\t\t// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing\n\t\t// inflation that pushed content into scrollback on terminal widen.\n\t\tconst workingHeight = Math.max(result.length, termHeight, minLinesNeeded);\n\n\t\t// Extend result with empty lines if content is too short for overlay placement or working area\n\t\twhile (result.length < workingHeight) {\n\t\t\tresult.push(\"\");\n\t\t}\n\n\t\tconst viewportStart = Math.max(0, workingHeight - termHeight);\n\n\t\t// Composite each overlay\n\t\tfor (const renderedOverlay of rendered) {\n\t\t\tconst { component, w, scrollback, aboveMarker } = renderedOverlay;\n\t\t\tlet { overlayLines, row, col } = renderedOverlay;\n\t\t\tif (aboveMarker) {\n\t\t\t\tconst markerRow = Math.max(1, aboveMarker.line - viewportStart + aboveMarker.offsetY);\n\t\t\t\tif (markerRow >= termHeight) continue;\n\t\t\t\twhile (overlayLines.length > markerRow && stripAnsi(overlayLines[0] ?? \"\").trim().length === 0) {\n\t\t\t\t\toverlayLines = overlayLines.slice(1);\n\t\t\t\t}\n\t\t\t\twhile (overlayLines.length > markerRow && stripAnsi(overlayLines.at(-1) ?? \"\").trim().length === 0) {\n\t\t\t\t\toverlayLines = overlayLines.slice(0, -1);\n\t\t\t\t}\n\t\t\t\tif (overlayLines.length > markerRow) {\n\t\t\t\t\toverlayLines = overlayLines.slice(overlayLines.length - markerRow);\n\t\t\t\t}\n\t\t\t\trow = markerRow - overlayLines.length;\n\t\t\t\tcol = Math.max(0, Math.min(aboveMarker.col, termWidth - w));\n\t\t\t}\n\n\t\t\tconst overlayStart =\n\t\t\t\tscrollback && !aboveMarker ? Math.max(0, workingHeight - (row + overlayLines.length)) : viewportStart;\n\t\t\tfor (let i = 0; i < overlayLines.length; i++) {\n\t\t\t\tconst idx = overlayStart + row + i;\n\t\t\t\tif (idx >= 0 && idx < result.length) {\n\t\t\t\t\t// Defensive: truncate overlay line to declared width before compositing\n\t\t\t\t\t// (components should already respect width, but this ensures it)\n\t\t\t\t\tconst truncatedOverlayLine =\n\t\t\t\t\t\tvisibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];\n\t\t\t\t\tresult[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);\n\t\t\t\t\tthis.subtractSelectionCoverage(overlaySelectionRegions, idx, col, col + w);\n\t\t\t\t\tconst span = component === this.focusedComponent ? this.selectableSpan(truncatedOverlayLine, w) : null;\n\t\t\t\t\tif (span) {\n\t\t\t\t\t\toverlaySelectionRegions.push({ line: idx, col: col + span.from, width: span.to - span.from });\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tthis.overlaySelectionRegions = overlaySelectionRegions;\n\t\treturn result;\n\t}\n\n\tprivate selectableSpan(line: string, maxWidth: number): { from: number; to: number } | null {\n\t\treturn visibleContentSpan(line, maxWidth);\n\t}\n\n\tprivate createDockSelectionRegions(\n\t\tframe: string[],\n\t\ttranscriptWindowHeight: number,\n\t\twidth: number,\n\t): FrameSelectionRegion[] {\n\t\tconst regions: FrameSelectionRegion[] = [];\n\t\tfor (let row = Math.max(0, transcriptWindowHeight); row < frame.length; row++) {\n\t\t\tconst span = this.selectableSpan(frame[row] ?? \"\", width);\n\t\t\tif (span) {\n\t\t\t\tregions.push({ line: row, col: span.from, width: span.to - span.from });\n\t\t\t}\n\t\t}\n\t\treturn regions;\n\t}\n\n\tprivate subtractSelectionCoverage(\n\t\tregions: FrameSelectionRegion[],\n\t\tline: number,\n\t\tcoverStart: number,\n\t\tcoverEnd: number,\n\t): void {\n\t\tfor (let i = regions.length - 1; i >= 0; i--) {\n\t\t\tconst region = regions[i];\n\t\t\tif (region.line !== line) continue;\n\t\t\tconst regionStart = region.col;\n\t\t\tconst regionEnd = region.col + region.width;\n\t\t\tif (coverEnd <= regionStart || coverStart >= regionEnd) continue;\n\n\t\t\tconst replacements: FrameSelectionRegion[] = [];\n\t\t\tif (regionStart < coverStart) {\n\t\t\t\treplacements.push({ line, col: regionStart, width: coverStart - regionStart });\n\t\t\t}\n\t\t\tif (coverEnd < regionEnd) {\n\t\t\t\treplacements.push({ line, col: coverEnd, width: regionEnd - coverEnd });\n\t\t\t}\n\t\t\tregions.splice(i, 1, ...replacements);\n\t\t}\n\t}\n\n\tprivate static readonly SEGMENT_RESET = \"\\x1b[0m\\x1b]8;;\\x07\";\n\n\tprivate applyLineResets(lines: string[]): string[] {\n\t\tconst reset = TUI.SEGMENT_RESET;\n\t\tfor (let i = 0; i < lines.length; i++) {\n\t\t\tconst line = lines[i];\n\t\t\tif (!isImageLine(line)) {\n\t\t\t\tlines[i] = normalizeTerminalOutput(line) + reset;\n\t\t\t}\n\t\t}\n\t\treturn lines;\n\t}\n\n\tprivate collectKittyImageIds(lines: string[]): Set<number> {\n\t\tconst ids = new Set<number>();\n\t\tfor (const line of lines) {\n\t\t\tfor (const id of extractKittyImageIds(line)) {\n\t\t\t\tids.add(id);\n\t\t\t}\n\t\t}\n\t\treturn ids;\n\t}\n\n\tprivate deleteKittyImages(ids: Iterable<number>): string {\n\t\tlet buffer = \"\";\n\t\tfor (const id of ids) {\n\t\t\tbuffer += deleteKittyImage(id);\n\t\t}\n\t\treturn buffer;\n\t}\n\n\tprivate expandLastChangedForKittyImages(firstChanged: number, lastChanged: number): number {\n\t\tlet expandedLastChanged = lastChanged;\n\t\tfor (let i = firstChanged; i < this.previousLines.length; i++) {\n\t\t\tif (extractKittyImageIds(this.previousLines[i]).length > 0) {\n\t\t\t\texpandedLastChanged = Math.max(expandedLastChanged, i);\n\t\t\t}\n\t\t}\n\t\treturn expandedLastChanged;\n\t}\n\n\tprivate deleteChangedKittyImages(firstChanged: number, lastChanged: number): string {\n\t\tif (firstChanged < 0 || lastChanged < firstChanged) return \"\";\n\n\t\tconst ids = new Set<number>();\n\t\tconst maxLine = Math.min(lastChanged, this.previousLines.length - 1);\n\t\tfor (let i = firstChanged; i <= maxLine; i++) {\n\t\t\tfor (const id of extractKittyImageIds(this.previousLines[i] ?? \"\")) {\n\t\t\t\tids.add(id);\n\t\t\t}\n\t\t}\n\n\t\treturn this.deleteKittyImages(ids);\n\t}\n\n\t/** Splice overlay content into a base line at a specific column. Single-pass optimized. */\n\tprivate compositeLineAt(\n\t\tbaseLine: string,\n\t\toverlayLine: string,\n\t\tstartCol: number,\n\t\toverlayWidth: number,\n\t\ttotalWidth: number,\n\t): string {\n\t\tif (isImageLine(baseLine)) return baseLine;\n\n\t\t// Single pass through baseLine extracts both before and after segments\n\t\tconst afterStart = startCol + overlayWidth;\n\t\tconst base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);\n\n\t\t// Extract overlay with width tracking (strict=true to exclude wide chars at boundary)\n\t\tconst overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);\n\n\t\t// Pad segments to target widths\n\t\tconst beforePad = Math.max(0, startCol - base.beforeWidth);\n\t\tconst overlayPad = Math.max(0, overlayWidth - overlay.width);\n\t\tconst actualBeforeWidth = Math.max(startCol, base.beforeWidth);\n\t\tconst actualOverlayWidth = Math.max(overlayWidth, overlay.width);\n\t\tconst afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);\n\t\tconst afterPad = Math.max(0, afterTarget - base.afterWidth);\n\n\t\t// Compose result\n\t\tconst r = TUI.SEGMENT_RESET;\n\t\tconst result =\n\t\t\tbase.before +\n\t\t\t\" \".repeat(beforePad) +\n\t\t\tr +\n\t\t\toverlay.text +\n\t\t\t\" \".repeat(overlayPad) +\n\t\t\tr +\n\t\t\tbase.after +\n\t\t\t\" \".repeat(afterPad);\n\n\t\t// CRITICAL: Always verify and truncate to terminal width.\n\t\t// This is the final safeguard against width overflow which would crash the TUI.\n\t\t// Width tracking can drift from actual visible width due to:\n\t\t// - Complex ANSI/OSC sequences (hyperlinks, colors)\n\t\t// - Wide characters at segment boundaries\n\t\t// - Edge cases in segment extraction\n\t\tconst resultWidth = visibleWidth(result);\n\t\tif (resultWidth <= totalWidth) {\n\t\t\treturn result;\n\t\t}\n\t\t// Truncate with strict=true to ensure we don't exceed totalWidth\n\t\treturn sliceByColumn(result, 0, totalWidth, true);\n\t}\n\n\t/**\n\t * Find and extract cursor position from rendered lines.\n\t * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.\n\t * Only scans the bottom terminal height lines (visible viewport).\n\t * @param lines - Rendered lines to search\n\t * @param height - Terminal height (visible viewport size)\n\t * @returns Cursor position { row, col } or null if no marker found\n\t */\n\tprivate extractCursorPosition(lines: string[], height: number): { row: number; col: number } | null {\n\t\t// Only scan the bottom `height` lines (visible viewport)\n\t\tconst viewportTop = Math.max(0, lines.length - height);\n\t\tfor (let row = lines.length - 1; row >= viewportTop; row--) {\n\t\t\tconst line = lines[row];\n\t\t\tconst markerIndex = line.indexOf(CURSOR_MARKER);\n\t\t\tif (markerIndex !== -1) {\n\t\t\t\t// Calculate visual column (width of text before marker)\n\t\t\t\tconst beforeMarker = line.slice(0, markerIndex);\n\t\t\t\tconst col = visibleWidth(beforeMarker);\n\n\t\t\t\t// Strip marker from the line\n\t\t\t\tlines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);\n\n\t\t\t\treturn { row, col };\n\t\t\t}\n\t\t}\n\t\treturn null;\n\t}\n\n\tprivate renderFullscreen(): void {\n\t\tconst fullscreen = this.fullscreen;\n\t\tif (!fullscreen) return;\n\t\tconst width = this.terminal.columns;\n\t\tconst height = this.terminal.rows;\n\t\tthis.syncFullscreenMouseTracking();\n\t\tthis.overlaySelectionRegions = [];\n\n\t\tconst transcript: string[] = [];\n\t\tconst selectionRegions: TableCellSelectionRegion[] = [];\n\t\tconst dock = withFullscreenImageFallback(() => {\n\t\t\tfor (const component of fullscreen.scroll) {\n\t\t\t\tconst lineOffset = transcript.length;\n\t\t\t\tconst componentLines = component.render(width);\n\t\t\t\tfor (const region of component.getSelectionRegions?.() ?? []) {\n\t\t\t\t\tselectionRegions.push({\n\t\t\t\t\t\t...region,\n\t\t\t\t\t\tline: region.line + lineOffset,\n\t\t\t\t\t\ttableTop: region.tableTop + lineOffset,\n\t\t\t\t\t\ttableBottom: region.tableBottom + lineOffset,\n\t\t\t\t\t});\n\t\t\t\t}\n\t\t\t\ttranscript.push(...componentLines);\n\t\t\t}\n\t\t\treturn fullscreen.dock.render(width);\n\t\t});\n\n\t\tlet frame = fullscreen.viewport.composeFrame(transcript, dock, height, selectionRegions);\n\t\tthis.overlaySelectionRegions.push(\n\t\t\t...this.createDockSelectionRegions(frame, fullscreen.viewport.windowHeight(), width),\n\t\t);\n\t\tconst scrollInfo = fullscreen.viewport.scrollInfo();\n\t\tif (fullscreen.viewportControls && !scrollInfo.following) {\n\t\t\t// Follow hint composited over the bottom of the transcript window,\n\t\t\t// just above the dock. Overlays still paint on top of it.\n\t\t\tconst followKey = getKeybindings().getKeys(\"tui.viewport.follow\")[0] ?? \"ctrl+shift+down\";\n\t\t\tconst label = ` ${followKey} to follow `;\n\t\t\tconst labelWidth = visibleWidth(label);\n\t\t\tconst row = fullscreen.viewport.windowHeight() - 1;\n\t\t\tif (row >= 0 && row < frame.length && labelWidth <= width) {\n\t\t\t\tconst col = Math.floor((width - labelWidth) / 2);\n\t\t\t\tframe[row] = this.compositeLineAt(frame[row], `\\x1b[7m${label}\\x1b[27m`, col, labelWidth, width);\n\t\t\t}\n\t\t}\n\t\tif (this.overlayStack.length > 0) {\n\t\t\tframe = withFullscreenImageFallback(() => this.compositeOverlays(frame, width, height));\n\t\t}\n\t\tconst cursorPos = this.extractCursorPosition(frame, height);\n\t\tfullscreen.viewport.applyFrameSelection(frame, height, this.overlaySelectionRegions);\n\t\tthis.applyLineResets(frame);\n\t\tfullscreen.viewport.paint((data) => this.terminal.write(data), frame, width, height, cursorPos);\n\t\tif (cursorPos && this.showHardwareCursor) {\n\t\t\tthis.terminal.showCursor();\n\t\t} else {\n\t\t\tthis.terminal.hideCursor();\n\t\t}\n\t}\n\n\tprivate doRender(): void {\n\t\tif (this.stopped) return;\n\t\tif (this.fullscreen) {\n\t\t\tthis.preserveViewportOnNextRender = false;\n\t\t\tthis.renderFullscreen();\n\t\t\treturn;\n\t\t}\n\t\t// One-shot: consume here so it never leaks into a later render.\n\t\tthis.overlaySelectionRegions = [];\n\t\tconst preserveViewport = this.preserveViewportOnNextRender;\n\t\tthis.preserveViewportOnNextRender = false;\n\t\tconst width = this.terminal.columns;\n\t\tconst height = this.terminal.rows;\n\t\tconst widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;\n\t\tconst heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;\n\t\tconst previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;\n\t\tlet prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;\n\t\tlet viewportTop = prevViewportTop;\n\t\tlet hardwareCursorRow = this.hardwareCursorRow;\n\t\tconst computeLineDiff = (targetRow: number): number => {\n\t\t\tconst currentScreenRow = hardwareCursorRow - prevViewportTop;\n\t\t\tconst targetScreenRow = targetRow - viewportTop;\n\t\t\treturn targetScreenRow - currentScreenRow;\n\t\t};\n\n\t\t// Render all components to get new lines\n\t\tlet newLines = this.render(width);\n\n\t\t// Composite overlays into the rendered lines (before differential compare)\n\t\tif (this.overlayStack.length > 0) {\n\t\t\tnewLines = this.compositeOverlays(newLines, width, height);\n\t\t}\n\n\t\t// Extract cursor position before applying line resets (marker must be found first)\n\t\tconst cursorPos = this.extractCursorPosition(newLines, height);\n\n\t\tnewLines = this.applyLineResets(newLines);\n\n\t\t// Helper to clear the viewport and repaint the current screen. Do not\n\t\t// clear terminal scrollback: users rely on it to read long prior messages.\n\t\tconst fullRender = (clear: boolean, preserveViewport = false): void => {\n\t\t\tthis.fullRedrawCount += 1;\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\n\t\t\t// Viewport-preserving repaint: rewrite only the visible viewport in\n\t\t\t// place, leaving terminal scrollback untouched. Keeps the user\n\t\t\t// anchored at their current focus instead of replaying the whole\n\t\t\t// (now-resized) transcript from the top. Only meaningful when there\n\t\t\t// is a previous frame on screen to paint over.\n\t\t\tif (preserveViewport && this.previousLines.length > 0) {\n\t\t\t\tconst windowStart = Math.max(0, newLines.length - height);\n\t\t\t\tconst visibleCount = newLines.length - windowStart;\n\t\t\t\t// Rows the previous frame occupied on screen.\n\t\t\t\tconst prevScreenRows = Math.min(height, this.previousLines.length);\n\t\t\t\t// Only delete Kitty images within the repainted viewport. Images that\n\t\t\t\t// live in scrollback above the visible slice are never redrawn here, so\n\t\t\t\t// deleting them would leave broken history when the user scrolls up.\n\t\t\t\tbuffer += this.deleteChangedKittyImages(prevViewportTop, prevViewportTop + prevScreenRows - 1);\n\t\t\t\t// Move the hardware cursor up to the top of the visible screen.\n\t\t\t\t// Use the local prevViewportTop (height-adjusted earlier in doRender)\n\t\t\t\t// rather than the field, so the move stays consistent with the rest\n\t\t\t\t// of the render when the terminal height changed this frame.\n\t\t\t\tconst screenRow = Math.max(0, Math.min(prevScreenRows - 1, this.hardwareCursorRow - prevViewportTop));\n\t\t\t\tif (screenRow > 0) buffer += `\\x1b[${screenRow}A`;\n\t\t\t\tbuffer += \"\\r\";\n\t\t\t\t// Clear the top row up front: the loop below clears it on its first\n\t\t\t\t// iteration, but when there is no content (visibleCount === 0) the\n\t\t\t\t// loop never runs and the leftover-clear moves down before clearing,\n\t\t\t\t// which would leave row 0 stale.\n\t\t\t\tif (visibleCount === 0) buffer += \"\\x1b[2K\";\n\t\t\t\tfor (let i = 0; i < visibleCount; i++) {\n\t\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\t\tbuffer += \"\\x1b[2K\"; // Clear current line\n\t\t\t\t\tbuffer += newLines[windowStart + i];\n\t\t\t\t}\n\t\t\t\t// Clear any rows the previous frame used below the new content.\n\t\t\t\t// Row 0 is already occupied (by content, or by the visibleCount === 0\n\t\t\t\t// clear above), so only clear the rows below it — clamping with\n\t\t\t\t// max(visibleCount, 1) avoids emitting a newline past the last screen\n\t\t\t\t// row, which would scroll the terminal.\n\t\t\t\tif (visibleCount < prevScreenRows) {\n\t\t\t\t\tconst leftover = prevScreenRows - Math.max(visibleCount, 1);\n\t\t\t\t\tfor (let i = 0; i < leftover; i++) {\n\t\t\t\t\t\tbuffer += \"\\r\\n\\x1b[2K\";\n\t\t\t\t\t}\n\t\t\t\t\tif (leftover > 0) buffer += `\\x1b[${leftover}A`; // Back up to the last content row\n\t\t\t\t}\n\t\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\t\tthis.terminal.write(buffer);\n\t\t\t\tthis.cursorRow = Math.max(0, newLines.length - 1);\n\t\t\t\tthis.hardwareCursorRow = this.cursorRow;\n\t\t\t\t// Reset (not just grow) the high-water mark to the repainted content,\n\t\t\t\t// mirroring the full-redraw path. Otherwise a preserving collapse\n\t\t\t\t// leaves maxLinesRendered inflated, and the next plain render would\n\t\t\t\t// re-trigger clearOnShrink.\n\t\t\t\tthis.maxLinesRendered = newLines.length;\n\t\t\t\tthis.previousViewportTop = windowStart;\n\t\t\t\tthis.positionHardwareCursor(cursorPos, newLines.length);\n\t\t\t\tthis.previousLines = newLines;\n\t\t\t\tthis.previousKittyImageIds = this.collectKittyImageIds(newLines);\n\t\t\t\tthis.previousWidth = width;\n\t\t\t\tthis.previousHeight = height;\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tconst renderStart = clear && this.previousLines.length > 0 ? Math.max(0, newLines.length - height) : 0;\n\t\t\tif (clear) {\n\t\t\t\tconst previousVisibleTop = Math.min(prevViewportTop, Math.max(0, this.previousLines.length - height));\n\t\t\t\tconst previousVisibleBottom = Math.min(this.previousLines.length - 1, previousVisibleTop + height - 1);\n\t\t\t\tbuffer += this.deleteChangedKittyImages(previousVisibleTop, previousVisibleBottom);\n\t\t\t\tbuffer += \"\\x1b[2J\\x1b[H\"; // Clear screen and home while preserving scrollback\n\t\t\t}\n\t\t\tfor (let i = renderStart; i < newLines.length; i++) {\n\t\t\t\tif (i > renderStart) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\tthis.cursorRow = Math.max(0, newLines.length - 1);\n\t\t\tthis.hardwareCursorRow = this.cursorRow;\n\t\t\t// Reset max lines when clearing, otherwise track growth\n\t\t\tif (clear) {\n\t\t\t\tthis.maxLinesRendered = newLines.length;\n\t\t\t} else {\n\t\t\t\tthis.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);\n\t\t\t}\n\t\t\tconst bufferLength = Math.max(height, newLines.length);\n\t\t\tthis.previousViewportTop = Math.max(0, bufferLength - height);\n\t\t\tthis.positionHardwareCursor(cursorPos, newLines.length);\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousKittyImageIds = this.collectKittyImageIds(newLines);\n\t\t\tthis.previousWidth = width;\n\t\t\tthis.previousHeight = height;\n\t\t};\n\n\t\tconst debugRedraw = process.env.PI_DEBUG_REDRAW === \"1\";\n\t\tconst logRedraw = (reason: string): void => {\n\t\t\tif (!debugRedraw) return;\n\t\t\tconst logPath = path.join(os.homedir(), \".prime\", \"agent\", \"pi-debug.log\");\n\t\t\tconst msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\\n`;\n\t\t\tfs.appendFileSync(logPath, msg);\n\t\t};\n\n\t\t// First render - just output everything without clearing (assumes clean screen)\n\t\tif (this.previousLines.length === 0 && !widthChanged && !heightChanged) {\n\t\t\tlogRedraw(\"first render\");\n\t\t\tfullRender(false);\n\t\t\treturn;\n\t\t}\n\n\t\t// Width changes always need a full re-render because wrapping changes.\n\t\tif (widthChanged) {\n\t\t\tlogRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);\n\t\t\tfullRender(true);\n\t\t\treturn;\n\t\t}\n\n\t\t// Height changes normally need a full re-render to keep the visible viewport aligned,\n\t\t// but Termux changes height when the software keyboard shows or hides.\n\t\t// In that environment, a full redraw causes the entire history to replay on every toggle.\n\t\tif (heightChanged && !isTermuxSession()) {\n\t\t\tlogRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);\n\t\t\tfullRender(true);\n\t\t\treturn;\n\t\t}\n\n\t\t// Content shrunk below the working area and no overlays - re-render to clear empty rows\n\t\t// (overlays need the padding, so only do this when no overlays are active)\n\t\t// Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var\n\t\tif (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {\n\t\t\tlogRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);\n\t\t\tfullRender(true, preserveViewport);\n\t\t\treturn;\n\t\t}\n\n\t\t// Find first and last changed lines\n\t\tlet firstChanged = -1;\n\t\tlet lastChanged = -1;\n\t\tconst maxLines = Math.max(newLines.length, this.previousLines.length);\n\t\tfor (let i = 0; i < maxLines; i++) {\n\t\t\tconst oldLine = i < this.previousLines.length ? this.previousLines[i] : \"\";\n\t\t\tconst newLine = i < newLines.length ? newLines[i] : \"\";\n\n\t\t\tif (oldLine !== newLine) {\n\t\t\t\tif (firstChanged === -1) {\n\t\t\t\t\tfirstChanged = i;\n\t\t\t\t}\n\t\t\t\tlastChanged = i;\n\t\t\t}\n\t\t}\n\t\tconst appendedLines = newLines.length > this.previousLines.length;\n\t\tif (appendedLines) {\n\t\t\tif (firstChanged === -1) {\n\t\t\t\tfirstChanged = this.previousLines.length;\n\t\t\t}\n\t\t\tlastChanged = newLines.length - 1;\n\t\t}\n\t\tif (firstChanged !== -1) {\n\t\t\tlastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);\n\t\t}\n\t\tconst appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;\n\n\t\t// No changes - but still need to update hardware cursor position if it moved\n\t\tif (firstChanged === -1) {\n\t\t\tthis.positionHardwareCursor(cursorPos, newLines.length);\n\t\t\tthis.previousViewportTop = prevViewportTop;\n\t\t\tthis.previousHeight = height;\n\t\t\treturn;\n\t\t}\n\n\t\t// All changes are in deleted lines (nothing to render, just clear)\n\t\tif (firstChanged >= newLines.length) {\n\t\t\tif (this.previousLines.length > newLines.length) {\n\t\t\t\tlet buffer = \"\\x1b[?2026h\";\n\t\t\t\tbuffer += this.deleteChangedKittyImages(firstChanged, lastChanged);\n\t\t\t\t// Move to end of new content (clamp to 0 for empty content)\n\t\t\t\tconst targetRow = Math.max(0, newLines.length - 1);\n\t\t\t\tif (targetRow < prevViewportTop) {\n\t\t\t\t\tlogRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);\n\t\t\t\t\tfullRender(true, preserveViewport);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tconst lineDiff = computeLineDiff(targetRow);\n\t\t\t\tif (lineDiff > 0) buffer += `\\x1b[${lineDiff}B`;\n\t\t\t\telse if (lineDiff < 0) buffer += `\\x1b[${-lineDiff}A`;\n\t\t\t\tbuffer += \"\\r\";\n\t\t\t\t// Clear extra lines without scrolling\n\t\t\t\tconst extraLines = this.previousLines.length - newLines.length;\n\t\t\t\tif (extraLines > height) {\n\t\t\t\t\tlogRedraw(`extraLines > height (${extraLines} > ${height})`);\n\t\t\t\t\tfullRender(true, preserveViewport);\n\t\t\t\t\treturn;\n\t\t\t\t}\n\t\t\t\tif (extraLines > 0) {\n\t\t\t\t\tbuffer += \"\\x1b[1B\";\n\t\t\t\t}\n\t\t\t\tfor (let i = 0; i < extraLines; i++) {\n\t\t\t\t\tbuffer += \"\\r\\x1b[2K\";\n\t\t\t\t\tif (i < extraLines - 1) buffer += \"\\x1b[1B\";\n\t\t\t\t}\n\t\t\t\tif (extraLines > 0) {\n\t\t\t\t\tbuffer += `\\x1b[${extraLines}A`;\n\t\t\t\t}\n\t\t\t\tbuffer += \"\\x1b[?2026l\";\n\t\t\t\tthis.terminal.write(buffer);\n\t\t\t\tthis.cursorRow = targetRow;\n\t\t\t\tthis.hardwareCursorRow = targetRow;\n\t\t\t}\n\t\t\tthis.positionHardwareCursor(cursorPos, newLines.length);\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousKittyImageIds = this.collectKittyImageIds(newLines);\n\t\t\tthis.previousWidth = width;\n\t\t\tthis.previousHeight = height;\n\t\t\tthis.previousViewportTop = prevViewportTop;\n\t\t\treturn;\n\t\t}\n\n\t\t// Differential rendering can only touch what was actually visible.\n\t\t// If the first changed line is above the previous viewport, the rows on\n\t\t// screen no longer correspond to newLines, so we have to repaint.\n\t\t//\n\t\t// When the transcript is taller than the viewport — e.g. attaching to a\n\t\t// long or still-streaming session, where off-screen tool results keep\n\t\t// resolving — replaying the whole transcript on every such change is what\n\t\t// makes the screen flicker and scroll from the\n\t\t// top. Repaint only the visible window in place instead, leaving\n\t\t// scrollback (and the user's history) untouched. Short transcripts that\n\t\t// fit on screen keep the cheap full redraw.\n\t\t//\n\t\t// Only do this while the transcript is growing (the streaming case). A\n\t\t// shrink — a rebuild or compaction that replaces the transcript with\n\t\t// fewer lines — leaves the now-removed lines stale in scrollback above the\n\t\t// visible window, so it still needs a full screen redraw. That is a\n\t\t// one-time event, so it costs no recurring flicker.\n\t\tif (firstChanged < prevViewportTop) {\n\t\t\tlogRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);\n\t\t\tconst preserveScrollback = newLines.length > height && newLines.length >= this.previousLines.length;\n\t\t\tfullRender(true, preserveScrollback || preserveViewport);\n\t\t\treturn;\n\t\t}\n\n\t\t// Render from first changed line to end\n\t\t// Build buffer with all updates wrapped in synchronized output\n\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\tbuffer += this.deleteChangedKittyImages(firstChanged, lastChanged);\n\t\tconst prevViewportBottom = prevViewportTop + height - 1;\n\t\tconst moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;\n\t\tif (moveTargetRow > prevViewportBottom) {\n\t\t\tconst currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));\n\t\t\tconst moveToBottom = height - 1 - currentScreenRow;\n\t\t\tif (moveToBottom > 0) {\n\t\t\t\tbuffer += `\\x1b[${moveToBottom}B`;\n\t\t\t}\n\t\t\tconst scroll = moveTargetRow - prevViewportBottom;\n\t\t\tbuffer += \"\\r\\n\".repeat(scroll);\n\t\t\tprevViewportTop += scroll;\n\t\t\tviewportTop += scroll;\n\t\t\thardwareCursorRow = moveTargetRow;\n\t\t}\n\n\t\t// Move cursor to first changed line (use hardwareCursorRow for actual position)\n\t\tconst lineDiff = computeLineDiff(moveTargetRow);\n\t\tif (lineDiff > 0) {\n\t\t\tbuffer += `\\x1b[${lineDiff}B`; // Move down\n\t\t} else if (lineDiff < 0) {\n\t\t\tbuffer += `\\x1b[${-lineDiff}A`; // Move up\n\t\t}\n\n\t\tbuffer += appendStart ? \"\\r\\n\" : \"\\r\"; // Move to column 0\n\n\t\t// Only render changed lines (firstChanged to lastChanged), not all lines to end\n\t\t// This reduces flicker when only a single line changes (e.g., spinner animation)\n\t\tconst renderEnd = Math.min(lastChanged, newLines.length - 1);\n\t\tfor (let i = firstChanged; i <= renderEnd; i++) {\n\t\t\tif (i > firstChanged) buffer += \"\\r\\n\";\n\t\t\tbuffer += \"\\x1b[2K\"; // Clear current line\n\t\t\tconst line = newLines[i];\n\t\t\tconst isImage = isImageLine(line);\n\t\t\tif (!isImage && visibleWidth(line) > width) {\n\t\t\t\t// Log all lines to crash file for debugging\n\t\t\t\tconst crashLogPath = path.join(os.homedir(), \".prime\", \"agent\", \"pi-crash.log\");\n\t\t\t\tconst crashData = [\n\t\t\t\t\t`Crash at ${new Date().toISOString()}`,\n\t\t\t\t\t`Terminal width: ${width}`,\n\t\t\t\t\t`Line ${i} visible width: ${visibleWidth(line)}`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"=== All rendered lines ===\",\n\t\t\t\t\t...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),\n\t\t\t\t\t\"\",\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tfs.mkdirSync(path.dirname(crashLogPath), { recursive: true });\n\t\t\t\tfs.writeFileSync(crashLogPath, crashData);\n\n\t\t\t\t// Clean up terminal state before throwing\n\t\t\t\tthis.stop();\n\n\t\t\t\tconst errorMsg = [\n\t\t\t\t\t`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"This is likely caused by a custom TUI component not truncating its output.\",\n\t\t\t\t\t\"Use visibleWidth() to measure and truncateToWidth() to truncate lines.\",\n\t\t\t\t\t\"\",\n\t\t\t\t\t`Debug log written to: ${crashLogPath}`,\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tthrow new Error(errorMsg);\n\t\t\t}\n\t\t\tbuffer += line;\n\t\t}\n\n\t\t// Track where cursor ended up after rendering\n\t\tlet finalCursorRow = renderEnd;\n\n\t\t// If we had more lines before, clear them and move cursor back\n\t\tif (this.previousLines.length > newLines.length) {\n\t\t\t// Move to end of new content first if we stopped before it\n\t\t\tif (renderEnd < newLines.length - 1) {\n\t\t\t\tconst moveDown = newLines.length - 1 - renderEnd;\n\t\t\t\tbuffer += `\\x1b[${moveDown}B`;\n\t\t\t\tfinalCursorRow = newLines.length - 1;\n\t\t\t}\n\t\t\tconst extraLines = this.previousLines.length - newLines.length;\n\t\t\tfor (let i = newLines.length; i < this.previousLines.length; i++) {\n\t\t\t\tbuffer += \"\\r\\n\\x1b[2K\";\n\t\t\t}\n\t\t\t// Move cursor back to end of new content\n\t\t\tbuffer += `\\x1b[${extraLines}A`;\n\t\t}\n\n\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\n\t\tif (process.env.PI_TUI_DEBUG === \"1\") {\n\t\t\tconst debugDir = \"/tmp/tui\";\n\t\t\tfs.mkdirSync(debugDir, { recursive: true });\n\t\t\tconst debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);\n\t\t\tconst debugData = [\n\t\t\t\t`firstChanged: ${firstChanged}`,\n\t\t\t\t`viewportTop: ${viewportTop}`,\n\t\t\t\t`cursorRow: ${this.cursorRow}`,\n\t\t\t\t`height: ${height}`,\n\t\t\t\t`lineDiff: ${lineDiff}`,\n\t\t\t\t`hardwareCursorRow: ${hardwareCursorRow}`,\n\t\t\t\t`renderEnd: ${renderEnd}`,\n\t\t\t\t`finalCursorRow: ${finalCursorRow}`,\n\t\t\t\t`cursorPos: ${JSON.stringify(cursorPos)}`,\n\t\t\t\t`newLines.length: ${newLines.length}`,\n\t\t\t\t`previousLines.length: ${this.previousLines.length}`,\n\t\t\t\t\"\",\n\t\t\t\t\"=== newLines ===\",\n\t\t\t\tJSON.stringify(newLines, null, 2),\n\t\t\t\t\"\",\n\t\t\t\t\"=== previousLines ===\",\n\t\t\t\tJSON.stringify(this.previousLines, null, 2),\n\t\t\t\t\"\",\n\t\t\t\t\"=== buffer ===\",\n\t\t\t\tJSON.stringify(buffer),\n\t\t\t].join(\"\\n\");\n\t\t\tfs.writeFileSync(debugPath, debugData);\n\t\t}\n\n\t\t// Write entire buffer at once\n\t\tthis.terminal.write(buffer);\n\n\t\t// Track cursor position for next render\n\t\t// cursorRow tracks end of content (for viewport calculation)\n\t\t// hardwareCursorRow tracks actual terminal cursor position (for movement)\n\t\tthis.cursorRow = Math.max(0, newLines.length - 1);\n\t\tthis.hardwareCursorRow = finalCursorRow;\n\t\t// Track terminal's working area (grows but doesn't shrink unless cleared)\n\t\tthis.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);\n\t\tthis.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);\n\n\t\t// Position hardware cursor for IME\n\t\tthis.positionHardwareCursor(cursorPos, newLines.length);\n\n\t\tthis.previousLines = newLines;\n\t\tthis.previousKittyImageIds = this.collectKittyImageIds(newLines);\n\t\tthis.previousWidth = width;\n\t\tthis.previousHeight = height;\n\t}\n\n\t/**\n\t * Position the hardware cursor for IME candidate window.\n\t * @param cursorPos The cursor position extracted from rendered output, or null\n\t * @param totalLines Total number of rendered lines\n\t */\n\tprivate positionHardwareCursor(cursorPos: { row: number; col: number } | null, totalLines: number): void {\n\t\tif (!cursorPos || totalLines <= 0) {\n\t\t\tthis.terminal.hideCursor();\n\t\t\treturn;\n\t\t}\n\n\t\t// Clamp cursor position to valid range\n\t\tconst targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));\n\t\tconst targetCol = Math.max(0, cursorPos.col);\n\n\t\t// Move cursor from current position to target\n\t\tconst rowDelta = targetRow - this.hardwareCursorRow;\n\t\tlet buffer = \"\";\n\t\tif (rowDelta > 0) {\n\t\t\tbuffer += `\\x1b[${rowDelta}B`; // Move down\n\t\t} else if (rowDelta < 0) {\n\t\t\tbuffer += `\\x1b[${-rowDelta}A`; // Move up\n\t\t}\n\t\t// Move to absolute column (1-indexed)\n\t\tbuffer += `\\x1b[${targetCol + 1}G`;\n\n\t\tif (buffer) {\n\t\t\tthis.terminal.write(buffer);\n\t\t}\n\n\t\tthis.hardwareCursorRow = targetRow;\n\t\tif (this.showHardwareCursor) {\n\t\t\tthis.terminal.showCursor();\n\t\t} else {\n\t\t\tthis.terminal.hideCursor();\n\t\t}\n\t}\n}\n"]}
|