@schovest/pi-tui 0.7.3

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.
Files changed (114) hide show
  1. package/README.md +791 -0
  2. package/dist/autocomplete.d.ts +57 -0
  3. package/dist/autocomplete.d.ts.map +1 -0
  4. package/dist/autocomplete.js +646 -0
  5. package/dist/autocomplete.js.map +1 -0
  6. package/dist/components/box.d.ts +22 -0
  7. package/dist/components/box.d.ts.map +1 -0
  8. package/dist/components/box.js +104 -0
  9. package/dist/components/box.js.map +1 -0
  10. package/dist/components/cancellable-loader.d.ts +22 -0
  11. package/dist/components/cancellable-loader.d.ts.map +1 -0
  12. package/dist/components/cancellable-loader.js +35 -0
  13. package/dist/components/cancellable-loader.js.map +1 -0
  14. package/dist/components/editor.d.ts +254 -0
  15. package/dist/components/editor.d.ts.map +1 -0
  16. package/dist/components/editor.js +1916 -0
  17. package/dist/components/editor.js.map +1 -0
  18. package/dist/components/image.d.ts +28 -0
  19. package/dist/components/image.d.ts.map +1 -0
  20. package/dist/components/image.js +89 -0
  21. package/dist/components/image.js.map +1 -0
  22. package/dist/components/input.d.ts +37 -0
  23. package/dist/components/input.d.ts.map +1 -0
  24. package/dist/components/input.js +385 -0
  25. package/dist/components/input.js.map +1 -0
  26. package/dist/components/loader.d.ts +31 -0
  27. package/dist/components/loader.d.ts.map +1 -0
  28. package/dist/components/loader.js +69 -0
  29. package/dist/components/loader.js.map +1 -0
  30. package/dist/components/markdown.d.ts +96 -0
  31. package/dist/components/markdown.d.ts.map +1 -0
  32. package/dist/components/markdown.js +648 -0
  33. package/dist/components/markdown.js.map +1 -0
  34. package/dist/components/select-list.d.ts +50 -0
  35. package/dist/components/select-list.d.ts.map +1 -0
  36. package/dist/components/select-list.js +161 -0
  37. package/dist/components/select-list.js.map +1 -0
  38. package/dist/components/settings-list.d.ts +50 -0
  39. package/dist/components/settings-list.d.ts.map +1 -0
  40. package/dist/components/settings-list.js +186 -0
  41. package/dist/components/settings-list.js.map +1 -0
  42. package/dist/components/spacer.d.ts +12 -0
  43. package/dist/components/spacer.d.ts.map +1 -0
  44. package/dist/components/spacer.js +23 -0
  45. package/dist/components/spacer.js.map +1 -0
  46. package/dist/components/text.d.ts +19 -0
  47. package/dist/components/text.d.ts.map +1 -0
  48. package/dist/components/text.js +89 -0
  49. package/dist/components/text.js.map +1 -0
  50. package/dist/components/truncated-text.d.ts +13 -0
  51. package/dist/components/truncated-text.d.ts.map +1 -0
  52. package/dist/components/truncated-text.js +51 -0
  53. package/dist/components/truncated-text.js.map +1 -0
  54. package/dist/editor-component.d.ts +39 -0
  55. package/dist/editor-component.d.ts.map +1 -0
  56. package/dist/editor-component.js +2 -0
  57. package/dist/editor-component.js.map +1 -0
  58. package/dist/fuzzy.d.ts +16 -0
  59. package/dist/fuzzy.d.ts.map +1 -0
  60. package/dist/fuzzy.js +110 -0
  61. package/dist/fuzzy.js.map +1 -0
  62. package/dist/index.d.ts +23 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +32 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/keybindings.d.ts +193 -0
  67. package/dist/keybindings.d.ts.map +1 -0
  68. package/dist/keybindings.js +174 -0
  69. package/dist/keybindings.js.map +1 -0
  70. package/dist/keys.d.ts +184 -0
  71. package/dist/keys.d.ts.map +1 -0
  72. package/dist/keys.js +1181 -0
  73. package/dist/keys.js.map +1 -0
  74. package/dist/kill-ring.d.ts +28 -0
  75. package/dist/kill-ring.d.ts.map +1 -0
  76. package/dist/kill-ring.js +44 -0
  77. package/dist/kill-ring.js.map +1 -0
  78. package/dist/native-modifiers.d.ts +3 -0
  79. package/dist/native-modifiers.d.ts.map +1 -0
  80. package/dist/native-modifiers.js +53 -0
  81. package/dist/native-modifiers.js.map +1 -0
  82. package/dist/stdin-buffer.d.ts +60 -0
  83. package/dist/stdin-buffer.d.ts.map +1 -0
  84. package/dist/stdin-buffer.js +406 -0
  85. package/dist/stdin-buffer.js.map +1 -0
  86. package/dist/terminal-image.d.ts +90 -0
  87. package/dist/terminal-image.d.ts.map +1 -0
  88. package/dist/terminal-image.js +366 -0
  89. package/dist/terminal-image.js.map +1 -0
  90. package/dist/terminal.d.ts +121 -0
  91. package/dist/terminal.d.ts.map +1 -0
  92. package/dist/terminal.js +464 -0
  93. package/dist/terminal.js.map +1 -0
  94. package/dist/tui.d.ts +347 -0
  95. package/dist/tui.d.ts.map +1 -0
  96. package/dist/tui.js +1608 -0
  97. package/dist/tui.js.map +1 -0
  98. package/dist/undo-stack.d.ts +17 -0
  99. package/dist/undo-stack.d.ts.map +1 -0
  100. package/dist/undo-stack.js +25 -0
  101. package/dist/undo-stack.js.map +1 -0
  102. package/dist/utils.d.ts +101 -0
  103. package/dist/utils.d.ts.map +1 -0
  104. package/dist/utils.js +1108 -0
  105. package/dist/utils.js.map +1 -0
  106. package/dist/word-navigation.d.ts +25 -0
  107. package/dist/word-navigation.d.ts.map +1 -0
  108. package/dist/word-navigation.js +96 -0
  109. package/dist/word-navigation.js.map +1 -0
  110. package/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  111. package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  112. package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  113. package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  114. package/package.json +47 -0
