omk-tui 1.3.1 → 1.4.0

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 (69) hide show
  1. package/CHANGELOG.md +28 -4
  2. package/dist/components/box.d.ts +5 -0
  3. package/dist/components/box.d.ts.map +1 -1
  4. package/dist/components/box.js +50 -15
  5. package/dist/components/box.js.map +1 -1
  6. package/dist/components/image.d.ts +2 -0
  7. package/dist/components/image.d.ts.map +1 -1
  8. package/dist/components/image.js +4 -0
  9. package/dist/components/image.js.map +1 -1
  10. package/dist/components/markdown.d.ts +4 -0
  11. package/dist/components/markdown.d.ts.map +1 -1
  12. package/dist/components/markdown.js +14 -0
  13. package/dist/components/markdown.js.map +1 -1
  14. package/dist/components/spacer.d.ts +2 -0
  15. package/dist/components/spacer.d.ts.map +1 -1
  16. package/dist/components/spacer.js +8 -0
  17. package/dist/components/spacer.js.map +1 -1
  18. package/dist/components/text.d.ts +4 -0
  19. package/dist/components/text.d.ts.map +1 -1
  20. package/dist/components/text.js +14 -0
  21. package/dist/components/text.js.map +1 -1
  22. package/dist/index.d.ts +3 -1
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +3 -0
  25. package/dist/index.js.map +1 -1
  26. package/dist/line-reset-memo.d.ts +27 -0
  27. package/dist/line-reset-memo.d.ts.map +1 -0
  28. package/dist/line-reset-memo.js +101 -0
  29. package/dist/line-reset-memo.js.map +1 -0
  30. package/dist/render-generation.d.ts +24 -0
  31. package/dist/render-generation.d.ts.map +1 -0
  32. package/dist/render-generation.js +48 -0
  33. package/dist/render-generation.js.map +1 -0
  34. package/dist/terminal-output.d.ts +20 -1
  35. package/dist/terminal-output.d.ts.map +1 -1
  36. package/dist/terminal-output.js +62 -1
  37. package/dist/terminal-output.js.map +1 -1
  38. package/dist/terminal-resize-log.d.ts +39 -0
  39. package/dist/terminal-resize-log.d.ts.map +1 -0
  40. package/dist/terminal-resize-log.js +57 -0
  41. package/dist/terminal-resize-log.js.map +1 -0
  42. package/dist/terminal-resync.d.ts +74 -0
  43. package/dist/terminal-resync.d.ts.map +1 -0
  44. package/dist/terminal-resync.js +112 -0
  45. package/dist/terminal-resync.js.map +1 -0
  46. package/dist/terminal.d.ts.map +1 -1
  47. package/dist/terminal.js +1 -1
  48. package/dist/terminal.js.map +1 -1
  49. package/dist/tui.d.ts +29 -9
  50. package/dist/tui.d.ts.map +1 -1
  51. package/dist/tui.js +63 -85
  52. package/dist/tui.js.map +1 -1
  53. package/dist/undo-stack.d.ts +8 -2
  54. package/dist/undo-stack.d.ts.map +1 -1
  55. package/dist/undo-stack.js +12 -2
  56. package/dist/undo-stack.js.map +1 -1
  57. package/dist/utils.d.ts +2 -4
  58. package/dist/utils.d.ts.map +1 -1
  59. package/dist/utils.js +2 -128
  60. package/dist/utils.js.map +1 -1
  61. package/dist/visible-width.d.ts +14 -0
  62. package/dist/visible-width.d.ts.map +1 -0
  63. package/dist/visible-width.js +163 -0
  64. package/dist/visible-width.js.map +1 -0
  65. package/dist/windowed-container.d.ts +63 -0
  66. package/dist/windowed-container.d.ts.map +1 -0
  67. package/dist/windowed-container.js +259 -0
  68. package/dist/windowed-container.js.map +1 -0
  69. package/package.json +1 -1
package/dist/tui.js CHANGED
@@ -6,30 +6,12 @@ import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { performance } from "node:perf_hooks";
8
8
  import { isKeyRelease, matchesKey } from "./keys.js";
9
+ import { extractKittyImageIds, LineResetMemo, SEGMENT_RESET } from "./line-reset-memo.js";
10
+ import { maxChildGeneration, nextRenderGeneration, releaseRenderCache } from "./render-generation.js";
9
11
  import { finishTerminalFrame } from "./terminal-final-frame.js";
10
12
  import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
