@jecruz/merc-tui 0.1.3 → 0.1.6

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 (77) hide show
  1. package/README.md +14 -1
  2. package/dist/autocomplete.d.ts +1 -0
  3. package/dist/autocomplete.d.ts.map +1 -1
  4. package/dist/autocomplete.js +85 -24
  5. package/dist/autocomplete.js.map +1 -1
  6. package/dist/components/box.d.ts +1 -0
  7. package/dist/components/box.d.ts.map +1 -1
  8. package/dist/components/box.js +6 -1
  9. package/dist/components/box.js.map +1 -1
  10. package/dist/components/editor.d.ts.map +1 -1
  11. package/dist/components/editor.js +80 -115
  12. package/dist/components/editor.js.map +1 -1
  13. package/dist/components/image.d.ts +12 -0
  14. package/dist/components/image.d.ts.map +1 -1
  15. package/dist/components/image.js +45 -6
  16. package/dist/components/image.js.map +1 -1
  17. package/dist/components/input.d.ts.map +1 -1
  18. package/dist/components/input.js +7 -55
  19. package/dist/components/input.js.map +1 -1
  20. package/dist/components/markdown.d.ts +1 -0
  21. package/dist/components/markdown.d.ts.map +1 -1
  22. package/dist/components/markdown.js +18 -6
  23. package/dist/components/markdown.js.map +1 -1
  24. package/dist/components/settings-list.d.ts.map +1 -1
  25. package/dist/components/settings-list.js +4 -7
  26. package/dist/components/settings-list.js.map +1 -1
  27. package/dist/components/text.d.ts +1 -0
  28. package/dist/components/text.d.ts.map +1 -1
  29. package/dist/components/text.js +11 -5
  30. package/dist/components/text.js.map +1 -1
  31. package/dist/fuzzy.d.ts.map +1 -1
  32. package/dist/fuzzy.js +23 -22
  33. package/dist/fuzzy.js.map +1 -1
  34. package/dist/index.d.ts +2 -2
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +1 -1
  37. package/dist/index.js.map +1 -1
  38. package/dist/keys.d.ts.map +1 -1
  39. package/dist/keys.js +9 -1
  40. package/dist/keys.js.map +1 -1
  41. package/dist/native-modifier-digests.d.ts +6 -0
  42. package/dist/native-modifier-digests.d.ts.map +1 -0
  43. package/dist/native-modifier-digests.js +6 -0
  44. package/dist/native-modifier-digests.js.map +1 -0
  45. package/dist/native-modifiers.d.ts +11 -0
  46. package/dist/native-modifiers.d.ts.map +1 -0
  47. package/dist/native-modifiers.js +98 -0
  48. package/dist/native-modifiers.js.map +1 -0
  49. package/dist/stdin-buffer.d.ts +25 -2
  50. package/dist/stdin-buffer.d.ts.map +1 -1
  51. package/dist/stdin-buffer.js +176 -3
  52. package/dist/stdin-buffer.js.map +1 -1
  53. package/dist/terminal-image.d.ts +10 -0
  54. package/dist/terminal-image.d.ts.map +1 -1
  55. package/dist/terminal-image.js +102 -17
  56. package/dist/terminal-image.js.map +1 -1
  57. package/dist/terminal.d.ts +17 -0
  58. package/dist/terminal.d.ts.map +1 -1
  59. package/dist/terminal.js +165 -56
  60. package/dist/terminal.js.map +1 -1
  61. package/dist/tui.d.ts +28 -7
  62. package/dist/tui.d.ts.map +1 -1
  63. package/dist/tui.js +322 -125
  64. package/dist/tui.js.map +1 -1
  65. package/dist/utils.d.ts +16 -1
  66. package/dist/utils.d.ts.map +1 -1
  67. package/dist/utils.js +115 -29
  68. package/dist/utils.js.map +1 -1
  69. package/dist/word-navigation.d.ts +25 -0
  70. package/dist/word-navigation.d.ts.map +1 -0
  71. package/dist/word-navigation.js +96 -0
  72. package/dist/word-navigation.js.map +1 -0
  73. package/native/darwin/README.md +13 -0
  74. package/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  75. package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  76. package/native/darwin/prebuilds/manifest.json +24 -0
  77. package/package.json +4 -1
package/dist/tui.js CHANGED
@@ -2,13 +2,70 @@
2
2
  * Minimal TUI implementation with differential rendering
3
3
  */
4
4
  import * as fs from "node:fs";
5
- 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
8
  import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
10
9
  import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
11
10
  const KITTY_SEQUENCE_PREFIX = "\x1b_G";
