@fleetagent/pi-tui 0.1.9 → 0.2.1

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 (109) hide show
  1. package/README.md +47 -7
  2. package/dist/autocomplete.d.ts +17 -23
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +129 -195
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/alt-screen-flash.d.ts +13 -0
  7. package/dist/components/alt-screen-flash.d.ts.map +1 -0
  8. package/dist/components/alt-screen-flash.js +37 -0
  9. package/dist/components/alt-screen-flash.js.map +1 -0
  10. package/dist/components/editor.d.ts +47 -4
  11. package/dist/components/editor.d.ts.map +1 -1
  12. package/dist/components/editor.js +615 -585
  13. package/dist/components/editor.js.map +1 -1
  14. package/dist/components/h-stack.d.ts +7 -0
  15. package/dist/components/h-stack.d.ts.map +1 -0
  16. package/dist/components/h-stack.js +43 -0
  17. package/dist/components/h-stack.js.map +1 -0
  18. package/dist/components/image.d.ts +4 -0
  19. package/dist/components/image.d.ts.map +1 -1
  20. package/dist/components/image.js +38 -51
  21. package/dist/components/image.js.map +1 -1
  22. package/dist/components/input.d.ts +9 -0
  23. package/dist/components/input.d.ts.map +1 -1
  24. package/dist/components/input.js +121 -147
  25. package/dist/components/input.js.map +1 -1
  26. package/dist/components/markdown.d.ts +34 -0
  27. package/dist/components/markdown.d.ts.map +1 -1
  28. package/dist/components/markdown.js +491 -429
  29. package/dist/components/markdown.js.map +1 -1
  30. package/dist/components/scroll-view.d.ts +53 -0
  31. package/dist/components/scroll-view.d.ts.map +1 -0
  32. package/dist/components/scroll-view.js +173 -0
  33. package/dist/components/scroll-view.js.map +1 -0
  34. package/dist/components/select-list.d.ts.map +1 -1
  35. package/dist/components/select-list.js.map +1 -1
  36. package/dist/components/settings-list.d.ts +2 -0
  37. package/dist/components/settings-list.d.ts.map +1 -1
  38. package/dist/components/settings-list.js +31 -29
  39. package/dist/components/settings-list.js.map +1 -1
  40. package/dist/components/stack.d.ts +32 -0
  41. package/dist/components/stack.d.ts.map +1 -0
  42. package/dist/components/stack.js +122 -0
  43. package/dist/components/stack.js.map +1 -0
  44. package/dist/components/v-stack.d.ts +8 -0
  45. package/dist/components/v-stack.d.ts.map +1 -0
  46. package/dist/components/v-stack.js +27 -0
  47. package/dist/components/v-stack.js.map +1 -0
  48. package/dist/fuzzy.d.ts.map +1 -1
  49. package/dist/fuzzy.js +37 -46
  50. package/dist/fuzzy.js.map +1 -1
  51. package/dist/index.d.ts +10 -3
  52. package/dist/index.d.ts.map +1 -1
  53. package/dist/index.js +11 -2
  54. package/dist/index.js.map +1 -1
  55. package/dist/keybindings.d.ts +54 -4
  56. package/dist/keybindings.d.ts.map +1 -1
  57. package/dist/keybindings.js +39 -4
  58. package/dist/keybindings.js.map +1 -1
  59. package/dist/keys.d.ts.map +1 -1
  60. package/dist/keys.js +359 -479
  61. package/dist/keys.js.map +1 -1
  62. package/dist/kill-ring.d.ts +6 -4
  63. package/dist/kill-ring.d.ts.map +1 -1
  64. package/dist/kill-ring.js +0 -7
  65. package/dist/kill-ring.js.map +1 -1
  66. package/dist/latex.d.ts +10 -0
  67. package/dist/latex.d.ts.map +1 -0
  68. package/dist/latex.js +1406 -0
  69. package/dist/latex.js.map +1 -0
  70. package/dist/layout-node.d.ts +43 -0
  71. package/dist/layout-node.d.ts.map +1 -0
  72. package/dist/layout-node.js +6 -0
  73. package/dist/layout-node.js.map +1 -0
  74. package/dist/layout.d.ts +40 -0
  75. package/dist/layout.d.ts.map +1 -0
  76. package/dist/layout.js +386 -0
  77. package/dist/layout.js.map +1 -0
  78. package/dist/stdin-buffer.d.ts +8 -0
  79. package/dist/stdin-buffer.d.ts.map +1 -1
  80. package/dist/stdin-buffer.js +116 -159
  81. package/dist/stdin-buffer.js.map +1 -1
  82. package/dist/terminal-image.d.ts +41 -19
  83. package/dist/terminal-image.d.ts.map +1 -1
  84. package/dist/terminal-image.js +171 -32
  85. package/dist/terminal-image.js.map +1 -1
  86. package/dist/terminal.d.ts +3 -0
  87. package/dist/terminal.d.ts.map +1 -1
  88. package/dist/terminal.js +27 -23
  89. package/dist/terminal.js.map +1 -1
  90. package/dist/tui-alt-screen.d.ts +114 -0
  91. package/dist/tui-alt-screen.d.ts.map +1 -0
  92. package/dist/tui-alt-screen.js +950 -0
  93. package/dist/tui-alt-screen.js.map +1 -0
  94. package/dist/tui-main-screen.d.ts +52 -0
  95. package/dist/tui-main-screen.d.ts.map +1 -0
  96. package/dist/tui-main-screen.js +511 -0
  97. package/dist/tui-main-screen.js.map +1 -0
  98. package/dist/tui.d.ts +112 -44
  99. package/dist/tui.d.ts.map +1 -1
  100. package/dist/tui.js +489 -699
  101. package/dist/tui.js.map +1 -1
  102. package/dist/utils.d.ts +29 -17
  103. package/dist/utils.d.ts.map +1 -1
  104. package/dist/utils.js +605 -528
  105. package/dist/utils.js.map +1 -1
  106. package/dist/word-navigation.d.ts.map +1 -1
  107. package/dist/word-navigation.js +37 -39
  108. package/dist/word-navigation.js.map +1 -1
  109. package/package.json +1 -1
package/dist/tui.js CHANGED
@@ -1,34 +1,12 @@
1
1
  /**
2
2
  * Minimal TUI implementation with differential rendering
3
3
  */
4
- import * as fs from "node:fs";
5
4
  import * as os from "node:os";
6
5
  import * as path from "node:path";
7
6
  import { performance } from "node:perf_hooks";
8
7
  import { isKeyRelease, matchesKey } from "./keys.js";
9
- import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
8
+ import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
10
9
  import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
11
- const KITTY_SEQUENCE_PREFIX = "\x1b_G";
12
- function extractKittyImageIds(line) {
13
- const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
14
- if (sequenceStart === -1)
15
- return [];
16
- const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
17
- const paramsEnd = line.indexOf(";", paramsStart);
18
- if (paramsEnd === -1)
19
- return [];
20
- const params = line.slice(paramsStart, paramsEnd);
21
- for (const param of params.split(",")) {
22
- const [key, value] = param.split("=", 2);
23
- if (key !== "i" || value === undefined)
24
- continue;
25
- const id = Number(value);
26
- if (Number.isInteger(id) && id > 0 && id <= 0xffffffff) {
27
- return [id];
28
- }
29
- }
30
- return [];
31
- }
32
10
  /** Type guard to check if a component implements Focusable */
33
11
  export function isFocusable(component) {
34
12
  return component !== null && "focused" in component;
@@ -54,9 +32,6 @@ function parseSizeValue(value, referenceSize) {
54
32
  }
55
33
  return undefined;
56
34
  }
57
- function isTermuxSession() {
58
- return Boolean(process.env.TERMUX_VERSION);
59
- }
60
35
  /**
61
36
  * Container - a component that contains other components
62
37
  */