11
- import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
12
- const KITTY_SEQUENCE_PREFIX = "\x1b_G";
13
- function extractKittyImageIds(line) {
14
- const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
15
- if (sequenceStart === -1)
16
- return [];
17
- const paramsStart = sequenceStart + KITTY_SEQUENCE_PREFIX.length;
18
- const paramsEnd = line.indexOf(";", paramsStart);
19
- if (paramsEnd === -1)
20
- return [];
21
- const params = line.slice(paramsStart, paramsEnd);
22
- for (const param of params.split(",")) {
23
- const [key, value] = param.split("=", 2);
24
- if (key !== "i" || value === undefined)
25
- continue;
26
- const id = Number(value);
27
- if (Number.isInteger(id) && id > 0 && id <= 0xffffffff) {
28
- return [id];
29
- }
30
- }
31
- return [];
32
- }
13
+ import { isTermuxSession, REPAINT_BUDGET_SCREENS, ResizeResync } from "./terminal-resync.js";
14
+ import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
33
15
  /** Type guard to check if a component implements Focusable */
34
16
  export function isFocusable(component) {
35
17
  return component !== null && "focused" in component;
@@ -44,51 +26,55 @@ export const CURSOR_MARKER = "\x1b_pi:c\x07";
44
26
  export { visibleWidth };
45
27
  /** Parse a SizeValue into absolute value given a reference size */
46
28
  function parseSizeValue(value, referenceSize) {
47
- if (value === undefined)
48
- return undefined;
49
- if (typeof value === "number")
29
+ if (value === undefined || typeof value === "number")
50
30
  return value;
51
31
  // Parse percentage string like "50%"
52
32
  const match = value.match(/^(\d+(?:\.\d+)?)%$/);
53
- if (match) {
54
- return Math.floor((referenceSize * parseFloat(match[1])) / 100);
55
- }
56
- return undefined;
57
- }
58
- function isTermuxSession() {
59
- return Boolean(process.env.TERMUX_VERSION);
33
+ return match ? Math.floor((referenceSize * parseFloat(match[1])) / 100) : undefined;
60
34
  }
61
35
  /**
62
36
  * Container - a component that contains other components
63
37
  */
64
38
  export class Container {
65
39
  children = [];
40
+ /** Stamp of the last structural change (see render-generation.ts). */
41
+ structureGeneration = 0;
66
42
  addChild(component) {
67
43
  this.children.push(component);
44
+ this.structureGeneration = nextRenderGeneration();
68
45
  }
69
46
  removeChild(component) {
70
47
  const index = this.children.indexOf(component);
71
48
  if (index !== -1) {
72
49
  this.children.splice(index, 1);
50
+ this.structureGeneration = nextRenderGeneration();
73
51
  }
74
52
  }
75
53
  clear() {
76
54
  this.children = [];
55
+ this.structureGeneration = nextRenderGeneration();
56
+ }
57
+ /** Raises whenever this container's child list or any descendant changes. */
58
+ getRenderGeneration() {
59
+ return maxChildGeneration(this.structureGeneration, this.children);
77
60
  }
78
61
  invalidate() {
79
62
  for (const child of this.children) {
80
63
  child.invalidate?.();
81
64
  }
82
65
  }
66
+ /** Forwards to children; a container keeps no render cache of its own. */
67
+ releaseRenderCache() {
68
+ for (const child of this.children)
69
+ releaseRenderCache(child);
70
+ }
83
71
  render(width) {
84
- const lines = [];
85
- for (const child of this.children) {
86
- const childLines = child.render(width);
87
- for (const line of childLines) {
88
- lines.push(line);
89
- }
90
- }
91
- return lines;
72
+ const parts = [];
73
+ for (const child of this.children)
74
+ parts.push(child.render(width));
75
+ // Always a fresh array (callers mutate it). Native concat bulk-copies large
76
+ // children such as a windowed transcript instead of pushing row by row.
77
+ return parts.length < 8192 ? [].concat(...parts) : parts.flat();
92
78
  }
93
79
  }
94
80
  /**
@@ -98,6 +84,7 @@ export class TUI extends Container {
98
84
  terminal;
99
85
  previousLines = [];
100
86
  previousKittyImageIds = new Set();
87
+ lineResets = new LineResetMemo();
101
88
  previousWidth = 0;
102
89
  previousHeight = 0;
103
90
  focusedComponent = null;
@@ -108,12 +95,6 @@ export class TUI extends Container {
108
95
  renderTimer;
109
96
  lastRenderAt = 0;
110
97
  static MIN_RENDER_INTERVAL_MS = 16;
111
- /**
112
- * How far back a clearing repair repaint may reach, in viewport screens.
113
- * Bounds the scrollback churn of fixing rows that already scrolled off
114
- * (see the repaint budget note in doRender's fullRender helper).
115
- */
116
- static REPAINT_BUDGET_SCREENS = 4;
117
98
  cursorRow = 0; // Logical cursor row (end of rendered content)
118
99
  hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
119
100
  showHardwareCursor = process.env.OMK_HARDWARE_CURSOR === "1";
@@ -123,6 +104,7 @@ export class TUI extends Container {
123
104
  static MIN_CONTENT_COLUMNS = 20;
124
105
  maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
125
106
  previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
107
+ resizeResync = new ResizeResync(); // pre-overlay rows of recent frames, for height resizes
126
108
  fullRedrawCount = 0;
127
109
  stopped = false;
128
110
  // Overlay stack for modal components rendered on top of base content
@@ -139,6 +121,10 @@ export class TUI extends Container {
139
121
  get fullRedraws() {
140
122
  return this.fullRedrawCount;
141
123
  }
124
+ /** First buffer row inside the viewport as of the last render (0 while the whole frame fits). */
125
+ get viewportTop() {
126
+ return this.previousViewportTop;
127
+ }
142
128
  getShowHardwareCursor() {
143
129
  return this.showHardwareCursor;
144
130
  }
@@ -807,26 +793,6 @@ export class TUI extends Container {
807
793
  }
808
794
  return result;
809
795
  }
810
- static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
811
- applyLineResets(lines) {
812
- const reset = TUI.SEGMENT_RESET;
813
- for (let i = 0; i < lines.length; i++) {
814
- const line = lines[i];
815
- if (!isImageLine(line)) {
816
- lines[i] = normalizeTerminalOutput(line) + reset;
817
- }
818
- }
819
- return lines;
820
- }
821
- collectKittyImageIds(lines) {
822
- const ids = new Set();
823
- for (const line of lines) {
824
- for (const id of extractKittyImageIds(line)) {
825
- ids.add(id);
826
- }
827
- }
828
- return ids;
829
- }
830
796
  deleteKittyImages(ids) {
831
797
  let buffer = "";
832
798
  for (const id of ids) {
@@ -835,6 +801,10 @@ export class TUI extends Container {
835
801
  return buffer;
836
802
  }
837
803
  expandLastChangedForKittyImages(firstChanged, lastChanged) {
804
+ // previousKittyImageIds is the id set of exactly these previousLines: when it
805
+ // is empty no row can match, so skip the O(history) scan.
806
+ if (this.previousKittyImageIds.size === 0)
807
+ return lastChanged;
838
808
  let expandedLastChanged = lastChanged;
839
809
  for (let i = firstChanged; i < this.previousLines.length; i++) {
840
810
  if (extractKittyImageIds(this.previousLines[i]).length > 0) {
@@ -872,7 +842,7 @@ export class TUI extends Container {
872
842
  const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
873
843
  const afterPad = Math.max(0, afterTarget - base.afterWidth);
874
844
  // Compose result
875
- const r = TUI.SEGMENT_RESET;
845
+ const r = SEGMENT_RESET;
876
846
  const result = base.before +
877
847
  " ".repeat(beforePad) +
878
848
  r +
@@ -938,20 +908,22 @@ export class TUI extends Container {
938
908
  // Render all components to get new lines. Children render at the content
939
909
  // width (terminal minus the reserved right gutter); overlays composite at
940
910
  // full-terminal coordinates below, so pinned rails own the gutter columns.
941
- let newLines = finalLines ?? this.render(width - this.resolveRightGutter(width, height));
911
+ let newLines = this.resizeResync.track(finalLines ?? this.render(width - this.resolveRightGutter(width, height)));
942
912
  // Composite overlays into the rendered lines (before differential compare)
943
913
  if (finalLines === undefined && this.overlayStack.length > 0) {
944
914
  newLines = this.compositeOverlays(newLines, width, height);
945
915
  }
946
916
  // Extract cursor position before applying line resets (marker must be found first)
947
917
  const cursorPos = this.extractCursorPosition(newLines, height);
948
- newLines = this.applyLineResets(newLines);
918
+ newLines = this.lineResets.apply(newLines);
949
919
  // Helper to optionally clear the visible viewport and render all new lines
950
- const fullRender = (clear, fromRow) => {
920
+ const fullRender = (clear, fromRow, resync = "") => {
951
921
  this.fullRedrawCount += 1;
952
922
  let buffer = "\x1b[?2026h"; // Begin synchronized output
953
923
  if (clear) {
954
- buffer += this.deleteKittyImages(this.previousKittyImageIds);
924
+ // A height resize first rewrites, relative to the cursor, the rows
925
+ // the resize may have cost scrollback (see resyncAfterResize).
926
+ buffer += this.deleteKittyImages(this.previousKittyImageIds) + resync;
955
927
  // Repaint the visible screen IN PLACE: home the cursor, erase every row
956
928
  // as it is rewritten below, then erase whatever is left over.
957
929
  // \x1b[2J (erase all) is deliberately not used: conpty/Windows Terminal
@@ -987,7 +959,7 @@ export class TUI extends Container {
987
959
  // destroys more history than it repairs (a long report loses its
988
960
  // beginning). Rows older than the budget keep their existing copy.
989
961
  // Without fromRow the clamp collapses to tailStart, i.e. tail-only.
990
- const repairStart = newLines.length - height * TUI.REPAINT_BUDGET_SCREENS;
962
+ const repairStart = newLines.length - height * REPAINT_BUDGET_SCREENS;
991
963
  const firstPrinted = clear ? Math.min(Math.max(fromRow ?? tailStart, repairStart), tailStart) : 0;
992
964
  for (let i = firstPrinted; i < newLines.length; i++) {
993
965
  if (i > firstPrinted)
@@ -1010,13 +982,12 @@ export class TUI extends Container {
1010
982
  this.previousViewportTop = Math.max(0, bufferLength - height);
1011
983
  this.positionHardwareCursor(cursorPos, newLines.length);
1012
984
  this.previousLines = newLines;
1013
- this.previousKittyImageIds = this.collectKittyImageIds(newLines);
985
+ this.previousKittyImageIds = this.lineResets.kittyImageIds;
1014
986
  this.previousWidth = width;
1015
987
  this.previousHeight = height;
1016
988
  };
1017
- const debugRedraw = process.env.OMK_DEBUG_REDRAW === "1";
1018
989
  const logRedraw = (reason) => {
1019
- if (!debugRedraw)
990
+ if (process.env.OMK_DEBUG_REDRAW !== "1")
1020
991
  return;
1021
992
  const agentDir = process.env.OMK_CODING_AGENT_DIR ?? path.join(os.homedir(), ".omk", "agent");
1022
993
  const logPath = path.join(agentDir, "omk-debug.log");
@@ -1039,22 +1010,29 @@ export class TUI extends Container {
1039
1010
  // but Termux changes height when the software keyboard shows or hides.
1040
1011
  // In that environment, a full redraw causes the entire history to replay on every toggle.
1041
1012
  if (heightChanged && !isTermuxSession()) {
1042
- logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
1043
- fullRender(true);
1013
+ const resync = this.resizeResync.after(newLines, this.hardwareCursorRow, height);
1014
+ logRedraw(`terminal height changed (${this.previousHeight} -> ${height}; ${this.resizeResync.detail})`);
1015
+ fullRender(true, undefined, resync);
1044
1016
  return;
1045
1017
  }
1046
1018
  // Find first and last changed lines
1019
+ // Scan inward from both ends: unchanged rows compare by reference, and a
1020
+ // change far above the viewport (which shifts every row below it) no longer
1021
+ // costs a content comparison per history row.
1047
1022
  let firstChanged = -1;
1048
1023
  let lastChanged = -1;
1049
1024
  const maxLines = Math.max(newLines.length, this.previousLines.length);
1025
+ const rowDiffers = (i) => (i < this.previousLines.length ? this.previousLines[i] : "") !== (i < newLines.length ? newLines[i] : "");
1050
1026
  for (let i = 0; i < maxLines; i++) {
1051
- const oldLine = i < this.previousLines.length ? this.previousLines[i] : "";
1052
- const newLine = i < newLines.length ? newLines[i] : "";
1053
- if (oldLine !== newLine) {
1054
- if (firstChanged === -1) {
1055
- firstChanged = i;
1056
- }
1027
+ if (rowDiffers(i)) {
1028
+ firstChanged = i;
1029
+ break;
1030
+ }
1031
+ }
1032
+ for (let i = maxLines - 1; firstChanged !== -1 && i >= firstChanged; i--) {
1033
+ if (rowDiffers(i)) {
1057
1034
  lastChanged = i;
1035
+ break;
1058
1036
  }
1059
1037
  }
1060
1038
  if (firstChanged !== -1) {
@@ -1134,7 +1112,7 @@ export class TUI extends Container {
1134
1112
  }
1135
1113
  this.positionHardwareCursor(cursorPos, newLines.length);
1136
1114
  this.previousLines = newLines;
1137
- this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1115
+ this.previousKittyImageIds = this.lineResets.kittyImageIds;
1138
1116
  this.previousWidth = width;
1139
1117
  this.previousHeight = height;
1140
1118
  this.previousViewportTop = prevViewportTop;
@@ -1270,7 +1248,7 @@ export class TUI extends Container {
1270
1248
  // Position hardware cursor for IME
1271
1249
  this.positionHardwareCursor(cursorPos, newLines.length);
1272
1250
  this.previousLines = newLines;
1273
- this.previousKittyImageIds = this.collectKittyImageIds(newLines);
1251
+ this.previousKittyImageIds = this.lineResets.kittyImageIds;
1274
1252
  this.previousWidth = width;
1275
1253
  this.previousHeight = height;
1276
1254
  }