@greppleai/grip-tui 0.0.0-stage → 0.1.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.
Files changed (158) hide show
  1. package/README.md +818 -2
  2. package/dist/autocomplete.d.ts +48 -0
  3. package/dist/autocomplete.d.ts.map +1 -0
  4. package/dist/autocomplete.js +566 -0
  5. package/dist/autocomplete.js.map +1 -0
  6. package/dist/components/alt-screen-flash.d.ts +13 -0
  7. package/dist/components/alt-screen-flash.d.ts.map +1 -0
  8. package/dist/components/alt-screen-flash.js +37 -0
  9. package/dist/components/alt-screen-flash.js.map +1 -0
  10. package/dist/components/box.d.ts +22 -0
  11. package/dist/components/box.d.ts.map +1 -0
  12. package/dist/components/box.js +104 -0
  13. package/dist/components/box.js.map +1 -0
  14. package/dist/components/cancellable-loader.d.ts +22 -0
  15. package/dist/components/cancellable-loader.d.ts.map +1 -0
  16. package/dist/components/cancellable-loader.js +35 -0
  17. package/dist/components/cancellable-loader.js.map +1 -0
  18. package/dist/components/editor.d.ts +292 -0
  19. package/dist/components/editor.d.ts.map +1 -0
  20. package/dist/components/editor.js +1887 -0
  21. package/dist/components/editor.js.map +1 -0
  22. package/dist/components/h-stack.d.ts +7 -0
  23. package/dist/components/h-stack.d.ts.map +1 -0
  24. package/dist/components/h-stack.js +43 -0
  25. package/dist/components/h-stack.js.map +1 -0
  26. package/dist/components/image.d.ts +32 -0
  27. package/dist/components/image.d.ts.map +1 -0
  28. package/dist/components/image.js +76 -0
  29. package/dist/components/image.js.map +1 -0
  30. package/dist/components/input.d.ts +46 -0
  31. package/dist/components/input.d.ts.map +1 -0
  32. package/dist/components/input.js +352 -0
  33. package/dist/components/input.js.map +1 -0
  34. package/dist/components/loader.d.ts +32 -0
  35. package/dist/components/loader.d.ts.map +1 -0
  36. package/dist/components/loader.js +80 -0
  37. package/dist/components/loader.js.map +1 -0
  38. package/dist/components/markdown.d.ts +132 -0
  39. package/dist/components/markdown.d.ts.map +1 -0
  40. package/dist/components/markdown.js +717 -0
  41. package/dist/components/markdown.js.map +1 -0
  42. package/dist/components/scroll-view.d.ts +53 -0
  43. package/dist/components/scroll-view.d.ts.map +1 -0
  44. package/dist/components/scroll-view.js +173 -0
  45. package/dist/components/scroll-view.js.map +1 -0
  46. package/dist/components/select-list.d.ts +50 -0
  47. package/dist/components/select-list.d.ts.map +1 -0
  48. package/dist/components/select-list.js +159 -0
  49. package/dist/components/select-list.js.map +1 -0
  50. package/dist/components/settings-list.d.ts +52 -0
  51. package/dist/components/settings-list.d.ts.map +1 -0
  52. package/dist/components/settings-list.js +187 -0
  53. package/dist/components/settings-list.js.map +1 -0
  54. package/dist/components/spacer.d.ts +12 -0
  55. package/dist/components/spacer.d.ts.map +1 -0
  56. package/dist/components/spacer.js +23 -0
  57. package/dist/components/spacer.js.map +1 -0
  58. package/dist/components/stack.d.ts +32 -0
  59. package/dist/components/stack.d.ts.map +1 -0
  60. package/dist/components/stack.js +122 -0
  61. package/dist/components/stack.js.map +1 -0
  62. package/dist/components/text.d.ts +19 -0
  63. package/dist/components/text.d.ts.map +1 -0
  64. package/dist/components/text.js +89 -0
  65. package/dist/components/text.js.map +1 -0
  66. package/dist/components/truncated-text.d.ts +13 -0
  67. package/dist/components/truncated-text.d.ts.map +1 -0
  68. package/dist/components/truncated-text.js +51 -0
  69. package/dist/components/truncated-text.js.map +1 -0
  70. package/dist/components/v-stack.d.ts +8 -0
  71. package/dist/components/v-stack.d.ts.map +1 -0
  72. package/dist/components/v-stack.js +27 -0
  73. package/dist/components/v-stack.js.map +1 -0
  74. package/dist/editor-component.d.ts +39 -0
  75. package/dist/editor-component.d.ts.map +1 -0
  76. package/dist/editor-component.js +2 -0
  77. package/dist/editor-component.js.map +1 -0
  78. package/dist/fuzzy.d.ts +16 -0
  79. package/dist/fuzzy.d.ts.map +1 -0
  80. package/dist/fuzzy.js +101 -0
  81. package/dist/fuzzy.js.map +1 -0
  82. package/dist/index.d.ts +30 -0
  83. package/dist/index.d.ts.map +1 -0
  84. package/dist/index.js +41 -0
  85. package/dist/index.js.map +1 -0
  86. package/dist/keybindings.d.ts +243 -0
  87. package/dist/keybindings.d.ts.map +1 -0
  88. package/dist/keybindings.js +209 -0
  89. package/dist/keybindings.js.map +1 -0
  90. package/dist/keys.d.ts +184 -0
  91. package/dist/keys.d.ts.map +1 -0
  92. package/dist/keys.js +1053 -0
  93. package/dist/keys.js.map +1 -0
  94. package/dist/kill-ring.d.ts +30 -0
  95. package/dist/kill-ring.d.ts.map +1 -0
  96. package/dist/kill-ring.js +37 -0
  97. package/dist/kill-ring.js.map +1 -0
  98. package/dist/latex.d.ts +10 -0
  99. package/dist/latex.d.ts.map +1 -0
  100. package/dist/latex.js +1406 -0
  101. package/dist/latex.js.map +1 -0
  102. package/dist/layout-node.d.ts +43 -0
  103. package/dist/layout-node.d.ts.map +1 -0
  104. package/dist/layout-node.js +6 -0
  105. package/dist/layout-node.js.map +1 -0
  106. package/dist/layout.d.ts +40 -0
  107. package/dist/layout.d.ts.map +1 -0
  108. package/dist/layout.js +386 -0
  109. package/dist/layout.js.map +1 -0
  110. package/dist/native-modifiers.d.ts +3 -0
  111. package/dist/native-modifiers.d.ts.map +1 -0
  112. package/dist/native-modifiers.js +53 -0
  113. package/dist/native-modifiers.js.map +1 -0
  114. package/dist/stdin-buffer.d.ts +58 -0
  115. package/dist/stdin-buffer.d.ts.map +1 -0
  116. package/dist/stdin-buffer.js +318 -0
  117. package/dist/stdin-buffer.js.map +1 -0
  118. package/dist/terminal-control.d.ts +5 -0
  119. package/dist/terminal-control.d.ts.map +1 -0
  120. package/dist/terminal-control.js +26 -0
  121. package/dist/terminal-control.js.map +1 -0
  122. package/dist/terminal-image.d.ts +112 -0
  123. package/dist/terminal-image.d.ts.map +1 -0
  124. package/dist/terminal-image.js +508 -0
  125. package/dist/terminal-image.js.map +1 -0
  126. package/dist/terminal.d.ts +117 -0
  127. package/dist/terminal.d.ts.map +1 -0
  128. package/dist/terminal.js +490 -0
  129. package/dist/terminal.js.map +1 -0
  130. package/dist/tui-alt-screen.d.ts +114 -0
  131. package/dist/tui-alt-screen.d.ts.map +1 -0
  132. package/dist/tui-alt-screen.js +950 -0
  133. package/dist/tui-alt-screen.js.map +1 -0
  134. package/dist/tui-main-screen.d.ts +52 -0
  135. package/dist/tui-main-screen.d.ts.map +1 -0
  136. package/dist/tui-main-screen.js +514 -0
  137. package/dist/tui-main-screen.js.map +1 -0
  138. package/dist/tui.d.ts +295 -0
  139. package/dist/tui.d.ts.map +1 -0
  140. package/dist/tui.js +868 -0
  141. package/dist/tui.js.map +1 -0
  142. package/dist/undo-stack.d.ts +17 -0
  143. package/dist/undo-stack.d.ts.map +1 -0
  144. package/dist/undo-stack.js +25 -0
  145. package/dist/undo-stack.js.map +1 -0
  146. package/dist/utils.d.ts +96 -0
  147. package/dist/utils.d.ts.map +1 -0
  148. package/dist/utils.js +1106 -0
  149. package/dist/utils.js.map +1 -0
  150. package/dist/word-navigation.d.ts +25 -0
  151. package/dist/word-navigation.d.ts.map +1 -0
  152. package/dist/word-navigation.js +94 -0
  153. package/dist/word-navigation.js.map +1 -0
  154. package/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
  155. package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
  156. package/native/win32/prebuilds/win32-arm64/win32-console-mode.node +0 -0
  157. package/native/win32/prebuilds/win32-x64/win32-console-mode.node +0 -0
  158. package/package.json +47 -5
