@draht/tui 2026.4.5 → 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/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
- lines.push(...child.render(width));
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
- // If we're waiting for cell size response, buffer input and parse
336
- if (this.cellSizeQueryPending) {
337
- this.inputBuffer += data;
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
- parseCellSizeResponse() {
407
+ consumeCellSizeResponse(data) {
374
408
  // Response format: ESC [ 6 ; height ; width t
375
- // Match the response pattern
376
- const responsePattern = /\x1b\[6;(\d+);(\d+)t/;
377
- const match = this.inputBuffer.match(responsePattern);
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
- // No cell size response found, return buffered data as user input
404
- const result = this.inputBuffer;
405
- this.inputBuffer = "";
406
- this.cellSizeQueryPending = false; // Give up waiting
407
- return result;
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
- let viewportTop = Math.max(0, this.maxLinesRendered - height);
665
- let prevViewportTop = this.previousViewportTop;
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
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
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 = Math.max(0, this.maxLinesRendered - height);
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 = Math.max(0, this.maxLinesRendered - height);
837
+ this.previousViewportTop = prevViewportTop;
817
838
  return;
818
839
  }
819
- // Check if firstChanged is above what was previously visible
820
- // Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
821
- const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
822
- if (firstChanged < previousContentViewportTop) {
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(0, this.maxLinesRendered - height);
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;