package/dist/tui.js ADDED
@@ -0,0 +1,1608 @@
1
+ /**
2
+ * Minimal TUI implementation with differential rendering
3
+ */
4
+ import { performance } from "node:perf_hooks";
5
+ import { isKeyRelease, matchesKey } from "./keys.js";
6
+ import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
7
+ import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, snapColToGraphemeBoundary, stripAnsi, visibleWidth, } from "./utils.js";
8
+ const KITTY_SEQUENCE_PREFIX = "\x1b_G";
9
+ function extractKittyImageIds(line) {
10
+ const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
11
+ if (sequenceStart === -1)
12
+ return [];
13
+ const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
14
+ const paramsEnd = line.indexOf(";", paramsStart);
15
+ if (paramsEnd === -1)
16
+ return [];
17
+ const params = line.slice(paramsStart, paramsEnd);
18
+ for (const param of params.split(",")) {
19
+ const [key, value] = param.split("=", 2);
20
+ if (key !== "i" || value === undefined)
21
+ continue;
22
+ const id = Number(value);
23
+ if (Number.isInteger(id) && id > 0 && id <= 0xffffffff) {
24
+ return [id];
25
+ }
26
+ }
27
+ return [];
28
+ }
29
+ /** Type guard to check if a component implements Focusable */
30
+ export function isFocusable(component) {
31
+ return component !== null && "focused" in component;
32
+ }
33
+ /**
34
+ * Cursor position marker - APC (Application Program Command) sequence.
35
+ * This is a zero-width escape sequence that terminals ignore.
36
+ * Components emit this at the cursor position when focused.
37
+ * TUI finds and strips this marker, then positions the hardware cursor there.
38
+ */
39
+ export const CURSOR_MARKER = "\x1b_pi:c\x07";
40
+ export { visibleWidth };
41
+ const AUTO_SCROLL_INTERVAL_MS = 100;
42
+ const AUTO_SCROLL_ROWS = 3;
43
+ /** Parse a SizeValue into absolute value given a reference size */
44
+ function parseSizeValue(value, referenceSize) {
45
+ if (value === undefined)
46
+ return undefined;
47
+ if (typeof value === "number")
48
+ return value;
49
+ // Parse percentage string like "50%"
50
+ const match = value.match(/^(\d+(?:\.\d+)?)%$/);
51
+ if (match) {
52
+ return Math.floor((referenceSize * parseFloat(match[1])) / 100);
53
+ }
54
+ return undefined;
55
+ }
56
+ function isTermuxSession() {
57
+ return Boolean(process.env.TERMUX_VERSION);
58
+ }
59
+ /**
60
+ * Container - a component that contains other components
61
+ */
62
+ export class Container {
63
+ children = [];
64
+ addChild(component) {
65
+ this.children.push(component);
66
+ }
67
+ removeChild(component) {
68
+ const index = this.children.indexOf(component);
69
+ if (index !== -1) {
70
+ this.children.splice(index, 1);
71
+ }
72
+ }
73
+ clear() {
74
+ this.children = [];
75
+ }
76
+ invalidate() {
77
+ for (const child of this.children) {
78
+ child.invalidate?.();
79
+ }
80
+ }
81
+ render(width) {
82
+ const lines = [];
83
+ for (const child of this.children) {
84
+ const childLines = child.render(width);
85
+ for (const line of childLines) {
86
+ lines.push(line);
87
+ }
88
+ }
89
+ return lines;
90
+ }
91
+ }
92
+ /**
93
+ * TUI - Main class for managing terminal UI with differential rendering
94
+ */
95
+ export class TUI extends Container {
96
+ terminal;
97
+ previousLines = [];
98
+ previousKittyImageIds = new Set();
99
+ previousWidth = 0;
100
+ previousHeight = 0;
101
+ focusedComponent = null;
102
+ inputListeners = new Set();
103
+ /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
104
+ onDebug;
105
+ renderRequested = false;
106
+ renderTimer;
107
+ lastRenderAt = 0;
108
+ static MIN_RENDER_INTERVAL_MS = 16;
109
+ // biome-ignore lint/correctness/noUnusedPrivateClassMembers: used in render and cursor positioning
110
+ cursorRow = 0; // Logical cursor row (end of rendered content)
111
+ hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
112
+ showHardwareCursor = process.env.PI_HARDWARE_CURSOR !== "0";
113
+ clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
114
+ maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
115
+ previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
116
+ fullRedrawCount = 0;
117
+ stopped = false;
118
+ fixedBottomCount = 0;
119
+ // Scrollable viewport top in the full-lines buffer; used for mouse coordinate mapping
120
+ currentScrollableViewportTop = 0;
121
+ // Full-lines buffer (pre-overlay, pre-highlight) cached each render for selection
122
+ // coordinate mapping and text extraction. Equals [...scrollableLines, ...fixedLines].
123
+ currentFullLines = [];
124
+ // Overlay-composited lines (post-overlay, pre-highlight) cached each render for text extraction and grapheme snapping when an overlay covers a row
125
+ currentCompositedLines = [];
126
+ currentScrollableLinesLength = 0;
127
+ autoScrollDirection = -1;
128
+ selection = null;
129
+ scrollOffset = 0;
130
+ autoFollow = true;
131
+ previousScrollableLineCount = 0;
132
+ lastScrollableViewport = 0;
133
+ autoScrollTimer = null;
134
+ mouseListenerRemover;
135
+ // Cached overlay layouts from the most recent render, used for selection clip
136
+ renderedOverlayLayouts = [];
137
+ // Scroll event debouncing: aggregate consecutive wheel events
138
+ pendingScrollDelta = 0;
139
+ scrollDebounceTimer = null;
140
+ static SCROLL_DEBOUNCE_MS = 8;
141
+ // Timestamp of the last mouse wheel event, used to suppress spurious
142
+ // arrow-key sequences that some terminals emit alongside scroll events.
143
+ lastMouseWheelTime = 0;
144
+ static MOUSE_WHEEL_SUPPRESS_MS = 50;
145
+ onCopySelection;
146
+ onScrollOffsetChange;
147
+ // Overlay stack for modal components rendered on top of base content
148
+ focusOrderCounter = 0;
149
+ overlayStack = [];
150
+ overlayFocusRestore = { status: "inactive" };
151
+ constructor(terminal, showHardwareCursor) {
152
+ super();
153
+ this.terminal = terminal;
154
+ if (showHardwareCursor !== undefined) {
155
+ this.showHardwareCursor = showHardwareCursor;
156
+ }
157
+ }
158
+ get fullRedraws() {
159
+ return this.fullRedrawCount;
160
+ }
161
+ getShowHardwareCursor() {
162
+ return this.showHardwareCursor;
163
+ }
164
+ setShowHardwareCursor(enabled) {
165
+ if (this.showHardwareCursor === enabled)
166
+ return;
167
+ this.showHardwareCursor = enabled;
168
+ if (enabled) {
169
+ // Set steady bar cursor shape when enabling hardware cursor
170
+ this.terminal.write("\x1b[6 q");
171
+ }
172
+ else {
173
+ this.terminal.hideCursor();
174
+ }
175
+ this.requestRender();
176
+ }
177
+ getClearOnShrink() {
178
+ return this.clearOnShrink;
179
+ }
180
+ /**
181
+ * Set whether to trigger full re-render when content shrinks.
182
+ * When true (default), empty rows are cleared when content shrinks.
183
+ * When false, empty rows remain (reduces redraws on slower terminals).
184
+ */
185
+ setClearOnShrink(enabled) {
186
+ this.clearOnShrink = enabled;
187
+ }
188
+ setFocus(component) {
189
+ this.setFocusInternal({ component, overlayFocusRestore: "clear" });
190
+ }
191
+ setFocusInternal({ component, overlayFocusRestore, }) {
192
+ const previousFocus = this.focusedComponent;
193
+ let nextFocus = component;
194
+ const previousFocusedOverlay = previousFocus
195
+ ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
196
+ : undefined;
197
+ const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false;
198
+ const restoreState = this.getVisibleOverlayFocusRestore();
199
+ if (nextFocus && !nextFocusIsOverlay) {
200
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
201
+ if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
202
+ nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
203
+ }
204
+ else {
205
+ this.overlayFocusRestore = {
206
+ status: "blocked",
207
+ overlay: restoreState.overlay,
208
+ blockedBy: nextFocus,
209
+ resume: restoreState.resume,
210
+ };
211
+ }
212
+ }
213
+ else if (previousFocusedOverlay &&
214
+ restoreState.status !== "inactive" &&
215
+ restoreState.overlay === previousFocusedOverlay &&
216
+ !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)) {
217
+ this.overlayFocusRestore = {
218
+ status: "blocked",
219
+ overlay: previousFocusedOverlay,
220
+ blockedBy: nextFocus,
221
+ resume: { status: "restore-overlay" },
222
+ };
223
+ }
224
+ }
225
+ else if (nextFocus === null) {
226
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
227
+ nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
228
+ }
229
+ else if (overlayFocusRestore === "clear") {
230
+ this.clearOverlayFocusRestore();
231
+ }
232
+ }
233
+ if (isFocusable(this.focusedComponent)) {
234
+ this.focusedComponent.focused = false;
235
+ }
236
+ this.focusedComponent = nextFocus;
237
+ if (isFocusable(nextFocus)) {
238
+ nextFocus.focused = true;
239
+ }
240
+ const focusedOverlay = nextFocus
241
+ ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
242
+ : undefined;
243
+ if (focusedOverlay) {
244
+ this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
245
+ }
246
+ }
247
+ clearOverlayFocusRestore() {
248
+ this.overlayFocusRestore = { status: "inactive" };
249
+ }
250
+ clearOverlayFocusRestoreFor(overlay) {
251
+ if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
252
+ this.clearOverlayFocusRestore();
253
+ }
254
+ }
255
+ resolveBlockedOverlayFocusResume(restoreState) {
256
+ if (restoreState.resume.status === "restore-overlay")
257
+ return restoreState.overlay.component;
258
+ this.clearOverlayFocusRestore();
259
+ return restoreState.resume.target;
260
+ }
261
+ getVisibleOverlayFocusRestore() {
262
+ const restoreState = this.overlayFocusRestore;
263
+ if (restoreState.status === "inactive")
264
+ return restoreState;
265
+ if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
266
+ return { status: "inactive" };
267
+ }
268
+ return restoreState;
269
+ }
270
+ isOverlayFocusAncestor(entry, component) {
271
+ const visited = new Set();
272
+ let current = entry.preFocus;
273
+ while (current && !visited.has(current)) {
274
+ visited.add(current);
275
+ if (current === component)
276
+ return true;
277
+ current = this.overlayStack.find((overlay) => overlay.component === current)?.preFocus ?? null;
278
+ }
279
+ return false;
280
+ }
281
+ retargetOverlayPreFocus(removed) {
282
+ for (const overlay of this.overlayStack) {
283
+ if (overlay !== removed && overlay.preFocus === removed.component) {
284
+ overlay.preFocus = removed.preFocus;
285
+ }
286
+ }
287
+ }
288
+ isComponentMounted(component) {
289
+ return this.children.some((child) => this.containsComponent(child, component));
290
+ }
291
+ containsComponent(root, target) {
292
+ if (root === target)
293
+ return true;
294
+ if (!(root instanceof Container))
295
+ return false;
296
+ return root.children.some((child) => this.containsComponent(child, target));
297
+ }
298
+ /**
299
+ * Show an overlay component with configurable positioning and sizing.
300
+ * Returns a handle to control the overlay's visibility.
301
+ */
302
+ showOverlay(component, options) {
303
+ const entry = {
304
+ component,
305
+ ...(options === undefined ? {} : { options }),
306
+ preFocus: this.focusedComponent,
307
+ hidden: false,
308
+ focusOrder: ++this.focusOrderCounter,
309
+ };
310
+ this.overlayStack.push(entry);
311
+ // Only focus if overlay is actually visible
312
+ if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
313
+ this.setFocus(component);
314
+ }
315
+ if (!this.showHardwareCursor) {
316
+ this.terminal.hideCursor();
317
+ }
318
+ this.requestRender();
319
+ // Return handle for controlling this overlay
320
+ return {
321
+ hide: () => {
322
+ const index = this.overlayStack.indexOf(entry);
323
+ if (index !== -1) {
324
+ this.clearOverlayFocusRestoreFor(entry);
325
+ this.retargetOverlayPreFocus(entry);
326
+ this.overlayStack.splice(index, 1);
327
+ // Restore focus if this overlay had focus
328
+ if (this.focusedComponent === component) {
329
+ const topVisible = this.getTopmostVisibleOverlay();
330
+ this.setFocus(topVisible?.component ?? entry.preFocus);
331
+ }
332
+ if (this.overlayStack.length === 0 && !this.showHardwareCursor) {
333
+ this.terminal.hideCursor();
334
+ }
335
+ this.requestRender();
336
+ }
337
+ },
338
+ setHidden: (hidden) => {
339
+ if (entry.hidden === hidden)
340
+ return;
341
+ entry.hidden = hidden;
342
+ // Update focus when hiding/showing
343
+ if (hidden) {
344
+ this.clearOverlayFocusRestoreFor(entry);
345
+ // If this overlay had focus, move focus to next visible or preFocus
346
+ if (this.focusedComponent === component) {
347
+ const topVisible = this.getTopmostVisibleOverlay();
348
+ this.setFocus(topVisible?.component ?? entry.preFocus);
349
+ }
350
+ }
351
+ else {
352
+ // Restore focus to this overlay when showing (if it's actually visible)
353
+ if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
354
+ entry.focusOrder = ++this.focusOrderCounter;
355
+ this.setFocus(component);
356
+ }
357
+ }
358
+ this.requestRender();
359
+ },
360
+ isHidden: () => entry.hidden,
361
+ focus: () => {
362
+ if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
363
+ return;
364
+ entry.focusOrder = ++this.focusOrderCounter;
365
+ this.setFocus(component);
366
+ this.requestRender();
367
+ },
368
+ unfocus: (unfocusOptions) => {
369
+ const isFocused = this.focusedComponent === component;
370
+ const restoreState = this.overlayFocusRestore;
371
+ const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
372
+ if (!isFocused && !hasPendingRestore)
373
+ return;
374
+ if (restoreState.status === "blocked" &&
375
+ restoreState.overlay === entry &&
376
+ this.focusedComponent === restoreState.blockedBy) {
377
+ if (unfocusOptions) {
378
+ this.overlayFocusRestore = {
379
+ status: "blocked",
380
+ overlay: entry,
381
+ blockedBy: restoreState.blockedBy,
382
+ resume: { status: "focus-target", target: unfocusOptions.target },
383
+ };
384
+ }
385
+ else {
386
+ this.clearOverlayFocusRestore();
387
+ }
388
+ this.requestRender();
389
+ return;
390
+ }
391
+ this.clearOverlayFocusRestoreFor(entry);
392
+ if (isFocused || unfocusOptions) {
393
+ const topVisible = this.getTopmostVisibleOverlay();
394
+ const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
395
+ this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
396
+ }
397
+ this.requestRender();
398
+ },
399
+ isFocused: () => this.focusedComponent === component,
400
+ };
401
+ }
402
+ /** Hide the topmost overlay and restore previous focus. */
403
+ hideOverlay() {
404
+ const overlay = this.overlayStack[this.overlayStack.length - 1];
405
+ if (!overlay)
406
+ return;
407
+ this.clearOverlayFocusRestoreFor(overlay);
408
+ this.retargetOverlayPreFocus(overlay);
409
+ this.overlayStack.pop();
410
+ if (this.focusedComponent === overlay.component) {
411
+ // Find topmost visible overlay, or fall back to preFocus
412
+ const topVisible = this.getTopmostVisibleOverlay();
413
+ this.setFocus(topVisible?.component ?? overlay.preFocus);
414
+ }
415
+ if (this.overlayStack.length === 0)
416
+ this.terminal.hideCursor();
417
+ this.requestRender();
418
+ }
419
+ /** Check if there are any visible overlays */
420
+ hasOverlay() {
421
+ return this.overlayStack.some((o) => this.isOverlayVisible(o));
422
+ }
423
+ /** Check if an overlay entry is currently visible */
424
+ isOverlayVisible(entry) {
425
+ if (entry.hidden)
426
+ return false;
427
+ if (entry.options?.visible) {
428
+ return entry.options.visible(this.terminal.columns, this.terminal.rows);
429
+ }
430
+ return true;
431
+ }
432
+ /** Find the visual-frontmost visible capturing overlay, if any */
433
+ getTopmostVisibleOverlay() {
434
+ let topmost;
435
+ for (const overlay of this.overlayStack) {
436
+ if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay))
437
+ continue;
438
+ if (!topmost || overlay.focusOrder > topmost.focusOrder) {
439
+ topmost = overlay;
440
+ }
441
+ }
442
+ return topmost;
443
+ }
444
+ invalidate() {
445
+ super.invalidate();
446
+ for (const overlay of this.overlayStack)
447
+ overlay.component.invalidate?.();
448
+ }
449
+ start() {
450
+ this.stopped = false;
451
+ this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
452
+ if (!this.showHardwareCursor) {
453
+ this.terminal.hideCursor();
454
+ }
455
+ this.queryCellSize();
456
+ this.setupMouseListener();
457
+ this.requestRender();
458
+ }
459
+ addInputListener(listener) {
460
+ this.inputListeners.add(listener);
461
+ return () => {
462
+ this.inputListeners.delete(listener);
463
+ };
464
+ }
465
+ removeInputListener(listener) {
466
+ this.inputListeners.delete(listener);
467
+ }
468
+ queryCellSize() {
469
+ // Only query if terminal supports images (cell size is only used for image rendering)
470
+ if (!getCapabilities().images) {
471
+ return;
472
+ }
473
+ // Query terminal for cell size in pixels: CSI 16 t
474
+ // Response format: CSI 6 ; height ; width t
475
+ this.terminal.write("\x1b[16t");
476
+ }
477
+ stop() {
478
+ this.stopped = true;
479
+ if (this.renderTimer) {
480
+ clearTimeout(this.renderTimer);
481
+ this.renderTimer = undefined;
482
+ }
483
+ if (this.scrollDebounceTimer) {
484
+ clearTimeout(this.scrollDebounceTimer);
485
+ this.scrollDebounceTimer = null;
486
+ }
487
+ this.clearAutoScrollTimer();
488
+ this.removeMouseListener();
489
+ // Move cursor to the end of the content to prevent overwriting/artifacts on exit
490
+ if (this.previousLines.length > 0) {
491
+ const targetRow = this.previousLines.length; // Line after the last content
492
+ const lineDiff = targetRow - this.hardwareCursorRow;
493
+ if (lineDiff > 0) {
494
+ this.terminal.write(`\x1b[${lineDiff}B`);
495
+ }
496
+ else if (lineDiff < 0) {
497
+ this.terminal.write(`\x1b[${-lineDiff}A`);
498
+ }
499
+ this.terminal.write("\r\n");
500
+ }
501
+ this.terminal.showCursor();
502
+ // Reset cursor shape to user default on exit
503
+ this.terminal.write("\x1b[0 q");
504
+ this.terminal.stop();
505
+ }
506
+ setupMouseListener() {
507
+ const stdinBuffer = this.terminal.stdinBuffer;
508
+ if (!stdinBuffer)
509
+ return;
510
+ const handler = (event) => this.handleMouseEvent(event);
511
+ stdinBuffer.on("mouse", handler);
512
+ this.mouseListenerRemover = () => {
513
+ stdinBuffer.removeListener("mouse", handler);
514
+ };
515
+ }
516
+ removeMouseListener() {
517
+ if (this.mouseListenerRemover) {
518
+ this.mouseListenerRemover();
519
+ this.mouseListenerRemover = undefined;
520
+ }
521
+ }
522
+ handleMouseEvent(event) {
523
+ if (event.type === "mouseWheel") {
524
+ this.lastMouseWheelTime = Date.now();
525
+ if (this.focusedComponent?.handleInput) {
526
+ const direction = event.button === 64 ? "scrollUp" : "scrollDown";
527
+ const consumed = this.focusedComponent.handleInput(`\x1b[${direction}`);
528
+ this.requestRender();
529
+ if (consumed)
530
+ return;
531
+ }
532
+ // Aggregate scroll delta for debouncing: first event flushes immediately,
533
+ // subsequent events within the debounce window are accumulated and flushed once
534
+ const delta = event.button === 64 ? AUTO_SCROLL_ROWS : -AUTO_SCROLL_ROWS;
535
+ this.pendingScrollDelta += delta;
536
+ if (!this.scrollDebounceTimer) {
537
+ // First event: flush immediately for responsive feel
538
+ this.flushPendingScroll();
539
+ this.scrollDebounceTimer = setTimeout(() => {
540
+ this.scrollDebounceTimer = null;
541
+ this.flushPendingScroll();
542
+ }, TUI.SCROLL_DEBOUNCE_MS);
543
+ }
544
+ // Otherwise: delta is accumulated, will be flushed when timer fires
545
+ return;
546
+ }
547
+ if (event.button !== 0)
548
+ return;
549
+ if (event.type === "mouseDown") {
550
+ const screenRow = event.row - 1;
551
+ const rawCol = event.col - 1;
552
+ const bufferRow = this.screenToBufferRow(screenRow);
553
+ const clip = this.getSelectionClipForRow(screenRow);
554
+ const line = clip != null && screenRow < this.currentCompositedLines.length
555
+ ? this.currentCompositedLines[screenRow]
556
+ : this.currentFullLines[bufferRow];
557
+ const col = line != null ? snapColToGraphemeBoundary(line, rawCol) : rawCol;
558
+ this.selection = {
559
+ active: true,
560
+ anchorRow: bufferRow,
561
+ anchorCol: col,
562
+ focusRow: bufferRow,
563
+ focusCol: col,
564
+ };
565
+ this.requestRender();
566
+ }
567
+ else if (event.type === "mouseMove" && this.selection) {
568
+ const screenRow = event.row - 1;
569
+ const rawCol = event.col - 1;
570
+ const bufferRow = this.screenToBufferRow(screenRow);
571
+ const clip = this.getSelectionClipForRow(screenRow);
572
+ const line = clip != null && screenRow < this.currentCompositedLines.length
573
+ ? this.currentCompositedLines[screenRow]
574
+ : this.currentFullLines[bufferRow];
575
+ const col = line != null ? snapColToGraphemeBoundary(line, rawCol) : rawCol;
576
+ this.selection.focusRow = bufferRow;
577
+ this.selection.focusCol = col;
578
+ if (event.row <= 1) {
579
+ this.startAutoScroll(-1);
580
+ }
581
+ else if (event.row >= this.lastScrollableViewport) {
582
+ // Trigger downward autoScroll at the bottom of the scrollable area,
583
+ // not the terminal bottom — the latter would require crossing any fixed
584
+ // bottom region (e.g. the input box) before scrolling kicks in.
585
+ this.startAutoScroll(1);
586
+ }
587
+ else {
588
+ this.clearAutoScrollTimer();
589
+ }
590
+ this.requestRender();
591
+ }
592
+ else if (event.type === "mouseUp" && this.selection) {
593
+ const sel = this.selection;
594
+ // Skip copy on plain click (no drag) to avoid polluting clipboard
595
+ const hasDragged = sel.anchorRow !== sel.focusRow || sel.anchorCol !== sel.focusCol;
596
+ const text = hasDragged ? this.extractSelectionText() : "";
597
+ this.clearAutoScrollTimer();
598
+ this.selection = null;
599
+ this.requestRender();
600
+ if (text.trim() && this.onCopySelection) {
601
+ this.onCopySelection(text);
602
+ }
603
+ }
604
+ }
605
+ getMaxScrollOffset() {
606
+ const width = this.terminal.columns;
607
+ const height = this.terminal.rows;
608
+ const childLines = [];
609
+ for (const child of this.children) {
610
+ childLines.push(child.render(width));
611
+ }
612
+ const fixedCount = this.fixedBottomCount;
613
+ let scrollableLines = 0;
614
+ let fixedLines = 0;
615
+ for (let i = 0; i < childLines.length; i++) {
616
+ if (i >= childLines.length - fixedCount) {
617
+ fixedLines += childLines[i].length;
618
+ }
619
+ else {
620
+ scrollableLines += childLines[i].length;
621
+ }
622
+ }
623
+ const scrollableViewport = Math.max(0, height - fixedLines);
624
+ return Math.max(0, scrollableLines - scrollableViewport);
625
+ }
626
+ /** Flush pending scroll delta and trigger a single render */
627
+ flushPendingScroll() {
628
+ if (this.pendingScrollDelta === 0)
629
+ return;
630
+ const delta = this.pendingScrollDelta;
631
+ this.pendingScrollDelta = 0;
632
+ if (delta > 0) {
633
+ this.autoFollow = false;
634
+ this.scrollOffset = Math.min(this.getMaxScrollOffset(), this.scrollOffset + delta);
635
+ }
636
+ else {
637
+ this.scrollOffset = Math.max(0, this.scrollOffset + delta);
638
+ if (this.scrollOffset === 0)
639
+ this.autoFollow = true;
640
+ }
641
+ this.onScrollOffsetChange?.(this.scrollOffset);
642
+ this.requestRender();
643
+ }
644
+ getScrollOffset() {
645
+ return this.scrollOffset;
646
+ }
647
+ setScrollOffset(offset) {
648
+ this.scrollOffset = offset;
649
+ this.autoFollow = offset === 0;
650
+ this.onScrollOffsetChange?.(offset);
651
+ this.requestRender();
652
+ }
653
+ resetScrollOffset() {
654
+ this.scrollOffset = 0;
655
+ this.autoFollow = true;
656
+ this.onScrollOffsetChange?.(0);
657
+ this.requestRender();
658
+ }
659
+ getAutoFollow() {
660
+ return this.autoFollow;
661
+ }
662
+ setAutoFollow(value) {
663
+ if (this.autoFollow === value)
664
+ return;
665
+ this.autoFollow = value;
666
+ if (value) {
667
+ this.scrollOffset = 0;
668
+ this.onScrollOffsetChange?.(0);
669
+ this.requestRender();
670
+ }
671
+ }
672
+ setFixedBottomCount(count) {
673
+ this.fixedBottomCount = count;
674
+ }
675
+ startAutoScroll(direction) {
676
+ // 同方向已运行则不重启,避免定时器堆积
677
+ if (this.autoScrollTimer && this.autoScrollDirection === direction)
678
+ return;
679
+ this.clearAutoScrollTimer();
680
+ this.autoFollow = false;
681
+ this.autoScrollDirection = direction;
682
+ this.autoScrollTimer = setInterval(() => {
683
+ const maxOffset = this.getMaxScrollOffset();
684
+ if (direction < 0) {
685
+ // 向旧内容方向滚动(视口顶向缓冲区起始扩展)
686
+ if (this.scrollOffset >= maxOffset)
687
+ return;
688
+ this.scrollOffset = Math.min(maxOffset, this.scrollOffset + AUTO_SCROLL_ROWS);
689
+ if (this.selection) {
690
+ // focus 指向视口顶行(刚滚入视野的更旧内容)
691
+ this.selection.focusRow = this.currentScrollableViewportTop;
692
+ }
693
+ }
694
+ else {
695
+ // 向新内容方向滚动(视口顶向缓冲区末尾收缩)
696
+ if (this.scrollOffset <= 0)
697
+ return;
698
+ this.scrollOffset = Math.max(0, this.scrollOffset - AUTO_SCROLL_ROWS);
699
+ if (this.selection) {
700
+ // focus 指向视口底行(刚滚入视野的更新内容)
701
+ this.selection.focusRow = this.currentScrollableViewportTop + this.lastScrollableViewport - 1;
702
+ }
703
+ }
704
+ this.onScrollOffsetChange?.(this.scrollOffset);
705
+ this.requestRender();
706
+ }, AUTO_SCROLL_INTERVAL_MS);
707
+ }
708
+ clearAutoScrollTimer() {
709
+ if (this.autoScrollTimer) {
710
+ clearInterval(this.autoScrollTimer);
711
+ this.autoScrollTimer = null;
712
+ }
713
+ }
714
+ /**
715
+ * Clear the current selection and stop any active autoScroll.
716
+ * Called by overlays when their internal scroll changes, to prevent
717
+ * stale selection highlighting on shifted content.
718
+ */
719
+ clearSelection() {
720
+ this.clearAutoScrollTimer();
721
+ this.selection = null;
722
+ this.requestRender();
723
+ }
724
+ extractSelectionText() {
725
+ if (!this.selection)
726
+ return "";
727
+ const sel = this.selection;
728
+ const lines = this.currentFullLines;
729
+ if (lines.length === 0)
730
+ return "";
731
+ const startRow = Math.min(sel.anchorRow, sel.focusRow);
732
+ const endRow = Math.max(sel.anchorRow, sel.focusRow);
733
+ let startCol;
734
+ let endCol;
735
+ if (startRow === endRow) {
736
+ startCol = Math.min(sel.anchorCol, sel.focusCol);
737
+ endCol = Math.max(sel.anchorCol, sel.focusCol);
738
+ }
739
+ else {
740
+ startCol = startRow === sel.anchorRow ? sel.anchorCol : sel.focusCol;
741
+ endCol = endRow === sel.anchorRow ? sel.anchorCol : sel.focusCol;
742
+ }
743
+ const parts = [];
744
+ for (let row = startRow; row <= endRow; row++) {
745
+ if (row < 0 || row >= lines.length)
746
+ continue;
747
+ // Apply selectionClip from overlays that cover this screen row
748
+ const screenRow = this.bufferToScreenRow(row);
749
+ const clip = screenRow >= 0 ? this.getSelectionClipForRow(screenRow) : null;
750
+ // Overlay-covered rows: extract from composited lines (overlay content).
751
+ // Non-overlay rows: extract from full buffer (supports cross-viewport selection).
752
+ const line = clip != null && screenRow >= 0 && screenRow < this.currentCompositedLines.length
753
+ ? this.currentCompositedLines[screenRow]
754
+ : lines[row];
755
+ const rowStartCol = row === startRow ? startCol : 0;
756
+ const rowEndCol = row === endRow ? endCol : visibleWidth(line) - 1;
757
+ let clipStart = rowStartCol;
758
+ let clipEnd = rowEndCol;
759
+ if (clip) {
760
+ // Clip the selection range to the selectable region within the overlay
761
+ clipStart = Math.max(clipStart, clip.col);
762
+ clipEnd = Math.min(clipEnd, clip.col + clip.width - 1);
763
+ }
764
+ if (clipStart > clipEnd)
765
+ continue;
766
+ const extracted = stripAnsi(sliceByColumn(line, clipStart, clipEnd - clipStart + 1));
767
+ // Trim trailing whitespace from composited lines since compositeLineAt
768
+ // pads them to terminal width with spaces, followed by ANSI resets.
769
+ parts.push(clip ? extracted.trimEnd() : extracted);
770
+ }
771
+ return parts.join("\n");
772
+ }
773
+ /**
774
+ * Get the selection clip region for a given screen row.
775
+ * If any overlay with a selectionClip callback covers this row,
776
+ * returns the clip region to restrict selection to that area.
777
+ */
778
+ getSelectionClipForRow(screenRow) {
779
+ for (const layout of this.renderedOverlayLayouts) {
780
+ // Check if this screen row falls within the overlay's row range
781
+ if (screenRow >= layout.row && screenRow < layout.row + layout.height) {
782
+ if (layout.selectionClip) {
783
+ const clip = layout.selectionClip(screenRow);
784
+ if (clip) {
785
+ // Adjust clip col to be relative to the overlay's position
786
+ return { col: layout.col + clip.col, width: clip.width };
787
+ }
788
+ }
789
+ }
790
+ }
791
+ return null;
792
+ }
793
+ /**
794
+ * Convert a screen row (0-indexed terminal row) to a buffer-absolute row index
795
+ * in `this.currentFullLines`. Used by mouseDown/mouseMove to set selection
796
+ * anchor/focus in stable buffer coordinates that survive scrolling.
797
+ *
798
+ * - Screen rows in the scrollable area map to:
799
+ * currentScrollableViewportTop + screenRow
800
+ * - Screen rows in the fixed area map to:
801
+ * currentScrollableLinesLength + (screenRow - lastScrollableViewport)
802
+ */
803
+ screenToBufferRow(screenRow) {
804
+ const svp = this.lastScrollableViewport;
805
+ if (screenRow < svp) {
806
+ return this.currentScrollableViewportTop + screenRow;
807
+ }
808
+ return this.currentScrollableLinesLength + (screenRow - svp);
809
+ }
810
+ /**
811
+ * Convert a buffer-absolute row index back to a screen row.
812
+ * Returns -1 if the buffer row is a scrollable line currently outside the viewport.
813
+ * Fixed-area buffer rows always map to a visible screen row.
814
+ */
815
+ bufferToScreenRow(bufferRow) {
816
+ const scrollableLen = this.currentScrollableLinesLength;
817
+ if (bufferRow < scrollableLen) {
818
+ const screenRow = bufferRow - this.currentScrollableViewportTop;
819
+ if (screenRow < 0 || screenRow >= this.lastScrollableViewport)
820
+ return -1;
821
+ return screenRow;
822
+ }
823
+ return this.lastScrollableViewport + (bufferRow - scrollableLen);
824
+ }
825
+ applySelectionHighlight(newLines, height) {
826
+ if (!this.selection)
827
+ return;
828
+ const sel = this.selection;
829
+ const startBufferRow = Math.min(sel.anchorRow, sel.focusRow);
830
+ const endBufferRow = Math.max(sel.anchorRow, sel.focusRow);
831
+ let startCol;
832
+ let endCol;
833
+ if (startBufferRow === endBufferRow) {
834
+ startCol = Math.min(sel.anchorCol, sel.focusCol);
835
+ endCol = Math.max(sel.anchorCol, sel.focusCol);
836
+ }
837
+ else {
838
+ startCol = startBufferRow === sel.anchorRow ? sel.anchorCol : sel.focusCol;
839
+ endCol = endBufferRow === sel.anchorRow ? sel.anchorCol : sel.focusCol;
840
+ }
841
+ for (let bufferRow = startBufferRow; bufferRow <= endBufferRow; bufferRow++) {
842
+ const screenRow = this.bufferToScreenRow(bufferRow);
843
+ if (screenRow < 0 || screenRow >= height)
844
+ continue;
845
+ if (bufferRow < 0 || bufferRow >= this.currentFullLines.length)
846
+ continue;
847
+ const targetLine = newLines[screenRow];
848
+ if (!targetLine || isImageLine(targetLine))
849
+ continue;
850
+ const lineVisibleWidth = visibleWidth(targetLine);
851
+ let colStart = bufferRow === startBufferRow ? Math.min(startCol, lineVisibleWidth) : 0;
852
+ let colEnd = bufferRow === endBufferRow ? Math.min(endCol, lineVisibleWidth - 1) : lineVisibleWidth - 1;
853
+ // Apply selectionClip from overlays that cover this screen row
854
+ const clip = this.getSelectionClipForRow(screenRow);
855
+ if (clip) {
856
+ colStart = Math.max(colStart, clip.col);
857
+ colEnd = Math.min(colEnd, clip.col + clip.width - 1);
858
+ }
859
+ if (colStart > colEnd)
860
+ continue;
861
+ const before = sliceByColumn(targetLine, 0, colStart, true);
862
+ const highlighted = sliceByColumn(targetLine, colStart, colEnd - colStart + 1);
863
+ const after = colEnd + 1 < lineVisibleWidth ? sliceByColumn(targetLine, colEnd + 1, lineVisibleWidth - colEnd - 1) : "";
864
+ // Preserve reverse video across SGR resets inside the highlighted region.
865
+ // \x1b[0m resets ALL attributes including \x1b[7m (reverse video),
866
+ // which would break the selection highlight. Re-apply \x1b[7m after each reset.
867
+ const preservedHighlight = highlighted.replace(/\x1b\[0m/g, "\x1b[0m\x1b[7m");
868
+ newLines[screenRow] = `${before}\x1b[7m${preservedHighlight}\x1b[27m${after}`;
869
+ }
870
+ }
871
+ requestRender(force = false) {
872
+ if (force) {
873
+ this.previousLines = [];
874
+ this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
875
+ this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
876
+ this.cursorRow = 0;
877
+ this.hardwareCursorRow = 0;
878
+ this.maxLinesRendered = 0;
879
+ this.previousViewportTop = 0;
880
+ if (this.renderTimer) {
881
+ clearTimeout(this.renderTimer);
882
+ this.renderTimer = undefined;
883
+ }
884
+ this.renderRequested = true;
885
+ process.nextTick(() => {
886
+ if (this.stopped || !this.renderRequested) {
887
+ return;
888
+ }
889
+ this.renderRequested = false;
890
+ this.lastRenderAt = performance.now();
891
+ this.doRender();
892
+ });
893
+ return;
894
+ }
895
+ if (this.renderRequested)
896
+ return;
897
+ this.renderRequested = true;
898
+ process.nextTick(() => this.scheduleRender());
899
+ }
900
+ scheduleRender() {
901
+ if (this.stopped || this.renderTimer || !this.renderRequested) {
902
+ return;
903
+ }
904
+ const elapsed = performance.now() - this.lastRenderAt;
905
+ const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);
906
+ this.renderTimer = setTimeout(() => {
907
+ this.renderTimer = undefined;
908
+ if (this.stopped || !this.renderRequested) {
909
+ return;
910
+ }
911
+ this.renderRequested = false;
912
+ this.lastRenderAt = performance.now();
913
+ this.doRender();
914
+ if (this.renderRequested) {
915
+ this.scheduleRender();
916
+ }
917
+ }, delay);
918
+ }
919
+ handleInput(data) {
920
+ if (this.inputListeners.size > 0) {
921
+ let current = data;
922
+ for (const listener of this.inputListeners) {
923
+ const result = listener(current);
924
+ if (result?.consume) {
925
+ return;
926
+ }
927
+ if (result?.data !== undefined) {
928
+ current = result.data;
929
+ }
930
+ }
931
+ if (current.length === 0) {
932
+ return;
933
+ }
934
+ data = current;
935
+ }
936
+ // Consume terminal cell size responses without blocking unrelated input.
937
+ if (this.consumeCellSizeResponse(data)) {
938
+ return;
939
+ }
940
+ // Global debug key handler (Shift+Ctrl+D)
941
+ if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
942
+ this.onDebug();
943
+ return;
944
+ }
945
+ // If focused component is an overlay, verify it's still visible
946
+ // (visibility can change due to terminal resize or visible() callback)
947
+ const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);
948
+ if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
949
+ // Focused overlay is no longer visible, redirect to topmost visible overlay
950
+ const topVisible = this.getTopmostVisibleOverlay();
951
+ if (topVisible) {
952
+ this.setFocus(topVisible.component);
953
+ }
954
+ else {
955
+ this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
956
+ }
957
+ }
958
+ const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent);
959
+ if (!focusIsOverlay) {
960
+ const restoreState = this.getVisibleOverlayFocusRestore();
961
+ if (restoreState.status === "eligible") {
962
+ this.setFocus(restoreState.overlay.component);
963
+ }
964
+ else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) {
965
+ if (restoreState.resume.status === "restore-overlay") {
966
+ this.setFocus(restoreState.overlay.component);
967
+ }
968
+ else {
969
+ this.clearOverlayFocusRestore();
970
+ this.setFocus(restoreState.resume.target);
971
+ }
972
+ }
973
+ }
974
+ // Suppress arrow keys that arrive shortly after a mouse wheel event.
975
+ // Some terminals (or intermediate layers like tmux) occasionally convert
976
+ // scroll events into arrow-key sequences; without this guard those
977
+ // spurious keys trigger unwanted actions such as input-history navigation.
978
+ if (Date.now() - this.lastMouseWheelTime < TUI.MOUSE_WHEEL_SUPPRESS_MS &&
979
+ (matchesKey(data, "up") || matchesKey(data, "down"))) {
980
+ return;
981
+ }
982
+ // Pass input to focused component first. If it returns true, it consumed the input.
983
+ if (this.focusedComponent?.handleInput) {
984
+ // Filter out key release events unless component opts in
985
+ if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
986
+ return;
987
+ }
988
+ const consumed = this.focusedComponent.handleInput(data);
989
+ this.requestRender();
990
+ if (consumed) {
991
+ return;
992
+ }
993
+ }
994
+ // Fallback: TUI handles scroll keys when focused component didn't consume them
995
+ if (matchesKey(data, "pageUp")) {
996
+ const pageSize = this.lastScrollableViewport || this.terminal.rows - 2;
997
+ this.setScrollOffset(this.scrollOffset + pageSize);
998
+ return;
999
+ }
1000
+ if (matchesKey(data, "pageDown")) {
1001
+ const pageSize = this.lastScrollableViewport || this.terminal.rows - 2;
1002
+ this.setScrollOffset(Math.max(0, this.scrollOffset - pageSize));
1003
+ return;
1004
+ }
1005
+ // Ctrl+Home / Ctrl+End: scroll to top/bottom of content
1006
+ if (matchesKey(data, "ctrl+home")) {
1007
+ this.setScrollOffset(this.getMaxScrollOffset());
1008
+ return;
1009
+ }
1010
+ if (matchesKey(data, "ctrl+end")) {
1011
+ this.setScrollOffset(0);
1012
+ return;
1013
+ }
1014
+ }
1015
+ consumeCellSizeResponse(data) {
1016
+ // Response format: ESC [ 6 ; height ; width t
1017
+ const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
1018
+ if (!match) {
1019
+ return false;
1020
+ }
1021
+ const heightPx = parseInt(match[1], 10);
1022
+ const widthPx = parseInt(match[2], 10);
1023
+ if (heightPx <= 0 || widthPx <= 0) {
1024
+ return true;
1025
+ }
1026
+ setCellDimensions({ widthPx, heightPx });
1027
+ // Invalidate all components so images re-render with correct dimensions.
1028
+ this.invalidate();
1029
+ this.requestRender();
1030
+ return true;
1031
+ }
1032
+ /**
1033
+ * Resolve overlay layout from options.
1034
+ * Returns { width, row, col, maxHeight } for rendering.
1035
+ */
1036
+ resolveOverlayLayout(options, overlayHeight, termWidth, termHeight) {
1037
+ const opt = options ?? {};
1038
+ // Parse margin (clamp to non-negative)
1039
+ const margin = typeof opt.margin === "number"
1040
+ ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
1041
+ : (opt.margin ?? {});
1042
+ const marginTop = Math.max(0, margin.top ?? 0);
1043
+ const marginRight = Math.max(0, margin.right ?? 0);
1044
+ const marginBottom = Math.max(0, margin.bottom ?? 0);
1045
+ const marginLeft = Math.max(0, margin.left ?? 0);
1046
+ // Available space after margins
1047
+ const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
1048
+ const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
1049
+ // === Resolve width ===
1050
+ let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
1051
+ // Apply minWidth
1052
+ if (opt.minWidth !== undefined) {
1053
+ width = Math.max(width, opt.minWidth);
1054
+ }
1055
+ // Clamp to available space
1056
+ width = Math.max(1, Math.min(width, availWidth));
1057
+ // === Resolve maxHeight ===
1058
+ let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
1059
+ // Clamp to available space
1060
+ if (maxHeight !== undefined) {
1061
+ maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
1062
+ }
1063
+ // Effective overlay height (may be clamped by maxHeight)
1064
+ const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
1065
+ // === Resolve position ===
1066
+ let row;
1067
+ let col;
1068
+ if (opt.row !== undefined) {
1069
+ if (typeof opt.row === "string") {
1070
+ // Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
1071
+ const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
1072
+ if (match) {
1073
+ const maxRow = Math.max(0, availHeight - effectiveHeight);
1074
+ const percent = parseFloat(match[1]) / 100;
1075
+ row = marginTop + Math.floor(maxRow * percent);
1076
+ }
1077
+ else {
1078
+ // Invalid format, fall back to center
1079
+ row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
1080
+ }
1081
+ }
1082
+ else {
1083
+ // Absolute row position
1084
+ row = opt.row;
1085
+ }
1086
+ }
1087
+ else {
1088
+ // Anchor-based (default: center)
1089
+ const anchor = opt.anchor ?? "center";
1090
+ row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);
1091
+ }
1092
+ if (opt.col !== undefined) {
1093
+ if (typeof opt.col === "string") {
1094
+ // Percentage: 0% = left, 100% = right (overlay stays within bounds)
1095
+ const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
1096
+ if (match) {
1097
+ const maxCol = Math.max(0, availWidth - width);
1098
+ const percent = parseFloat(match[1]) / 100;
1099
+ col = marginLeft + Math.floor(maxCol * percent);
1100
+ }
1101
+ else {
1102
+ // Invalid format, fall back to center
1103
+ col = this.resolveAnchorCol("center", width, availWidth, marginLeft);
1104
+ }
1105
+ }
1106
+ else {
1107
+ // Absolute column position
1108
+ col = opt.col;
1109
+ }
1110
+ }
1111
+ else {
1112
+ // Anchor-based (default: center)
1113
+ const anchor = opt.anchor ?? "center";
1114
+ col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);
1115
+ }
1116
+ // Apply offsets
1117
+ if (opt.offsetY !== undefined)
1118
+ row += opt.offsetY;
1119
+ if (opt.offsetX !== undefined)
1120
+ col += opt.offsetX;
1121
+ // Clamp to terminal bounds (respecting margins)
1122
+ row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
1123
+ col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
1124
+ return { width, row, col, maxHeight };
1125
+ }
1126
+ resolveAnchorRow(anchor, height, availHeight, marginTop) {
1127
+ switch (anchor) {
1128
+ case "top-left":
1129
+ case "top-center":
1130
+ case "top-right":
1131
+ return marginTop;
1132
+ case "bottom-left":
1133
+ case "bottom-center":
1134
+ case "bottom-right":
1135
+ return marginTop + availHeight - height;
1136
+ case "left-center":
1137
+ case "center":
1138
+ case "right-center":
1139
+ return marginTop + Math.floor((availHeight - height) / 2);
1140
+ }
1141
+ }
1142
+ resolveAnchorCol(anchor, width, availWidth, marginLeft) {
1143
+ switch (anchor) {
1144
+ case "top-left":
1145
+ case "left-center":
1146
+ case "bottom-left":
1147
+ return marginLeft;
1148
+ case "top-right":
1149
+ case "right-center":
1150
+ case "bottom-right":
1151
+ return marginLeft + availWidth - width;
1152
+ case "top-center":
1153
+ case "center":
1154
+ case "bottom-center":
1155
+ return marginLeft + Math.floor((availWidth - width) / 2);
1156
+ }
1157
+ }
1158
+ /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
1159
+ compositeOverlays(lines, termWidth, termHeight) {
1160
+ const result = [...lines];
1161
+ // Pre-render all visible overlays and calculate positions
1162
+ const rendered = [];
1163
+ let minLinesNeeded = result.length;
1164
+ const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
1165
+ visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
1166
+ for (const entry of visibleEntries) {
1167
+ const { component, options } = entry;
1168
+ // Get layout with height=0 first to determine width and maxHeight
1169
+ // (width and maxHeight don't depend on overlay height)
1170
+ const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
1171
+ // Render component at calculated width
1172
+ let overlayLines = component.render(width);
1173
+ // Apply maxHeight if specified
1174
+ if (maxHeight !== undefined && overlayLines.length > maxHeight) {
1175
+ overlayLines = overlayLines.slice(0, maxHeight);
1176
+ }
1177
+ // Get final row/col with actual overlay height
1178
+ const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
1179
+ rendered.push({
1180
+ overlayLines,
1181
+ row,
1182
+ col,
1183
+ w: width,
1184
+ background: options?.background,
1185
+ selectionClip: options?.selectionClip,
1186
+ });
1187
+ minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
1188
+ }
1189
+ // Cache overlay layouts for selection clip calculations
1190
+ this.renderedOverlayLayouts = rendered.map(({ row, col, w, overlayLines, selectionClip }) => ({
1191
+ row,
1192
+ col,
1193
+ width: w,
1194
+ height: overlayLines.length,
1195
+ selectionClip,
1196
+ }));
1197
+ // Pad to at least terminal height so overlays have screen-relative positions.
1198
+ // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
1199
+ // inflation that pushed content into scrollback on terminal widen.
1200
+ const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
1201
+ // Extend result with empty lines if content is too short for overlay placement or working area
1202
+ while (result.length < workingHeight) {
1203
+ result.push("");
1204
+ }
1205
+ const viewportStart = Math.max(0, workingHeight - termHeight);
1206
+ // Composite each overlay
1207
+ for (const { overlayLines, row, col, w, background: bg } of rendered) {
1208
+ for (let i = 0; i < overlayLines.length; i++) {
1209
+ const idx = viewportStart + row + i;
1210
+ if (idx >= 0 && idx < result.length) {
1211
+ // Defensive: truncate overlay line to declared width before compositing
1212
+ // (components should already respect width, but this ensures it)
1213
+ const truncatedOverlayLine = visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
1214
+ let baseLine = result[idx];
1215
+ if (bg) {
1216
+ baseLine = this.applyOverlayBackground(baseLine, col, w, bg, termWidth);
1217
+ }
1218
+ result[idx] = this.compositeLineAt(baseLine, truncatedOverlayLine, col, w, termWidth);
1219
+ }
1220
+ }
1221
+ }
1222
+ return result;
1223
+ }
1224
+ static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
1225
+ applyLineResets(lines) {
1226
+ const reset = TUI.SEGMENT_RESET;
1227
+ for (let i = 0; i < lines.length; i++) {
1228
+ const line = lines[i];
1229
+ if (!isImageLine(line)) {
1230
+ lines[i] = normalizeTerminalOutput(line) + reset;
1231
+ }
1232
+ }
1233
+ return lines;
1234
+ }
1235
+ collectKittyImageIds(lines) {
1236
+ const ids = new Set();
1237
+ for (const line of lines) {
1238
+ for (const id of extractKittyImageIds(line)) {
1239
+ ids.add(id);
1240
+ }
1241
+ }
1242
+ return ids;
1243
+ }
1244
+ deleteKittyImages(ids) {
1245
+ let buffer = "";
1246
+ for (const id of ids) {
1247
+ buffer += deleteKittyImage(id);
1248
+ }
1249
+ return buffer;
1250
+ }
1251
+ expandLastChangedForKittyImages(firstChanged, lastChanged) {
1252
+ let expandedLastChanged = lastChanged;
1253
+ for (let i = firstChanged; i < this.previousLines.length; i++) {
1254
+ if (extractKittyImageIds(this.previousLines[i]).length > 0) {
1255
+ expandedLastChanged = Math.max(expandedLastChanged, i);
1256
+ }
1257
+ }
1258
+ return expandedLastChanged;
1259
+ }
1260
+ deleteChangedKittyImages(firstChanged, lastChanged) {
1261
+ if (firstChanged < 0 || lastChanged < firstChanged)
1262
+ return "";
1263
+ const ids = new Set();
1264
+ const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
1265
+ for (let i = firstChanged; i <= maxLine; i++) {
1266
+ for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
1267
+ ids.add(id);
1268
+ }
1269
+ }
1270
+ return this.deleteKittyImages(ids);
1271
+ }
1272
+ /** Apply background fill to the overlay region of a base line. */
1273
+ applyOverlayBackground(baseLine, startCol, overlayWidth, bg, totalWidth) {
1274
+ if (isImageLine(baseLine))
1275
+ return baseLine;
1276
+ const afterStart = startCol + overlayWidth;
1277
+ const segments = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
1278
+ const r = TUI.SEGMENT_RESET;
1279
+ const beforePad = Math.max(0, startCol - segments.beforeWidth);
1280
+ const overlayFill = bg + " ".repeat(overlayWidth) + r;
1281
+ const afterTarget = Math.max(0, totalWidth - Math.max(startCol, segments.beforeWidth) - overlayWidth);
1282
+ const afterPad = Math.max(0, afterTarget - segments.afterWidth);
1283
+ return segments.before + " ".repeat(beforePad) + r + overlayFill + segments.after + " ".repeat(afterPad);
1284
+ }
1285
+ /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
1286
+ compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
1287
+ if (isImageLine(baseLine))
1288
+ return baseLine;
1289
+ // Single pass through baseLine extracts both before and after segments
1290
+ const afterStart = startCol + overlayWidth;
1291
+ const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
1292
+ // Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
1293
+ const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
1294
+ // Pad segments to target widths
1295
+ const beforePad = Math.max(0, startCol - base.beforeWidth);
1296
+ const overlayPad = Math.max(0, overlayWidth - overlay.width);
1297
+ const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
1298
+ const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
1299
+ const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
1300
+ const afterPad = Math.max(0, afterTarget - base.afterWidth);
1301
+ // Compose result
1302
+ const r = TUI.SEGMENT_RESET;
1303
+ const result = base.before +
1304
+ " ".repeat(beforePad) +
1305
+ r +
1306
+ overlay.text +
1307
+ " ".repeat(overlayPad) +
1308
+ r +
1309
+ base.after +
1310
+ " ".repeat(afterPad);
1311
+ // CRITICAL: Always verify and truncate to terminal width.
1312
+ // This is the final safeguard against width overflow which would crash the TUI.
1313
+ // Width tracking can drift from actual visible width due to:
1314
+ // - Complex ANSI/OSC sequences (hyperlinks, colors)
1315
+ // - Wide characters at segment boundaries
1316
+ // - Edge cases in segment extraction
1317
+ const resultWidth = visibleWidth(result);
1318
+ if (resultWidth <= totalWidth) {
1319
+ return result;
1320
+ }
1321
+ // Truncate with strict=true to ensure we don't exceed totalWidth
1322
+ return sliceByColumn(result, 0, totalWidth, true);
1323
+ }
1324
+ /**
1325
+ * Find and extract cursor position from rendered lines.
1326
+ * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
1327
+ * Only scans the bottom terminal height lines (visible viewport).
1328
+ * @param lines - Rendered lines to search
1329
+ * @param height - Terminal height (visible viewport size)
1330
+ * @returns Cursor position { row, col } or null if no marker found
1331
+ */
1332
+ extractCursorPosition(lines, height) {
1333
+ // Only scan the bottom `height` lines (visible viewport)
1334
+ const viewportTop = Math.max(0, lines.length - height);
1335
+ for (let row = lines.length - 1; row >= viewportTop; row--) {
1336
+ const line = lines[row];
1337
+ const markerIndex = line.indexOf(CURSOR_MARKER);
1338
+ if (markerIndex !== -1) {
1339
+ // Calculate visual column (width of text before marker)
1340
+ const beforeMarker = line.slice(0, markerIndex);
1341
+ const col = visibleWidth(beforeMarker);
1342
+ // Strip marker from the line
1343
+ lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
1344
+ return { row, col };
1345
+ }
1346
+ }
1347
+ return null;
1348
+ }
1349
+ renderingSuspended = false;
1350
+ suspendRendering() {
1351
+ this.renderingSuspended = true;
1352
+ if (this.renderTimer) {
1353
+ clearTimeout(this.renderTimer);
1354
+ this.renderTimer = undefined;
1355
+ }
1356
+ this.renderRequested = false;
1357
+ }
1358
+ resumeRendering() {
1359
+ this.renderingSuspended = false;
1360
+ this.requestRender(true);
1361
+ }
1362
+ doRender() {
1363
+ if (this.stopped || this.renderingSuspended)
1364
+ return;
1365
+ const width = this.terminal.columns;
1366
+ const height = this.terminal.rows;
1367
+ // Render all children to get full content
1368
+ const childLines = [];
1369
+ for (const child of this.children) {
1370
+ childLines.push(child.render(width));
1371
+ }
1372
+ // Split children into scrollable (top) and fixed (bottom)
1373
+ // The fixedBottomCount property determines how many trailing children
1374
+ // are always pinned to the bottom of the screen
1375
+ const fixedCount = this.fixedBottomCount;
1376
+ const scrollableLines = [];
1377
+ const fixedLines = [];
1378
+ for (let i = 0; i < childLines.length; i++) {
1379
+ if (i >= childLines.length - fixedCount) {
1380
+ fixedLines.push(...childLines[i]);
1381
+ }
1382
+ else {
1383
+ scrollableLines.push(...childLines[i]);
1384
+ }
1385
+ }
1386
+ // Calculate available space for scrollable area
1387
+ const fixedHeight = fixedLines.length;
1388
+ const scrollableViewport = Math.max(0, height - fixedHeight);
1389
+ this.lastScrollableViewport = scrollableViewport;
1390
+ // Apply scrollOffset to scrollable content only
1391
+ const maxScroll = Math.max(0, scrollableLines.length - scrollableViewport);
1392
+ const lineCountDelta = scrollableLines.length - this.previousScrollableLineCount;
1393
+ if (lineCountDelta > 0) {
1394
+ if (this.autoFollow) {
1395
+ this.scrollOffset = 0;
1396
+ }
1397
+ else if (this.scrollOffset > 0) {
1398
+ this.scrollOffset += lineCountDelta;
1399
+ }
1400
+ }
1401
+ if (this.scrollOffset > maxScroll)
1402
+ this.scrollOffset = maxScroll;
1403
+ this.previousScrollableLineCount = scrollableLines.length;
1404
+ // Cache full lines (pre-overlay, pre-highlight) for selection text extraction
1405
+ // and absolute-row coordinate mapping
1406
+ this.currentFullLines = [...scrollableLines, ...fixedLines];
1407
+ this.currentScrollableLinesLength = scrollableLines.length;
1408
+ // Determine visible scrollable lines
1409
+ const scrollableViewportTop = Math.max(0, scrollableLines.length - scrollableViewport - this.scrollOffset);
1410
+ const visibleScrollable = scrollableLines.slice(scrollableViewportTop, scrollableViewportTop + scrollableViewport);
1411
+ // Combine: visible scrollable content + fixed bottom content
1412
+ let newLines = [...visibleScrollable, ...fixedLines];
1413
+ // Pad to fill screen height if content is shorter
1414
+ while (newLines.length < height)
1415
+ newLines.push("");
1416
+ // Truncate to screen height
1417
+ newLines = newLines.slice(0, height);
1418
+ // Composite overlays (clear stale layout cache first, then let compositeOverlays refill it)
1419
+ this.renderedOverlayLayouts = [];
1420
+ if (this.overlayStack.length > 0) {
1421
+ newLines = this.compositeOverlays([...newLines], width, height);
1422
+ }
1423
+ // Cache composited lines (post-overlay, pre-highlight) for overlay text extraction
1424
+ this.currentCompositedLines = [...newLines];
1425
+ const cursorPos = this.extractCursorPosition(newLines, height);
1426
+ newLines = this.applyLineResets(newLines);
1427
+ // Update viewport-top state before selection highlight, so bufferToScreenRow
1428
+ // inside applySelectionHighlight uses the correct viewport offset.
1429
+ this.currentScrollableViewportTop = scrollableViewportTop;
1430
+ // Selection highlight: buffer-absolute row iteration via bufferToScreenRow
1431
+ this.applySelectionHighlight(newLines, height);
1432
+ const renderChanged = () => {
1433
+ this.fullRedrawCount += 1;
1434
+ // Sync mode + cursor home + sequential line output (no full screen clear).
1435
+ // \x1b[?2026h defers display until \x1b[?2026l for atomic update.
1436
+ // \x1b[K clears to end-of-line after each line to remove residual content
1437
+ // from previous renders, replacing the flicker-causing \x1b[2J clear screen.
1438
+ // Uses sequential \r\n output (like the original) for minimal bandwidth.
1439
+ let buffer = `\x1b[?2026h${TUI.CURSOR_HIDE_PREFIX}\x1b[H`;
1440
+ buffer += this.deleteKittyImages(this.previousKittyImageIds);
1441
+ for (let i = 0; i < newLines.length; i++) {
1442
+ if (i > 0)
1443
+ buffer += "\r\n";
1444
+ buffer += newLines[i];
1445
+ buffer += "\x1b[K";
1446
+ }
1447
+ buffer += this.buildHardwareCursorSequence(cursorPos, newLines.length);
1448
+ buffer += "\x1b[?2026l";
1449
+ this.terminal.write(buffer);
1450
+ this.cursorRow = Math.max(0, newLines.length - 1);
1451
+ this.maxLinesRendered = newLines.length;
1452
+ this.previousViewportTop = scrollableViewportTop;
1453
+ this.previousLines = newLines;
1454
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1455
+ this.previousWidth = width;
1456
+ this.previousHeight = height;
1457
+ };
1458
+ const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
1459
+ const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
1460
+ if (this.previousLines.length === 0) {
1461
+ renderChanged();
1462
+ return;
1463
+ }
1464
+ if (widthChanged || (heightChanged && !isTermuxSession())) {
1465
+ renderChanged();
1466
+ return;
1467
+ }
1468
+ if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
1469
+ renderChanged();
1470
+ return;
1471
+ }
1472
+ let firstChanged = -1;
1473
+ let lastChanged = -1;
1474
+ const maxLines = Math.max(newLines.length, this.previousLines.length);
1475
+ for (let i = 0; i < maxLines; i++) {
1476
+ const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
1477
+ const newLine = i < newLines.length ? newLines[i] : "";
1478
+ if (oldLine !== newLine) {
1479
+ if (firstChanged === -1)
1480
+ firstChanged = i;
1481
+ lastChanged = i;
1482
+ }
1483
+ }
1484
+ if (newLines.length > this.previousLines.length) {
1485
+ if (firstChanged === -1)
1486
+ firstChanged = this.previousLines.length;
1487
+ lastChanged = newLines.length - 1;
1488
+ }
1489
+ if (firstChanged !== -1)
1490
+ lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
1491
+ if (firstChanged === -1) {
1492
+ // No content changed, but cursor position may have — wrap in
1493
+ // sync mode so the cursor move is atomic with the frame.
1494
+ const seq = this.buildHardwareCursorSequence(cursorPos, newLines.length);
1495
+ this.terminal.write(`\x1b[?2026h${TUI.CURSOR_HIDE_PREFIX}${seq}\x1b[?2026l`);
1496
+ this.previousViewportTop = scrollableViewportTop;
1497
+ this.previousHeight = height;
1498
+ return;
1499
+ }
1500
+ // If >50% of visible lines changed, do a full re-render (more efficient than incremental)
1501
+ if (lastChanged - firstChanged + 1 > height * 0.5 || firstChanged < 0) {
1502
+ renderChanged();
1503
+ return;
1504
+ }
1505
+ // All changes are in deleted lines (nothing to render, just clear)
1506
+ if (firstChanged >= newLines.length) {
1507
+ if (this.previousLines.length > newLines.length) {
1508
+ let buffer = "\x1b[?2026h";
1509
+ buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
1510
+ // Move to end of new content (clamp to 0 for empty content)
1511
+ const targetRow = Math.max(0, newLines.length - 1);
1512
+ if (targetRow < this.previousViewportTop) {
1513
+ renderChanged();
1514
+ return;
1515
+ }
1516
+ const lineDiff = targetRow - this.hardwareCursorRow;
1517
+ if (lineDiff > 0)
1518
+ buffer += `\x1b[${lineDiff}B`;
1519
+ else if (lineDiff < 0)
1520
+ buffer += `\x1b[${-lineDiff}A`;
1521
+ buffer += "\r";
1522
+ // Clear extra lines without scrolling
1523
+ const extraLines = this.previousLines.length - newLines.length;
1524
+ if (extraLines > height) {
1525
+ renderChanged();
1526
+ return;
1527
+ }
1528
+ const clearStartOffset = newLines.length === 0 ? 0 : 1;
1529
+ if (extraLines > 0 && clearStartOffset > 0) {
1530
+ buffer += `\x1b[${clearStartOffset}B`;
1531
+ }
1532
+ for (let i = 0; i < extraLines; i++) {
1533
+ buffer += "\r\x1b[2K";
1534
+ if (i < extraLines - 1)
1535
+ buffer += "\x1b[1B";
1536
+ }
1537
+ const moveBack = Math.max(0, extraLines - 1 + clearStartOffset);
1538
+ if (moveBack > 0) {
1539
+ buffer += `\x1b[${moveBack}A`;
1540
+ }
1541
+ buffer += "\x1b[?2026l";
1542
+ this.terminal.write(buffer);
1543
+ this.cursorRow = targetRow;
1544
+ this.hardwareCursorRow = targetRow;
1545
+ }
1546
+ this.terminal.write(this.buildHardwareCursorSequence(cursorPos, newLines.length));
1547
+ this.previousLines = newLines;
1548
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1549
+ this.previousWidth = width;
1550
+ this.previousHeight = height;
1551
+ this.previousViewportTop = scrollableViewportTop;
1552
+ return;
1553
+ }
1554
+ let buffer = `\x1b[?2026h${TUI.CURSOR_HIDE_PREFIX}`;
1555
+ buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
1556
+ const renderEnd = Math.min(lastChanged, newLines.length - 1);
1557
+ for (let i = firstChanged; i <= renderEnd; i++) {
1558
+ buffer += `\x1b[${i + 1};1H\x1b[2K\x1b[0m${newLines[i]}`;
1559
+ }
1560
+ if (newLines.length < this.previousLines.length) {
1561
+ for (let i = newLines.length; i < this.previousLines.length; i++) {
1562
+ buffer += `\x1b[${i + 1};1H\x1b[2K`;
1563
+ }
1564
+ }
1565
+ buffer += this.buildHardwareCursorSequence(cursorPos, newLines.length);
1566
+ buffer += "\x1b[?2026l";
1567
+ this.terminal.write(buffer);
1568
+ this.cursorRow = Math.max(0, newLines.length - 1);
1569
+ this.previousLines = newLines;
1570
+ this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1571
+ this.previousWidth = width;
1572
+ this.previousHeight = height;
1573
+ this.previousViewportTop = scrollableViewportTop;
1574
+ }
1575
+ /**
1576
+ * Build escape sequences for positioning the hardware cursor.
1577
+ * Returned string should be appended to the render buffer *before*
1578
+ * the sync-mode terminator (\x1b[?2026l) so the cursor state change
1579
+ * is atomic with the content update and never causes a visible flash.
1580
+ * @param cursorPos The cursor position extracted from rendered output, or null
1581
+ * @param totalLines Total number of rendered lines
1582
+ */
1583
+ buildHardwareCursorSequence(cursorPos, totalLines) {
1584
+ if (!cursorPos || totalLines <= 0) {
1585
+ return "\x1b[?25l";
1586
+ }
1587
+ const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
1588
+ const targetCol = Math.max(0, cursorPos.col);
1589
+ this.hardwareCursorRow = targetRow;
1590
+ if (this.showHardwareCursor) {
1591
+ // DECSCUSR Ps=6: steady bar cursor — non-blinking, unobtrusive,
1592
+ // and ensures IME frameworks track the cursor position reliably.
1593
+ return `\x1b[${targetRow + 1};${targetCol + 1}H\x1b[6 q\x1b[?25h`;
1594
+ }
1595
+ // Hide then move — both inside the sync-mode buffer so the
1596
+ // terminal never shows the cursor at an intermediate position.
1597
+ return `\x1b[?25l\x1b[${targetRow + 1};${targetCol + 1}H`;
1598
+ }
1599
+ /**
1600
+ * Cursor-hide prefix to place at the start of every render buffer,
1601
+ * before any content updates. Guarantees the hardware cursor is
1602
+ * invisible before the terminal processes line changes, which
1603
+ * prevents the cursor from briefly appearing at stale positions
1604
+ * on terminals with imperfect sync-mode support.
1605
+ */
1606
+ static CURSOR_HIDE_PREFIX = "\x1b[?25l";
1607
+ }
1608
+ //# sourceMappingURL=tui.js.map