@draht/tui 2026.4.5 → 2026.4.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/autocomplete.d.ts +12 -12
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +88 -102
- package/dist/autocomplete.js.map +1 -1
- package/dist/components/editor.d.ts +13 -2
- package/dist/components/editor.d.ts.map +1 -1
- package/dist/components/editor.js +115 -97
- package/dist/components/editor.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/keys.d.ts.map +1 -1
- package/dist/keys.js +43 -7
- package/dist/keys.js.map +1 -1
- package/dist/terminal.d.ts.map +1 -1
- package/dist/terminal.js +1 -1
- package/dist/terminal.js.map +1 -1
- package/dist/tui.d.ts +5 -3
- package/dist/tui.d.ts.map +1 -1
- package/dist/tui.js +80 -61
- package/dist/tui.js.map +1 -1
- package/package.json +1 -1
package/dist/tui.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as os from "node:os";
|
|
6
6
|
import * as path from "node:path";
|
|
7
|
+
import { performance } from "node:perf_hooks";
|
|
7
8
|
import { isKeyRelease, matchesKey } from "./keys.js";
|
|
8
9
|
import { getCapabilities, isImageLine, setCellDimensions } from "./terminal-image.js";
|
|
9
10
|
import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
|
|
@@ -60,7 +61,10 @@ export class Container {
|
|
|
60
61
|
render(width) {
|
|
61
62
|
const lines = [];
|
|
62
63
|
for (const child of this.children) {
|
|
63
|
-
|
|
64
|
+
const childLines = child.render(width);
|
|
65
|
+
for (const line of childLines) {
|
|
66
|
+
lines.push(line);
|
|
67
|
+
}
|
|
64
68
|
}
|
|
65
69
|
return lines;
|
|
66
70
|
}
|
|
@@ -78,10 +82,11 @@ export class TUI extends Container {
|
|
|
78
82
|
/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */
|
|
79
83
|
onDebug;
|
|
80
84
|
renderRequested = false;
|
|
85
|
+
renderTimer;
|
|
86
|
+
lastRenderAt = 0;
|
|
87
|
+
static MIN_RENDER_INTERVAL_MS = 16;
|
|
81
88
|
cursorRow = 0; // Logical cursor row (end of rendered content)
|
|
82
89
|
hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
|
|
83
|
-
inputBuffer = ""; // Buffer for parsing terminal responses
|
|
84
|
-
cellSizeQueryPending = false;
|
|
85
90
|
showHardwareCursor = process.env.PI_HARDWARE_CURSOR === "1";
|
|
86
91
|
clearOnShrink = process.env.PI_CLEAR_ON_SHRINK === "1"; // Clear empty rows when content shrinks (default: off)
|
|
87
92
|
maxLinesRendered = 0; // Track terminal's working area (max lines ever rendered)
|
|
@@ -277,11 +282,14 @@ export class TUI extends Container {
|
|
|
277
282
|
}
|
|
278
283
|
// Query terminal for cell size in pixels: CSI 16 t
|
|
279
284
|
// Response format: CSI 6 ; height ; width t
|
|
280
|
-
this.cellSizeQueryPending = true;
|
|
281
285
|
this.terminal.write("\x1b[16t");
|
|
282
286
|
}
|
|
283
287
|
stop() {
|
|
284
288
|
this.stopped = true;
|
|
289
|
+
if (this.renderTimer) {
|
|
290
|
+
clearTimeout(this.renderTimer);
|
|
291
|
+
this.renderTimer = undefined;
|
|
292
|
+
}
|
|
285
293
|
// Move cursor to the end of the content to prevent overwriting/artifacts on exit
|
|
286
294
|
if (this.previousLines.length > 0) {
|
|
287
295
|
const targetRow = this.previousLines.length; // Line after the last content
|
|
@@ -306,14 +314,44 @@ export class TUI extends Container {
|
|
|
306
314
|
this.hardwareCursorRow = 0;
|
|
307
315
|
this.maxLinesRendered = 0;
|
|
308
316
|
this.previousViewportTop = 0;
|
|
317
|
+
if (this.renderTimer) {
|
|
318
|
+
clearTimeout(this.renderTimer);
|
|
319
|
+
this.renderTimer = undefined;
|
|
320
|
+
}
|
|
321
|
+
this.renderRequested = true;
|
|
322
|
+
process.nextTick(() => {
|
|
323
|
+
if (this.stopped || !this.renderRequested) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
this.renderRequested = false;
|
|
327
|
+
this.lastRenderAt = performance.now();
|
|
328
|
+
this.doRender();
|
|
329
|
+
});
|
|
330
|
+
return;
|
|
309
331
|
}
|
|
310
332
|
if (this.renderRequested)
|
|
311
333
|
return;
|
|
312
334
|
this.renderRequested = true;
|
|
313
|
-
process.nextTick(() =>
|
|
335
|
+
process.nextTick(() => this.scheduleRender());
|
|
336
|
+
}
|
|
337
|
+
scheduleRender() {
|
|
338
|
+
if (this.stopped || this.renderTimer || !this.renderRequested) {
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
const elapsed = performance.now() - this.lastRenderAt;
|
|
342
|
+
const delay = Math.max(0, TUI.MIN_RENDER_INTERVAL_MS - elapsed);
|
|
343
|
+
this.renderTimer = setTimeout(() => {
|
|
344
|
+
this.renderTimer = undefined;
|
|
345
|
+
if (this.stopped || !this.renderRequested) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
314
348
|
this.renderRequested = false;
|
|
349
|
+
this.lastRenderAt = performance.now();
|
|
315
350
|
this.doRender();
|
|
316
|
-
|
|
351
|
+
if (this.renderRequested) {
|
|
352
|
+
this.scheduleRender();
|
|
353
|
+
}
|
|
354
|
+
}, delay);
|
|
317
355
|
}
|
|
318
356
|
handleInput(data) {
|
|
319
357
|
if (this.inputListeners.size > 0) {
|
|
@@ -332,13 +370,9 @@ export class TUI extends Container {
|
|
|
332
370
|
}
|
|
333
371
|
data = current;
|
|
334
372
|
}
|
|
335
|
-
//
|
|
336
|
-
if (this.
|
|
337
|
-
|
|
338
|
-
const filtered = this.parseCellSizeResponse();
|
|
339
|
-
if (filtered.length === 0)
|
|
340
|
-
return;
|
|
341
|
-
data = filtered;
|
|
373
|
+
// Consume terminal cell size responses without blocking unrelated input.
|
|
374
|
+
if (this.consumeCellSizeResponse(data)) {
|
|
375
|
+
return;
|
|
342
376
|
}
|
|
343
377
|
// Global debug key handler (Shift+Ctrl+D)
|
|
344
378
|
if (matchesKey(data, "shift+ctrl+d") && this.onDebug) {
|
|
@@ -370,41 +404,22 @@ export class TUI extends Container {
|
|
|
370
404
|
this.requestRender();
|
|
371
405
|
}
|
|
372
406
|
}
|
|
373
|
-
|
|
407
|
+
consumeCellSizeResponse(data) {
|
|
374
408
|
// Response format: ESC [ 6 ; height ; width t
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
if (match) {
|
|
379
|
-
const heightPx = parseInt(match[1], 10);
|
|
380
|
-
const widthPx = parseInt(match[2], 10);
|
|
381
|
-
if (heightPx > 0 && widthPx > 0) {
|
|
382
|
-
setCellDimensions({ widthPx, heightPx });
|
|
383
|
-
// Invalidate all components so images re-render with correct dimensions
|
|
384
|
-
this.invalidate();
|
|
385
|
-
this.requestRender();
|
|
386
|
-
}
|
|
387
|
-
// Remove the response from buffer
|
|
388
|
-
this.inputBuffer = this.inputBuffer.replace(responsePattern, "");
|
|
389
|
-
this.cellSizeQueryPending = false;
|
|
390
|
-
}
|
|
391
|
-
// Check if we have a partial cell size response starting (wait for more data)
|
|
392
|
-
// Patterns that could be incomplete cell size response: \x1b, \x1b[, \x1b[6, \x1b[6;...(no t yet)
|
|
393
|
-
const partialCellSizePattern = /\x1b(\[6?;?[\d;]*)?$/;
|
|
394
|
-
if (partialCellSizePattern.test(this.inputBuffer)) {
|
|
395
|
-
// Check if it's actually a complete different escape sequence (ends with a letter)
|
|
396
|
-
// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.
|
|
397
|
-
const lastChar = this.inputBuffer[this.inputBuffer.length - 1];
|
|
398
|
-
if (!/[a-zA-Z~]/.test(lastChar)) {
|
|
399
|
-
// Doesn't end with a terminator, might be incomplete - wait for more
|
|
400
|
-
return "";
|
|
401
|
-
}
|
|
409
|
+
const match = data.match(/^\x1b\[6;(\d+);(\d+)t$/);
|
|
410
|
+
if (!match) {
|
|
411
|
+
return false;
|
|
402
412
|
}
|
|
403
|
-
|
|
404
|
-
const
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
413
|
+
const heightPx = parseInt(match[1], 10);
|
|
414
|
+
const widthPx = parseInt(match[2], 10);
|
|
415
|
+
if (heightPx <= 0 || widthPx <= 0) {
|
|
416
|
+
return true;
|
|
417
|
+
}
|
|
418
|
+
setCellDimensions({ widthPx, heightPx });
|
|
419
|
+
// Invalidate all components so images re-render with correct dimensions.
|
|
420
|
+
this.invalidate();
|
|
421
|
+
this.requestRender();
|
|
422
|
+
return true;
|
|
408
423
|
}
|
|
409
424
|
/**
|
|
410
425
|
* Resolve overlay layout from options.
|
|
@@ -661,8 +676,11 @@ export class TUI extends Container {
|
|
|
661
676
|
return;
|
|
662
677
|
const width = this.terminal.columns;
|
|
663
678
|
const height = this.terminal.rows;
|
|
664
|
-
|
|
665
|
-
|
|
679
|
+
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
680
|
+
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
|
|
681
|
+
const previousBufferLength = this.previousHeight > 0 ? this.previousViewportTop + this.previousHeight : height;
|
|
682
|
+
let prevViewportTop = heightChanged ? Math.max(0, previousBufferLength - height) : this.previousViewportTop;
|
|
683
|
+
let viewportTop = prevViewportTop;
|
|
666
684
|
let hardwareCursorRow = this.hardwareCursorRow;
|
|
667
685
|
const computeLineDiff = (targetRow) => {
|
|
668
686
|
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
|
@@ -678,9 +696,6 @@ export class TUI extends Container {
|
|
|
678
696
|
// Extract cursor position before applying line resets (marker must be found first)
|
|
679
697
|
const cursorPos = this.extractCursorPosition(newLines, height);
|
|
680
698
|
newLines = this.applyLineResets(newLines);
|
|
681
|
-
// Width or height changed - need full re-render
|
|
682
|
-
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
683
|
-
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
|
|
684
699
|
// Helper to clear scrollback and viewport and render all new lines
|
|
685
700
|
const fullRender = (clear) => {
|
|
686
701
|
this.fullRedrawCount += 1;
|
|
@@ -703,7 +718,8 @@ export class TUI extends Container {
|
|
|
703
718
|
else {
|
|
704
719
|
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
705
720
|
}
|
|
706
|
-
|
|
721
|
+
const bufferLength = Math.max(height, newLines.length);
|
|
722
|
+
this.previousViewportTop = Math.max(0, bufferLength - height);
|
|
707
723
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
708
724
|
this.previousLines = newLines;
|
|
709
725
|
this.previousWidth = width;
|
|
@@ -770,7 +786,7 @@ export class TUI extends Container {
|
|
|
770
786
|
// No changes - but still need to update hardware cursor position if it moved
|
|
771
787
|
if (firstChanged === -1) {
|
|
772
788
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
773
|
-
this.previousViewportTop =
|
|
789
|
+
this.previousViewportTop = prevViewportTop;
|
|
774
790
|
this.previousHeight = height;
|
|
775
791
|
return;
|
|
776
792
|
}
|
|
@@ -780,6 +796,11 @@ export class TUI extends Container {
|
|
|
780
796
|
let buffer = "\x1b[?2026h";
|
|
781
797
|
// Move to end of new content (clamp to 0 for empty content)
|
|
782
798
|
const targetRow = Math.max(0, newLines.length - 1);
|
|
799
|
+
if (targetRow < prevViewportTop) {
|
|
800
|
+
logRedraw(`deleted lines moved viewport up (${targetRow} < ${prevViewportTop})`);
|
|
801
|
+
fullRender(true);
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
783
804
|
const lineDiff = computeLineDiff(targetRow);
|
|
784
805
|
if (lineDiff > 0)
|
|
785
806
|
buffer += `\x1b[${lineDiff}B`;
|
|
@@ -813,15 +834,13 @@ export class TUI extends Container {
|
|
|
813
834
|
this.previousLines = newLines;
|
|
814
835
|
this.previousWidth = width;
|
|
815
836
|
this.previousHeight = height;
|
|
816
|
-
this.previousViewportTop =
|
|
837
|
+
this.previousViewportTop = prevViewportTop;
|
|
817
838
|
return;
|
|
818
839
|
}
|
|
819
|
-
//
|
|
820
|
-
//
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
// First change is above previous viewport - need full re-render
|
|
824
|
-
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${previousContentViewportTop})`);
|
|
840
|
+
// Differential rendering can only touch what was actually visible.
|
|
841
|
+
// If the first changed line is above the previous viewport, we need a full redraw.
|
|
842
|
+
if (firstChanged < prevViewportTop) {
|
|
843
|
+
logRedraw(`firstChanged < viewportTop (${firstChanged} < ${prevViewportTop})`);
|
|
825
844
|
fullRender(true);
|
|
826
845
|
return;
|
|
827
846
|
}
|
|
@@ -943,7 +962,7 @@ export class TUI extends Container {
|
|
|
943
962
|
this.hardwareCursorRow = finalCursorRow;
|
|
944
963
|
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
|
945
964
|
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
946
|
-
this.previousViewportTop = Math.max(
|
|
965
|
+
this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
|
|
947
966
|
// Position hardware cursor for IME
|
|
948
967
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
949
968
|
this.previousLines = newLines;
|