@dreb/tui 2.25.4 → 2.27.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tui.js CHANGED
@@ -32,9 +32,6 @@ function parseSizeValue(value, referenceSize) {
32
32
  }
33
33
  return undefined;
34
34
  }
35
- function isTermuxSession() {
36
- return Boolean(process.env.TERMUX_VERSION);
37
- }
38
35
  /**
39
36
  * Container - a component that contains other components
40
37
  */
@@ -68,28 +65,45 @@ export class Container {
68
65
  }
69
66
  const RENDER_THROTTLE_MS = 16; // ~60fps
70
67
  /**
71
- * TUI - Main class for managing terminal UI with differential rendering
68
+ * TUI - Main class for managing terminal UI with differential rendering.
69
+ *
70
+ * Supports a committed-scrollback + live-region rendering model:
71
+ * - **Committed region**: the first `committedChildCount` children. Their output
72
+ * is written to terminal scrollback once and never re-rendered by the
73
+ * differential renderer.
74
+ * - **Live region**: children after the committed boundary. This is the only
75
+ * content the differential renderer manages — keeps full redraws cheap and
76
+ * prevents transcript replay into scrollback.
77
+ *
78
+ * Use `setCommittedChildCount()` + `commit()` to advance the boundary.
79
+ * Use `recommitAll()` for global actions that need to repaint everything
80
+ * (theme change, width resize, expand-all, etc.).
72
81
  */
73
82
  export class TUI extends Container {
74
83
  terminal;
75
- previousLines = [];
84
+ previousLines = []; // Live-region lines only (after committed boundary)
76
85
  previousWidth = 0;
77
86
  previousHeight = 0;
78
87
  focusedComponent = null;
79
88
  inputListeners = new Set();
80
89
  /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
81
90
  onDebug;
91
+ /** Callback fired after every render completes (doRender differential path, fullRender, or recommitAll). */
92
+ onPostRender;
82
93
  renderTimer = null;
83
94
  lastRenderAt = 0;
84
- cursorRow = 0; // Logical cursor row (end of rendered content)
85
- hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
95
+ cursorRow = 0; // Logical cursor row within live region (end of live content)
96
+ hardwareCursorRow = 0; // Actual cursor row within live region (may differ due to IME)
86
97
  inputBuffer = ""; // Buffer for parsing terminal responses
87
98
  cellSizeQueryPending = false;
88
99
  showHardwareCursor = process.env.DREB_HARDWARE_CURSOR === "1";
89
- maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
90
- previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
100
+ maxLinesRendered = 0; // High-water mark of live-region lines rendered
101
+ previousViewportTop = 0; // Previous viewport top within live region
91
102
  fullRedrawCount = 0;
92
103
  stopped = false;
104
+ // Committed-scrollback state
105
+ committedChildCount = 0; // children[0..n) are committed to scrollback
106
+ committedLineCount = 0; // total lines written to scrollback from committed children
93
107
  // Overlay stack for modal components rendered on top of base content
94
108
  focusOrderCounter = 0;
95
109
  overlayStack = [];
@@ -158,7 +172,10 @@ export class TUI extends Container {
158
172
  }
159
173
  if (this.overlayStack.length === 0)
160
174
  this.terminal.hideCursor();
161
- this.requestRender();
175
+ // Overlay dismissed — user was at the bottom of the TUI by definition,
176
+ // so a full recommit is safe and ensures no ghost whitespace from
177
+ // overlay padding lines.
178
+ this.recommitAll();
162
179
  }
163
180
  },
164
181
  setHidden: (hidden) => {
@@ -214,7 +231,8 @@ export class TUI extends Container {
214
231
  }
215
232
  if (this.overlayStack.length === 0)
216
233
  this.terminal.hideCursor();
217
- this.requestRender();
234
+ // Overlay dismissed — full recommit clears any ghost padding lines.
235
+ this.recommitAll();
218
236
  }
219
237
  /** Check if there are any visible overlays */