package/dist/tui.js ADDED
@@ -0,0 +1,868 @@
1
+ /**
2
+ * Minimal TUI implementation with differential rendering
3
+ */
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+ import { performance } from "node:perf_hooks";
7
+ import { isKeyRelease, matchesKey } from "./keys.js";
8
+ import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
9
+ import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
10
+ /** Type guard to check if a component implements Focusable */
11
+ export function isFocusable(component) {
12
+ return component !== null && "focused" in component;
13
+ }
14
+ /**
15
+ * Cursor position marker - APC (Application Program Command) sequence.
16
+ * This is a zero-width escape sequence that terminals ignore.
17
+ * Components emit this at the cursor position when focused.
18
+ * TUI finds and strips this marker, then positions the hardware cursor there.
19
+ */
20
+ export const CURSOR_MARKER = "\x1b_pi:c\x07";
21
+ export { visibleWidth };
22
+ /** Parse a SizeValue into absolute value given a reference size */
23
+ function parseSizeValue(value, referenceSize) {
24
+ if (value === undefined)
25
+ return undefined;
26
+ if (typeof value === "number")
27
+ return value;
28
+ // Parse percentage string like "50%"
29
+ const match = value.match(/^(\d+(?:\.\d+)?)%$/);
30
+ if (match) {
31
+ return Math.floor((referenceSize * parseFloat(match[1])) / 100);
32
+ }
33
+ return undefined;
34
+ }
35
+ /**
36
+ * Container - a component that contains other components
37
+ */
38
+ export class Container {
39
+ children = [];
40
+ addChild(component) {
41
+ this.children.push(component);
42
+ }
43
+ removeChild(component) {
44
+ const index = this.children.indexOf(component);
45
+ if (index !== -1) {
46
+ this.children.splice(index, 1);
47
+ }
48
+ }
49
+ clear() {
50
+ this.children = [];
51
+ }
52
+ invalidate() {
53
+ for (const child of this.children) {
54
+ child.invalidate?.();
55
+ }
56
+ }
57
+ render(width) {
58
+ const lines = [];
59
+ for (const child of this.children) {
60
+ const childLines = child.render(width);
61
+ for (const line of childLines) {
62
+ lines.push(line);
63
+ }
64
+ }
65
+ return lines;
66
+ }
67
+ }
68
+ /**
69
+ * TUI - Main class for managing terminal UI with differential rendering
70
+ */
71
+ const SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
72
+ /** Composite overlay content into a terminal line at a fixed column. */
73
+ export function compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
74
+ if (isImageLine(baseLine))
75
+ return baseLine;
76
+ const afterStart = startCol + overlayWidth;
77
+ const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
78
+ const overlay = sliceWithWidth(overlayLine, 0, overlayWidth, true);
79
+ const beforePad = Math.max(0, startCol - base.beforeWidth);
80
+ const overlayPad = Math.max(0, overlayWidth - overlay.width);
81
+ const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
82
+ const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
83
+ const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
84
+ const afterPad = Math.max(0, afterTarget - base.afterWidth);
85
+ const result = base.before +
86
+ " ".repeat(beforePad) +
87
+ SEGMENT_RESET +
88
+ overlay.text +
89
+ " ".repeat(overlayPad) +
90
+ SEGMENT_RESET +
91
+ base.after +
92
+ " ".repeat(afterPad);
93
+ return visibleWidth(result) <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
94
+ }
95
+ export const VIEWPORT_TUI = Symbol.for("@greppleai/grip-tui/viewport");
96
+ export function isViewportTUI(tui) {
97
+ return tui[VIEWPORT_TUI] === true;
98
+ }
99
+ export class TuiBase extends Container {
100
+ terminal;
101
+ focusedComponent = null;
102
+ inputListeners = new Set();
103
+ /** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
104
+ onDebug;
105
+ renderRequested = false;
106
+ immediateRenderScheduled = false;
107
+ renderTimer;
108
+ lastRenderAt = 0;
109
+ static MIN_RENDER_INTERVAL_MS = 16;
110
+ showHardwareCursor = process.env.GRIP_HARDWARE_CURSOR === "1";
111
+ clearOnShrink = process.env.GRIP_CLEAR_ON_SHRINK === "1";
112
+ fullRedrawCount = 0;
113
+ stopped = false;
114
+ logDirectory;
115
+ // Overlay stack for modal components rendered on top of base content
116
+ focusOrderCounter = 0;
117
+ overlayStack = [];
118
+ get hasOverlayEntries() {
119
+ return this.overlayStack.length > 0;
120
+ }
121
+ overlayFocusRestore = { status: "inactive" };
122
+ constructor(terminal, showHardwareCursor, logDirectory) {
123
+ super();
124
+ this.terminal = terminal;
125
+ this.logDirectory =
126
+ logDirectory ?? process.env.GRIP_CODING_AGENT_DIR ?? path.join(os.homedir(), ".grip", "agent");
127
+ if (showHardwareCursor !== undefined) {
128
+ this.showHardwareCursor = showHardwareCursor;
129
+ }
130
+ }
131
+ resetRenderState() { }
132
+ beforeTerminalStart() { }
133
+ afterTerminalStart() { }
134
+ onTerminalResize() { }
135
+ onOverlayStackChanged() { }
136
+ beforeTerminalStop(_options) { }
137
+ afterTerminalStop(_options) { }
138
+ get fullRedraws() {
139
+ return this.fullRedrawCount;
140
+ }
141
+ getShowHardwareCursor() {
142
+ return this.showHardwareCursor;
143
+ }
144
+ setShowHardwareCursor(enabled) {
145
+ if (this.showHardwareCursor === enabled)
146
+ return;
147
+ this.showHardwareCursor = enabled;
148
+ if (!enabled) {
149
+ this.terminal.hideCursor();
150
+ }
151
+ this.requestRender();
152
+ }
153
+ getClearOnShrink() {
154
+ return this.clearOnShrink;
155
+ }
156
+ /**
157
+ * Set whether to trigger full re-render when content shrinks.
158
+ * When true (default), empty rows are cleared when content shrinks.
159
+ * When false, empty rows remain (reduces redraws on slower terminals).
160
+ */
161
+ setClearOnShrink(enabled) {
162
+ this.clearOnShrink = enabled;
163
+ }
164
+ getFocusedComponent() {
165
+ return this.focusedComponent;
166
+ }
167
+ setFocus(component) {
168
+ this.setFocusInternal({ component, overlayFocusRestore: "clear" });
169
+ }
170
+ resolveNonOverlayFocus(nextFocus, previousFocus, restoreState) {
171
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
172
+ if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
173
+ return this.resolveBlockedOverlayFocusResume(restoreState);
174
+ }
175
+ this.overlayFocusRestore = {
176
+ status: "blocked",
177
+ overlay: restoreState.overlay,
178
+ blockedBy: nextFocus,
179
+ resume: restoreState.resume,
180
+ };
181
+ return nextFocus;
182
+ }
183
+ const previousFocusedOverlay = previousFocus
184
+ ? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
185
+ : undefined;
186
+ if (previousFocusedOverlay &&
187
+ restoreState.status !== "inactive" &&
188
+ restoreState.overlay === previousFocusedOverlay &&
189
+ !this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)) {
190
+ this.overlayFocusRestore = {
191
+ status: "blocked",
192
+ overlay: previousFocusedOverlay,
193
+ blockedBy: nextFocus,
194
+ resume: { status: "restore-overlay" },
195
+ };
196
+ }
197
+ return nextFocus;
198
+ }
199
+ resolveNullFocus(previousFocus, restoreState, overlayFocusRestore) {
200
+ if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
201
+ return this.resolveBlockedOverlayFocusResume(restoreState);
202
+ }
203
+ if (overlayFocusRestore === "clear")
204
+ this.clearOverlayFocusRestore();
205
+ return null;
206
+ }
207
+ resolveFocusTarget(component, overlayFocusRestore) {
208
+ const previousFocus = this.focusedComponent;
209
+ const restoreState = this.getVisibleOverlayFocusRestore();
210
+ if (component === null)
211
+ return this.resolveNullFocus(previousFocus, restoreState, overlayFocusRestore);
212
+ const isOverlay = this.overlayStack.some((entry) => entry.component === component);
213
+ if (isOverlay)
214
+ return component;
215
+ return this.resolveNonOverlayFocus(component, previousFocus, restoreState);
216
+ }
217
+ applyFocus(nextFocus) {
218
+ if (isFocusable(this.focusedComponent))
219
+ this.focusedComponent.focused = false;
220
+ this.focusedComponent = nextFocus;
221
+ if (isFocusable(nextFocus))
222
+ nextFocus.focused = true;
223
+ const focusedOverlay = nextFocus
224
+ ? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
225
+ : undefined;
226
+ if (focusedOverlay)
227
+ this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
228
+ }
229
+ setFocusInternal({ component, overlayFocusRestore }) {
230
+ const nextFocus = this.resolveFocusTarget(component, overlayFocusRestore);
231
+ this.applyFocus(nextFocus);
232
+ }
233
+ clearOverlayFocusRestore() {
234
+ this.overlayFocusRestore = { status: "inactive" };
235
+ }
236
+ clearOverlayFocusRestoreFor(overlay) {
237
+ if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
238
+ this.clearOverlayFocusRestore();
239
+ }
240
+ }
241
+ resolveBlockedOverlayFocusResume(restoreState) {
242
+ if (restoreState.resume.status === "restore-overlay")
243
+ return restoreState.overlay.component;
244
+ this.clearOverlayFocusRestore();
245
+ return restoreState.resume.target;
246
+ }
247
+ getVisibleOverlayFocusRestore() {
248
+ const restoreState = this.overlayFocusRestore;
249
+ if (restoreState.status === "inactive")
250
+ return restoreState;
251
+ if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
252
+ return { status: "inactive" };
253
+ }
254
+ return restoreState;
255
+ }
256
+ isOverlayFocusAncestor(entry, component) {
257
+ const preFocusByComponent = new Map();
258
+ for (const overlay of this.overlayStack) {
259
+ if (!preFocusByComponent.has(overlay.component)) {
260
+ preFocusByComponent.set(overlay.component, overlay.preFocus);
261
+ }
262
+ }
263
+ const visited = new Set();
264
+ let current = entry.preFocus;
265
+ while (current && !visited.has(current)) {
266
+ visited.add(current);
267
+ if (current === component)
268
+ return true;
269
+ current = preFocusByComponent.get(current) ?? null;
270
+ }
271
+ return false;
272
+ }
273
+ retargetOverlayPreFocus(removed) {
274
+ for (const overlay of this.overlayStack) {
275
+ if (overlay !== removed && overlay.preFocus === removed.component) {
276
+ overlay.preFocus = removed.preFocus;
277
+ }
278
+ }
279
+ }
280
+ getMountedRoots() {
281
+ return this.children;
282
+ }
283
+ isComponentMounted(component) {
284
+ return this.getMountedRoots().some((child) => this.containsComponent(child, component));
285
+ }
286
+ containsComponent(root, target) {
287
+ if (root === target)
288
+ return true;
289
+ if (!(root instanceof Container))
290
+ return false;
291
+ return root.children.some((child) => this.containsComponent(child, target));
292
+ }
293
+ applyBlockedOverlayUnfocus(entry, restoreState, options) {
294
+ if (restoreState.status !== "blocked" ||
295
+ restoreState.overlay !== entry ||
296
+ this.focusedComponent !== restoreState.blockedBy) {
297
+ return false;
298
+ }
299
+ if (options) {
300
+ this.overlayFocusRestore = {
301
+ status: "blocked",
302
+ overlay: entry,
303
+ blockedBy: restoreState.blockedBy,
304
+ resume: { status: "focus-target", target: options.target },
305
+ };
306
+ }
307
+ else {
308
+ this.clearOverlayFocusRestore();
309
+ }
310
+ this.requestRender();
311
+ return true;
312
+ }
313
+ unfocusOverlay(entry, options) {
314
+ const isFocused = this.focusedComponent === entry.component;
315
+ const restoreState = this.overlayFocusRestore;
316
+ const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
317
+ if (!isFocused && !hasPendingRestore)
318
+ return;
319
+ if (this.applyBlockedOverlayUnfocus(entry, restoreState, options))
320
+ return;
321
+ this.clearOverlayFocusRestoreFor(entry);
322
+ if (isFocused || options) {
323
+ const topVisible = this.getTopmostVisibleOverlay();
324
+ const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
325
+ this.setFocus(options ? options.target : fallbackTarget);
326
+ }
327
+ this.requestRender();
328
+ }
329
+ /**
330
+ * Show an overlay component with configurable positioning and sizing.
331
+ * Returns a handle to control the overlay's visibility.
332
+ */
333
+ showOverlay(component, options) {
334
+ const entry = {
335
+ component,
336
+ ...(options === undefined ? {} : { options }),
337
+ preFocus: this.focusedComponent,
338
+ hidden: false,
339
+ focusOrder: ++this.focusOrderCounter,
340
+ visible: false,
341
+ };
342
+ this.overlayStack.push(entry);
343
+ entry.visible = this.isOverlayVisible(entry);
344
+ this.onOverlayStackChanged();
345
+ // Only focus if overlay is actually visible
346
+ if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
347
+ this.setFocus(component);
348
+ }
349
+ this.terminal.hideCursor();
350
+ this.requestRender();
351
+ // Return handle for controlling this overlay
352
+ return {
353
+ hide: () => {
354
+ const index = this.overlayStack.indexOf(entry);
355
+ if (index !== -1) {
356
+ this.clearOverlayFocusRestoreFor(entry);
357
+ this.retargetOverlayPreFocus(entry);
358
+ this.overlayStack.splice(index, 1);
359
+ this.onOverlayStackChanged();
360
+ // Restore focus if this overlay had focus
361
+ if (this.focusedComponent === component) {
362
+ const topVisible = this.getTopmostVisibleOverlay();
363
+ this.setFocus(topVisible?.component ?? entry.preFocus);
364
+ }
365
+ if (this.overlayStack.length === 0)
366
+ this.terminal.hideCursor();
367
+ this.requestRender();
368
+ }
369
+ },
370
+ setHidden: (hidden) => {
371
+ if (entry.hidden === hidden)
372
+ return;
373
+ entry.hidden = hidden;
374
+ entry.visible = this.isOverlayVisible(entry);
375
+ // Update focus when hiding/showing
376
+ if (hidden) {
377
+ this.clearOverlayFocusRestoreFor(entry);
378
+ // If this overlay had focus, move focus to next visible or preFocus
379
+ if (this.focusedComponent === component) {
380
+ const topVisible = this.getTopmostVisibleOverlay();
381
+ this.setFocus(topVisible?.component ?? entry.preFocus);
382
+ }
383
+ }
384
+ else {
385
+ // Restore focus to this overlay when showing (if it's actually visible)
386
+ if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
387
+ entry.focusOrder = ++this.focusOrderCounter;
388
+ this.setFocus(component);
389
+ }
390
+ }
391
+ this.onOverlayStackChanged();
392
+ this.requestRender();
393
+ },
394
+ isHidden: () => entry.hidden,
395
+ focus: () => {
396
+ if (!this.overlayStack.includes(entry) || !this.isOverlayVisible(entry))
397
+ return;
398
+ entry.focusOrder = ++this.focusOrderCounter;
399
+ this.setFocus(component);
400
+ this.requestRender();
401
+ },
402
+ unfocus: (unfocusOptions) => this.unfocusOverlay(entry, unfocusOptions),
403
+ isFocused: () => this.focusedComponent === component,
404
+ };
405
+ }
406
+ /** Hide the topmost overlay and restore previous focus. */
407
+ hideOverlay() {
408
+ const overlay = this.overlayStack[this.overlayStack.length - 1];
409
+ if (!overlay)
410
+ return;
411
+ this.clearOverlayFocusRestoreFor(overlay);
412
+ this.retargetOverlayPreFocus(overlay);
413
+ this.overlayStack.pop();
414
+ this.onOverlayStackChanged();
415
+ if (this.focusedComponent === overlay.component) {
416
+ // Find topmost visible overlay, or fall back to preFocus
417
+ const topVisible = this.getTopmostVisibleOverlay();
418
+ this.setFocus(topVisible?.component ?? overlay.preFocus);
419
+ }
420
+ if (this.overlayStack.length === 0)
421
+ this.terminal.hideCursor();
422
+ this.requestRender();
423
+ }
424
+ /** Check if there are any visible overlays */
425
+ hasOverlay() {
426
+ return this.overlayStack.some((o) => this.isOverlayVisible(o));
427
+ }
428
+ hasCapturingOverlay() {
429
+ return this.overlayStack.some((o) => !o.options?.nonCapturing && this.isOverlayVisible(o));
430
+ }
431
+ /** Check if an overlay entry is currently visible */
432
+ isOverlayVisible(entry) {
433
+ if (entry.hidden)
434
+ return false;
435
+ if (entry.options?.visible) {
436
+ return entry.options.visible(this.terminal.columns, this.terminal.rows);
437
+ }
438
+ return true;
439
+ }
440
+ /** Find the visual-frontmost visible capturing overlay, if any */
441
+ getTopmostVisibleOverlay() {
442
+ let topmost;
443
+ for (const overlay of this.overlayStack) {
444
+ if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay))
445
+ continue;
446
+ if (!topmost || overlay.focusOrder > topmost.focusOrder) {
447
+ topmost = overlay;
448
+ }
449
+ }
450
+ return topmost;
451
+ }
452
+ refreshOverlayVisibility() {
453
+ let newlyVisible;
454
+ for (const overlay of this.overlayStack) {
455
+ const visible = this.isOverlayVisible(overlay);
456
+ if (visible && !overlay.visible && !overlay.options?.nonCapturing) {
457
+ if (!newlyVisible || overlay.focusOrder > newlyVisible.focusOrder)
458
+ newlyVisible = overlay;
459
+ }
460
+ overlay.visible = visible;
461
+ }
462
+ return newlyVisible;
463
+ }
464
+ retargetFocusFromHiddenOverlay() {
465
+ const focusedOverlay = this.overlayStack.find((overlay) => overlay.component === this.focusedComponent);
466
+ if (!focusedOverlay || this.isOverlayVisible(focusedOverlay))
467
+ return;
468
+ const topVisible = this.getTopmostVisibleOverlay();
469
+ if (topVisible) {
470
+ this.setFocus(topVisible.component);
471
+ return;
472
+ }
473
+ this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
474
+ }
475
+ focusNewlyVisibleOverlay(newlyVisible) {
476
+ if (!newlyVisible)
477
+ return;
478
+ const currentFocusedOverlay = this.overlayStack.find((overlay) => overlay.component === this.focusedComponent && this.isOverlayVisible(overlay));
479
+ if (!currentFocusedOverlay || newlyVisible.focusOrder > currentFocusedOverlay.focusOrder) {
480
+ this.setFocus(newlyVisible.component);
481
+ }
482
+ }
483
+ restoreVisibleOverlayFocus() {
484
+ if (this.overlayStack.some((overlay) => overlay.component === this.focusedComponent))
485
+ return;
486
+ const restoreState = this.getVisibleOverlayFocusRestore();
487
+ if (restoreState.status === "eligible") {
488
+ this.setFocus(restoreState.overlay.component);
489
+ return;
490
+ }
491
+ if (restoreState.status !== "blocked" || restoreState.blockedBy === this.focusedComponent)
492
+ return;
493
+ if (restoreState.resume.status === "restore-overlay") {
494
+ this.setFocus(restoreState.overlay.component);
495
+ return;
496
+ }
497
+ this.clearOverlayFocusRestore();
498
+ this.setFocus(restoreState.resume.target);
499
+ }
500
+ reconcileOverlayFocus() {
501
+ const newlyVisible = this.refreshOverlayVisibility();
502
+ this.retargetFocusFromHiddenOverlay();
503
+ this.focusNewlyVisibleOverlay(newlyVisible);
504
+ this.restoreVisibleOverlayFocus();
505
+ }
506
+ invalidate() {
507
+ for (const root of this.getMountedRoots())
508
+ root.invalidate();
509
+ for (const overlay of this.overlayStack)
510
+ overlay.component.invalidate?.();
511
+ }
512
+ start() {
513
+ this.stopped = false;
514
+ try {
515
+ this.beforeTerminalStart();
516
+ this.terminal.start((data) => this.handleTerminalInput(data), () => {
517
+ this.reconcileOverlayFocus();
518
+ this.onTerminalResize();
519
+ this.requestRender();
520
+ });
521
+ this.afterTerminalStart();
522
+ this.terminal.hideCursor();
523
+ this.queryCellSize();
524
+ this.requestRender();
525
+ }
526
+ catch (error) {
527
+ // A terminal may throw after partially entering raw or alternate-screen mode.
528
+ // Route startup failures through the same complete cleanup path as signals.
529
+ try {
530
+ this.stop();
531
+ }
532
+ catch {
533
+ // Preserve the startup failure; cleanup is best-effort but exhaustive.
534
+ }
535
+ throw error;
536
+ }
537
+ }
538
+ addInputListener(listener) {
539
+ this.inputListeners.add(listener);
540
+ return () => {
541
+ this.inputListeners.delete(listener);
542
+ };
543
+ }
544
+ removeInputListener(listener) {
545
+ this.inputListeners.delete(listener);
546
+ }
547
+ queryCellSize() {
548
+ // Only query if terminal supports images (cell size is only used for image rendering)
549
+ if (!getCapabilities().images) {
550
+ return;
551
+ }
552
+ // Query terminal for cell size in pixels: CSI 16 t
553
+ // Response format: CSI 6 ; height ; width t
554
+ this.terminal.write("\x1b[16t");
555
+ }
556
+ stop(options) {
557
+ this.stopped = true;
558
+ this.renderRequested = false;
559
+ this.cancelRenderTimer();
560
+ let failure;
561
+ const cleanup = (action) => {
562
+ try {
563
+ action();
564
+ }
565
+ catch (error) {
566
+ failure ??= error;
567
+ }
568
+ };
569
+ cleanup(() => this.beforeTerminalStop(options));
570
+ cleanup(() => this.terminal.showCursor());
571
+ cleanup(() => this.terminal.stop());
572
+ // Alternate-screen exit and capability restoration must run even when
573
+ // keyboard/raw-mode shutdown reports an error.
574
+ cleanup(() => this.afterTerminalStop(options));
575
+ if (failure !== undefined)
576
+ throw failure;
577
+ }
578
+ renderNow(force = false) {
579
+ if (force)
580
+ this.resetRenderState();
581
+ this.renderRequested = false;
582
+ this.cancelRenderTimer();
583
+ this.lastRenderAt = performance.now();
584
+ this.doRender();
585
+ }
586
+ requestRender(force = false) {
587
+ if (force) {
588
+ this.resetRenderState();
589
+ this.requestImmediateRender();
590
+ return;
591
+ }
592
+ if (this.renderRequested)
593
+ return;
594
+ this.renderRequested = true;
595
+ process.nextTick(() => this.scheduleRender());
596
+ }
597
+ requestImmediateRender() {
598
+ this.cancelRenderTimer();
599
+ this.renderRequested = true;
600
+ if (this.immediateRenderScheduled)
601
+ return;
602
+ this.immediateRenderScheduled = true;
603
+ process.nextTick(() => {
604
+ this.immediateRenderScheduled = false;
605
+ if (this.stopped || !this.renderRequested)
606
+ return;
607
+ this.cancelRenderTimer();
608
+ this.renderRequested = false;
609
+ this.lastRenderAt = performance.now();
610
+ this.doRender();
611
+ });
612
+ }
613
+ cancelRenderTimer() {
614
+ if (!this.renderTimer)
615
+ return;
616
+ clearTimeout(this.renderTimer);
617
+ this.renderTimer = undefined;
618
+ }
619
+ scheduleRender() {
620
+ if (this.stopped || this.renderTimer || !this.renderRequested)
621
+ return;
622
+ const elapsed = performance.now() - this.lastRenderAt;
623
+ const delay = Math.max(0, TuiBase.MIN_RENDER_INTERVAL_MS - elapsed);
624
+ this.renderTimer = setTimeout(() => {
625
+ this.renderTimer = undefined;
626
+ if (this.stopped || !this.renderRequested)
627
+ return;
628
+ this.renderRequested = false;
629
+ this.lastRenderAt = performance.now();
630
+ this.doRender();
631
+ if (this.renderRequested)
632
+ this.scheduleRender();
633
+ }, delay);
634
+ }
635
+ applyInputListeners(data) {
636
+ if (this.inputListeners.size === 0)
637
+ return data;
638
+ let current = data;
639
+ for (const listener of this.inputListeners) {
640
+ const result = listener(current);
641
+ if (result?.consume)
642
+ return undefined;
643
+ if (result?.data !== undefined)
644
+ current = result.data;
645
+ }
646
+ return current.length === 0 ? undefined : current;
647
+ }
648
+ handleTerminalInput(data) {
649
+ this.reconcileOverlayFocus();
650
+ const filteredData = this.applyInputListeners(data);
651
+ if (filteredData === undefined)
652
+ return;
653
+ data = filteredData;
654
+ // Consume terminal cell size responses without blocking unrelated input.
655
+ if (this.consumeCellSizeResponse(data))
656
+ return;
657
+ // Global debug key handler (Shift+Ctrl+D)
658
+ if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
659
+ this.onDebug();
660
+ return;
661
+ }
662
+ // Pass input to focused component (including Ctrl+C).
663
+ // The focused component can decide how to handle Ctrl+C.
664
+ if (!this.focusedComponent?.handleInput)
665
+ return;
666
+ // Filter out key release events unless component opts in.
667
+ if (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease)
668
+ return;
669
+ this.focusedComponent.handleInput(data);
670
+ this.requestImmediateRender();
671
+ }
672
+ consumeCellSizeResponse(data) {
673
+ // Response format: ESC [ 6 ; height ; width t
674
+ const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
675
+ if (!match) {
676
+ return false;
677
+ }
678
+ const heightPx = parseInt(match[1], 10);
679
+ const widthPx = parseInt(match[2], 10);
680
+ if (heightPx <= 0 || widthPx <= 0) {
681
+ return true;
682
+ }
683
+ setCellDimensions({ widthPx, heightPx });
684
+ // Invalidate all components so images re-render with correct dimensions.
685
+ this.invalidate();
686
+ this.requestRender();
687
+ return true;
688
+ }
689
+ resolveOverlayBounds(options, termWidth, termHeight) {
690
+ const margin = typeof options.margin === "number"
691
+ ? { top: options.margin, right: options.margin, bottom: options.margin, left: options.margin }
692
+ : (options.margin ?? {});
693
+ const marginTop = Math.max(0, margin.top ?? 0);
694
+ const marginRight = Math.max(0, margin.right ?? 0);
695
+ const marginBottom = Math.max(0, margin.bottom ?? 0);
696
+ const marginLeft = Math.max(0, margin.left ?? 0);
697
+ return {
698
+ marginTop,
699
+ marginRight,
700
+ marginBottom,
701
+ marginLeft,
702
+ availableWidth: Math.max(1, termWidth - marginLeft - marginRight),
703
+ availableHeight: Math.max(1, termHeight - marginTop - marginBottom),
704
+ };
705
+ }
706
+ resolveOverlayWidth(options, termWidth, bounds) {
707
+ let width = parseSizeValue(options.width, termWidth) ?? Math.min(80, bounds.availableWidth);
708
+ if (options.minWidth !== undefined)
709
+ width = Math.max(width, options.minWidth);
710
+ return Math.max(1, Math.min(width, bounds.availableWidth));
711
+ }
712
+ resolveOverlayMaxHeight(options, termHeight, bounds) {
713
+ const maxHeight = parseSizeValue(options.maxHeight, termHeight);
714
+ return maxHeight === undefined ? undefined : Math.max(1, Math.min(maxHeight, bounds.availableHeight));
715
+ }
716
+ resolveOverlayRow(options, overlayHeight, bounds) {
717
+ if (options.row === undefined) {
718
+ return this.resolveAnchorRow(options.anchor ?? "center", overlayHeight, bounds.availableHeight, bounds.marginTop);
719
+ }
720
+ if (typeof options.row === "number")
721
+ return options.row;
722
+ const match = options.row.match(/^(\d+(?:\.\d+)?)%$/);
723
+ if (!match)
724
+ return this.resolveAnchorRow("center", overlayHeight, bounds.availableHeight, bounds.marginTop);
725
+ const maxRow = Math.max(0, bounds.availableHeight - overlayHeight);
726
+ return bounds.marginTop + Math.floor(maxRow * (parseFloat(match[1]) / 100));
727
+ }
728
+ resolveOverlayColumn(options, width, bounds) {
729
+ if (options.col === undefined) {
730
+ return this.resolveAnchorCol(options.anchor ?? "center", width, bounds.availableWidth, bounds.marginLeft);
731
+ }
732
+ if (typeof options.col === "number")
733
+ return options.col;
734
+ const match = options.col.match(/^(\d+(?:\.\d+)?)%$/);
735
+ if (!match)
736
+ return this.resolveAnchorCol("center", width, bounds.availableWidth, bounds.marginLeft);
737
+ const maxCol = Math.max(0, bounds.availableWidth - width);
738
+ return bounds.marginLeft + Math.floor(maxCol * (parseFloat(match[1]) / 100));
739
+ }
740
+ /** Resolve overlay layout from options. */
741
+ resolveOverlayLayout(options, overlayHeight, termWidth, termHeight) {
742
+ const resolvedOptions = options ?? {};
743
+ const bounds = this.resolveOverlayBounds(resolvedOptions, termWidth, termHeight);
744
+ const width = this.resolveOverlayWidth(resolvedOptions, termWidth, bounds);
745
+ const maxHeight = this.resolveOverlayMaxHeight(resolvedOptions, termHeight, bounds);
746
+ const effectiveHeight = maxHeight === undefined ? overlayHeight : Math.min(overlayHeight, maxHeight);
747
+ let row = this.resolveOverlayRow(resolvedOptions, effectiveHeight, bounds) + (resolvedOptions.offsetY ?? 0);
748
+ let col = this.resolveOverlayColumn(resolvedOptions, width, bounds) + (resolvedOptions.offsetX ?? 0);
749
+ row = Math.max(bounds.marginTop, Math.min(row, termHeight - bounds.marginBottom - effectiveHeight));
750
+ col = Math.max(bounds.marginLeft, Math.min(col, termWidth - bounds.marginRight - width));
751
+ return { width, row, col, maxHeight };
752
+ }
753
+ resolveAnchorRow(anchor, height, availHeight, marginTop) {
754
+ switch (anchor) {
755
+ case "top-left":
756
+ case "top-center":
757
+ case "top-right":
758
+ return marginTop;
759
+ case "bottom-left":
760
+ case "bottom-center":
761
+ case "bottom-right":
762
+ return marginTop + availHeight - height;
763
+ case "left-center":
764
+ case "center":
765
+ case "right-center":
766
+ return marginTop + Math.floor((availHeight - height) / 2);
767
+ }
768
+ }
769
+ resolveAnchorCol(anchor, width, availWidth, marginLeft) {
770
+ switch (anchor) {
771
+ case "top-left":
772
+ case "left-center":
773
+ case "bottom-left":
774
+ return marginLeft;
775
+ case "top-right":
776
+ case "right-center":
777
+ case "bottom-right":
778
+ return marginLeft + availWidth - width;
779
+ case "top-center":
780
+ case "center":
781
+ case "bottom-center":
782
+ return marginLeft + Math.floor((availWidth - width) / 2);
783
+ }
784
+ }
785
+ prepareOverlayRenderPlan(baseLineCount, termWidth, termHeight) {
786
+ const visibleEntries = this.overlayStack.filter((entry) => this.isOverlayVisible(entry));
787
+ visibleEntries.sort((left, right) => left.focusOrder - right.focusOrder);
788
+ const overlays = [];
789
+ let minimumLineCount = baseLineCount;
790
+ for (const entry of visibleEntries) {
791
+ const { width, maxHeight } = this.resolveOverlayLayout(entry.options, 0, termWidth, termHeight);
792
+ let overlayLines = entry.component.render(width);
793
+ if (maxHeight !== undefined && overlayLines.length > maxHeight) {
794
+ overlayLines = overlayLines.slice(0, maxHeight);
795
+ }
796
+ const { row, col } = this.resolveOverlayLayout(entry.options, overlayLines.length, termWidth, termHeight);
797
+ overlays.push({ lines: overlayLines, row, col, width });
798
+ minimumLineCount = Math.max(minimumLineCount, row + overlayLines.length);
799
+ }
800
+ return { overlays, minimumLineCount };
801
+ }
802
+ compositeRenderedOverlay(result, overlay, viewportStart, termWidth) {
803
+ for (let lineIndex = 0; lineIndex < overlay.lines.length; lineIndex++) {
804
+ const targetIndex = viewportStart + overlay.row + lineIndex;
805
+ if (targetIndex < 0 || targetIndex >= result.length)
806
+ continue;
807
+ const overlayLine = overlay.lines[lineIndex];
808
+ const truncatedLine = visibleWidth(overlayLine) > overlay.width
809
+ ? sliceByColumn(overlayLine, 0, overlay.width, true)
810
+ : overlayLine;
811
+ result[targetIndex] = this.compositeLineAt(result[targetIndex], truncatedLine, overlay.col, overlay.width, termWidth);
812
+ }
813
+ }
814
+ /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
815
+ compositeOverlays(lines, termWidth, termHeight) {
816
+ if (this.overlayStack.length === 0)
817
+ return lines;
818
+ const result = [...lines];
819
+ const plan = this.prepareOverlayRenderPlan(result.length, termWidth, termHeight);
820
+ const workingHeight = Math.max(result.length, termHeight, plan.minimumLineCount);
821
+ while (result.length < workingHeight)
822
+ result.push("");
823
+ const viewportStart = Math.max(0, workingHeight - termHeight);
824
+ for (const overlay of plan.overlays) {
825
+ this.compositeRenderedOverlay(result, overlay, viewportStart, termWidth);
826
+ }
827
+ return result;
828
+ }
829
+ applyLineResets(lines) {
830
+ const reset = SEGMENT_RESET;
831
+ for (let i = 0; i < lines.length; i++) {
832
+ const line = lines[i];
833
+ if (!isImageLine(line)) {
834
+ lines[i] = normalizeTerminalOutput(line) + reset;
835
+ }
836
+ }
837
+ return lines;
838
+ }
839
+ compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
840
+ return compositeTuiLine(baseLine, overlayLine, startCol, overlayWidth, totalWidth);
841
+ }
842
+ /**
843
+ * Find and extract cursor position from rendered lines.
844
+ * Searches for CURSOR_MARKER, calculates its position, and strips it from the output.
845
+ * Only scans the bottom terminal height lines (visible viewport).
846
+ * @param lines - Rendered lines to search
847
+ * @param height - Terminal height (visible viewport size)
848
+ * @returns Cursor position { row, col } or null if no marker found
849
+ */
850
+ extractCursorPosition(lines, height) {
851
+ // Only scan the bottom `height` lines (visible viewport)
852
+ const viewportTop = Math.max(0, lines.length - height);
853
+ for (let row = lines.length - 1; row >= viewportTop; row--) {
854
+ const line = lines[row];
855
+ const markerIndex = line.indexOf(CURSOR_MARKER);
856
+ if (markerIndex !== -1) {
857
+ // Calculate visual column (width of text before marker)
858
+ const beforeMarker = line.slice(0, markerIndex);
859
+ const col = visibleWidth(beforeMarker);
860
+ // Strip marker from the line
861
+ lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
862
+ return { row, col };
863
+ }
864
+ }
865
+ return null;
866
+ }
867
+ }
868
+ //# sourceMappingURL=tui.js.map