@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/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.
@@ -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
- // Ensure result covers the terminal working area to keep overlay positioning stable across resizes.
562
- // maxLinesRendered can exceed current content length after a shrink; pad to keep viewportStart consistent.
563
- const workingHeight = Math.max(this.maxLinesRendered, minLinesNeeded);
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
- let viewportTop = Math.max(0, this.maxLinesRendered - height);
664
- 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;
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
- this.previousViewportTop = Math.max(0, this.maxLinesRendered - height);
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 = Math.max(0, this.maxLinesRendered - height);
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 = Math.max(0, this.maxLinesRendered - height);
837
+ this.previousViewportTop = prevViewportTop;
816
838
  return;
817
839
  }
818
- // Check if firstChanged is above what was previously visible
819
- // Use previousLines.length (not maxLinesRendered) to avoid false positives after content shrinks
820
- const previousContentViewportTop = Math.max(0, this.previousLines.length - height);
821
- if (firstChanged < previousContentViewportTop) {
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(0, this.maxLinesRendered - height);
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;