220
238
  hasOverlay() {
@@ -295,15 +313,130 @@ export class TUI extends Container {
295
313
  requestRender(force = false) {
296
314
  if (force) {
297
315
  this.previousLines = [];
298
- this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
299
- this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
316
+ // Don't set previousWidth/Height to -1 that would trigger recommitAll
317
+ // (scrollback clear) on the next doRender. Force should only re-render
318
+ // the live region cleanly, not wipe committed scrollback.
319
+ this.previousWidth = 0;
320
+ this.previousHeight = 0;
321
+ // Keep hardwareCursorRow intact — it tracks the physical cursor position,
322
+ // which hasn't moved. fullRender needs it to calculate movement to
323
+ // live-region start. cursorRow can be reset since it's the logical end.
300
324
  this.cursorRow = 0;
301
- this.hardwareCursorRow = 0;
302
325
  this.maxLinesRendered = 0;
303
326
  this.previousViewportTop = 0;
304
327
  }
305
328
  this.scheduleRender();
306
329
  }
330
+ /**
331
+ * Get the number of committed children.
332
+ */
333
+ getCommittedChildCount() {
334
+ return this.committedChildCount;
335
+ }
336
+ /**
337
+ * Set how many leading children are committed (their output is in scrollback).
338
+ * Must be followed by `commit()` to update line tracking.
339
+ */
340
+ setCommittedChildCount(count) {
341
+ this.committedChildCount = count;
342
+ }
343
+ /**
344
+ * Update committed line tracking after components were added to committed containers.
345
+ * Re-renders committed children to count their current lines, then trims
346
+ * `previousLines` and adjusts cursor state so the differential renderer
347
+ * only operates on the live region.
348
+ */
349
+ commit() {
350
+ const width = this.terminal.columns;
351
+ // Count lines from committed children
352
+ let newCommittedLineCount = 0;
353
+ for (let i = 0; i < this.committedChildCount && i < this.children.length; i++) {
354
+ const childLines = this.children[i].render(width);
355
+ newCommittedLineCount += childLines.length;
356
+ }
357
+ const delta = newCommittedLineCount - this.committedLineCount;
358
+ if (delta <= 0)
359
+ return; // nothing new to commit
360
+ // Trim previousLines: remove the leading committed lines
361
+ if (this.previousLines.length >= delta) {
362
+ this.previousLines = this.previousLines.slice(delta);
363
+ }
364
+ else {
365
+ this.previousLines = [];
366
+ }
367
+ // Adjust cursor positions (now relative to smaller live region)
368
+ this.hardwareCursorRow = Math.max(0, this.hardwareCursorRow - delta);
369
+ this.cursorRow = Math.max(0, this.cursorRow - delta);
370
+ this.committedLineCount = newCommittedLineCount;
371
+ // Reset live-region tracking
372
+ this.maxLinesRendered = this.previousLines.length;
373
+ this.previousViewportTop = Math.max(0, this.previousLines.length - this.terminal.rows);
374
+ }
375
+ /**
376
+ * Clear screen + scrollback, re-render the entire transcript (committed + live),
377
+ * and re-establish the committed boundary. Used for global actions that need to
378
+ * repaint finalized content (theme change, width resize, expand-all, etc.).
379
+ */
380
+ recommitAll() {
381
+ if (this.stopped)
382
+ return;
383
+ const width = this.terminal.columns;
384
+ const height = this.terminal.rows;
385
+ // Render ALL children (committed + live)
386
+ const allLines = [];
387
+ let newCommittedLineCount = 0;
388
+ for (let i = 0; i < this.children.length; i++) {
389
+ const childLines = this.children[i].render(width);
390
+ for (const line of childLines)
391
+ allLines.push(line);
392
+ if (i < this.committedChildCount) {
393
+ newCommittedLineCount += childLines.length;
394
+ }
395
+ }
396
+ // Extract cursor position before applying resets
397
+ const cursorPos = this.extractCursorPosition(allLines, height);
398
+ this.applyLineResets(allLines);
399
+ // Clear screen + scrollback, write everything
400
+ this.fullRedrawCount += 1;
401
+ let buffer = "\x1b[?2026h\x1b[2J\x1b[H\x1b[3J";
402
+ for (let i = 0; i < allLines.length; i++) {
403
+ if (i > 0)
404
+ buffer += "\r\n";
405
+ buffer += allLines[i];
406
+ }
407
+ buffer += "\x1b[?2026l";
408
+ this.terminal.write(buffer);
409
+ // Update state: previousLines holds only live portion
410
+ const liveLines = allLines.slice(newCommittedLineCount);
411
+ this.committedLineCount = newCommittedLineCount;
412
+ this.previousLines = liveLines;
413
+ this.cursorRow = Math.max(0, liveLines.length - 1);
414
+ this.hardwareCursorRow = this.cursorRow;
415
+ this.maxLinesRendered = liveLines.length;
416
+ this.previousViewportTop = Math.max(0, liveLines.length - height);
417
+ this.previousWidth = width;
418
+ this.previousHeight = height;
419
+ // Position hardware cursor (cursorPos is absolute, adjust to live-relative)
420
+ if (cursorPos && cursorPos.row >= newCommittedLineCount) {
421
+ const liveCursorPos = { row: cursorPos.row - newCommittedLineCount, col: cursorPos.col };
422
+ this.positionHardwareCursor(liveCursorPos, liveLines.length);
423
+ }
424
+ else {
425
+ this.positionHardwareCursor(null, liveLines.length);
426
+ }
427
+ this.onPostRender?.();
428
+ }
429
+ /**
430
+ * Render only the live-region children (after the committed boundary).
431
+ */
432
+ renderLive(width) {
433
+ const lines = [];
434
+ for (let i = this.committedChildCount; i < this.children.length; i++) {
435
+ for (const line of this.children[i].render(width))
436
+ lines.push(line);
437
+ }
438
+ return lines;
439
+ }
307
440
  scheduleRender() {
308
441
  if (this.renderTimer !== null)
309
442
  return;
@@ -681,8 +814,8 @@ export class TUI extends Container {
681
814
  const targetScreenRow = targetRow - viewportTop;
682
815
  return targetScreenRow - currentScreenRow;
683
816
  };
684
- // Render all components to get new lines
685
- let newLines = this.render(width);
817
+ // Render only live-region children (after committed boundary)
818
+ let newLines = this.renderLive(width);
686
819
  // Composite overlays into the rendered lines (before differential compare)
687
820
  if (this.overlayStack.length > 0) {
688
821
  newLines = this.compositeOverlays(newLines, width, height);
@@ -690,14 +823,18 @@ export class TUI extends Container {
690
823
  // Extract cursor position before applying line resets (marker must be found first)
691
824
  const cursorPos = this.extractCursorPosition(newLines, height);
692
825
  newLines = this.applyLineResets(newLines);
693
- // Helper to clear scrollback and viewport and render all new lines
694
- const fullRender = (clear, clearScrollback = false) => {
826
+ // Helper to clear the live region and re-render live-region lines.
827
+ // Only clears from the live-region start to the end of the screen —
828
+ // committed scrollback above is never touched.
829
+ const fullRender = (clear) => {
695
830
  this.fullRedrawCount += 1;
696
831
  let buffer = "\x1b[?2026h"; // Begin synchronized output
697
832
  if (clear) {
698
- buffer += "\x1b[2J\x1b[H"; // Clear screen, home
699
- if (clearScrollback)
700
- buffer += "\x1b[3J"; // Clear scrollback (only for width changes)
833
+ // Move cursor to start of live region (row 0 in live-relative coords)
834
+ const moveUp = hardwareCursorRow; // Use local (captured from this.hardwareCursorRow)
835
+ if (moveUp > 0)
836
+ buffer += `\x1b[${moveUp}A`;
837
+ buffer += "\r\x1b[J"; // Carriage return + clear from cursor to end of screen
701
838
  }
702
839
  for (let i = 0; i < newLines.length; i++) {
703
840
  if (i > 0)
@@ -721,6 +858,7 @@ export class TUI extends Container {
721
858
  this.previousLines = newLines;
722
859
  this.previousWidth = width;
723
860
  this.previousHeight = height;
861
+ this.onPostRender?.();
724
862
  };
725
863
  const debugRedraw = process.env.DREB_DEBUG_REDRAW === "1";
726
864
  const logRedraw = (reason) => {
@@ -730,24 +868,26 @@ export class TUI extends Container {
730
868
  const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
731
869
  fs.appendFileSync(logPath, msg);
732
870
  };
733
- // First render - just output everything without clearing (assumes clean screen)
871
+ // First render or force re-render clear live region and write
734
872
  if (this.previousLines.length === 0 && !widthChanged && !heightChanged) {
735
873
  logRedraw("first render");
736
- fullRender(false);
874
+ fullRender(true);
737
875
  return;
738
876
  }
739
- // Width changes always need a full re-render because wrapping changes.
877
+ // Width changes need a full re-render of everything (including committed
878
+ // content, since wrapping changes). Use recommitAll to clear screen +
879
+ // scrollback and re-render the entire transcript at the new width.
740
880
  if (widthChanged) {
741
881
  logRedraw(`terminal width changed (${this.previousWidth} -> ${width})`);
742
- fullRender(true, true);
882
+ this.recommitAll();
743
883
  return;
744
884
  }
745
- // Height changes normally need a full re-render to keep the visible viewport aligned,
746
- // but Termux changes height when the software keyboard shows or hides.
747
- // In that environment, a full redraw causes the entire history to replay on every toggle.
748
- if (heightChanged && !isTermuxSession()) {
885
+ // Height changes need a full re-render to keep the visible viewport aligned.
886
+ // With the committed-scrollback model, only the live region is re-rendered,
887
+ // so this is cheap and safe even on Termux (no transcript replay).
888
+ if (heightChanged) {
749
889
  logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
750
- fullRender(true, false);
890
+ fullRender(true);
751
891
  return;
752
892
  }
753
893
  // Find first and last changed lines
@@ -777,6 +917,7 @@ export class TUI extends Container {
777
917
  this.positionHardwareCursor(cursorPos, newLines.length);
778
918
  this.previousViewportTop = prevViewportTop;
779
919
  this.previousHeight = height;
920
+ this.onPostRender?.();
780
921
  return;
781
922
  }
782
923
  // All changes are in deleted lines (nothing to render, just clear)
@@ -787,7 +928,7 @@ export class TUI extends Container {
787
928
  const targetRow = Math.max(0, newLines.length - 1);
788
929
  if (targetRow < prevViewportTop) {
789
930
  logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
790
- fullRender(true, false);
931
+ fullRender(true);
791
932
  return;
792
933
  }
793
934
  const lineDiff = computeLineDiff(targetRow);
@@ -804,7 +945,7 @@ export class TUI extends Container {
804
945
  const extraLines = this.previousLines.length - newLines.length;
805
946
  if (extraLines > height) {
806
947
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
807
- fullRender(true, false);
948
+ fullRender(true);
808
949
  return;
809
950
  }
810
951
  if (extraLines > 0) {
@@ -835,6 +976,7 @@ export class TUI extends Container {
835
976
  this.previousWidth = width;
836
977
  this.previousHeight = height;
837
978
  this.previousViewportTop = prevViewportTop;
979
+ this.onPostRender?.();
838
980
  return;
839
981
  }
840
982
  // If changes are above the viewport, decide whether to clamp or full redraw
@@ -861,7 +1003,7 @@ export class TUI extends Container {
861
1003
  else {
862
1004
  // Viewport needs to shift — full redraw without scrollback clear
863
1005
  logRedraw(`firstChanged < viewportTop with viewport shift (${firstChanged} < ${prevViewportTop})`);
864
- fullRender(true, false);
1006
+ fullRender(true);
865
1007
  return;
866
1008
  }
867
1009
  }
@@ -934,8 +1076,17 @@ export class TUI extends Container {
934
1076
  // Use a full redraw (clear screen) to avoid ghost whitespace from terminals
935
1077
  // that don't properly collapse cleared lines below the cursor.
936
1078
  if (this.previousLines.length > newLines.length) {
1079
+ if (this.previousLines.length > height) {
1080
+ // Previous live content exceeded the terminal viewport — overlay padding
1081
+ // or long streaming content caused scrolling. A live-region-only clear
1082
+ // can't restore the viewport (CUU can't reach past viewport top into
1083
+ // scrollback), so do a full recommit to repaint everything cleanly.
1084
+ logRedraw(`content shrank past viewport (${this.previousLines.length} -> ${newLines.length}, height=${height})`);
1085
+ this.recommitAll();
1086
+ return;
1087
+ }
937
1088
  logRedraw(`content shrank (${this.previousLines.length} -> ${newLines.length})`);
938
- fullRender(true, false);
1089
+ fullRender(true);
939
1090
  return;
940
1091
  }
941
1092
  buffer += "\x1b[?2026l"; // End synchronized output
@@ -987,6 +1138,7 @@ export class TUI extends Container {
987
1138
  this.previousLines = newLines;
988
1139
  this.previousWidth = width;
989
1140
  this.previousHeight = height;
1141
+ this.onPostRender?.();
990
1142
  }
991
1143
  /**
992
1144
  * Position the hardware cursor for IME candidate window.