@@ -93,38 +68,72 @@ export class Container {
93
68
  /**
94
69
  * TUI - Main class for managing terminal UI with differential rendering
95
70
  */
96
- export class TUI extends Container {
71
+ const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
72
+ /** Composite overlay content into a terminal line at a fixed column. */
73
+ export function compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
74
+ if (isImageLine(baseLine))
75
+ return baseLine;
76
+ const afterStart = startCol + overlayWidth;
77
+ const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
78
+ const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
79
+ const beforePad = Math.max(0, startCol - base.beforeWidth);
80
+ const overlayPad = Math.max(0, overlayWidth - overlay.width);
81
+ const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
82
+ const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
83
+ const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
84
+ const afterPad = Math.max(0, afterTarget - base.afterWidth);
85
+ const result = base.before +
86
+ " ".repeat(beforePad) +
87
+ SEGMENT_RESET +
88
+ overlay.text +
89
+ " ".repeat(overlayPad) +
90
+ SEGMENT_RESET +
91
+ base.after +
92
+ " ".repeat(afterPad);
93
+ return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
94
+ }
95
+ export const VIEWPORT_TUI = Symbol.for("@fleetagent/pi-tui/viewport");
96
+ export function isViewportTUI(tui) {
97
+ return tui[VIEWPORT_TUI] === true;
98
+ }
99
+ export class TuiBase extends Container {
97
100
  terminal;
98
- previousLines = [];
99
- previousKittyImageIds = new Set();
100
- previousWidth = 0;
101
- previousHeight = 0;
102
101
  focusedComponent = null;
103
102
  inputListeners = new Set();
104
103
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
105
104
  onDebug;
106
105
  renderRequested = false;
106
+ immediateRenderScheduled = false;
107
107
  renderTimer;
108
108
  lastRenderAt = 0;
109
109
  static MIN_RENDER_INTERVAL_MS = 16;
110
- cursorRow = 0; // Logical cursor row (end of rendered content)
111
- hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
112
110
  showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
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
111
+ clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1";
116
112
  fullRedrawCount = 0;
117
113
  stopped = false;
114
+ logDirectory;
118
115
  // Overlay stack for modal components rendered on top of base content
119
116
  focusOrderCounter = 0;
120
117
  overlayStack = [];
121
- constructor(terminal, showHardwareCursor) {
118
+ get hasOverlayEntries() {
119
+ return this.overlayStack.length > 0;
120
+ }
121
+ overlayFocusRestore = { status: "inactive" };
122
+ constructor(terminal, showHardwareCursor, logDirectory) {
122
123
  super();
123
124
  this.terminal = terminal;
125
+ this.logDirectory = logDirectory ?? process.env.PI_CODING_AGENT_DIR ?? path.join(os.homedir(), ".pi", "agent");
124
126
  if (showHardwareCursor !== undefined) {
125
127
  this.showHardwareCursor = showHardwareCursor;
126
128
  }
127
129
  }
130
+ resetRenderState() { }
131
+ beforeTerminalStart() { }
132
+ afterTerminalStart() { }
133
+ onTerminalResize() { }
134
+ onOverlayStackChanged() { }
135
+ beforeTerminalStop(_options) { }
136
+ afterTerminalStop(_options) { }
128
137
  get fullRedraws() {
129
138
  return this.fullRedrawCount;
130
139
  }
@@ -151,16 +160,170 @@ export class TUI extends Container {
151
160
  setClearOnShrink(enabled) {
152
161
  this.clearOnShrink = enabled;
153
162
  }
163
+ getFocusedComponent() {
164
+ return this.focusedComponent;
165
+ }
154
166
  setFocus(component) {
155
- // Clear focused flag on old component
156
- if (isFocusable(this.focusedComponent)) {
167
+ this.setFocusInternal({ component, overlayFocusRestore: "clear" });
168
+ }
169
+ resolveNonOverlayFocus(nextFocus, previousFocus, restoreState) {
170
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
171
+ if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
172
+ return this.resolveBlockedOverlayFocusResume(restoreState);
173
+ }
174
+ this.overlayFocusRestore = {
175
+ status: "blocked",
176
+ overlay: restoreState.overlay,
177
+ blockedBy: nextFocus,
178
+ resume: restoreState.resume,
179
+ };
180
+ return nextFocus;
181
+ }
182
+ const previousFocusedOverlay = previousFocus
183
+ ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
184
+ : undefined;
185
+ if (previousFocusedOverlay &&
186
+ restoreState.status !== "inactive" &&
187
+ restoreState.overlay === previousFocusedOverlay &&
188
+ !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)) {
189
+ this.overlayFocusRestore = {
190
+ status: "blocked",
191
+ overlay: previousFocusedOverlay,
192
+ blockedBy: nextFocus,
193
+ resume: { status: "restore-overlay" },
194
+ };
195
+ }
196
+ return nextFocus;
197
+ }
198
+ resolveNullFocus(previousFocus, restoreState, overlayFocusRestore) {
199
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
200
+ return this.resolveBlockedOverlayFocusResume(restoreState);
201
+ }
202
+ if (overlayFocusRestore === "clear")
203
+ this.clearOverlayFocusRestore();
204
+ return null;
205
+ }
206
+ resolveFocusTarget(component, overlayFocusRestore) {
207
+ const previousFocus = this.focusedComponent;
208
+ const restoreState = this.getVisibleOverlayFocusRestore();
209
+ if (component === null)
210
+ return this.resolveNullFocus(previousFocus, restoreState, overlayFocusRestore);
211
+ const isOverlay = this.overlayStack.some((entry) => entry.component === component);
212
+ if (isOverlay)
213
+ return component;
214
+ return this.resolveNonOverlayFocus(component, previousFocus, restoreState);
215
+ }
216
+ applyFocus(nextFocus) {
217
+ if (isFocusable(this.focusedComponent))
157
218
  this.focusedComponent.focused = false;
219
+ this.focusedComponent = nextFocus;
220
+ if (isFocusable(nextFocus))
221
+ nextFocus.focused = true;
222
+ const focusedOverlay = nextFocus
223
+ ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
224
+ : undefined;
225
+ if (focusedOverlay)
226
+ this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
227
+ }
228
+ setFocusInternal({ component, overlayFocusRestore }) {
229
+ const nextFocus = this.resolveFocusTarget(component, overlayFocusRestore);
230
+ this.applyFocus(nextFocus);
231
+ }
232
+ clearOverlayFocusRestore() {
233
+ this.overlayFocusRestore = { status: "inactive" };
234
+ }
235
+ clearOverlayFocusRestoreFor(overlay) {
236
+ if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
237
+ this.clearOverlayFocusRestore();
238
+ }
239
+ }
240
+ resolveBlockedOverlayFocusResume(restoreState) {
241
+ if (restoreState.resume.status === "restore-overlay")
242
+ return restoreState.overlay.component;
243
+ this.clearOverlayFocusRestore();
244
+ return restoreState.resume.target;
245
+ }
246
+ getVisibleOverlayFocusRestore() {
247
+ const restoreState = this.overlayFocusRestore;
248
+ if (restoreState.status === "inactive")
249
+ return restoreState;
250
+ if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
251
+ return { status: "inactive" };
252
+ }
253
+ return restoreState;
254
+ }
255
+ isOverlayFocusAncestor(entry, component) {
256
+ const preFocusByComponent = new Map();
257
+ for (const overlay of this.overlayStack) {
258
+ if (!preFocusByComponent.has(overlay.component)) {
259
+ preFocusByComponent.set(overlay.component, overlay.preFocus);
260
+ }
261
+ }
262
+ const visited = new Set();
263
+ let current = entry.preFocus;
264
+ while (current && !visited.has(current)) {
265
+ visited.add(current);
266
+ if (current === component)
267
+ return true;
268
+ current = preFocusByComponent.get(current) ?? null;
269
+ }
270
+ return false;
271
+ }
272
+ retargetOverlayPreFocus(removed) {
273
+ for (const overlay of this.overlayStack) {
274
+ if (overlay !== removed && overlay.preFocus === removed.component) {
275
+ overlay.preFocus = removed.preFocus;
276
+ }
277
+ }
278
+ }
279
+ getMountedRoots() {
280
+ return this.children;
281
+ }
282
+ isComponentMounted(component) {
283
+ return this.getMountedRoots().some((child) => this.containsComponent(child, component));
284
+ }
285
+ containsComponent(root, target) {
286
+ if (root === target)
287
+ return true;
288
+ if (!(root instanceof Container))
289
+ return false;
290
+ return root.children.some((child) => this.containsComponent(child, target));
291
+ }
292
+ applyBlockedOverlayUnfocus(entry, restoreState, options) {
293
+ if (restoreState.status !== "blocked" ||
294
+ restoreState.overlay !== entry ||
295
+ this.focusedComponent !== restoreState.blockedBy) {
296
+ return false;
158
297
  }
159
- this.focusedComponent = component;
160
- // Set focused flag on new component
161
- if (isFocusable(component)) {
162
- component.focused = true;
298
+ if (options) {
299
+ this.overlayFocusRestore = {
300
+ status: "blocked",
301
+ overlay: entry,
302
+ blockedBy: restoreState.blockedBy,
303
+ resume: { status: "focus-target", target: options.target },
304
+ };
305
+ }
306
+ else {
307
+ this.clearOverlayFocusRestore();
163
308
  }
309
+ this.requestRender();
310
+ return true;
311
+ }
312
+ unfocusOverlay(entry, options) {
313
+ const isFocused = this.focusedComponent === entry.component;
314
+ const restoreState = this.overlayFocusRestore;
315
+ const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
316
+ if (!isFocused && !hasPendingRestore)
317
+ return;
318
+ if (this.applyBlockedOverlayUnfocus(entry, restoreState, options))
319
+ return;
320
+ this.clearOverlayFocusRestoreFor(entry);
321
+ if (isFocused || options) {
322
+ const topVisible = this.getTopmostVisibleOverlay();
323
+ const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
324
+ this.setFocus(options ? options.target : fallbackTarget);
325
+ }
326
+ this.requestRender();
164
327
  }
165
328
  /**
166
329
  * Show an overlay component with configurable positioning and sizing.
@@ -169,12 +332,15 @@ export class TUI extends Container {
169
332
  showOverlay(component, options) {
170
333
  const entry = {
171
334
  component,
172
- options,
335
+ ...(options === undefined ? {} : { options }),
173
336
  preFocus: this.focusedComponent,
174
337
  hidden: false,
175
338
  focusOrder: ++this.focusOrderCounter,
339
+ visible: false,
176
340
  };
177
341
  this.overlayStack.push(entry);
342
+ entry.visible = this.isOverlayVisible(entry);
343
+ this.onOverlayStackChanged();
178
344
  // Only focus if overlay is actually visible
179
345
  if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
180
346
  this.setFocus(component);
@@ -186,7 +352,10 @@ export class TUI extends Container {
186
352
  hide: () => {
187
353
  const index = this.overlayStack.indexOf(entry);
188
354
  if (index !== -1) {
355
+ this.clearOverlayFocusRestoreFor(entry);
356
+ this.retargetOverlayPreFocus(entry);
189
357
  this.overlayStack.splice(index, 1);
358
+ this.onOverlayStackChanged();
190
359
  // Restore focus if this overlay had focus
191
360
  if (this.focusedComponent === component) {
192
361
  const topVisible = this.getTopmostVisibleOverlay();
@@ -201,8 +370,10 @@ export class TUI extends Container {
201
370
  if (entry.hidden === hidden)
202
371
  return;
203
372
  entry.hidden = hidden;
373
+ entry.visible = this.isOverlayVisible(entry);
204
374
  // Update focus when hiding/showing
205
375
  if (hidden) {
376
+ this.clearOverlayFocusRestoreFor(entry);
206
377
  // If this overlay had focus, move focus to next visible or preFocus
207
378
  if (this.focusedComponent === component) {
208
379
  const topVisible = this.getTopmostVisibleOverlay();
@@ -216,33 +387,30 @@ export class TUI extends Container {
216
387
  this.setFocus(component);
217
388
  }
218
389
  }
390
+ this.onOverlayStackChanged();
219
391
  this.requestRender();
220
392
  },
221
393
  isHidden: () => entry.hidden,
222
394
  focus: () => {
223
395
  if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
224
396
  return;
225
- if (this.focusedComponent !== component) {
226
- this.setFocus(component);
227
- }
228
397
  entry.focusOrder = ++this.focusOrderCounter;
398
+ this.setFocus(component);
229
399
  this.requestRender();
230
400
  },
231
- unfocus: () => {
232
- if (this.focusedComponent !== component)
233
- return;
234
- const topVisible = this.getTopmostVisibleOverlay();
235
- this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
236
- this.requestRender();
237
- },
401
+ unfocus: (unfocusOptions) => this.unfocusOverlay(entry, unfocusOptions),
238
402
  isFocused: () => this.focusedComponent === component,
239
403
  };
240
404
  }
241
405
  /** Hide the topmost overlay and restore previous focus. */
242
406
  hideOverlay() {
243
- const overlay = this.overlayStack.pop();
407
+ const overlay = this.overlayStack[this.overlayStack.length - 1];
244
408
  if (!overlay)
245
409
  return;
410
+ this.clearOverlayFocusRestoreFor(overlay);
411
+ this.retargetOverlayPreFocus(overlay);
412
+ this.overlayStack.pop();
413
+ this.onOverlayStackChanged();
246
414
  if (this.focusedComponent === overlay.component) {
247
415
  // Find topmost visible overlay, or fall back to preFocus
248
416
  const topVisible = this.getTopmostVisibleOverlay();
@@ -256,6 +424,9 @@ export class TUI extends Container {
256
424
  hasOverlay() {
257
425
  return this.overlayStack.some((o) => this.isOverlayVisible(o));
258
426
  }
427
+ hasCapturingOverlay() {
428
+ return this.overlayStack.some((o) => !o.options?.nonCapturing && this.isOverlayVisible(o));
429
+ }
259
430
  /** Check if an overlay entry is currently visible */
260
431
  isOverlayVisible(entry) {
261
432
  if (entry.hidden)
@@ -265,28 +436,103 @@ export class TUI extends Container {
265
436
  }
266
437
  return true;
267
438
  }
268
- /** Find the topmost visible capturing overlay, if any */
439
+ /** Find the visual-frontmost visible capturing overlay, if any */
269
440
  getTopmostVisibleOverlay() {
270
- for (let i = this.overlayStack.length - 1; i >= 0; i--) {
271
- if (this.overlayStack[i].options?.nonCapturing)
441
+ let topmost;
442
+ for (const overlay of this.overlayStack) {
443
+ if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay))
272
444
  continue;
273
- if (this.isOverlayVisible(this.overlayStack[i])) {
274
- return this.overlayStack[i];
445
+ if (!topmost || overlay.focusOrder > topmost.focusOrder) {
446
+ topmost = overlay;
275
447
  }
276
448
  }
277
- return undefined;
449
+ return topmost;
450
+ }
451
+ refreshOverlayVisibility() {
452
+ let newlyVisible;
453
+ for (const overlay of this.overlayStack) {
454
+ const visible = this.isOverlayVisible(overlay);
455
+ if (visible && !overlay.visible && !overlay.options?.nonCapturing) {
456
+ if (!newlyVisible || overlay.focusOrder > newlyVisible.focusOrder)
457
+ newlyVisible = overlay;
458
+ }
459
+ overlay.visible = visible;
460
+ }
461
+ return newlyVisible;
462
+ }
463
+ retargetFocusFromHiddenOverlay() {
464
+ const focusedOverlay = this.overlayStack.find((overlay) => overlay.component === this.focusedComponent);
465
+ if (!focusedOverlay || this.isOverlayVisible(focusedOverlay))
466
+ return;
467
+ const topVisible = this.getTopmostVisibleOverlay();
468
+ if (topVisible) {
469
+ this.setFocus(topVisible.component);
470
+ return;
471
+ }
472
+ this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
473
+ }
474
+ focusNewlyVisibleOverlay(newlyVisible) {
475
+ if (!newlyVisible)
476
+ return;
477
+ const currentFocusedOverlay = this.overlayStack.find((overlay) => overlay.component === this.focusedComponent && this.isOverlayVisible(overlay));
478
+ if (!currentFocusedOverlay || newlyVisible.focusOrder > currentFocusedOverlay.focusOrder) {
479
+ this.setFocus(newlyVisible.component);
480
+ }
481
+ }
482
+ restoreVisibleOverlayFocus() {
483
+ if (this.overlayStack.some((overlay) => overlay.component === this.focusedComponent))
484
+ return;
485
+ const restoreState = this.getVisibleOverlayFocusRestore();
486
+ if (restoreState.status === "eligible") {
487
+ this.setFocus(restoreState.overlay.component);
488
+ return;
489
+ }
490
+ if (restoreState.status !== "blocked" || restoreState.blockedBy === this.focusedComponent)
491
+ return;
492
+ if (restoreState.resume.status === "restore-overlay") {
493
+ this.setFocus(restoreState.overlay.component);
494
+ return;
495
+ }
496
+ this.clearOverlayFocusRestore();
497
+ this.setFocus(restoreState.resume.target);
498
+ }
499
+ reconcileOverlayFocus() {
500
+ const newlyVisible = this.refreshOverlayVisibility();
501
+ this.retargetFocusFromHiddenOverlay();
502
+ this.focusNewlyVisibleOverlay(newlyVisible);
503
+ this.restoreVisibleOverlayFocus();
278
504
  }
279
505
  invalidate() {
280
- super.invalidate();
506
+ for (const root of this.getMountedRoots())
507
+ root.invalidate();
281
508
  for (const overlay of this.overlayStack)
282
509
  overlay.component.invalidate?.();
283
510
  }
284
511
  start() {
285
512
  this.stopped = false;
286
- this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
287
- this.terminal.hideCursor();
288
- this.queryCellSize();
289
- this.requestRender();
513
+ try {
514
+ this.beforeTerminalStart();
515
+ this.terminal.start((data) => this.handleTerminalInput(data), () => {
516
+ this.reconcileOverlayFocus();
517
+ this.onTerminalResize();
518
+ this.requestRender();
519
+ });
520
+ this.afterTerminalStart();
521
+ this.terminal.hideCursor();
522
+ this.queryCellSize();
523
+ this.requestRender();
524
+ }
525
+ catch (error) {
526
+ // A terminal may throw after partially entering raw or alternate-screen mode.
527
+ // Route startup failures through the same complete cleanup path as signals.
528
+ try {
529
+ this.stop();
530
+ }
531
+ catch {
532
+ // Preserve the startup failure; cleanup is best-effort but exhaustive.
533
+ }
534
+ throw error;
535
+ }
290
536
  }
291
537
  addInputListener(listener) {
292
538
  this.inputListeners.add(listener);
@@ -306,49 +552,40 @@ export class TUI extends Container {
306
552
  // Response format: CSI 6 ; height ; width t
307
553
  this.terminal.write("\x1b[16t");
308
554
  }
309
- stop() {
555
+ stop(options) {
310
556
  this.stopped = true;
311
- if (this.renderTimer) {
312
- clearTimeout(this.renderTimer);
313
- this.renderTimer = undefined;
314
- }
315
- // Move cursor to the end of the content to prevent overwriting/artifacts on exit
316
- if (this.previousLines.length > 0) {
317
- const targetRow = this.previousLines.length; // Line after the last content
318
- const lineDiff = targetRow - this.hardwareCursorRow;
319
- if (lineDiff > 0) {
320
- this.terminal.write(`\x1b[${lineDiff}B`);
557
+ this.renderRequested = false;
558
+ this.cancelRenderTimer();
559
+ let failure;
560
+ const cleanup = (action) => {
561
+ try {
562
+ action();
321
563
  }
322
- else if (lineDiff < 0) {
323
- this.terminal.write(`\x1b[${-lineDiff}A`);
564
+ catch (error) {
565
+ failure ??= error;
324
566
  }
325
- this.terminal.write("\r\n");
326
- }
327
- this.terminal.showCursor();
328
- this.terminal.stop();
567
+ };
568
+ cleanup(() => this.beforeTerminalStop(options));
569
+ cleanup(() => this.terminal.showCursor());
570
+ cleanup(() => this.terminal.stop());
571
+ // Alternate-screen exit and capability restoration must run even when
572
+ // keyboard/raw-mode shutdown reports an error.
573
+ cleanup(() => this.afterTerminalStop(options));
574
+ if (failure !== undefined)
575
+ throw failure;
576
+ }
577
+ renderNow(force = false) {
578
+ if (force)
579
+ this.resetRenderState();
580
+ this.renderRequested = false;
581
+ this.cancelRenderTimer();
582
+ this.lastRenderAt = performance.now();
583
+ this.doRender();
329
584
  }
330
585
  requestRender(force = false) {
331
586
  if (force) {
332
- this.previousLines = [];
333
- this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
334
- this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
335
- this.cursorRow = 0;
336
- this.hardwareCursorRow = 0;
337
- this.maxLinesRendered = 0;
338
- this.previousViewportTop = 0;
339
- if (this.renderTimer) {
340
- clearTimeout(this.renderTimer);
341
- this.renderTimer = undefined;
342
- }
343
- this.renderRequested = true;
344
- process.nextTick(() => {
345
- if (this.stopped || !this.renderRequested) {
346
- return;
347
- }
348
- this.renderRequested = false;
349
- this.lastRenderAt = performance.now();
350
- this.doRender();
351
- });
587
+ this.resetRenderState();
588
+ this.requestImmediateRender();
352
589
  return;
353
590
  }
354
591
  if (this.renderRequested)
@@ -356,75 +593,80 @@ export class TUI extends Container {
356
593
  this.renderRequested = true;
357
594
  process.nextTick(() => this.scheduleRender());
358
595
  }
596
+ requestImmediateRender() {
597
+ this.cancelRenderTimer();
598
+ this.renderRequested = true;
599
+ if (this.immediateRenderScheduled)
600
+ return;
601
+ this.immediateRenderScheduled = true;
602
+ process.nextTick(() => {
603
+ this.immediateRenderScheduled = false;
604
+ if (this.stopped || !this.renderRequested)
605
+ return;
606
+ this.cancelRenderTimer();
607
+ this.renderRequested = false;
608
+ this.lastRenderAt = performance.now();
609
+ this.doRender();
610
+ });
611
+ }
612
+ cancelRenderTimer() {
613
+ if (!this.renderTimer)
614
+ return;
615
+ clearTimeout(this.renderTimer);
616
+ this.renderTimer = undefined;
617
+ }
359
618
  scheduleRender() {
360
- if (this.stopped || this.renderTimer || !this.renderRequested) {
619
+ if (this.stopped || this.renderTimer || !this.renderRequested)
361
620
  return;
362
- }
363
621
  const elapsed = performance.now() - this.lastRenderAt;
364
- const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);
622
+ const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed);
365
623
  this.renderTimer = setTimeout(() => {
366
624
  this.renderTimer = undefined;
367
- if (this.stopped || !this.renderRequested) {
625
+ if (this.stopped || !this.renderRequested)
368
626
  return;
369
- }
370
627
  this.renderRequested = false;
371
628
  this.lastRenderAt = performance.now();
372
629
  this.doRender();
373
- if (this.renderRequested) {
630
+ if (this.renderRequested)
374
631
  this.scheduleRender();
375
- }
376
632
  }, delay);
377
633
  }
378
- handleInput(data) {
379
- if (this.inputListeners.size > 0) {
380
- let current = data;
381
- for (const listener of this.inputListeners) {
382
- const result = listener(current);
383
- if (result?.consume) {
384
- return;
385
- }
386
- if (result?.data !== undefined) {
387
- current = result.data;
388
- }
389
- }
390
- if (current.length === 0) {
391
- return;
392
- }
393
- data = current;
394
- }
634
+ applyInputListeners(data) {
635
+ if (this.inputListeners.size === 0)
636
+ return data;
637
+ let current = data;
638
+ for (const listener of this.inputListeners) {
639
+ const result = listener(current);
640
+ if (result?.consume)
641
+ return undefined;
642
+ if (result?.data !== undefined)
643
+ current = result.data;
644
+ }
645
+ return current.length === 0 ? undefined : current;
646
+ }
647
+ handleTerminalInput(data) {
648
+ this.reconcileOverlayFocus();
649
+ const filteredData = this.applyInputListeners(data);
650
+ if (filteredData === undefined)
651
+ return;
652
+ data = filteredData;
395
653
  // Consume terminal cell size responses without blocking unrelated input.
396
- if (this.consumeCellSizeResponse(data)) {
654
+ if (this.consumeCellSizeResponse(data))
397
655
  return;
398
- }
399
656
  // Global debug key handler (Shift+Ctrl+D)
400
657
  if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
401
658
  this.onDebug();
402
659
  return;
403
660
  }
404
- // If focused component is an overlay, verify it's still visible
405
- // (visibility can change due to terminal resize or visible() callback)
406
- const focusedOverlay = this.overlayStack.find((o) => o.component === this.focusedComponent);
407
- if (focusedOverlay && !this.isOverlayVisible(focusedOverlay)) {
408
- // Focused overlay is no longer visible, redirect to topmost visible overlay
409
- const topVisible = this.getTopmostVisibleOverlay();
410
- if (topVisible) {
411
- this.setFocus(topVisible.component);
412
- }
413
- else {
414
- // No visible overlays, restore to preFocus
415
- this.setFocus(focusedOverlay.preFocus);
416
- }
417
- }
418
- // Pass input to focused component (including Ctrl+C)
419
- // The focused component can decide how to handle Ctrl+C
420
- if (this.focusedComponent?.handleInput) {
421
- // Filter out key release events unless component opts in
422
- if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {
423
- return;
424
- }
425
- this.focusedComponent.handleInput(data);
426
- this.requestRender();
427
- }
661
+ // Pass input to focused component (including Ctrl+C).
662
+ // The focused component can decide how to handle Ctrl+C.
663
+ if (!this.focusedComponent?.handleInput)
664
+ return;
665
+ // Filter out key release events unless component opts in.
666
+ if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease)
667
+ return;
668
+ this.focusedComponent.handleInput(data);
669
+ this.requestImmediateRender();
428
670
  }
429
671
  consumeCellSizeResponse(data) {
430
672
  // Response format: ESC [ 6 ; height ; width t
@@ -443,98 +685,68 @@ export class TUI extends Container {
443
685
  this.requestRender();
444
686
  return true;
445
687
  }
446
- /**
447
- * Resolve overlay layout from options.
448
- * Returns { width, row, col, maxHeight } for rendering.
449
- */
450
- resolveOverlayLayout(options, overlayHeight, termWidth, termHeight) {
451
- const opt = options ?? {};
452
- // Parse margin (clamp to non-negative)
453
- const margin = typeof opt.margin === "number"
454
- ? { top: opt.margin, right: opt.margin, bottom: opt.margin, left: opt.margin }
455
- : (opt.margin ?? {});
688
+ resolveOverlayBounds(options, termWidth, termHeight) {
689
+ const margin = typeof options.margin === "number"
690
+ ? { top: options.margin, right: options.margin, bottom: options.margin, left: options.margin }
691
+ : (options.margin ?? {});
456
692
  const marginTop = Math.max(0, margin.top ?? 0);
457
693
  const marginRight = Math.max(0, margin.right ?? 0);
458
694
  const marginBottom = Math.max(0, margin.bottom ?? 0);
459
695
  const marginLeft = Math.max(0, margin.left ?? 0);
460
- // Available space after margins
461
- const availWidth = Math.max(1, termWidth - marginLeft - marginRight);
462
- const availHeight = Math.max(1, termHeight - marginTop - marginBottom);
463
- // === Resolve width ===
464
- let width = parseSizeValue(opt.width, termWidth) ?? Math.min(80, availWidth);
465
- // Apply minWidth
466
- if (opt.minWidth !== undefined) {
467
- width = Math.max(width, opt.minWidth);
468
- }
469
- // Clamp to available space
470
- width = Math.max(1, Math.min(width, availWidth));
471
- // === Resolve maxHeight ===
472
- let maxHeight = parseSizeValue(opt.maxHeight, termHeight);
473
- // Clamp to available space
474
- if (maxHeight !== undefined) {
475
- maxHeight = Math.max(1, Math.min(maxHeight, availHeight));
476
- }
477
- // Effective overlay height (may be clamped by maxHeight)
478
- const effectiveHeight = maxHeight !== undefined ? Math.min(overlayHeight, maxHeight) : overlayHeight;
479
- // === Resolve position ===
480
- let row;
481
- let col;
482
- if (opt.row !== undefined) {
483
- if (typeof opt.row === "string") {
484
- // Percentage: 0% = top, 100% = bottom (overlay stays within bounds)
485
- const match = opt.row.match(/^(\d+(?:\.\d+)?)%$/);
486
- if (match) {
487
- const maxRow = Math.max(0, availHeight - effectiveHeight);
488
- const percent = parseFloat(match[1]) / 100;
489
- row = marginTop + Math.floor(maxRow * percent);
490
- }
491
- else {
492
- // Invalid format, fall back to center
493
- row = this.resolveAnchorRow("center", effectiveHeight, availHeight, marginTop);
494
- }
495
- }
496
- else {
497
- // Absolute row position
498
- row = opt.row;
499
- }
500
- }
501
- else {
502
- // Anchor-based (default: center)
503
- const anchor = opt.anchor ?? "center";
504
- row = this.resolveAnchorRow(anchor, effectiveHeight, availHeight, marginTop);
505
- }
506
- if (opt.col !== undefined) {
507
- if (typeof opt.col === "string") {
508
- // Percentage: 0% = left, 100% = right (overlay stays within bounds)
509
- const match = opt.col.match(/^(\d+(?:\.\d+)?)%$/);
510
- if (match) {
511
- const maxCol = Math.max(0, availWidth - width);
512
- const percent = parseFloat(match[1]) / 100;
513
- col = marginLeft + Math.floor(maxCol * percent);
514
- }
515
- else {
516
- // Invalid format, fall back to center
517
- col = this.resolveAnchorCol("center", width, availWidth, marginLeft);
518
- }
519
- }
520
- else {
521
- // Absolute column position
522
- col = opt.col;
523
- }
524
- }
525
- else {
526
- // Anchor-based (default: center)
527
- const anchor = opt.anchor ?? "center";
528
- col = this.resolveAnchorCol(anchor, width, availWidth, marginLeft);
529
- }
530
- // Apply offsets
531
- if (opt.offsetY !== undefined)
532
- row += opt.offsetY;
533
- if (opt.offsetX !== undefined)
534
- col += opt.offsetX;
535
- // Clamp to terminal bounds (respecting margins)
536
- row = Math.max(marginTop, Math.min(row, termHeight - marginBottom - effectiveHeight));
537
- col = Math.max(marginLeft, Math.min(col, termWidth - marginRight - width));
696
+ return {
697
+ marginTop,
698
+ marginRight,
699
+ marginBottom,
700
+ marginLeft,
701
+ availableWidth: Math.max(1, termWidth - marginLeft - marginRight),
702
+ availableHeight: Math.max(1, termHeight - marginTop - marginBottom),
703
+ };
704
+ }
705
+ resolveOverlayWidth(options, termWidth, bounds) {
706
+ let width = parseSizeValue(options.width, termWidth) ?? Math.min(80, bounds.availableWidth);
707
+ if (options.minWidth !== undefined)
708
+ width = Math.max(width, options.minWidth);
709
+ return Math.max(1, Math.min(width, bounds.availableWidth));
710
+ }
711
+ resolveOverlayMaxHeight(options, termHeight, bounds) {
712
+ const maxHeight = parseSizeValue(options.maxHeight, termHeight);
713
+ return maxHeight === undefined ? undefined : Math.max(1, Math.min(maxHeight, bounds.availableHeight));
714
+ }
715
+ resolveOverlayRow(options, overlayHeight, bounds) {
716
+ if (options.row === undefined) {
717
+ return this.resolveAnchorRow(options.anchor ?? "center", overlayHeight, bounds.availableHeight, bounds.marginTop);
718
+ }
719
+ if (typeof options.row === "number")
720
+ return options.row;
721
+ const match = options.row.match(/^(\d+(?:\.\d+)?)%$/);
722
+ if (!match)
723
+ return this.resolveAnchorRow("center", overlayHeight, bounds.availableHeight, bounds.marginTop);
724
+ const maxRow = Math.max(0, bounds.availableHeight - overlayHeight);
725
+ return bounds.marginTop + Math.floor(maxRow * (parseFloat(match[1]) / 100));
726
+ }
727
+ resolveOverlayColumn(options, width, bounds) {
728
+ if (options.col === undefined) {
729
+ return this.resolveAnchorCol(options.anchor ?? "center", width, bounds.availableWidth, bounds.marginLeft);
730
+ }
731
+ if (typeof options.col === "number")
732
+ return options.col;
733
+ const match = options.col.match(/^(\d+(?:\.\d+)?)%$/);
734
+ if (!match)
735
+ return this.resolveAnchorCol("center", width, bounds.availableWidth, bounds.marginLeft);
736
+ const maxCol = Math.max(0, bounds.availableWidth - width);
737
+ return bounds.marginLeft + Math.floor(maxCol * (parseFloat(match[1]) / 100));
738
+ }
739
+ /** Resolve overlay layout from options. */
740
+ resolveOverlayLayout(options, overlayHeight, termWidth, termHeight) {
741
+ const resolvedOptions = options ?? {};
742
+ const bounds = this.resolveOverlayBounds(resolvedOptions, termWidth, termHeight);
743
+ const width = this.resolveOverlayWidth(resolvedOptions, termWidth, bounds);
744
+ const maxHeight = this.resolveOverlayMaxHeight(resolvedOptions, termHeight, bounds);
745
+ const effectiveHeight = maxHeight === undefined ? overlayHeight : Math.min(overlayHeight, maxHeight);
746
+ let row = this.resolveOverlayRow(resolvedOptions, effectiveHeight, bounds) + (resolvedOptions.offsetY ?? 0);
747
+ let col = this.resolveOverlayColumn(resolvedOptions, width, bounds) + (resolvedOptions.offsetX ?? 0);
748
+ row = Math.max(bounds.marginTop, Math.min(row, termHeight - bounds.marginBottom - effectiveHeight));
749
+ col = Math.max(bounds.marginLeft, Math.min(col, termWidth - bounds.marginRight - width));
538
750
  return { width, row, col, maxHeight };
539
751
  }
540
752
  resolveAnchorRow(anchor, height, availHeight, marginTop) {
@@ -569,58 +781,52 @@ export class TUI extends Container {
569
781
  return marginLeft + Math.floor((availWidth - width) / 2);
570
782
  }
571
783
  }
572
- /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
573
- compositeOverlays(lines, termWidth, termHeight) {
574
- if (this.overlayStack.length === 0)
575
- return lines;
576
- const result = [...lines];
577
- // Pre-render all visible overlays and calculate positions
578
- const rendered = [];
579
- let minLinesNeeded = result.length;
580
- const visibleEntries = this.overlayStack.filter((e) => this.isOverlayVisible(e));
581
- visibleEntries.sort((a, b) => a.focusOrder - b.focusOrder);
784
+ prepareOverlayRenderPlan(baseLineCount, termWidth, termHeight) {
785
+ const visibleEntries = this.overlayStack.filter((entry) => this.isOverlayVisible(entry));
786
+ visibleEntries.sort((left, right) => left.focusOrder - right.focusOrder);
787
+ const overlays = [];
788
+ let minimumLineCount = baseLineCount;
582
789
  for (const entry of visibleEntries) {
583
- const { component, options } = entry;
584
- // Get layout with height=0 first to determine width and maxHeight
585
- // (width and maxHeight don't depend on overlay height)
586
- const { width, maxHeight } = this.resolveOverlayLayout(options, 0, termWidth, termHeight);
587
- // Render component at calculated width
588
- let overlayLines = component.render(width);
589
- // Apply maxHeight if specified
790
+ const { width, maxHeight } = this.resolveOverlayLayout(entry.options, 0, termWidth, termHeight);
791
+ let overlayLines = entry.component.render(width);
590
792
  if (maxHeight !== undefined && overlayLines.length > maxHeight) {
591
793
  overlayLines = overlayLines.slice(0, maxHeight);
592
794
  }
593
- // Get final row/col with actual overlay height
594
- const { row, col } = this.resolveOverlayLayout(options, overlayLines.length, termWidth, termHeight);
595
- rendered.push({ overlayLines, row, col, w: width });
596
- minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
795
+ const { row, col } = this.resolveOverlayLayout(entry.options, overlayLines.length, termWidth, termHeight);
796
+ overlays.push({ lines: overlayLines, row, col, width });
797
+ minimumLineCount = Math.max(minimumLineCount, row + overlayLines.length);
597
798
  }
598
- // Pad to at least terminal height so overlays have screen-relative positions.
599
- // Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
600
- // inflation that pushed content into scrollback on terminal widen.
601
- const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
602
- // Extend result with empty lines if content is too short for overlay placement or working area
603
- while (result.length < workingHeight) {
604
- result.push("");
799
+ return { overlays, minimumLineCount };
800
+ }
801
+ compositeRenderedOverlay(result, overlay, viewportStart, termWidth) {
802
+ for (let lineIndex = 0; lineIndex < overlay.lines.length; lineIndex++) {
803
+ const targetIndex = viewportStart + overlay.row + lineIndex;
804
+ if (targetIndex < 0 || targetIndex >= result.length)
805
+ continue;
806
+ const overlayLine = overlay.lines[lineIndex];
807
+ const truncatedLine = visibleWidth(overlayLine) > overlay.width
808
+ ? sliceByColumn(overlayLine, 0, overlay.width, true)
809
+ : overlayLine;
810
+ result[targetIndex] = this.compositeLineAt(result[targetIndex], truncatedLine, overlay.col, overlay.width, termWidth);
605
811
  }
812
+ }
813
+ /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
814
+ compositeOverlays(lines, termWidth, termHeight) {
815
+ if (this.overlayStack.length === 0)
816
+ return lines;
817
+ const result = [...lines];
818
+ const plan = this.prepareOverlayRenderPlan(result.length, termWidth, termHeight);
819
+ const workingHeight = Math.max(result.length, termHeight, plan.minimumLineCount);
820
+ while (result.length < workingHeight)
821
+ result.push("");
606
822
  const viewportStart = Math.max(0, workingHeight - termHeight);
607
- // Composite each overlay
608
- for (const { overlayLines, row, col, w } of rendered) {
609
- for (let i = 0; i < overlayLines.length; i++) {
610
- const idx = viewportStart + row + i;
611
- if (idx >= 0 && idx < result.length) {
612
- // Defensive: truncate overlay line to declared width before compositing
613
- // (components should already respect width, but this ensures it)
614
- const truncatedOverlayLine = visibleWidth(overlayLines[i]) > w ? sliceByColumn(overlayLines[i], 0, w, true) : overlayLines[i];
615
- result[idx] = this.compositeLineAt(result[idx], truncatedOverlayLine, col, w, termWidth);
616
- }
617
- }
823
+ for (const overlay of plan.overlays) {
824
+ this.compositeRenderedOverlay(result, overlay, viewportStart, termWidth);
618
825
  }
619
826
  return result;
620
827
  }
621
- static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
622
828
  applyLineResets(lines) {
623
- const reset = TUI.SEGMENT_RESET;
829
+ const reset = SEGMENT_RESET;
624
830
  for (let i = 0; i < lines.length; i++) {
625
831
  const line = lines[i];
626
832
  if (!isImageLine(line)) {
@@ -629,81 +835,8 @@ export class TUI extends Container {
629
835
  }
630
836
  return lines;
631
837
  }
632
- collectKittyImageIds(lines) {
633
- const ids = new Set();
634
- for (const line of lines) {
635
- for (const id of extractKittyImageIds(line)) {
636
- ids.add(id);
637
- }
638
- }
639
- return ids;
640
- }
641
- deleteKittyImages(ids) {
642
- let buffer = "";
643
- for (const id of ids) {
644
- buffer += deleteKittyImage(id);
645
- }
646
- return buffer;
647
- }
648
- expandLastChangedForKittyImages(firstChanged, lastChanged) {
649
- let expandedLastChanged = lastChanged;
650
- for (let i = firstChanged; i < this.previousLines.length; i++) {
651
- if (extractKittyImageIds(this.previousLines[i]).length > 0) {
652
- expandedLastChanged = Math.max(expandedLastChanged, i);
653
- }
654
- }
655
- return expandedLastChanged;
656
- }
657
- deleteChangedKittyImages(firstChanged, lastChanged) {
658
- if (firstChanged < 0 || lastChanged < firstChanged)
659
- return "";
660
- const ids = new Set();
661
- const maxLine = Math.min(lastChanged, this.previousLines.length - 1);
662
- for (let i = firstChanged; i <= maxLine; i++) {
663
- for (const id of extractKittyImageIds(this.previousLines[i] ?? "")) {
664
- ids.add(id);
665
- }
666
- }
667
- return this.deleteKittyImages(ids);
668
- }
669
- /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
670
838
  compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
671
- if (isImageLine(baseLine))
672
- return baseLine;
673
- // Single pass through baseLine extracts both before and after segments
674
- const afterStart = startCol + overlayWidth;
675
- const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
676
- // Extract overlay with width tracking (strict=true to exclude wide chars at boundary)
677
- const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
678
- // Pad segments to target widths
679
- const beforePad = Math.max(0, startCol - base.beforeWidth);
680
- const overlayPad = Math.max(0, overlayWidth - overlay.width);
681
- const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
682
- const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
683
- const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
684
- const afterPad = Math.max(0, afterTarget - base.afterWidth);
685
- // Compose result
686
- const r = TUI.SEGMENT_RESET;
687
- const result = base.before +
688
- " ".repeat(beforePad) +
689
- r +
690
- overlay.text +
691
- " ".repeat(overlayPad) +
692
- r +
693
- base.after +
694
- " ".repeat(afterPad);
695
- // CRITICAL: Always verify and truncate to terminal width.
696
- // This is the final safeguard against width overflow which would crash the TUI.
697
- // Width tracking can drift from actual visible width due to:
698
- // - Complex ANSI/OSC sequences (hyperlinks, colors)
699
- // - Wide characters at segment boundaries
700
- // - Edge cases in segment extraction
701
- const resultWidth = visibleWidth(result);
702
- if (resultWidth <= totalWidth) {
703
- return result;
704
- }
705
- // Truncate with strict=true to ensure we don't exceed totalWidth
706
- return sliceByColumn(result, 0, totalWidth, true);
839
+ return compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth);
707
840
  }
708
841
  /**
709
842
  * Find and extract cursor position from rendered lines.
@@ -730,348 +863,5 @@ export class TUI extends Container {
730
863
  }
731
864
  return null;
732
865
  }
733
- doRender() {
734
- if (this.stopped)
735
- return;
736
- const width = this.terminal.columns;
737
- const height = this.terminal.rows;
738
- const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
739
- const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
740
- const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
741
- let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;
742
- let viewportTop = prevViewportTop;
743
- let hardwareCursorRow = this.hardwareCursorRow;
744
- const computeLineDiff = (targetRow) => {
745
- const currentScreenRow = hardwareCursorRow - prevViewportTop;
746
- const targetScreenRow = targetRow - viewportTop;
747
- return targetScreenRow - currentScreenRow;
748
- };
749
- // Render all components to get new lines
750
- let newLines = this.render(width);
751
- // Composite overlays into the rendered lines (before differential compare)
752
- if (this.overlayStack.length > 0) {
753
- newLines = this.compositeOverlays(newLines, width, height);
754
- }
755
- // Extract cursor position before applying line resets (marker must be found first)
756
- const cursorPos = this.extractCursorPosition(newLines, height);
757
- newLines = this.applyLineResets(newLines);
758
- // Helper to clear scrollback and viewport and render all new lines
759
- const fullRender = (clear) => {
760
- this.fullRedrawCount += 1;
761
- let buffer = "\x1b[?2026h"; // Begin synchronized output
762
- if (clear) {
763
- buffer += this.deleteKittyImages(this.previousKittyImageIds);
764
- buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
765
- }
766
- for (let i = 0; i < newLines.length; i++) {
767
- if (i > 0)
768
- buffer += "\r\n";
769
- buffer += newLines[i];
770
- }
771
- buffer += "\x1b[?2026l"; // End synchronized output
772
- this.terminal.write(buffer);
773
- this.cursorRow = Math.max(0, newLines.length - 1);
774
- this.hardwareCursorRow = this.cursorRow;
775
- // Reset max lines when clearing, otherwise track growth
776
- if (clear) {
777
- this.maxLinesRendered = newLines.length;
778
- }
779
- else {
780
- this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
781
- }
782
- const bufferLength = Math.max(height, newLines.length);
783
- this.previousViewportTop = Math.max(0, bufferLength - height);
784
- this.positionHardwareCursor(cursorPos, newLines.length);
785
- this.previousLines = newLines;
786
- this.previousKittyImageIds = this.collectKittyImageIds(newLines);
787
- this.previousWidth = width;
788
- this.previousHeight = height;
789
- };
790
- const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
791
- const logRedraw = (reason) => {
792
- if (!debugRedraw)
793
- return;
794
- const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log");
795
- const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
796
- fs.appendFileSync(logPath, msg);
797
- };
798
- // First render - just output everything without clearing (assumes clean screen)
799
- if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
800
- logRedraw("first render");
801
- fullRender(false);
802
- return;
803
- }
804
- // Width changes always need a full re-render because wrapping changes.
805
- if (widthChanged) {
806
- logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
807
- fullRender(true);
808
- return;
809
- }
810
- // Height changes normally need a full re-render to keep the visible viewport aligned,
811
- // but Termux changes height when the software keyboard shows or hides.
812
- // In that environment, a full redraw causes the entire history to replay on every toggle.
813
- if (heightChanged && !isTermuxSession()) {
814
- logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
815
- fullRender(true);
816
- return;
817
- }
818
- // Content shrunk below the working area and no overlays - re-render to clear empty rows
819
- // (overlays need the padding, so only do this when no overlays are active)
820
- // Configurable via setClearOnShrink() or PI_CLEAR_ON_SHRINK=0 env var
821
- if (this.clearOnShrink && newLines.length < this.maxLinesRendered && this.overlayStack.length === 0) {
822
- logRedraw(`clearOnShrink (maxLinesRendered=${this.maxLinesRendered})`);
823
- fullRender(true);
824
- return;
825
- }
826
- // Find first and last changed lines
827
- let firstChanged = -1;
828
- let lastChanged = -1;
829
- const maxLines = Math.max(newLines.length, this.previousLines.length);
830
- for (let i = 0; i < maxLines; i++) {
831
- const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
832
- const newLine = i < newLines.length ? newLines[i] : "";
833
- if (oldLine !== newLine) {
834
- if (firstChanged === -1) {
835
- firstChanged = i;
836
- }
837
- lastChanged = i;
838
- }
839
- }
840
- const appendedLines = newLines.length > this.previousLines.length;
841
- if (appendedLines) {
842
- if (firstChanged === -1) {
843
- firstChanged = this.previousLines.length;
844
- }
845
- lastChanged = newLines.length - 1;
846
- }
847
- if (firstChanged !== -1) {
848
- lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
849
- }
850
- const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
851
- // No changes - but still need to update hardware cursor position if it moved
852
- if (firstChanged === -1) {
853
- this.positionHardwareCursor(cursorPos, newLines.length);
854
- this.previousViewportTop = prevViewportTop;
855
- this.previousHeight = height;
856
- return;
857
- }
858
- // All changes are in deleted lines (nothing to render, just clear)
859
- if (firstChanged >= newLines.length) {
860
- if (this.previousLines.length > newLines.length) {
861
- let buffer = "\x1b[?2026h";
862
- buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
863
- // Move to end of new content (clamp to 0 for empty content)
864
- const targetRow = Math.max(0, newLines.length - 1);
865
- if (targetRow < prevViewportTop) {
866
- logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
867
- fullRender(true);
868
- return;
869
- }
870
- const lineDiff = computeLineDiff(targetRow);
871
- if (lineDiff > 0)
872
- buffer += `\x1b[${lineDiff}B`;
873
- else if (lineDiff < 0)
874
- buffer += `\x1b[${-lineDiff}A`;
875
- buffer += "\r";
876
- // Clear extra lines without scrolling
877
- const extraLines = this.previousLines.length - newLines.length;
878
- if (extraLines > height) {
879
- logRedraw(`extraLines > height (${extraLines} > ${height})`);
880
- fullRender(true);
881
- return;
882
- }
883
- if (extraLines > 0) {
884
- buffer += "\x1b[1B";
885
- }
886
- for (let i = 0; i < extraLines; i++) {
887
- buffer += "\r\x1b[2K";
888
- if (i < extraLines - 1)
889
- buffer += "\x1b[1B";
890
- }
891
- if (extraLines > 0) {
892
- buffer += `\x1b[${extraLines}A`;
893
- }
894
- buffer += "\x1b[?2026l";
895
- this.terminal.write(buffer);
896
- this.cursorRow = targetRow;
897
- this.hardwareCursorRow = targetRow;
898
- }
899
- this.positionHardwareCursor(cursorPos, newLines.length);
900
- this.previousLines = newLines;
901
- this.previousKittyImageIds = this.collectKittyImageIds(newLines);
902
- this.previousWidth = width;
903
- this.previousHeight = height;
904
- this.previousViewportTop = prevViewportTop;
905
- return;
906
- }
907
- // Differential rendering can only touch what was actually visible.
908
- // If the first changed line is above the previous viewport, we need a full redraw.
909
- if (firstChanged < prevViewportTop) {
910
- logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
911
- fullRender(true);
912
- return;
913
- }
914
- // Render from first changed line to end
915
- // Build buffer with all updates wrapped in synchronized output
916
- let buffer = "\x1b[?2026h"; // Begin synchronized output
917
- buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
918
- const prevViewportBottom = prevViewportTop + height - 1;
919
- const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
920
- if (moveTargetRow > prevViewportBottom) {
921
- const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
922
- const moveToBottom = height - 1 - currentScreenRow;
923
- if (moveToBottom > 0) {
924
- buffer += `\x1b[${moveToBottom}B`;
925
- }
926
- const scroll = moveTargetRow - prevViewportBottom;
927
- buffer += "\r\n".repeat(scroll);
928
- prevViewportTop += scroll;
929
- viewportTop += scroll;
930
- hardwareCursorRow = moveTargetRow;
931
- }
932
- // Move cursor to first changed line (use hardwareCursorRow for actual position)
933
- const lineDiff = computeLineDiff(moveTargetRow);
934
- if (lineDiff > 0) {
935
- buffer += `\x1b[${lineDiff}B`; // Move down
936
- }
937
- else if (lineDiff < 0) {
938
- buffer += `\x1b[${-lineDiff}A`; // Move up
939
- }
940
- buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
941
- // Only render changed lines (firstChanged to lastChanged), not all lines to end
942
- // This reduces flicker when only a single line changes (e.g., spinner animation)
943
- const renderEnd = Math.min(lastChanged, newLines.length - 1);
944
- for (let i = firstChanged; i <= renderEnd; i++) {
945
- if (i > firstChanged)
946
- buffer += "\r\n";
947
- buffer += "\x1b[2K"; // Clear current line
948
- const line = newLines[i];
949
- const isImage = isImageLine(line);
950
- if (!isImage && visibleWidth(line) > width) {
951
- // Log all lines to crash file for debugging
952
- const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log");
953
- const crashData = [
954
- `Crash at ${new Date().toISOString()}`,
955
- `Terminal width: ${width}`,
956
- `Line ${i} visible width: ${visibleWidth(line)}`,
957
- "",
958
- "=== All rendered lines ===",
959
- ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
960
- "",
961
- ].join("\n");
962
- fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
963
- fs.writeFileSync(crashLogPath, crashData);
964
- // Clean up terminal state before throwing
965
- this.stop();
966
- const errorMsg = [
967
- `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
968
- "",
969
- "This is likely caused by a custom TUI component not truncating its output.",
970
- "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
971
- "",
972
- `Debug log written to: ${crashLogPath}`,
973
- ].join("\n");
974
- throw new Error(errorMsg);
975
- }
976
- buffer += line;
977
- }
978
- // Track where cursor ended up after rendering
979
- let finalCursorRow = renderEnd;
980
- // If we had more lines before, clear them and move cursor back
981
- if (this.previousLines.length > newLines.length) {
982
- // Move to end of new content first if we stopped before it
983
- if (renderEnd < newLines.length - 1) {
984
- const moveDown = newLines.length - 1 - renderEnd;
985
- buffer += `\x1b[${moveDown}B`;
986
- finalCursorRow = newLines.length - 1;
987
- }
988
- const extraLines = this.previousLines.length - newLines.length;
989
- for (let i = newLines.length; i < this.previousLines.length; i++) {
990
- buffer += "\r\n\x1b[2K";
991
- }
992
- // Move cursor back to end of new content
993
- buffer += `\x1b[${extraLines}A`;
994
- }
995
- buffer += "\x1b[?2026l"; // End synchronized output
996
- if (process.env.PI_TUI_DEBUG === "1") {
997
- const debugDir = "/tmp/tui";
998
- fs.mkdirSync(debugDir, { recursive: true });
999
- const debugPath = path.join(debugDir, `render-${Date.now()}-${Math.random().toString(36).slice(2)}.log`);
1000
- const debugData = [
1001
- `firstChanged: ${firstChanged}`,
1002
- `viewportTop: ${viewportTop}`,
1003
- `cursorRow: ${this.cursorRow}`,
1004
- `height: ${height}`,
1005
- `lineDiff: ${lineDiff}`,
1006
- `hardwareCursorRow: ${hardwareCursorRow}`,
1007
- `renderEnd: ${renderEnd}`,
1008
- `finalCursorRow: ${finalCursorRow}`,
1009
- `cursorPos: ${JSON.stringify(cursorPos)}`,
1010
- `newLines.length: ${newLines.length}`,
1011
- `previousLines.length: ${this.previousLines.length}`,
1012
- "",
1013
- "=== newLines ===",
1014
- JSON.stringify(newLines, null, 2),
1015
- "",
1016
- "=== previousLines ===",
1017
- JSON.stringify(this.previousLines, null, 2),
1018
- "",
1019
- "=== buffer ===",
1020
- JSON.stringify(buffer),
1021
- ].join("\n");
1022
- fs.writeFileSync(debugPath, debugData);
1023
- }
1024
- // Write entire buffer at once
1025
- this.terminal.write(buffer);
1026
- // Track cursor position for next render
1027
- // cursorRow tracks end of content (for viewport calculation)
1028
- // hardwareCursorRow tracks actual terminal cursor position (for movement)
1029
- this.cursorRow = Math.max(0, newLines.length - 1);
1030
- this.hardwareCursorRow = finalCursorRow;
1031
- // Track terminal's working area (grows but doesn't shrink unless cleared)
1032
- this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
1033
- this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
1034
- // Position hardware cursor for IME
1035
- this.positionHardwareCursor(cursorPos, newLines.length);
1036
- this.previousLines = newLines;
1037
- this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1038
- this.previousWidth = width;
1039
- this.previousHeight = height;
1040
- }
1041
- /**
1042
- * Position the hardware cursor for IME candidate window.
1043
- * @param cursorPos The cursor position extracted from rendered output, or null
1044
- * @param totalLines Total number of rendered lines
1045
- */
1046
- positionHardwareCursor(cursorPos, totalLines) {
1047
- if (!cursorPos || totalLines <= 0) {
1048
- this.terminal.hideCursor();
1049
- return;
1050
- }
1051
- // Clamp cursor position to valid range
1052
- const targetRow = Math.max(0, Math.min(cursorPos.row, totalLines - 1));
1053
- const targetCol = Math.max(0, cursorPos.col);
1054
- // Move cursor from current position to target
1055
- const rowDelta = targetRow - this.hardwareCursorRow;
1056
- let buffer = "";
1057
- if (rowDelta > 0) {
1058
- buffer += `\x1b[${rowDelta}B`; // Move down
1059
- }
1060
- else if (rowDelta < 0) {
1061
- buffer += `\x1b[${-rowDelta}A`; // Move up
1062
- }
1063
- // Move to absolute column (1-indexed)
1064
- buffer += `\x1b[${targetCol + 1}G`;
1065
- if (buffer) {
1066
- this.terminal.write(buffer);
1067
- }
1068
- this.hardwareCursorRow = targetRow;
1069
- if (this.showHardwareCursor) {
1070
- this.terminal.showCursor();
1071
- }
1072
- else {
1073
- this.terminal.hideCursor();
1074
- }
1075
- }
1076
866
  }
1077
867
  //# sourceMappingURL=tui.js.map