@draht/tui 2026.3.25 → 2026.4.23
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 +15 -13
- package/dist/autocomplete.d.ts.map +1 -1
- package/dist/autocomplete.js +88 -100
- 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/components/markdown.d.ts.map +1 -1
- package/dist/components/markdown.js +3 -0
- package/dist/components/markdown.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 +84 -64
- 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.
|
|
@@ -558,9 +573,10 @@ export class TUI extends Container {
|
|
|
558
573
|
rendered.push({ overlayLines, row, col, w: width });
|
|
559
574
|
minLinesNeeded = Math.max(minLinesNeeded, row + overlayLines.length);
|
|
560
575
|
}
|
|
561
|
-
//
|
|
562
|
-
// maxLinesRendered
|
|
563
|
-
|
|
576
|
+
// Pad to at least terminal height so overlays have screen-relative positions.
|
|
577
|
+
// Excludes maxLinesRendered: the historical high-water mark caused self-reinforcing
|
|
578
|
+
// inflation that pushed content into scrollback on terminal widen.
|
|
579
|
+
const workingHeight = Math.max(result.length, termHeight, minLinesNeeded);
|
|
564
580
|
// Extend result with empty lines if content is too short for overlay placement or working area
|
|
565
581
|
while (result.length < workingHeight) {
|
|
566
582
|
result.push("");
|
|
@@ -660,8 +676,11 @@ export class TUI extends Container {
|
|
|
660
676
|
return;
|
|
661
677
|
const width = this.terminal.columns;
|
|
662
678
|
const height = this.terminal.rows;
|
|
663
|
-
|
|
664
|
-
|
|
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;
|
|
665
684
|
let hardwareCursorRow = this.hardwareCursorRow;
|
|
666
685
|
const computeLineDiff = (targetRow) => {
|
|
667
686
|
const currentScreenRow = hardwareCursorRow - prevViewportTop;
|
|
@@ -677,9 +696,6 @@ export class TUI extends Container {
|
|
|
677
696
|
// Extract cursor position before applying line resets (marker must be found first)
|
|
678
697
|
const cursorPos = this.extractCursorPosition(newLines, height);
|
|
679
698
|
newLines = this.applyLineResets(newLines);
|
|
680
|
-
// Width or height changed - need full re-render
|
|
681
|
-
const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
|
|
682
|
-
const heightChanged = this.previousHeight !== 0 && this.previousHeight !== height;
|
|
683
699
|
// Helper to clear scrollback and viewport and render all new lines
|
|
684
700
|
const fullRender = (clear) => {
|
|
685
701
|
this.fullRedrawCount += 1;
|
|
@@ -702,7 +718,8 @@ export class TUI extends Container {
|
|
|
702
718
|
else {
|
|
703
719
|
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
704
720
|
}
|
|
705
|
-
|
|
721
|
+
const bufferLength = Math.max(height, newLines.length);
|
|
722
|
+
this.previousViewportTop = Math.max(0, bufferLength - height);
|
|
706
723
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
707
724
|
this.previousLines = newLines;
|
|
708
725
|
this.previousWidth = width;
|
|
@@ -769,7 +786,7 @@ export class TUI extends Container {
|
|
|
769
786
|
// No changes - but still need to update hardware cursor position if it moved
|
|
770
787
|
if (firstChanged === -1) {
|
|
771
788
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
772
|
-
this.previousViewportTop =
|
|
789
|
+
this.previousViewportTop = prevViewportTop;
|
|
773
790
|
this.previousHeight = height;
|
|
774
791
|
return;
|
|
775
792
|
}
|
|
@@ -779,6 +796,11 @@ export class TUI extends Container {
|
|
|
779
796
|
let buffer = "\x1b[?2026h";
|
|
780
797
|
// Move to end of new content (clamp to 0 for empty content)
|
|
781
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
|
+
}
|
|
782
804
|
const lineDiff = computeLineDiff(targetRow);
|
|
783
805
|
if (lineDiff > 0)
|
|
784
806
|
buffer += `\x1b[${lineDiff}B`;
|
|
@@ -812,15 +834,13 @@ export class TUI extends Container {
|
|
|
812
834
|
this.previousLines = newLines;
|
|
813
835
|
this.previousWidth = width;
|
|
814
836
|
this.previousHeight = height;
|
|
815
|
-
this.previousViewportTop =
|
|
837
|
+
this.previousViewportTop = prevViewportTop;
|
|
816
838
|
return;
|
|
817
839
|
}
|
|
818
|
-
//
|
|
819
|
-
//
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
// First change is above previous viewport - need full re-render
|
|
823
|
-
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})`);
|
|
824
844
|
fullRender(true);
|
|
825
845
|
return;
|
|
826
846
|
}
|
|
@@ -942,7 +962,7 @@ export class TUI extends Container {
|
|
|
942
962
|
this.hardwareCursorRow = finalCursorRow;
|
|
943
963
|
// Track terminal's working area (grows but doesn't shrink unless cleared)
|
|
944
964
|
this.maxLinesRendered = Math.max(this.maxLinesRendered, newLines.length);
|
|
945
|
-
this.previousViewportTop = Math.max(
|
|
965
|
+
this.previousViewportTop = Math.max(prevViewportTop, finalCursorRow - height + 1);
|
|
946
966
|
// Position hardware cursor for IME
|
|
947
967
|
this.positionHardwareCursor(cursorPos, newLines.length);
|
|
948
968
|
this.previousLines = newLines;
|