11
+ const MAX_RENDER_WRITE_CHARS = 1024 * 1024;
12
+ /**
13
+ * Streams terminal output in 1 MiB chunks so a full render never forms one string large enough to exceed V8's limit.
14
+ *
15
+ * `append()` fills the current chunk and flushes it when full. Oversized input is split at chunk boundaries, preserving
16
+ * surrogate pairs so each write remains valid UTF-16. Callers append synchronized-output begin/end sequences themselves;
17
+ * the final `flush()` writes any remainder, including the end sequence.
18
+ */
19
+ class BoundedTerminalWriter {
20
+ buffer = "";
21
+ writtenChars = 0;
22
+ write;
23
+ constructor(write) {
24
+ this.write = write;
25
+ }
26
+ /**
27
+ * Append terminal data, flushing full chunks as needed. Callers must call `flush()` after the final append.
28
+ * @param value Terminal data to write in order; oversized values are split without splitting surrogate pairs.
29
+ */
30
+ append(value) {
31
+ let offset = 0;
32
+ while (offset < value.length) {
33
+ const capacity = MAX_RENDER_WRITE_CHARS - this.buffer.length;
34
+ if (capacity === 0) {
35
+ this.flush();
36
+ continue;
37
+ }
38
+ let end = Math.min(value.length, offset + capacity);
39
+ if (end < value.length &&
40
+ value.charCodeAt(end - 1) >= 0xd800 &&
41
+ value.charCodeAt(end - 1) <= 0xdbff &&
42
+ value.charCodeAt(end) >= 0xdc00 &&
43
+ value.charCodeAt(end) <= 0xdfff) {
44
+ end--;
45
+ }
46
+ if (end === offset) {
47
+ this.flush();
48
+ continue;
49
+ }
50
+ this.buffer += value.slice(offset, end);
51
+ offset = end;
52
+ if (this.buffer.length === MAX_RENDER_WRITE_CHARS) {
53
+ this.flush();
54
+ }
55
+ }
56
+ }
57
+ /** Write the current chunk, if any, and retain only its character count for debug output. */
58
+ flush() {
59
+ if (!this.buffer)
60
+ return;
61
+ this.write(this.buffer);
62
+ this.writtenChars += this.buffer.length;
63
+ this.buffer = "";
64
+ }
65
+ get length() {
66
+ return this.writtenChars + this.buffer.length;
67
+ }
68
+ }
12
69
  function parseKittyImageHeader(line) {
13
70
  const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
14
71
  if (sequenceStart === -1)
@@ -120,6 +177,7 @@ export class TUI extends Container {
120
177
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
121
178
  onDebug;
122
179
  renderRequested = false;
180
+ immediateRenderScheduled = false;
123
181
  renderTimer;
124
182
  lastRenderAt = 0;
125
183
  static MIN_RENDER_INTERVAL_MS = 16;
@@ -131,13 +189,20 @@ export class TUI extends Container {
131
189
  previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
132
190
  skippedOffscreenChangeRange;
133
191
  fullRedrawCount = 0;
192
+ logDirectory;
134
193
  stopped = false;
135
194
  // Overlay stack for modal components rendered on top of base content
136
195
  focusOrderCounter = 0;
137
196
  overlayStack = [];
138
- constructor(terminal, showHardwareCursor) {
197
+ overlayFocusRestore = { status: "inactive" };
198
+ /**
199
+ * @param logDirectory Private directory for redraw debug logs and render crash dumps. Without one,
200
+ * neither is written.
201
+ */
202
+ constructor(terminal, showHardwareCursor, logDirectory) {
139
203
  super();
140
204
  this.terminal = terminal;
205
+ this.logDirectory = logDirectory;
141
206
  if (showHardwareCursor !== undefined) {
142
207
  this.showHardwareCursor = showHardwareCursor;
143
208
  }
@@ -153,7 +218,7 @@ export class TUI extends Container {
153
218
  return;
154
219
  this.showHardwareCursor = enabled;
155
220
  if (!enabled) {
156
- this.terminal.hideCursor();
221
+ this.hideTerminalCursor();
157
222
  }
158
223
  this.requestRender();
159
224
  }
@@ -169,31 +234,54 @@ export class TUI extends Container {
169
234
  this.clearOnShrink = enabled;
170
235
  }
171
236
  setFocus(component) {
237
+ this.setFocusInternal({ component, overlayFocusRestore: "clear" });
238
+ }
239
+ setFocusInternal({ component, overlayFocusRestore, }) {
172
240
  const previousFocus = this.focusedComponent;
173
241
  let nextFocus = component;
174
242
  const previousFocusedOverlay = previousFocus
175
243
  ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
176
244
  : undefined;
177
245
  const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false;
246
+ const restoreState = this.getVisibleOverlayFocusRestore();
178
247
  if (nextFocus && !nextFocusIsOverlay) {
179
- const restoreOverlay = this.getRestoreFocusOverlay();
180
- if (restoreOverlay?.restoreFocus.status === "blocked" &&
181
- restoreOverlay.restoreFocus.blockedBy === previousFocus) {
182
- restoreOverlay.restoreFocus = { status: "eligible" };
183
- nextFocus = restoreOverlay.component;
248
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
249
+ if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
250
+ nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
251
+ }
252
+ else {
253
+ this.overlayFocusRestore = {
254
+ status: "blocked",
255
+ overlay: restoreState.overlay,
256
+ blockedBy: nextFocus,
257
+ resume: restoreState.resume,
258
+ };
259
+ }
184
260
  }
185
261
  else if (previousFocusedOverlay &&
186
- previousFocusedOverlay.restoreFocus.status !== "inactive" &&
262
+ restoreState.status !== "inactive" &&
263
+ restoreState.overlay === previousFocusedOverlay &&
187
264
  !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)) {
188
- previousFocusedOverlay.restoreFocus = { status: "blocked", blockedBy: nextFocus };
265
+ this.overlayFocusRestore = {
266
+ status: "blocked",
267
+ overlay: previousFocusedOverlay,
268
+ blockedBy: nextFocus,
269
+ resume: { status: "restore-overlay" },
270
+ };
271
+ }
272
+ }
273
+ else if (nextFocus === null) {
274
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
275
+ nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
276
+ }
277
+ else if (overlayFocusRestore === "clear") {
278
+ this.clearOverlayFocusRestore();
189
279
  }
190
280
  }
191
- // Clear focused flag on old component
192
281
  if (isFocusable(this.focusedComponent)) {
193
282
  this.focusedComponent.focused = false;
194
283
  }
195
284
  this.focusedComponent = nextFocus;
196
- // Set focused flag on new component
197
285
  if (isFocusable(nextFocus)) {
198
286
  nextFocus.focused = true;
199
287
  }
@@ -201,20 +289,31 @@ export class TUI extends Container {
201
289
  ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
202
290
  : undefined;
203
291
  if (focusedOverlay) {
204
- this.markOverlayRestoreFocusEligible(focusedOverlay);
292
+ this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
205
293
  }
206
294
  }
207
- markOverlayRestoreFocusEligible(entry) {
208
- for (const overlay of this.overlayStack) {
209
- this.clearOverlayRestoreFocus(overlay);
295
+ clearOverlayFocusRestore() {
296
+ this.overlayFocusRestore = { status: "inactive" };
297
+ }
298
+ clearOverlayFocusRestoreFor(overlay) {
299
+ if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
300
+ this.clearOverlayFocusRestore();
210
301
  }
211
- entry.restoreFocus = { status: "eligible" };
212
302
  }
213
- clearOverlayRestoreFocus(entry) {
214
- entry.restoreFocus = { status: "inactive" };
303
+ resolveBlockedOverlayFocusResume(restoreState) {
304
+ if (restoreState.resume.status === "restore-overlay")
305
+ return restoreState.overlay.component;
306
+ this.clearOverlayFocusRestore();
307
+ return restoreState.resume.target;
215
308
  }
216
- getRestoreFocusOverlay() {
217
- return this.overlayStack.find((overlay) => overlay.restoreFocus.status !== "inactive" && this.isOverlayVisible(overlay));
309
+ getVisibleOverlayFocusRestore() {
310
+ const restoreState = this.overlayFocusRestore;
311
+ if (restoreState.status === "inactive")
312
+ return restoreState;
313
+ if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
314
+ return { status: "inactive" };
315
+ }
316
+ return restoreState;
218
317
  }
219
318
  isOverlayFocusAncestor(entry, component) {
220
319
  const visited = new Set();
@@ -227,6 +326,23 @@ export class TUI extends Container {
227
326
  }
228
327
  return false;
229
328
  }
329
+ retargetOverlayPreFocus(removed) {
330
+ for (const overlay of this.overlayStack) {
331
+ if (overlay !== removed && overlay.preFocus === removed.component) {
332
+ overlay.preFocus = removed.preFocus;
333
+ }
334
+ }
335
+ }
336
+ isComponentMounted(component) {
337
+ return this.children.some((child) => this.containsComponent(child, component));
338
+ }
339
+ containsComponent(root, target) {
340
+ if (root === target)
341
+ return true;
342
+ if (!(root instanceof Container))
343
+ return false;
344
+ return root.children.some((child) => this.containsComponent(child, target));
345
+ }
230
346
  /**
231
347
  * Show an overlay component with configurable positioning and sizing.
232
348
  * Returns a handle to control the overlay's visibility.
@@ -234,25 +350,25 @@ export class TUI extends Container {
234
350
  showOverlay(component, options) {
235
351
  const entry = {
236
352
  component,
237
- options,
353
+ ...(options === undefined ? {} : { options }),
238
354
  preFocus: this.focusedComponent,
239
355
  hidden: false,
240
356
  focusOrder: ++this.focusOrderCounter,
241
- restoreFocus: { status: "inactive" },
242
357
  };
243
358
  this.overlayStack.push(entry);
244
359
  // Only focus if overlay is actually visible
245
360
  if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
246
361
  this.setFocus(component);
247
362
  }
248
- this.terminal.hideCursor();
363
+ this.hideTerminalCursor();
249
364
  this.requestRender();
250
365
  // Return handle for controlling this overlay
251
366
  return {
252
367
  hide: () => {
253
368
  const index = this.overlayStack.indexOf(entry);
254
369
  if (index !== -1) {
255
- this.clearOverlayRestoreFocus(entry);
370
+ this.clearOverlayFocusRestoreFor(entry);
371
+ this.retargetOverlayPreFocus(entry);
256
372
  this.overlayStack.splice(index, 1);
257
373
  // Restore focus if this overlay had focus
258
374
  if (this.focusedComponent === component) {
@@ -260,7 +376,7 @@ export class TUI extends Container {
260
376
  this.setFocus(topVisible?.component ?? entry.preFocus);
261
377
  }
262
378
  if (this.overlayStack.length === 0)
263
- this.terminal.hideCursor();
379
+ this.hideTerminalCursor();
264
380
  this.requestRender();
265
381
  }
266
382
  },
@@ -270,7 +386,7 @@ export class TUI extends Container {
270
386
  entry.hidden = hidden;
271
387
  // Update focus when hiding/showing
272
388
  if (hidden) {
273
- this.clearOverlayRestoreFocus(entry);
389
+ this.clearOverlayFocusRestoreFor(entry);
274
390
  // If this overlay had focus, move focus to next visible or preFocus
275
391
  if (this.focusedComponent === component) {
276
392
  const topVisible = this.getTopmostVisibleOverlay();
@@ -294,12 +410,35 @@ export class TUI extends Container {
294
410
  this.setFocus(component);
295
411
  this.requestRender();
296
412
  },
297
- unfocus: () => {
298
- if (this.focusedComponent !== component)
413
+ unfocus: (unfocusOptions) => {
414
+ const isFocused = this.focusedComponent === component;
415
+ const restoreState = this.overlayFocusRestore;
416
+ const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
417
+ if (!isFocused && !hasPendingRestore)
299
418
  return;
300
- this.clearOverlayRestoreFocus(entry);
301
- const topVisible = this.getTopmostVisibleOverlay();
302
- this.setFocus(topVisible && topVisible !== entry ? topVisible.component : entry.preFocus);
419
+ if (restoreState.status === "blocked" &&
420
+ restoreState.overlay === entry &&
421
+ this.focusedComponent === restoreState.blockedBy) {
422
+ if (unfocusOptions) {
423
+ this.overlayFocusRestore = {
424
+ status: "blocked",
425
+ overlay: entry,
426
+ blockedBy: restoreState.blockedBy,
427
+ resume: { status: "focus-target", target: unfocusOptions.target },
428
+ };
429
+ }
430
+ else {
431
+ this.clearOverlayFocusRestore();
432
+ }
433
+ this.requestRender();
434
+ return;
435
+ }
436
+ this.clearOverlayFocusRestoreFor(entry);
437
+ if (isFocused || unfocusOptions) {
438
+ const topVisible = this.getTopmostVisibleOverlay();
439
+ const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
440
+ this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
441
+ }
303
442
  this.requestRender();
304
443
  },
305
444
  isFocused: () => this.focusedComponent === component,
@@ -307,19 +446,26 @@ export class TUI extends Container {
307
446
  }
308
447
  /** Hide the topmost overlay and restore previous focus. */
309
448
  hideOverlay() {
310
- const overlay = this.overlayStack.pop();
449
+ const overlay = this.overlayStack[this.overlayStack.length - 1];
311
450
  if (!overlay)
312
451
  return;
313
- this.clearOverlayRestoreFocus(overlay);
452
+ this.clearOverlayFocusRestoreFor(overlay);
453
+ this.retargetOverlayPreFocus(overlay);
454
+ this.overlayStack.pop();
314
455
  if (this.focusedComponent === overlay.component) {
315
456
  // Find topmost visible overlay, or fall back to preFocus
316
457
  const topVisible = this.getTopmostVisibleOverlay();
317
458
  this.setFocus(topVisible?.component ?? overlay.preFocus);
318
459
  }
319
460
  if (this.overlayStack.length === 0)
320
- this.terminal.hideCursor();
461
+ this.hideTerminalCursor();
321
462
  this.requestRender();
322
463
  }
464
+ /** Hide the cursor while running. After stop(), the shell owns the cursor and it must stay visible. */
465
+ hideTerminalCursor() {
466
+ if (!this.stopped)
467
+ this.terminal.hideCursor();
468
+ }
323
469
  /** Check if there are any visible overlays */
324
470
  hasOverlay() {
325
471
  return this.overlayStack.some((o) => this.isOverlayVisible(o));
@@ -333,16 +479,17 @@ export class TUI extends Container {
333
479
  }
334
480
  return true;
335
481
  }
336
- /** Find the topmost visible capturing overlay, if any */
482
+ /** Find the visual-frontmost visible capturing overlay, if any */
337
483
  getTopmostVisibleOverlay() {
338
- for (let i = this.overlayStack.length - 1; i >= 0; i--) {
339
- if (this.overlayStack[i].options?.nonCapturing)
484
+ let topmost;
485
+ for (const overlay of this.overlayStack) {
486
+ if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay))
340
487
  continue;
341
- if (this.isOverlayVisible(this.overlayStack[i])) {
342
- return this.overlayStack[i];
488
+ if (!topmost || overlay.focusOrder > topmost.focusOrder) {
489
+ topmost = overlay;
343
490
  }
344
491
  }
345
- return undefined;
492
+ return topmost;
346
493
  }
347
494
  invalidate() {
348
495
  super.invalidate();
@@ -376,12 +523,11 @@ export class TUI extends Container {
376
523
  }
377
524
  stop() {
378
525
  this.stopped = true;
379
- if (this.renderTimer) {
380
- clearTimeout(this.renderTimer);
381
- this.renderTimer = undefined;
382
- }
526
+ this.cancelRenderTimer();
383
527
  // Move cursor to the end of the content to prevent overwriting/artifacts on exit
384
528
  if (this.previousLines.length > 0) {
529
+ // Overwrite the inverted cursor with a normal space to clear the artifact
530
+ this.terminal.write(" ");
385
531
  const targetRow = this.previousLines.length; // Line after the last content
386
532
  const lineDiff = targetRow - this.hardwareCursorRow;
387
533
  if (lineDiff > 0) {
@@ -405,19 +551,7 @@ export class TUI extends Container {
405
551
  this.maxLinesRendered = 0;
406
552
  this.previousViewportTop = 0;
407
553
  this.skippedOffscreenChangeRange = undefined;
408
- if (this.renderTimer) {
409
- clearTimeout(this.renderTimer);
410
- this.renderTimer = undefined;
411
- }
412
- this.renderRequested = true;
413
- process.nextTick(() => {
414
- if (this.stopped || !this.renderRequested) {
415
- return;
416
- }
417
- this.renderRequested = false;
418
- this.lastRenderAt = performance.now();
419
- this.doRender();
420
- });
554
+ this.requestImmediateRender();
421
555
  return;
422
556
  }
423
557
  if (this.renderRequested)
@@ -425,6 +559,30 @@ export class TUI extends Container {
425
559
  this.renderRequested = true;
426
560
  process.nextTick(() => this.scheduleRender());
427
561
  }
562
+ requestImmediateRender() {
563
+ this.cancelRenderTimer();
564
+ this.renderRequested = true;
565
+ if (this.immediateRenderScheduled)
566
+ return;
567
+ this.immediateRenderScheduled = true;
568
+ process.nextTick(() => {
569
+ this.immediateRenderScheduled = false;
570
+ if (this.stopped || !this.renderRequested)
571
+ return;
572
+ // A previously queued scheduleRender() can create a timer before this
573
+ // callback runs. User input must preempt that throttled frame.
574
+ this.cancelRenderTimer();
575
+ this.renderRequested = false;
576
+ this.lastRenderAt = performance.now();
577
+ this.doRender();
578
+ });
579
+ }
580
+ cancelRenderTimer() {
581
+ if (!this.renderTimer)
582
+ return;
583
+ clearTimeout(this.renderTimer);
584
+ this.renderTimer = undefined;
585
+ }
428
586
  scheduleRender() {
429
587
  if (this.stopped || this.renderTimer || !this.renderRequested) {
430
588
  return;
@@ -480,17 +638,23 @@ export class TUI extends Container {
480
638
  this.setFocus(topVisible.component);
481
639
  }
482
640
  else {
483
- // No visible overlays, restore to preFocus
484
- this.setFocus(focusedOverlay.preFocus);
641
+ this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
485
642
  }
486
643
  }
487
644
  const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent);
488
645
  if (!focusIsOverlay) {
489
- const overlayToRestore = this.getRestoreFocusOverlay();
490
- if (overlayToRestore &&
491
- (overlayToRestore.restoreFocus.status === "eligible" ||
492
- overlayToRestore.restoreFocus.blockedBy !== this.focusedComponent)) {
493
- this.setFocus(overlayToRestore.component);
646
+ const restoreState = this.getVisibleOverlayFocusRestore();
647
+ if (restoreState.status === "eligible") {
648
+ this.setFocus(restoreState.overlay.component);
649
+ }
650
+ else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) {
651
+ if (restoreState.resume.status === "restore-overlay") {
652
+ this.setFocus(restoreState.overlay.component);
653
+ }
654
+ else {
655
+ this.clearOverlayFocusRestore();
656
+ this.setFocus(restoreState.resume.target);
657
+ }
494
658
  }
495
659
  }
496
660
  // Pass input to focused component (including Ctrl+C)
@@ -501,7 +665,9 @@ export class TUI extends Container {
501
665
  return;
502
666
  }
503
667
  this.focusedComponent.handleInput(data);
504
- this.requestRender();
668
+ // Keyboard input is latency-sensitive. Avoid the throttled timer path,
669
+ // where even setTimeout(0) can take a full 16 ms tick on Windows.
670
+ this.requestImmediateRender();
505
671
  }
506
672
  }
507
673
  consumeCellSizeResponse(data) {
@@ -882,18 +1048,32 @@ export class TUI extends Container {
882
1048
  // Helper to clear scrollback and viewport and render all new lines
883
1049
  const fullRender = (clear) => {
884
1050
  this.fullRedrawCount += 1;
885
- let buffer = "\x1b[?2026h"; // Begin synchronized output
1051
+ const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
1052
+ output.append("\x1b[?2026h"); // Begin synchronized output
886
1053
  if (clear) {
887
- buffer += this.deleteKittyImages(this.previousKittyImageIds);
888
- buffer += "\x1b[2J\x1b[H\x1b[3J"; // Clear screen, home, then clear scrollback
1054
+ output.append(this.deleteKittyImages(this.previousKittyImageIds));
1055
+ output.append("\x1b[2J\x1b[H\x1b[3J"); // Clear screen, home, then clear scrollback
889
1056
  }
890
1057
  for (let i = 0; i < newLines.length; i++) {
891
1058
  if (i > 0)
892
- buffer += "\r\n";
893
- buffer += newLines[i];
1059
+ output.append("\r\n");
1060
+ const line = newLines[i];
1061
+ const isImage = isImageLine(line);
1062
+ const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;
1063
+ if (imageReservedRows > 1 && imageReservedRows <= height) {
1064
+ for (let row = 1; row < imageReservedRows; row++) {
1065
+ output.append("\r\n");
1066
+ }
1067
+ output.append(`\x1b[${imageReservedRows - 1}A`);
1068
+ output.append(line);
1069
+ output.append(`\x1b[${imageReservedRows - 1}B`);
1070
+ i += imageReservedRows - 1;
1071
+ continue;
1072
+ }
1073
+ output.append(line);
894
1074
  }
895
- buffer += "\x1b[?2026l"; // End synchronized output
896
- this.terminal.write(buffer);
1075
+ output.append("\x1b[?2026l"); // End synchronized output
1076
+ output.flush();
897
1077
  this.cursorRow = Math.max(0, newLines.length - 1);
898
1078
  this.hardwareCursorRow = this.cursorRow;
899
1079
  // Reset max lines when clearing, otherwise track growth
@@ -912,11 +1092,12 @@ export class TUI extends Container {
912
1092
  this.previousHeight = height;
913
1093
  this.skippedOffscreenChangeRange = undefined;
914
1094
  };
915
- const debugRedraw = process.env.PI_DEBUG_REDRAW === "1";
1095
+ const redrawLogDirectory = process.env.PI_DEBUG_REDRAW === "1" ? this.logDirectory : undefined;
916
1096
  const logRedraw = (reason) => {
917
- if (!debugRedraw)
1097
+ if (redrawLogDirectory === undefined)
918
1098
  return;
919
- const logPath = path.join(os.homedir(), ".pi", "agent", "pi-debug.log");
1099
+ const logPath = path.join(redrawLogDirectory, "tui-debug.log");
1100
+ fs.mkdirSync(redrawLogDirectory, { recursive: true });
920
1101
  const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
921
1102
  fs.appendFileSync(logPath, msg);
922
1103
  };
@@ -935,7 +1116,17 @@ export class TUI extends Container {
935
1116
  // Height changes normally need a full re-render to keep the visible viewport aligned,
936
1117
  // but Termux changes height when the software keyboard shows or hides.
937
1118
  // In that environment, a full redraw causes the entire history to replay on every toggle.
938
- if (heightChanged && !isTermuxSession()) {
1119
+ // If the new Termux viewport would reveal rows skipped by the offscreen optimization,
1120
+ // redraw once so stale scrollback does not become visible.
1121
+ if (heightChanged && isTermuxSession()) {
1122
+ const viewportBottom = prevViewportTop + height - 1;
1123
+ if (this.skippedOffscreenChangesIntersect(prevViewportTop, viewportBottom)) {
1124
+ logRedraw(`terminal height changed revealing skipped offscreen changes (${this.previousHeight} -> ${height})`);
1125
+ fullRender(true);
1126
+ return;
1127
+ }
1128
+ }
1129
+ else if (heightChanged) {
939
1130
  logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
940
1131
  fullRender(true);
941
1132
  return;
@@ -1010,8 +1201,9 @@ export class TUI extends Container {
1010
1201
  // All changes are in deleted lines (nothing to render, just clear)
1011
1202
  if (firstChanged >= newLines.length) {
1012
1203
  if (this.previousLines.length > newLines.length) {
1013
- let buffer = "\x1b[?2026h";
1014
- buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
1204
+ const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
1205
+ output.append("\x1b[?2026h");
1206
+ output.append(this.deleteChangedKittyImages(firstChanged, lastChanged));
1015
1207
  // Move to end of new content (clamp to 0 for empty content)
1016
1208
  const targetRow = Math.max(0, newLines.length - 1);
1017
1209
  if (targetRow < prevViewportTop) {
@@ -1021,10 +1213,10 @@ export class TUI extends Container {
1021
1213
  }
1022
1214
  const lineDiff = computeLineDiff(targetRow);
1023
1215
  if (lineDiff > 0)
1024
- buffer += `\x1b[${lineDiff}B`;
1216
+ output.append(`\x1b[${lineDiff}B`);
1025
1217
  else if (lineDiff < 0)
1026
- buffer += `\x1b[${-lineDiff}A`;
1027
- buffer += "\r";
1218
+ output.append(`\x1b[${-lineDiff}A`);
1219
+ output.append("\r");
1028
1220
  // Clear extra lines without scrolling
1029
1221
  const extraLines = this.previousLines.length - newLines.length;
1030
1222
  if (extraLines > height) {
@@ -1033,18 +1225,18 @@ export class TUI extends Container {
1033
1225
  return;
1034
1226
  }
1035
1227
  if (extraLines > 0) {
1036
- buffer += "\x1b[1B";
1228
+ output.append("\x1b[1B");
1037
1229
  }
1038
1230
  for (let i = 0; i < extraLines; i++) {
1039
- buffer += "\r\x1b[2K";
1231
+ output.append("\r\x1b[2K");
1040
1232
  if (i < extraLines - 1)
1041
- buffer += "\x1b[1B";
1233
+ output.append("\x1b[1B");
1042
1234
  }
1043
1235
  if (extraLines > 0) {
1044
- buffer += `\x1b[${extraLines}A`;
1236
+ output.append(`\x1b[${extraLines}A`);
1045
1237
  }
1046
- buffer += "\x1b[?2026l";
1047
- this.terminal.write(buffer);
1238
+ output.append("\x1b[?2026l");
1239
+ output.flush();
1048
1240
  this.cursorRow = targetRow;
1049
1241
  this.hardwareCursorRow = targetRow;
1050
1242
  }
@@ -1064,19 +1256,20 @@ export class TUI extends Container {
1064
1256
  return;
1065
1257
  }
1066
1258
  // Render from first changed line to end
1067
- // Build buffer with all updates wrapped in synchronized output
1068
- let buffer = "\x1b[?2026h"; // Begin synchronized output
1069
- buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
1259
+ // Keep updates wrapped in synchronized output while writing bounded chunks.
1260
+ const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
1261
+ output.append("\x1b[?2026h"); // Begin synchronized output
1262
+ output.append(this.deleteChangedKittyImages(firstChanged, lastChanged));
1070
1263
  const prevViewportBottom = prevViewportTop + height - 1;
1071
1264
  const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
1072
1265
  if (moveTargetRow > prevViewportBottom) {
1073
1266
  const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
1074
1267
  const moveToBottom = height - 1 - currentScreenRow;
1075
1268
  if (moveToBottom > 0) {
1076
- buffer += `\x1b[${moveToBottom}B`;
1269
+ output.append(`\x1b[${moveToBottom}B`);
1077
1270
  }
1078
1271
  const scroll = moveTargetRow - prevViewportBottom;
1079
- buffer += "\r\n".repeat(scroll);
1272
+ output.append("\r\n".repeat(scroll));
1080
1273
  prevViewportTop += scroll;
1081
1274
  viewportTop += scroll;
1082
1275
  hardwareCursorRow = moveTargetRow;
@@ -1084,18 +1277,18 @@ export class TUI extends Container {
1084
1277
  // Move cursor to first changed line (use hardwareCursorRow for actual position)
1085
1278
  const lineDiff = computeLineDiff(moveTargetRow);
1086
1279
  if (lineDiff > 0) {
1087
- buffer += `\x1b[${lineDiff}B`; // Move down
1280
+ output.append(`\x1b[${lineDiff}B`); // Move down
1088
1281
  }
1089
1282
  else if (lineDiff < 0) {
1090
- buffer += `\x1b[${-lineDiff}A`; // Move up
1283
+ output.append(`\x1b[${-lineDiff}A`); // Move up
1091
1284
  }
1092
- buffer += appendStart ? "\r\n" : "\r"; // Move to column 0
1285
+ output.append(appendStart ? "\r\n" : "\r"); // Move to column 0
1093
1286
  // Only render changed lines (firstChanged to lastChanged), not all lines to end
1094
1287
  // This reduces flicker when only a single line changes (e.g., spinner animation)
1095
1288
  const renderEnd = Math.min(lastChanged, newLines.length - 1);
1096
1289
  for (let i = firstChanged; i <= renderEnd; i++) {
1097
1290
  if (i > firstChanged)
1098
- buffer += "\r\n";
1291
+ output.append("\r\n");
1099
1292
  const line = newLines[i];
1100
1293
  const isImage = isImageLine(line);
1101
1294
  const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1;
@@ -1106,31 +1299,34 @@ export class TUI extends Container {
1106
1299
  fullRender(true);
1107
1300
  return;
1108
1301
  }
1109
- buffer += "\x1b[2K";
1302
+ output.append("\x1b[2K");
1110
1303
  for (let row = 1; row < imageReservedRows; row++) {
1111
- buffer += "\r\n\x1b[2K";
1304
+ output.append("\r\n\x1b[2K");
1112
1305
  }
1113
- buffer += `\x1b[${imageReservedRows - 1}A`;
1114
- buffer += line;
1115
- buffer += `\x1b[${imageReservedRows - 1}B`;
1306
+ output.append(`\x1b[${imageReservedRows - 1}A`);
1307
+ output.append(line);
1308
+ output.append(`\x1b[${imageReservedRows - 1}B`);
1116
1309
  i += imageReservedRows - 1;
1117
1310
  continue;
1118
1311
  }
1119
- buffer += "\x1b[2K"; // Clear current line
1312
+ output.append("\x1b[2K"); // Clear current line
1120
1313
  if (!isImage && visibleWidth(line) > width) {
1121
- // Log all lines to crash file for debugging
1122
- const crashLogPath = path.join(os.homedir(), ".pi", "agent", "pi-crash.log");
1123
- const crashData = [
1124
- `Crash at ${new Date().toISOString()}`,
1125
- `Terminal width: ${width}`,
1126
- `Line ${i} visible width: ${visibleWidth(line)}`,
1127
- "",
1128
- "=== All rendered lines ===",
1129
- ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
1130
- "",
1131
- ].join("\n");
1132
- fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
1133
- fs.writeFileSync(crashLogPath, crashData);
1314
+ // Log all lines to a crash file for debugging. Only into the caller's private log directory: the
1315
+ // dump holds the full rendered transcript, so a predictable path in a shared temp dir could leak it.
1316
+ const crashLogPath = this.logDirectory ? path.join(this.logDirectory, "tui-crash.log") : undefined;
1317
+ if (crashLogPath) {
1318
+ const crashData = [
1319
+ `Crash at ${new Date().toISOString()}`,
1320
+ `Terminal width: ${width}`,
1321
+ `Line ${i} visible width: ${visibleWidth(line)}`,
1322
+ "",
1323
+ "=== All rendered lines ===",
1324
+ ...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
1325
+ "",
1326
+ ].join("\n");
1327
+ fs.mkdirSync(path.dirname(crashLogPath), { recursive: true, mode: 0o700 });
1328
+ fs.writeFileSync(crashLogPath, crashData, { mode: 0o600 });
1329
+ }
1134
1330
  // Clean up terminal state before throwing
1135
1331
  this.stop();
1136
1332
  const errorMsg = [
@@ -1139,11 +1335,13 @@ export class TUI extends Container {
1139
1335
  "This is likely caused by a custom TUI component not truncating its output.",
1140
1336
  "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
1141
1337
  "",
1142
- `Debug log written to: ${crashLogPath}`,
1338
+ crashLogPath
1339
+ ? `Debug log written to: ${crashLogPath}`
1340
+ : "No debug log written (no log directory configured).",
1143
1341
  ].join("\n");
1144
1342
  throw new Error(errorMsg);
1145
1343
  }
1146
- buffer += line;
1344
+ output.append(line);
1147
1345
  }
1148
1346
  // Track where cursor ended up after rendering
1149
1347
  let finalCursorRow = renderEnd;
@@ -1152,17 +1350,17 @@ export class TUI extends Container {
1152
1350
  // Move to end of new content first if we stopped before it
1153
1351
  if (renderEnd < newLines.length - 1) {
1154
1352
  const moveDown = newLines.length - 1 - renderEnd;
1155
- buffer += `\x1b[${moveDown}B`;
1353
+ output.append(`\x1b[${moveDown}B`);
1156
1354
  finalCursorRow = newLines.length - 1;
1157
1355
  }
1158
1356
  const extraLines = this.previousLines.length - newLines.length;
1159
1357
  for (let i = newLines.length; i < this.previousLines.length; i++) {
1160
- buffer += "\r\n\x1b[2K";
1358
+ output.append("\r\n\x1b[2K");
1161
1359
  }
1162
1360
  // Move cursor back to end of new content
1163
- buffer += `\x1b[${extraLines}A`;
1361
+ output.append(`\x1b[${extraLines}A`);
1164
1362
  }
1165
- buffer += "\x1b[?2026l"; // End synchronized output
1363
+ output.append("\x1b[?2026l"); // End synchronized output
1166
1364
  if (process.env.PI_TUI_DEBUG === "1") {
1167
1365
  const debugDir = "/tmp/tui";
1168
1366
  fs.mkdirSync(debugDir, { recursive: true });
@@ -1187,12 +1385,11 @@ export class TUI extends Container {
1187
1385
  JSON.stringify(this.previousLines, null, 2),
1188
1386
  "",
1189
1387
  "=== buffer ===",
1190
- JSON.stringify(buffer),
1388
+ `[${output.length} chars written in bounded chunks]`,
1191
1389
  ].join("\n");
1192
1390
  fs.writeFileSync(debugPath, debugData);
1193
1391
  }
1194
- // Write entire buffer at once
1195
- this.terminal.write(buffer);
1392
+ output.flush();
1196
1393
  // Track cursor position for next render
1197
1394
  // cursorRow tracks end of content (for viewport calculation)
1198
1395
  // hardwareCursorRow tracks actual terminal cursor position (for movement)