@dreb/tui 2.25.4 → 2.27.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/editor.d.ts +1 -0
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +26 -2
- package/dist/components/editor.js.map +1 -1
- package/dist/tui.d.ts +43 -1
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +186 -34
- package/dist/tui.js.map +1 -1
- package/package.json +2 -2
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
|
|
85
|
-
hardwareCursorRow = 0; // Actual
|
|
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; //
|
|
90
|
-
previousViewportTop = 0; //
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
299
|
-
|
|
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
|
|
685
|
-
let newLines = this.
|
|
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
|
|
694
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
|
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(
|
|
874
|
+
fullRender(true);
|
|
737
875
|
return;
|
|
738
876
|
}
|
|
739
|
-
// Width 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
|
-
|
|
882
|
+
this.recommitAll();
|
|
743
883
|
return;
|
|
744
884
|
}
|
|
745
|
-
// Height changes
|
|
746
|
-
//
|
|
747
|
-
//
|
|
748
|
-
if (heightChanged
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|