@jecruz/merc-tui 0.1.3 → 0.1.6
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/README.md +14 -1
- package/dist/autocomplete.d.ts +1 -0
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +85 -24
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/box.d.ts +1 -0
- package/dist/components/box.d.ts.map +1 -1
- package/dist/components/box.js +6 -1
- package/dist/components/box.js.map +1 -1
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +80 -115
- package/dist/components/editor.js.map +1 -1
- package/dist/components/image.d.ts +12 -0
- package/dist/components/image.d.ts.map +1 -1
- package/dist/components/image.js +45 -6
- package/dist/components/image.js.map +1 -1
- package/dist/components/input.d.ts.map +1 -1
- package/dist/components/input.js +7 -55
- package/dist/components/input.js.map +1 -1
- package/dist/components/markdown.d.ts +1 -0
- package/dist/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +18 -6
- package/dist/components/markdown.js.map +1 -1
- package/dist/components/settings-list.d.ts.map +1 -1
- package/dist/components/settings-list.js +4 -7
- package/dist/components/settings-list.js.map +1 -1
- package/dist/components/text.d.ts +1 -0
- package/dist/components/text.d.ts.map +1 -1
- package/dist/components/text.js +11 -5
- package/dist/components/text.js.map +1 -1
- package/dist/fuzzy.d.ts.map +1 -1
- package/dist/fuzzy.js +23 -22
- package/dist/fuzzy.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +9 -1
- package/dist/keys.js.map +1 -1
- package/dist/native-modifier-digests.d.ts +6 -0
- package/dist/native-modifier-digests.d.ts.map +1 -0
- package/dist/native-modifier-digests.js +6 -0
- package/dist/native-modifier-digests.js.map +1 -0
- package/dist/native-modifiers.d.ts +11 -0
- package/dist/native-modifiers.d.ts.map +1 -0
- package/dist/native-modifiers.js +98 -0
- package/dist/native-modifiers.js.map +1 -0
- package/dist/stdin-buffer.d.ts +25 -2
- package/dist/stdin-buffer.d.ts.map +1 -1
- package/dist/stdin-buffer.js +176 -3
- package/dist/stdin-buffer.js.map +1 -1
- package/dist/terminal-image.d.ts +10 -0
- package/dist/terminal-image.d.ts.map +1 -1
- package/dist/terminal-image.js +102 -17
- package/dist/terminal-image.js.map +1 -1
- package/dist/terminal.d.ts +17 -0
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +165 -56
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts +28 -7
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +322 -125
- package/dist/tui.js.map +1 -1
- package/dist/utils.d.ts +16 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +115 -29
- package/dist/utils.js.map +1 -1
- package/dist/word-navigation.d.ts +25 -0
- package/dist/word-navigation.d.ts.map +1 -0
- package/dist/word-navigation.js +96 -0
- package/dist/word-navigation.js.map +1 -0
- package/native/darwin/README.md +13 -0
- package/native/darwin/prebuilds/darwin-arm64/darwin-modifiers.node +0 -0
- package/native/darwin/prebuilds/darwin-x64/darwin-modifiers.node +0 -0
- package/native/darwin/prebuilds/manifest.json +24 -0
- package/package.json +4 -1
package/dist/tui.js
CHANGED
|
@@ -2,13 +2,70 @@
|
|
|
2
2
|
* Minimal TUI implementation with differential rendering
|
|
3
3
|
*/
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
|
-
import * as os from "node:os";
|
|
6
5
|
import * as path from "node:path";
|
|
7
6
|
import { performance } from "node:perf_hooks";
|
|
8
7
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
9
8
|
import { deleteKittyImage, getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
10
9
|
import { extractSegments, normalizeTerminalOutput, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
|
|
11
10
|
const KITTY_SEQUENCE_PREFIX = "\x1b_G";
|
|
11
|
+
const MAX_RENDER_WRITE_CHARS = 1024 * 1024;
|
|
12
|
+
/**
|
|
13
|
+
* Streams terminal output in 1 MiB chunks so a full render never forms one string large enough to exceed V8's limit.
|
|
14
|
+
*
|
|
15
|
+
* `append()` fills the current chunk and flushes it when full. Oversized input is split at chunk boundaries, preserving
|
|
16
|
+
* surrogate pairs so each write remains valid UTF-16. Callers append synchronized-output begin/end sequences themselves;
|
|
17
|
+
* the final `flush()` writes any remainder, including the end sequence.
|
|
18
|
+
*/
|
|
19
|
+
class BoundedTerminalWriter {
|
|
20
|
+
buffer = "";
|
|
21
|
+
writtenChars = 0;
|
|
22
|
+
write;
|
|
23
|
+
constructor(write) {
|
|
24
|
+
this.write = write;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Append terminal data, flushing full chunks as needed. Callers must call `flush()` after the final append.
|
|
28
|
+
* @param value Terminal data to write in order; oversized values are split without splitting surrogate pairs.
|
|
29
|
+
*/
|
|
30
|
+
append(value) {
|
|
31
|
+
let offset = 0;
|
|
32
|
+
while (offset < value.length) {
|
|
33
|
+
const capacity = MAX_RENDER_WRITE_CHARS - this.buffer.length;
|
|
34
|
+
if (capacity === 0) {
|
|
35
|
+
this.flush();
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
let end = Math.min(value.length, offset + capacity);
|
|
39
|
+
if (end < value.length &&
|
|
40
|
+
value.charCodeAt(end - 1) >= 0xd800 &&
|
|
41
|
+
value.charCodeAt(end - 1) <= 0xdbff &&
|
|
42
|
+
value.charCodeAt(end) >= 0xdc00 &&
|
|
43
|
+
value.charCodeAt(end) <= 0xdfff) {
|
|
44
|
+
end--;
|
|
45
|
+
}
|
|
46
|
+
if (end === offset) {
|
|
47
|
+
this.flush();
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
this.buffer += value.slice(offset, end);
|
|
51
|
+
offset = end;
|
|
52
|
+
if (this.buffer.length === MAX_RENDER_WRITE_CHARS) {
|
|
53
|
+
this.flush();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** Write the current chunk, if any, and retain only its character count for debug output. */
|
|
58
|
+
flush() {
|
|
59
|
+
if (!this.buffer)
|
|
60
|
+
return;
|
|
61
|
+
this.write(this.buffer);
|
|
62
|
+
this.writtenChars += this.buffer.length;
|
|
63
|
+
this.buffer = "";
|
|
64
|
+
}
|
|
65
|
+
get length() {
|
|
66
|
+
return this.writtenChars + this.buffer.length;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
12
69
|
function parseKittyImageHeader(line) {
|
|
13
70
|
const sequenceStart = line.indexOf(KITTY_SEQUENCE_PREFIX);
|
|
14
71
|
if (sequenceStart === -1)
|
|
@@ -120,6 +177,7 @@ export class TUI extends Container {
|
|
|
120
177
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
121
178
|
onDebug;
|
|
122
179
|
renderRequested = false;
|
|
180
|
+
immediateRenderScheduled = false;
|
|
123
181
|
renderTimer;
|
|
124
182
|
lastRenderAt = 0;
|
|
125
183
|
static MIN_RENDER_INTERVAL_MS = 16;
|
|
@@ -131,13 +189,20 @@ export class TUI extends Container {
|
|
|
131
189
|
previousViewportTop = 0; // Track previous viewport top for resize-aware cursor moves
|
|
132
190
|
skippedOffscreenChangeRange;
|
|
133
191
|
fullRedrawCount = 0;
|
|
192
|
+
logDirectory;
|
|
134
193
|
stopped = false;
|
|
135
194
|
// Overlay stack for modal components rendered on top of base content
|
|
136
195
|
focusOrderCounter = 0;
|
|
137
196
|
overlayStack = [];
|
|
138
|
-
|
|
197
|
+
overlayFocusRestore = { status: "inactive" };
|
|
198
|
+
/**
|
|
199
|
+
* @param logDirectory Private directory for redraw debug logs and render crash dumps. Without one,
|
|
200
|
+
* neither is written.
|
|
201
|
+
*/
|
|
202
|
+
constructor(terminal, showHardwareCursor, logDirectory) {
|
|
139
203
|
super();
|
|
140
204
|
this.terminal = terminal;
|
|
205
|
+
this.logDirectory = logDirectory;
|
|
141
206
|
if (showHardwareCursor !== undefined) {
|
|
142
207
|
this.showHardwareCursor = showHardwareCursor;
|
|
143
208
|
}
|
|
@@ -153,7 +218,7 @@ export class TUI extends Container {
|
|
|
153
218
|
return;
|
|
154
219
|
this.showHardwareCursor = enabled;
|
|
155
220
|
if (!enabled) {
|
|
156
|
-
this.
|
|
221
|
+
this.hideTerminalCursor();
|
|
157
222
|
}
|
|
158
223
|
this.requestRender();
|
|
159
224
|
}
|
|
@@ -169,31 +234,54 @@ export class TUI extends Container {
|
|
|
169
234
|
this.clearOnShrink = enabled;
|
|
170
235
|
}
|
|
171
236
|
setFocus(component) {
|
|
237
|
+
this.setFocusInternal({ component, overlayFocusRestore: "clear" });
|
|
238
|
+
}
|
|
239
|
+
setFocusInternal({ component, overlayFocusRestore, }) {
|
|
172
240
|
const previousFocus = this.focusedComponent;
|
|
173
241
|
let nextFocus = component;
|
|
174
242
|
const previousFocusedOverlay = previousFocus
|
|
175
243
|
? this.overlayStack.find((entry) => entry.component === previousFocus && this.isOverlayVisible(entry))
|
|
176
244
|
: undefined;
|
|
177
245
|
const nextFocusIsOverlay = nextFocus ? this.overlayStack.some((entry) => entry.component === nextFocus) : false;
|
|
246
|
+
const restoreState = this.getVisibleOverlayFocusRestore();
|
|
178
247
|
if (nextFocus && !nextFocusIsOverlay) {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
248
|
+
if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
|
|
249
|
+
if (restoreState.resume.status === "focus-target" || !this.isComponentMounted(restoreState.blockedBy)) {
|
|
250
|
+
nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
|
|
251
|
+
}
|
|
252
|
+
else {
|
|
253
|
+
this.overlayFocusRestore = {
|
|
254
|
+
status: "blocked",
|
|
255
|
+
overlay: restoreState.overlay,
|
|
256
|
+
blockedBy: nextFocus,
|
|
257
|
+
resume: restoreState.resume,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
184
260
|
}
|
|
185
261
|
else if (previousFocusedOverlay &&
|
|
186
|
-
|
|
262
|
+
restoreState.status !== "inactive" &&
|
|
263
|
+
restoreState.overlay === previousFocusedOverlay &&
|
|
187
264
|
!this.isOverlayFocusAncestor(previousFocusedOverlay, nextFocus)) {
|
|
188
|
-
|
|
265
|
+
this.overlayFocusRestore = {
|
|
266
|
+
status: "blocked",
|
|
267
|
+
overlay: previousFocusedOverlay,
|
|
268
|
+
blockedBy: nextFocus,
|
|
269
|
+
resume: { status: "restore-overlay" },
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
else if (nextFocus === null) {
|
|
274
|
+
if (restoreState.status === "blocked" && restoreState.blockedBy === previousFocus) {
|
|
275
|
+
nextFocus = this.resolveBlockedOverlayFocusResume(restoreState);
|
|
276
|
+
}
|
|
277
|
+
else if (overlayFocusRestore === "clear") {
|
|
278
|
+
this.clearOverlayFocusRestore();
|
|
189
279
|
}
|
|
190
280
|
}
|
|
191
|
-
// Clear focused flag on old component
|
|
192
281
|
if (isFocusable(this.focusedComponent)) {
|
|
193
282
|
this.focusedComponent.focused = false;
|
|
194
283
|
}
|
|
195
284
|
this.focusedComponent = nextFocus;
|
|
196
|
-
// Set focused flag on new component
|
|
197
285
|
if (isFocusable(nextFocus)) {
|
|
198
286
|
nextFocus.focused = true;
|
|
199
287
|
}
|
|
@@ -201,20 +289,31 @@ export class TUI extends Container {
|
|
|
201
289
|
? this.overlayStack.find((entry) => entry.component === nextFocus && this.isOverlayVisible(entry))
|
|
202
290
|
: undefined;
|
|
203
291
|
if (focusedOverlay) {
|
|
204
|
-
this.
|
|
292
|
+
this.overlayFocusRestore = { status: "eligible", overlay: focusedOverlay };
|
|
205
293
|
}
|
|
206
294
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
295
|
+
clearOverlayFocusRestore() {
|
|
296
|
+
this.overlayFocusRestore = { status: "inactive" };
|
|
297
|
+
}
|
|
298
|
+
clearOverlayFocusRestoreFor(overlay) {
|
|
299
|
+
if (this.overlayFocusRestore.status !== "inactive" && this.overlayFocusRestore.overlay === overlay) {
|
|
300
|
+
this.clearOverlayFocusRestore();
|
|
210
301
|
}
|
|
211
|
-
entry.restoreFocus = { status: "eligible" };
|
|
212
302
|
}
|
|
213
|
-
|
|
214
|
-
|
|
303
|
+
resolveBlockedOverlayFocusResume(restoreState) {
|
|
304
|
+
if (restoreState.resume.status === "restore-overlay")
|
|
305
|
+
return restoreState.overlay.component;
|
|
306
|
+
this.clearOverlayFocusRestore();
|
|
307
|
+
return restoreState.resume.target;
|
|
215
308
|
}
|
|
216
|
-
|
|
217
|
-
|
|
309
|
+
getVisibleOverlayFocusRestore() {
|
|
310
|
+
const restoreState = this.overlayFocusRestore;
|
|
311
|
+
if (restoreState.status === "inactive")
|
|
312
|
+
return restoreState;
|
|
313
|
+
if (!this.overlayStack.includes(restoreState.overlay) || !this.isOverlayVisible(restoreState.overlay)) {
|
|
314
|
+
return { status: "inactive" };
|
|
315
|
+
}
|
|
316
|
+
return restoreState;
|
|
218
317
|
}
|
|
219
318
|
isOverlayFocusAncestor(entry, component) {
|
|
220
319
|
const visited = new Set();
|
|
@@ -227,6 +326,23 @@ export class TUI extends Container {
|
|
|
227
326
|
}
|
|
228
327
|
return false;
|
|
229
328
|
}
|
|
329
|
+
retargetOverlayPreFocus(removed) {
|
|
330
|
+
for (const overlay of this.overlayStack) {
|
|
331
|
+
if (overlay !== removed && overlay.preFocus === removed.component) {
|
|
332
|
+
overlay.preFocus = removed.preFocus;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
isComponentMounted(component) {
|
|
337
|
+
return this.children.some((child) => this.containsComponent(child, component));
|
|
338
|
+
}
|
|
339
|
+
containsComponent(root, target) {
|
|
340
|
+
if (root === target)
|
|
341
|
+
return true;
|
|
342
|
+
if (!(root instanceof Container))
|
|
343
|
+
return false;
|
|
344
|
+
return root.children.some((child) => this.containsComponent(child, target));
|
|
345
|
+
}
|
|
230
346
|
/**
|
|
231
347
|
* Show an overlay component with configurable positioning and sizing.
|
|
232
348
|
* Returns a handle to control the overlay's visibility.
|
|
@@ -234,25 +350,25 @@ export class TUI extends Container {
|
|
|
234
350
|
showOverlay(component, options) {
|
|
235
351
|
const entry = {
|
|
236
352
|
component,
|
|
237
|
-
options,
|
|
353
|
+
...(options === undefined ? {} : { options }),
|
|
238
354
|
preFocus: this.focusedComponent,
|
|
239
355
|
hidden: false,
|
|
240
356
|
focusOrder: ++this.focusOrderCounter,
|
|
241
|
-
restoreFocus: { status: "inactive" },
|
|
242
357
|
};
|
|
243
358
|
this.overlayStack.push(entry);
|
|
244
359
|
// Only focus if overlay is actually visible
|
|
245
360
|
if (!options?.nonCapturing && this.isOverlayVisible(entry)) {
|
|
246
361
|
this.setFocus(component);
|
|
247
362
|
}
|
|
248
|
-
this.
|
|
363
|
+
this.hideTerminalCursor();
|
|
249
364
|
this.requestRender();
|
|
250
365
|
// Return handle for controlling this overlay
|
|
251
366
|
return {
|
|
252
367
|
hide: () => {
|
|
253
368
|
const index = this.overlayStack.indexOf(entry);
|
|
254
369
|
if (index !== -1) {
|
|
255
|
-
this.
|
|
370
|
+
this.clearOverlayFocusRestoreFor(entry);
|
|
371
|
+
this.retargetOverlayPreFocus(entry);
|
|
256
372
|
this.overlayStack.splice(index, 1);
|
|
257
373
|
// Restore focus if this overlay had focus
|
|
258
374
|
if (this.focusedComponent === component) {
|
|
@@ -260,7 +376,7 @@ export class TUI extends Container {
|
|
|
260
376
|
this.setFocus(topVisible?.component ?? entry.preFocus);
|
|
261
377
|
}
|
|
262
378
|
if (this.overlayStack.length === 0)
|
|
263
|
-
this.
|
|
379
|
+
this.hideTerminalCursor();
|
|
264
380
|
this.requestRender();
|
|
265
381
|
}
|
|
266
382
|
},
|
|
@@ -270,7 +386,7 @@ export class TUI extends Container {
|
|
|
270
386
|
entry.hidden = hidden;
|
|
271
387
|
// Update focus when hiding/showing
|
|
272
388
|
if (hidden) {
|
|
273
|
-
this.
|
|
389
|
+
this.clearOverlayFocusRestoreFor(entry);
|
|
274
390
|
// If this overlay had focus, move focus to next visible or preFocus
|
|
275
391
|
if (this.focusedComponent === component) {
|
|
276
392
|
const topVisible = this.getTopmostVisibleOverlay();
|
|
@@ -294,12 +410,35 @@ export class TUI extends Container {
|
|
|
294
410
|
this.setFocus(component);
|
|
295
411
|
this.requestRender();
|
|
296
412
|
},
|
|
297
|
-
unfocus: () => {
|
|
298
|
-
|
|
413
|
+
unfocus: (unfocusOptions) => {
|
|
414
|
+
const isFocused = this.focusedComponent === component;
|
|
415
|
+
const restoreState = this.overlayFocusRestore;
|
|
416
|
+
const hasPendingRestore = restoreState.status !== "inactive" && restoreState.overlay === entry;
|
|
417
|
+
if (!isFocused && !hasPendingRestore)
|
|
299
418
|
return;
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
419
|
+
if (restoreState.status === "blocked" &&
|
|
420
|
+
restoreState.overlay === entry &&
|
|
421
|
+
this.focusedComponent === restoreState.blockedBy) {
|
|
422
|
+
if (unfocusOptions) {
|
|
423
|
+
this.overlayFocusRestore = {
|
|
424
|
+
status: "blocked",
|
|
425
|
+
overlay: entry,
|
|
426
|
+
blockedBy: restoreState.blockedBy,
|
|
427
|
+
resume: { status: "focus-target", target: unfocusOptions.target },
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
else {
|
|
431
|
+
this.clearOverlayFocusRestore();
|
|
432
|
+
}
|
|
433
|
+
this.requestRender();
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
this.clearOverlayFocusRestoreFor(entry);
|
|
437
|
+
if (isFocused || unfocusOptions) {
|
|
438
|
+
const topVisible = this.getTopmostVisibleOverlay();
|
|
439
|
+
const fallbackTarget = topVisible && topVisible !== entry ? topVisible.component : entry.preFocus;
|
|
440
|
+
this.setFocus(unfocusOptions ? unfocusOptions.target : fallbackTarget);
|
|
441
|
+
}
|
|
303
442
|
this.requestRender();
|
|
304
443
|
},
|
|
305
444
|
isFocused: () => this.focusedComponent === component,
|
|
@@ -307,19 +446,26 @@ export class TUI extends Container {
|
|
|
307
446
|
}
|
|
308
447
|
/** Hide the topmost overlay and restore previous focus. */
|
|
309
448
|
hideOverlay() {
|
|
310
|
-
const overlay = this.overlayStack.
|
|
449
|
+
const overlay = this.overlayStack[this.overlayStack.length - 1];
|
|
311
450
|
if (!overlay)
|
|
312
451
|
return;
|
|
313
|
-
this.
|
|
452
|
+
this.clearOverlayFocusRestoreFor(overlay);
|
|
453
|
+
this.retargetOverlayPreFocus(overlay);
|
|
454
|
+
this.overlayStack.pop();
|
|
314
455
|
if (this.focusedComponent === overlay.component) {
|
|
315
456
|
// Find topmost visible overlay, or fall back to preFocus
|
|
316
457
|
const topVisible = this.getTopmostVisibleOverlay();
|
|
317
458
|
this.setFocus(topVisible?.component ?? overlay.preFocus);
|
|
318
459
|
}
|
|
319
460
|
if (this.overlayStack.length === 0)
|
|
320
|
-
this.
|
|
461
|
+
this.hideTerminalCursor();
|
|
321
462
|
this.requestRender();
|
|
322
463
|
}
|
|
464
|
+
/** Hide the cursor while running. After stop(), the shell owns the cursor and it must stay visible. */
|
|
465
|
+
hideTerminalCursor() {
|
|
466
|
+
if (!this.stopped)
|
|
467
|
+
this.terminal.hideCursor();
|
|
468
|
+
}
|
|
323
469
|
/** Check if there are any visible overlays */
|
|
324
470
|
hasOverlay() {
|
|
325
471
|
return this.overlayStack.some((o) => this.isOverlayVisible(o));
|
|
@@ -333,16 +479,17 @@ export class TUI extends Container {
|
|
|
333
479
|
}
|
|
334
480
|
return true;
|
|
335
481
|
}
|
|
336
|
-
/** Find the
|
|
482
|
+
/** Find the visual-frontmost visible capturing overlay, if any */
|
|
337
483
|
getTopmostVisibleOverlay() {
|
|
338
|
-
|
|
339
|
-
|
|
484
|
+
let topmost;
|
|
485
|
+
for (const overlay of this.overlayStack) {
|
|
486
|
+
if (overlay.options?.nonCapturing || !this.isOverlayVisible(overlay))
|
|
340
487
|
continue;
|
|
341
|
-
if (
|
|
342
|
-
|
|
488
|
+
if (!topmost || overlay.focusOrder > topmost.focusOrder) {
|
|
489
|
+
topmost = overlay;
|
|
343
490
|
}
|
|
344
491
|
}
|
|
345
|
-
return
|
|
492
|
+
return topmost;
|
|
346
493
|
}
|
|
347
494
|
invalidate() {
|
|
348
495
|
super.invalidate();
|
|
@@ -376,12 +523,11 @@ export class TUI extends Container {
|
|
|
376
523
|
}
|
|
377
524
|
stop() {
|
|
378
525
|
this.stopped = true;
|
|
379
|
-
|
|
380
|
-
clearTimeout(this.renderTimer);
|
|
381
|
-
this.renderTimer = undefined;
|
|
382
|
-
}
|
|
526
|
+
this.cancelRenderTimer();
|
|
383
527
|
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
|
384
528
|
if (this.previousLines.length > 0) {
|
|
529
|
+
// Overwrite the inverted cursor with a normal space to clear the artifact
|
|
530
|
+
this.terminal.write(" ");
|
|
385
531
|
const targetRow = this.previousLines.length; // Line after the last content
|
|
386
532
|
const lineDiff = targetRow - this.hardwareCursorRow;
|
|
387
533
|
if (lineDiff > 0) {
|
|
@@ -405,19 +551,7 @@ export class TUI extends Container {
|
|
|
405
551
|
this.maxLinesRendered = 0;
|
|
406
552
|
this.previousViewportTop = 0;
|
|
407
553
|
this.skippedOffscreenChangeRange = undefined;
|
|
408
|
-
|
|
409
|
-
clearTimeout(this.renderTimer);
|
|
410
|
-
this.renderTimer = undefined;
|
|
411
|
-
}
|
|
412
|
-
this.renderRequested = true;
|
|
413
|
-
process.nextTick(() => {
|
|
414
|
-
if (this.stopped || !this.renderRequested) {
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
this.renderRequested = false;
|
|
418
|
-
this.lastRenderAt = performance.now();
|
|
419
|
-
this.doRender();
|
|
420
|
-
});
|
|
554
|
+
this.requestImmediateRender();
|
|
421
555
|
return;
|
|
422
556
|
}
|
|
423
557
|
if (this.renderRequested)
|
|
@@ -425,6 +559,30 @@ export class TUI extends Container {
|
|
|
425
559
|
this.renderRequested = true;
|
|
426
560
|
process.nextTick(() => this.scheduleRender());
|
|
427
561
|
}
|
|
562
|
+
requestImmediateRender() {
|
|
563
|
+
this.cancelRenderTimer();
|
|
564
|
+
this.renderRequested = true;
|
|
565
|
+
if (this.immediateRenderScheduled)
|
|
566
|
+
return;
|
|
567
|
+
this.immediateRenderScheduled = true;
|
|
568
|
+
process.nextTick(() => {
|
|
569
|
+
this.immediateRenderScheduled = false;
|
|
570
|
+
if (this.stopped || !this.renderRequested)
|
|
571
|
+
return;
|
|
572
|
+
// A previously queued scheduleRender() can create a timer before this
|
|
573
|
+
// callback runs. User input must preempt that throttled frame.
|
|
574
|
+
this.cancelRenderTimer();
|
|
575
|
+
this.renderRequested = false;
|
|
576
|
+
this.lastRenderAt = performance.now();
|
|
577
|
+
this.doRender();
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
cancelRenderTimer() {
|
|
581
|
+
if (!this.renderTimer)
|
|
582
|
+
return;
|
|
583
|
+
clearTimeout(this.renderTimer);
|
|
584
|
+
this.renderTimer = undefined;
|
|
585
|
+
}
|
|
428
586
|
scheduleRender() {
|
|
429
587
|
if (this.stopped || this.renderTimer || !this.renderRequested) {
|
|
430
588
|
return;
|
|
@@ -480,17 +638,23 @@ export class TUI extends Container {
|
|
|
480
638
|
this.setFocus(topVisible.component);
|
|
481
639
|
}
|
|
482
640
|
else {
|
|
483
|
-
|
|
484
|
-
this.setFocus(focusedOverlay.preFocus);
|
|
641
|
+
this.setFocusInternal({ component: focusedOverlay.preFocus, overlayFocusRestore: "preserve" });
|
|
485
642
|
}
|
|
486
643
|
}
|
|
487
644
|
const focusIsOverlay = this.overlayStack.some((o) => o.component === this.focusedComponent);
|
|
488
645
|
if (!focusIsOverlay) {
|
|
489
|
-
const
|
|
490
|
-
if (
|
|
491
|
-
(
|
|
492
|
-
|
|
493
|
-
|
|
646
|
+
const restoreState = this.getVisibleOverlayFocusRestore();
|
|
647
|
+
if (restoreState.status === "eligible") {
|
|
648
|
+
this.setFocus(restoreState.overlay.component);
|
|
649
|
+
}
|
|
650
|
+
else if (restoreState.status === "blocked" && restoreState.blockedBy !== this.focusedComponent) {
|
|
651
|
+
if (restoreState.resume.status === "restore-overlay") {
|
|
652
|
+
this.setFocus(restoreState.overlay.component);
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
this.clearOverlayFocusRestore();
|
|
656
|
+
this.setFocus(restoreState.resume.target);
|
|
657
|
+
}
|
|
494
658
|
}
|
|
495
659
|
}
|
|
496
660
|
// Pass input to focused component (including Ctrl+C)
|
|
@@ -501,7 +665,9 @@ export class TUI extends Container {
|
|
|
501
665
|
return;
|
|
502
666
|
}
|
|
503
667
|
this.focusedComponent.handleInput(data);
|
|
504
|
-
|
|
668
|
+
// Keyboard input is latency-sensitive. Avoid the throttled timer path,
|
|
669
|
+
// where even setTimeout(0) can take a full 16 ms tick on Windows.
|
|
670
|
+
this.requestImmediateRender();
|
|
505
671
|
}
|
|
506
672
|
}
|
|
507
673
|
consumeCellSizeResponse(data) {
|
|
@@ -882,18 +1048,32 @@ export class TUI extends Container {
|
|
|
882
1048
|
// Helper to clear scrollback and viewport and render all new lines
|
|
883
1049
|
const fullRender = (clear) => {
|
|
884
1050
|
this.fullRedrawCount += 1;
|
|
885
|
-
|
|
1051
|
+
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
|
|
1052
|
+
output.append("\x1b[?2026h"); // Begin synchronized output
|
|
886
1053
|
if (clear) {
|
|
887
|
-
|
|
888
|
-
|
|
1054
|
+
output.append(this.deleteKittyImages(this.previousKittyImageIds));
|
|
1055
|
+
output.append("\x1b[2J\x1b[H\x1b[3J"); // Clear screen, home, then clear scrollback
|
|
889
1056
|
}
|
|
890
1057
|
for (let i = 0; i < newLines.length; i++) {
|
|
891
1058
|
if (i > 0)
|
|
892
|
-
|
|
893
|
-
|
|
1059
|
+
output.append("\r\n");
|
|
1060
|
+
const line = newLines[i];
|
|
1061
|
+
const isImage = isImageLine(line);
|
|
1062
|
+
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i) : 1;
|
|
1063
|
+
if (imageReservedRows > 1 && imageReservedRows <= height) {
|
|
1064
|
+
for (let row = 1; row < imageReservedRows; row++) {
|
|
1065
|
+
output.append("\r\n");
|
|
1066
|
+
}
|
|
1067
|
+
output.append(`\x1b[${imageReservedRows - 1}A`);
|
|
1068
|
+
output.append(line);
|
|
1069
|
+
output.append(`\x1b[${imageReservedRows - 1}B`);
|
|
1070
|
+
i += imageReservedRows - 1;
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
output.append(line);
|
|
894
1074
|
}
|
|
895
|
-
|
|
896
|
-
|
|
1075
|
+
output.append("\x1b[?2026l"); // End synchronized output
|
|
1076
|
+
output.flush();
|
|
897
1077
|
this.cursorRow = Math.max(0, newLines.length - 1);
|
|
898
1078
|
this.hardwareCursorRow = this.cursorRow;
|
|
899
1079
|
// Reset max lines when clearing, otherwise track growth
|
|
@@ -912,11 +1092,12 @@ export class TUI extends Container {
|
|
|
912
1092
|
this.previousHeight = height;
|
|
913
1093
|
this.skippedOffscreenChangeRange = undefined;
|
|
914
1094
|
};
|
|
915
|
-
const
|
|
1095
|
+
const redrawLogDirectory = process.env.PI_DEBUG_REDRAW === "1" ? this.logDirectory : undefined;
|
|
916
1096
|
const logRedraw = (reason) => {
|
|
917
|
-
if (
|
|
1097
|
+
if (redrawLogDirectory === undefined)
|
|
918
1098
|
return;
|
|
919
|
-
const logPath = path.join(
|
|
1099
|
+
const logPath = path.join(redrawLogDirectory, "tui-debug.log");
|
|
1100
|
+
fs.mkdirSync(redrawLogDirectory, { recursive: true });
|
|
920
1101
|
const msg = `[${new Date().toISOString()}] fullRender: ${reason} (prev=${this.previousLines.length}, new=${newLines.length}, height=${height})\n`;
|
|
921
1102
|
fs.appendFileSync(logPath, msg);
|
|
922
1103
|
};
|
|
@@ -935,7 +1116,17 @@ export class TUI extends Container {
|
|
|
935
1116
|
// Height changes normally need a full re-render to keep the visible viewport aligned,
|
|
936
1117
|
// but Termux changes height when the software keyboard shows or hides.
|
|
937
1118
|
// In that environment, a full redraw causes the entire history to replay on every toggle.
|
|
938
|
-
|
|
1119
|
+
// If the new Termux viewport would reveal rows skipped by the offscreen optimization,
|
|
1120
|
+
// redraw once so stale scrollback does not become visible.
|
|
1121
|
+
if (heightChanged && isTermuxSession()) {
|
|
1122
|
+
const viewportBottom = prevViewportTop + height - 1;
|
|
1123
|
+
if (this.skippedOffscreenChangesIntersect(prevViewportTop, viewportBottom)) {
|
|
1124
|
+
logRedraw(`terminal height changed revealing skipped offscreen changes (${this.previousHeight} -> ${height})`);
|
|
1125
|
+
fullRender(true);
|
|
1126
|
+
return;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
else if (heightChanged) {
|
|
939
1130
|
logRedraw(`terminal height changed (${this.previousHeight} -> ${height})`);
|
|
940
1131
|
fullRender(true);
|
|
941
1132
|
return;
|
|
@@ -1010,8 +1201,9 @@ export class TUI extends Container {
|
|
|
1010
1201
|
// All changes are in deleted lines (nothing to render, just clear)
|
|
1011
1202
|
if (firstChanged >= newLines.length) {
|
|
1012
1203
|
if (this.previousLines.length > newLines.length) {
|
|
1013
|
-
|
|
1014
|
-
|
|
1204
|
+
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
|
|
1205
|
+
output.append("\x1b[?2026h");
|
|
1206
|
+
output.append(this.deleteChangedKittyImages(firstChanged, lastChanged));
|
|
1015
1207
|
// Move to end of new content (clamp to 0 for empty content)
|
|
1016
1208
|
const targetRow = Math.max(0, newLines.length - 1);
|
|
1017
1209
|
if (targetRow < prevViewportTop) {
|
|
@@ -1021,10 +1213,10 @@ export class TUI extends Container {
|
|
|
1021
1213
|
}
|
|
1022
1214
|
const lineDiff = computeLineDiff(targetRow);
|
|
1023
1215
|
if (lineDiff > 0)
|
|
1024
|
-
|
|
1216
|
+
output.append(`\x1b[${lineDiff}B`);
|
|
1025
1217
|
else if (lineDiff < 0)
|
|
1026
|
-
|
|
1027
|
-
|
|
1218
|
+
output.append(`\x1b[${-lineDiff}A`);
|
|
1219
|
+
output.append("\r");
|
|
1028
1220
|
// Clear extra lines without scrolling
|
|
1029
1221
|
const extraLines = this.previousLines.length - newLines.length;
|
|
1030
1222
|
if (extraLines > height) {
|
|
@@ -1033,18 +1225,18 @@ export class TUI extends Container {
|
|
|
1033
1225
|
return;
|
|
1034
1226
|
}
|
|
1035
1227
|
if (extraLines > 0) {
|
|
1036
|
-
|
|
1228
|
+
output.append("\x1b[1B");
|
|
1037
1229
|
}
|
|
1038
1230
|
for (let i = 0; i < extraLines; i++) {
|
|
1039
|
-
|
|
1231
|
+
output.append("\r\x1b[2K");
|
|
1040
1232
|
if (i < extraLines - 1)
|
|
1041
|
-
|
|
1233
|
+
output.append("\x1b[1B");
|
|
1042
1234
|
}
|
|
1043
1235
|
if (extraLines > 0) {
|
|
1044
|
-
|
|
1236
|
+
output.append(`\x1b[${extraLines}A`);
|
|
1045
1237
|
}
|
|
1046
|
-
|
|
1047
|
-
|
|
1238
|
+
output.append("\x1b[?2026l");
|
|
1239
|
+
output.flush();
|
|
1048
1240
|
this.cursorRow = targetRow;
|
|
1049
1241
|
this.hardwareCursorRow = targetRow;
|
|
1050
1242
|
}
|
|
@@ -1064,19 +1256,20 @@ export class TUI extends Container {
|
|
|
1064
1256
|
return;
|
|
1065
1257
|
}
|
|
1066
1258
|
// Render from first changed line to end
|
|
1067
|
-
//
|
|
1068
|
-
|
|
1069
|
-
|
|
1259
|
+
// Keep updates wrapped in synchronized output while writing bounded chunks.
|
|
1260
|
+
const output = new BoundedTerminalWriter((data) => this.terminal.write(data));
|
|
1261
|
+
output.append("\x1b[?2026h"); // Begin synchronized output
|
|
1262
|
+
output.append(this.deleteChangedKittyImages(firstChanged, lastChanged));
|
|
1070
1263
|
const prevViewportBottom = prevViewportTop + height - 1;
|
|
1071
1264
|
const moveTargetRow = appendStart ? firstChanged - 1 : firstChanged;
|
|
1072
1265
|
if (moveTargetRow > prevViewportBottom) {
|
|
1073
1266
|
const currentScreenRow = Math.max(0, Math.min(height - 1, hardwareCursorRow - prevViewportTop));
|
|
1074
1267
|
const moveToBottom = height - 1 - currentScreenRow;
|
|
1075
1268
|
if (moveToBottom > 0) {
|
|
1076
|
-
|
|
1269
|
+
output.append(`\x1b[${moveToBottom}B`);
|
|
1077
1270
|
}
|
|
1078
1271
|
const scroll = moveTargetRow - prevViewportBottom;
|
|
1079
|
-
|
|
1272
|
+
output.append("\r\n".repeat(scroll));
|
|
1080
1273
|
prevViewportTop += scroll;
|
|
1081
1274
|
viewportTop += scroll;
|
|
1082
1275
|
hardwareCursorRow = moveTargetRow;
|
|
@@ -1084,18 +1277,18 @@ export class TUI extends Container {
|
|
|
1084
1277
|
// Move cursor to first changed line (use hardwareCursorRow for actual position)
|
|
1085
1278
|
const lineDiff = computeLineDiff(moveTargetRow);
|
|
1086
1279
|
if (lineDiff > 0) {
|
|
1087
|
-
|
|
1280
|
+
output.append(`\x1b[${lineDiff}B`); // Move down
|
|
1088
1281
|
}
|
|
1089
1282
|
else if (lineDiff < 0) {
|
|
1090
|
-
|
|
1283
|
+
output.append(`\x1b[${-lineDiff}A`); // Move up
|
|
1091
1284
|
}
|
|
1092
|
-
|
|
1285
|
+
output.append(appendStart ? "\r\n" : "\r"); // Move to column 0
|
|
1093
1286
|
// Only render changed lines (firstChanged to lastChanged), not all lines to end
|
|
1094
1287
|
// This reduces flicker when only a single line changes (e.g., spinner animation)
|
|
1095
1288
|
const renderEnd = Math.min(lastChanged, newLines.length - 1);
|
|
1096
1289
|
for (let i = firstChanged; i <= renderEnd; i++) {
|
|
1097
1290
|
if (i > firstChanged)
|
|
1098
|
-
|
|
1291
|
+
output.append("\r\n");
|
|
1099
1292
|
const line = newLines[i];
|
|
1100
1293
|
const isImage = isImageLine(line);
|
|
1101
1294
|
const imageReservedRows = isImage ? this.getKittyImageReservedRows(newLines, i, renderEnd) : 1;
|
|
@@ -1106,31 +1299,34 @@ export class TUI extends Container {
|
|
|
1106
1299
|
fullRender(true);
|
|
1107
1300
|
return;
|
|
1108
1301
|
}
|
|
1109
|
-
|
|
1302
|
+
output.append("\x1b[2K");
|
|
1110
1303
|
for (let row = 1; row < imageReservedRows; row++) {
|
|
1111
|
-
|
|
1304
|
+
output.append("\r\n\x1b[2K");
|
|
1112
1305
|
}
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1306
|
+
output.append(`\x1b[${imageReservedRows - 1}A`);
|
|
1307
|
+
output.append(line);
|
|
1308
|
+
output.append(`\x1b[${imageReservedRows - 1}B`);
|
|
1116
1309
|
i += imageReservedRows - 1;
|
|
1117
1310
|
continue;
|
|
1118
1311
|
}
|
|
1119
|
-
|
|
1312
|
+
output.append("\x1b[2K"); // Clear current line
|
|
1120
1313
|
if (!isImage && visibleWidth(line) > width) {
|
|
1121
|
-
// Log all lines to crash file for debugging
|
|
1122
|
-
|
|
1123
|
-
const
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1314
|
+
// Log all lines to a crash file for debugging. Only into the caller's private log directory: the
|
|
1315
|
+
// dump holds the full rendered transcript, so a predictable path in a shared temp dir could leak it.
|
|
1316
|
+
const crashLogPath = this.logDirectory ? path.join(this.logDirectory, "tui-crash.log") : undefined;
|
|
1317
|
+
if (crashLogPath) {
|
|
1318
|
+
const crashData = [
|
|
1319
|
+
`Crash at ${new Date().toISOString()}`,
|
|
1320
|
+
`Terminal width: ${width}`,
|
|
1321
|
+
`Line ${i} visible width: ${visibleWidth(line)}`,
|
|
1322
|
+
"",
|
|
1323
|
+
"=== All rendered lines ===",
|
|
1324
|
+
...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),
|
|
1325
|
+
"",
|
|
1326
|
+
].join("\n");
|
|
1327
|
+
fs.mkdirSync(path.dirname(crashLogPath), { recursive: true, mode: 0o700 });
|
|
1328
|
+
fs.writeFileSync(crashLogPath, crashData, { mode: 0o600 });
|
|
1329
|
+
}
|
|
1134
1330
|
// Clean up terminal state before throwing
|
|
1135
1331
|
this.stop();
|
|
1136
1332
|
const errorMsg = [
|
|
@@ -1139,11 +1335,13 @@ export class TUI extends Container {
|
|
|
1139
1335
|
"This is likely caused by a custom TUI component not truncating its output.",
|
|
1140
1336
|
"Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
|
|
1141
1337
|
"",
|
|
1142
|
-
|
|
1338
|
+
crashLogPath
|
|
1339
|
+
? `Debug log written to: ${crashLogPath}`
|
|
1340
|
+
: "No debug log written (no log directory configured).",
|
|
1143
1341
|
].join("\n");
|
|
1144
1342
|
throw new Error(errorMsg);
|
|
1145
1343
|
}
|
|
1146
|
-
|
|
1344
|
+
output.append(line);
|
|
1147
1345
|
}
|
|
1148
1346
|
// Track where cursor ended up after rendering
|
|
1149
1347
|
let finalCursorRow = renderEnd;
|
|
@@ -1152,17 +1350,17 @@ export class TUI extends Container {
|
|
|
1152
1350
|
// Move to end of new content first if we stopped before it
|
|
1153
1351
|
if (renderEnd < newLines.length - 1) {
|
|
1154
1352
|
const moveDown = newLines.length - 1 - renderEnd;
|
|
1155
|
-
|
|
1353
|
+
output.append(`\x1b[${moveDown}B`);
|
|
1156
1354
|
finalCursorRow = newLines.length - 1;
|
|
1157
1355
|
}
|
|
1158
1356
|
const extraLines = this.previousLines.length - newLines.length;
|
|
1159
1357
|
for (let i = newLines.length; i < this.previousLines.length; i++) {
|
|
1160
|
-
|
|
1358
|
+
output.append("\r\n\x1b[2K");
|
|
1161
1359
|
}
|
|
1162
1360
|
// Move cursor back to end of new content
|
|
1163
|
-
|
|
1361
|
+
output.append(`\x1b[${extraLines}A`);
|
|
1164
1362
|
}
|
|
1165
|
-
|
|
1363
|
+
output.append("\x1b[?2026l"); // End synchronized output
|
|
1166
1364
|
if (process.env.PI_TUI_DEBUG === "1") {
|
|
1167
1365
|
const debugDir = "/tmp/tui";
|
|
1168
1366
|
fs.mkdirSync(debugDir, { recursive: true });
|
|
@@ -1187,12 +1385,11 @@ export class TUI extends Container {
|
|
|
1187
1385
|
JSON.stringify(this.previousLines, null, 2),
|
|
1188
1386
|
"",
|
|
1189
1387
|
"=== buffer ===",
|
|
1190
|
-
|
|
1388
|
+
`[${output.length} chars written in bounded chunks]`,
|
|
1191
1389
|
].join("\n");
|
|
1192
1390
|
fs.writeFileSync(debugPath, debugData);
|
|
1193
1391
|
}
|
|
1194
|
-
|
|
1195
|
-
this.terminal.write(buffer);
|
|
1392
|
+
output.flush();
|
|
1196
1393
|
// Track cursor position for next render
|
|
1197
1394
|
// cursorRow tracks end of content (for viewport calculation)
|
|
1198
1395
|
// hardwareCursorRow tracks actual terminal cursor position (for movement)
|