@kolisachint/hoocode-tui 0.4.111 → 0.4.113

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
@@ -62,31 +62,54 @@ function isTermuxSession() {
62
62
  */
63
63
  export class Container {
64
64
  children = [];
65
+ // Flatten memo: children are always render()ed (side effects and their own
66
+ // caches must run), but when every child returns the same array reference as
67
+ // last time, the previously flattened array is returned as-is. Unchanged
68
+ // subtrees thus stay reference-stable all the way up, which lets the TUI
69
+ // root diff whole regions by identity instead of re-flattening the world.
70
+ renderMemo;
65
71
  addChild(component) {
66
72
  this.children.push(component);
73
+ this.renderMemo = undefined;
67
74
  }
68
75
  removeChild(component) {
69
76
  const index = this.children.indexOf(component);
70
77
  if (index !== -1) {
71
78
  this.children.splice(index, 1);
79
+ this.renderMemo = undefined;
72
80
  }
73
81
  }
74
82
  clear() {
75
83
  this.children = [];
84
+ this.renderMemo = undefined;
76
85
  }
77
86
  invalidate() {
87
+ this.renderMemo = undefined;
78
88
  for (const child of this.children) {
79
89
  child.invalidate?.();
80
90
  }
81
91
  }
82
92
  render(width) {
93
+ const n = this.children.length;
94
+ const memo = this.renderMemo;
95
+ const refs = new Array(n);
96
+ let unchanged = memo !== undefined && memo.width === width && memo.refs.length === n;
97
+ for (let i = 0; i < n; i++) {
98
+ refs[i] = this.children[i].render(width);
99
+ if (unchanged && refs[i] !== memo.refs[i]) {
100
+ unchanged = false;
101
+ }
102
+ }
103
+ if (unchanged) {
104
+ return memo.lines;
105
+ }
83
106
  const lines = [];
84
- for (const child of this.children) {
85
- const childLines = child.render(width);
107
+ for (const childLines of refs) {
86
108
  for (const line of childLines) {
87
109
  lines.push(line);
88
110
  }
89
111
  }
112
+ this.renderMemo = { width, refs, lines };
90
113
  return lines;
91
114
  }
92
115
  }
@@ -96,7 +119,28 @@ export class Container {
96
119
  export class TUI extends Container {
97
120
  terminal;
98
121
  previousLines = [];
122
+ // Root flat-line cache (see the render() override): per-child line arrays,
123
+ // their offsets into the flat buffer, and the flat buffer itself. Active
124
+ // only when no overlays are up and no image has been drawn; otherwise the
125
+ // legacy full-flatten + full-diff path runs.
126
+ flatCache;
127
+ flatLines;
128
+ /** What the last render() call changed: "full" = unknown (legacy diff must
129
+ * scan), null = nothing, otherwise the dirty row range + previous length. */
130
+ lastPatch = "full";
131
+ /** Cursor position extracted on the last frame; reused when the dirty range
132
+ * shows the marker's row untouched (the marker was already stripped). */
133
+ lastCursorPos = undefined;
134
+ /** Set by render() when this frame's patches invalidate lastCursorPos: the
135
+ * marker's row was overwritten, or a patched-in line carries a marker. */
136
+ cursorRowOverwritten = false;
99
137
  previousKittyImageIds = new Set();
138
+ /** Flips true the first time an image line is emitted. While false no image
139
+ * has ever been drawn, so there are no kitty ids on screen to track and the
140
+ * per-frame full-buffer scan (collectKittyImageIds) is skipped entirely —
141
+ * the common case for a pure-text session. */
142
+ sawImageLine = false;
143
+ static EMPTY_KITTY_IDS = new Set();
100
144
  previousWidth = 0;
101
145
  previousHeight = 0;
102
146
  focusedComponent = null;
@@ -424,7 +468,24 @@ export class TUI extends Container {
424
468
  }
425
469
  this.focusedComponent.handleInput(data);
426
470
  this.requestRender();
471
+ // Keystroke echo should not queue behind the animation coalescing
472
+ // window: render the input's effect immediately instead of waiting out
473
+ // MIN_RENDER_INTERVAL_MS behind spinner/streaming frames.
474
+ this.expediteRender();
475
+ }
476
+ }
477
+ /** Run a requested render now, bypassing the coalescing delay. Used for
478
+ * input-driven frames where echo latency matters more than batching. */
479
+ expediteRender() {
480
+ if (this.stopped || !this.renderRequested)
481
+ return;
482
+ if (this.renderTimer) {
483
+ clearTimeout(this.renderTimer);
484
+ this.renderTimer = undefined;
427
485
  }
486
+ this.renderRequested = false;
487
+ this.lastRenderAt = performance.now();
488
+ this.doRender();
428
489
  }
429
490
  consumeCellSizeResponse(data) {
430
491
  // Response format: ESC [ 6 ; height ; width t
@@ -619,17 +680,29 @@ export class TUI extends Container {
619
680
  return result;
620
681
  }
621
682
  static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
622
- applyLineResets(lines) {
623
- const reset = TUI.SEGMENT_RESET;
624
- for (let i = 0; i < lines.length; i++) {
625
- const line = lines[i];
626
- if (!isImageLine(line)) {
627
- lines[i] = normalizeTerminalOutput(line) + reset;
628
- }
683
+ /**
684
+ * Append the per-line style/hyperlink reset (and normalize Thai/Lao AM
685
+ * vowels) at the moment a line is written to the terminal. This is
686
+ * deliberately kept OFF the cached/diffed line arrays: leaf components cache
687
+ * their lines without the reset, so leaving `newLines`/`previousLines`
688
+ * un-reset keeps unchanged lines reference-stable frame to frame. The
689
+ * differential compare then short-circuits on identity for every unchanged
690
+ * line instead of allocating a fresh reset-appended string per line and
691
+ * doing a full content compare across the whole transcript every frame.
692
+ * Image lines carry no trailing style and are emitted verbatim.
693
+ */
694
+ emitLine(line) {
695
+ if (isImageLine(line)) {
696
+ this.sawImageLine = true;
697
+ return line;
629
698
  }
630
- return lines;
699
+ return normalizeTerminalOutput(line) + TUI.SEGMENT_RESET;
631
700
  }
632
701
  collectKittyImageIds(lines) {
702
+ // No image has ever been drawn: nothing on screen carries a kitty id, so
703
+ // skip the full-buffer scan and the Set allocation.
704
+ if (!this.sawImageLine)
705
+ return TUI.EMPTY_KITTY_IDS;
633
706
  const ids = new Set();
634
707
  for (const line of lines) {
635
708
  for (const id of extractKittyImageIds(line)) {
@@ -646,6 +719,10 @@ export class TUI extends Container {
646
719
  return buffer;
647
720
  }
648
721
  expandLastChangedForKittyImages(firstChanged, lastChanged) {
722
+ // No image ever drawn: nothing to expand over, skip the scan (also,
723
+ // on patched frames previousLines is not the previous content).
724
+ if (!this.sawImageLine)
725
+ return lastChanged;
649
726
  let expandedLastChanged = lastChanged;
650
727
  for (let i = firstChanged; i < this.previousLines.length; i++) {
651
728
  if (extractKittyImageIds(this.previousLines[i]).length > 0) {
@@ -730,6 +807,124 @@ export class TUI extends Container {
730
807
  }
731
808
  return null;
732
809
  }
810
+ /**
811
+ * Root flatten with patch tracking. Children stay memoized (Container), so
812
+ * a frame where only one region changed patches that region into the
813
+ * persistent flat buffer and reports the dirty row range via lastPatch —
814
+ * doRender then skips the whole-transcript diff. Falls back to a fresh
815
+ * flatten (lastPatch = "full") when overlays are up, an image has been
816
+ * drawn (kitty bookkeeping needs true previous content), the width changed,
817
+ * or the child list changed.
818
+ */
819
+ render(width) {
820
+ const cacheAllowed = this.overlayStack.length === 0 && !this.sawImageLine;
821
+ const cache = this.flatCache;
822
+ if (!cacheAllowed || !cache || cache.width !== width || cache.refs.length !== this.children.length) {
823
+ const n = this.children.length;
824
+ const refs = new Array(n);
825
+ const offsets = new Array(n);
826
+ const flat = [];
827
+ for (let i = 0; i < n; i++) {
828
+ refs[i] = this.children[i].render(width);
829
+ offsets[i] = flat.length;
830
+ for (const line of refs[i])
831
+ flat.push(line);
832
+ }
833
+ if (cacheAllowed) {
834
+ this.flatCache = { width, refs, offsets };
835
+ this.flatLines = flat;
836
+ }
837
+ else {
838
+ this.flatCache = undefined;
839
+ this.flatLines = undefined;
840
+ }
841
+ this.lastPatch = "full";
842
+ return flat;
843
+ }
844
+ let flat = this.flatLines;
845
+ const prevLength = flat.length;
846
+ let low = Infinity;
847
+ let high = -1;
848
+ let delta = 0;
849
+ // Cursor bookkeeping: the marker was stripped out of the persistent flat
850
+ // when last extracted, so the cached position stays valid until the row
851
+ // it lives on is overwritten by re-imported child content — and it
852
+ // shifts when content above it grows or shrinks.
853
+ let cp = this.lastCursorPos ?? null;
854
+ this.cursorRowOverwritten = false;
855
+ for (let i = 0; i < this.children.length; i++) {
856
+ const r = this.children[i].render(width);
857
+ const old = cache.refs[i];
858
+ if (r === old)
859
+ continue;
860
+ const off = cache.offsets[i] + delta;
861
+ if (r.length === old.length) {
862
+ for (let k = 0; k < r.length; k++) {
863
+ if (old[k] !== r[k]) {
864
+ const row = off + k;
865
+ flat[row] = r[k];
866
+ if (row < low)
867
+ low = row;
868
+ if (row > high)
869
+ high = row;
870
+ // Overwrote the marker's row, or imported a line carrying a
871
+ // (possibly relocated) marker: position must be re-extracted.
872
+ if (cp && cp.row === row)
873
+ this.cursorRowOverwritten = true;
874
+ if (r[k].includes(CURSOR_MARKER))
875
+ this.cursorRowOverwritten = true;
876
+ }
877
+ }
878
+ }
879
+ else {
880
+ // Length changed: find the first differing line, then splice the
881
+ // child's new lines in. Everything from there down shifts rows, so
882
+ // the dirty range extends to the end (positional diff semantics).
883
+ let p = 0;
884
+ const minLen = Math.min(old.length, r.length);
885
+ while (p < minLen && old[p] === r[p])
886
+ p++;
887
+ flat = flat.slice(0, off + p).concat(r.slice(p), flat.slice(off + old.length));
888
+ if (off + p < low)
889
+ low = off + p;
890
+ if (cp) {
891
+ if (cp.row >= off + old.length) {
892
+ // Below the replaced region: shifts with it.
893
+ cp = { row: cp.row + (r.length - old.length), col: cp.col };
894
+ }
895
+ else if (cp.row >= off + p) {
896
+ // Inside the replaced region: fresh content, re-extract.
897
+ this.cursorRowOverwritten = true;
898
+ }
899
+ }
900
+ if (!this.cursorRowOverwritten) {
901
+ for (let k = p; k < r.length; k++) {
902
+ if (r[k].includes(CURSOR_MARKER)) {
903
+ this.cursorRowOverwritten = true;
904
+ break;
905
+ }
906
+ }
907
+ }
908
+ delta += r.length - old.length;
909
+ }
910
+ cache.refs[i] = r;
911
+ }
912
+ this.lastCursorPos = cp;
913
+ const spliced = flat !== this.flatLines;
914
+ if (spliced) {
915
+ let acc = 0;
916
+ for (let i = 0; i < cache.refs.length; i++) {
917
+ cache.offsets[i] = acc;
918
+ acc += cache.refs[i].length;
919
+ }
920
+ this.flatLines = flat;
921
+ // Rows below the first splice all shifted; positional diff semantics
922
+ // mean everything from there to the end must be treated as dirty.
923
+ high = Math.max(prevLength - 1, flat.length - 1);
924
+ }
925
+ this.lastPatch = low === Infinity && high === -1 ? null : { low: low === Infinity ? 0 : low, high, prevLength };
926
+ return flat;
927
+ }
733
928
  doRender() {
734
929
  if (this.stopped)
735
930
  return;
@@ -746,15 +941,39 @@ export class TUI extends Container {
746
941
  const targetScreenRow = targetRow - viewportTop;
747
942
  return targetScreenRow - currentScreenRow;
748
943
  };
749
- // Render all components to get new lines
944
+ // Render all components to get new lines. The root render() reports what
945
+ // it changed via lastPatch; consume it here (it is per-frame state).
750
946
  let newLines = this.render(width);
947
+ const patch = this.lastPatch;
948
+ this.lastPatch = "full";
751
949
  // Composite overlays into the rendered lines (before differential compare)
752
950
  if (this.overlayStack.length > 0) {
753
951
  newLines = this.compositeOverlays(newLines, width, height);
754
952
  }
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);
953
+ // Extract cursor position before the marker could be obscured. The reset
954
+ // is applied per-line at write time (see emitLine), so newLines stays the
955
+ // un-reset, reference-stable output of the component tree from here on.
956
+ // On patched frames the persistent flat buffer already had the marker
957
+ // stripped; the cached position (row-shifted by render()) stays valid
958
+ // unless its row was overwritten by re-imported child content, or a
959
+ // marker could have newly appeared in changed content.
960
+ let cursorPos;
961
+ if (patch !== "full" && this.lastCursorPos !== undefined) {
962
+ const cp = this.lastCursorPos;
963
+ if (cp !== null && !this.cursorRowOverwritten) {
964
+ cursorPos = cp;
965
+ }
966
+ else if (patch === null) {
967
+ cursorPos = cp;
968
+ }
969
+ else {
970
+ cursorPos = this.extractCursorPosition(newLines, height);
971
+ }
972
+ }
973
+ else {
974
+ cursorPos = this.extractCursorPosition(newLines, height);
975
+ }
976
+ this.lastCursorPos = cursorPos;
758
977
  // Helper to clear scrollback and viewport and render all new lines
759
978
  const fullRender = (clear) => {
760
979
  this.fullRedrawCount += 1;
@@ -766,7 +985,7 @@ export class TUI extends Container {
766
985
  for (let i = 0; i < newLines.length; i++) {
767
986
  if (i > 0)
768
987
  buffer += "\r\n";
769
- buffer += newLines[i];
988
+ buffer += this.emitLine(newLines[i]);
770
989
  }
771
990
  buffer += "\x1b[?2026l"; // End synchronized output
772
991
  this.terminal.write(buffer);
@@ -824,31 +1043,52 @@ export class TUI extends Container {
824
1043
  fullRender(true);
825
1044
  return;
826
1045
  }
827
- // Find first and last changed lines
828
- let firstChanged = -1;
829
- let lastChanged = -1;
830
- const maxLines = Math.max(newLines.length, this.previousLines.length);
831
- for (let i = 0; i < maxLines; i++) {
832
- const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
833
- const newLine = i < newLines.length ? newLines[i] : "";
834
- if (oldLine !== newLine) {
835
- if (firstChanged === -1) {
836
- firstChanged = i;
1046
+ // Find first and last changed lines. When the root render() produced a
1047
+ // patch report the dirty range is already known and the whole-buffer scan
1048
+ // is skipped. On patched frames previousLines is the same in-place-updated
1049
+ // array as newLines, so the previous length must come from the report.
1050
+ let firstChanged;
1051
+ let lastChanged;
1052
+ let prevLineCount;
1053
+ if (patch !== "full") {
1054
+ if (patch === null) {
1055
+ prevLineCount = newLines.length;
1056
+ firstChanged = -1;
1057
+ lastChanged = -1;
1058
+ }
1059
+ else {
1060
+ prevLineCount = patch.prevLength;
1061
+ firstChanged = patch.low;
1062
+ lastChanged = patch.high;
1063
+ }
1064
+ }
1065
+ else {
1066
+ prevLineCount = this.previousLines.length;
1067
+ firstChanged = -1;
1068
+ lastChanged = -1;
1069
+ const maxLines = Math.max(newLines.length, prevLineCount);
1070
+ for (let i = 0; i < maxLines; i++) {
1071
+ const oldLine = i < prevLineCount ? this.previousLines[i] : "";
1072
+ const newLine = i < newLines.length ? newLines[i] : "";
1073
+ if (oldLine !== newLine) {
1074
+ if (firstChanged === -1) {
1075
+ firstChanged = i;
1076
+ }
1077
+ lastChanged = i;
837
1078
  }
838
- lastChanged = i;
839
1079
  }
840
1080
  }
841
- const appendedLines = newLines.length > this.previousLines.length;
1081
+ const appendedLines = newLines.length > prevLineCount;
842
1082
  if (appendedLines) {
843
1083
  if (firstChanged === -1) {
844
- firstChanged = this.previousLines.length;
1084
+ firstChanged = prevLineCount;
845
1085
  }
846
1086
  lastChanged = newLines.length - 1;
847
1087
  }
848
1088
  if (firstChanged !== -1) {
849
1089
  lastChanged = this.expandLastChangedForKittyImages(firstChanged, lastChanged);
850
1090
  }
851
- const appendStart = appendedLines && firstChanged === this.previousLines.length && firstChanged > 0;
1091
+ const appendStart = appendedLines && firstChanged === prevLineCount && firstChanged > 0;
852
1092
  // No changes - but still need to update hardware cursor position if it moved
853
1093
  if (firstChanged === -1) {
854
1094
  this.positionHardwareCursor(cursorPos, newLines.length);
@@ -858,7 +1098,7 @@ export class TUI extends Container {
858
1098
  }
859
1099
  // All changes are in deleted lines (nothing to render, just clear)
860
1100
  if (firstChanged >= newLines.length) {
861
- if (this.previousLines.length > newLines.length) {
1101
+ if (prevLineCount > newLines.length) {
862
1102
  let buffer = "\x1b[?2026h";
863
1103
  buffer += this.deleteChangedKittyImages(firstChanged, lastChanged);
864
1104
  // Move to end of new content (clamp to 0 for empty content)
@@ -875,7 +1115,7 @@ export class TUI extends Container {
875
1115
  buffer += `\x1b[${-lineDiff}A`;
876
1116
  buffer += "\r";
877
1117
  // Clear extra lines without scrolling
878
- const extraLines = this.previousLines.length - newLines.length;
1118
+ const extraLines = prevLineCount - newLines.length;
879
1119
  if (extraLines > height) {
880
1120
  logRedraw(`extraLines > height (${extraLines} > ${height})`);
881
1121
  fullRender(true);
@@ -975,20 +1215,22 @@ export class TUI extends Container {
975
1215
  ].join("\n");
976
1216
  throw new Error(errorMsg);
977
1217
  }
978
- buffer += line;
1218
+ if (isImage)
1219
+ this.sawImageLine = true;
1220
+ buffer += isImage ? line : normalizeTerminalOutput(line) + TUI.SEGMENT_RESET;
979
1221
  }
980
1222
  // Track where cursor ended up after rendering
981
1223
  let finalCursorRow = renderEnd;
982
1224
  // If we had more lines before, clear them and move cursor back
983
- if (this.previousLines.length > newLines.length) {
1225
+ if (prevLineCount > newLines.length) {
984
1226
  // Move to end of new content first if we stopped before it
985
1227
  if (renderEnd < newLines.length - 1) {
986
1228
  const moveDown = newLines.length - 1 - renderEnd;
987
1229
  buffer += `\x1b[${moveDown}B`;
988
1230
  finalCursorRow = newLines.length - 1;
989
1231
  }
990
- const extraLines = this.previousLines.length - newLines.length;
991
- for (let i = newLines.length; i < this.previousLines.length; i++) {
1232
+ const extraLines = prevLineCount - newLines.length;
1233
+ for (let i = newLines.length; i < prevLineCount; i++) {
992
1234
  buffer += "\r\n\x1b[2K";
993
1235
  }
994
1236
  // Move cursor back to end of new content