@code-yeongyu/senpi-tui 2026.10.2 → 2026.10.4

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/index.d.ts CHANGED
@@ -34,7 +34,7 @@ export { parseTerminalColorSchemeReport, type RgbColor, type TerminalColorScheme
34
34
  export declare function calculateImageRows(imageDimensions: ImageDimensions, targetWidthCells: number, cellDimensions?: CellDimensions): number;
35
35
  export { allocateImageId, buildKittyPlaceholderRow, type CellDimensions, deleteAllKittyImages, deleteKittyImage, detectCapabilities, encodeITerm2, encodeKitty, getCapabilities, getCellDimensions, getGifDimensions, getImageDimensions, getJpegDimensions, getPngDimensions, getTerminalColorMode, getWebpDimensions, hyperlink, type ImageDimensions, type ImageProtocol, type ImageRenderOptions, imageFallback, KITTY_PLACEHOLDER_MAX, outerKittyGraphicsMode, renderImage, resetCapabilitiesCache, setCapabilities, setCapabilityOverrides, setCellDimensions, type TerminalCapabilities, type TmuxPassthroughState, wrapTmuxPassthrough, } from "./terminal-image.ts";
36
36
  export { sanitizeTerminalLabel, shortenImagePath } from "./terminal-text.ts";
37
- export { type Component, CompositeRevision, Container, CURSOR_MARKER, claimFrameRow, compositeTuiLine, currentRenderRevision, dispatchMouseEvent, type Focusable, frameMode, frameScrollbackRows, isFocusable, isViewportTUI, joinLineArrays, mainScreenHistoryLines, nextRenderRevision, type OverlayAnchor, type OverlayBounds, type OverlayHandle, type OverlayMargin, type OverlayOptions, type OverlayUnfocusOptions, renderAtFrameRow, resetMainScreenHistoryLines, type SizeValue, TUI, type TuiInputListener, type TuiInputListenerResult, type TuiMode, type TuiMouseButton, type TuiMouseDispatchResult, type TuiMouseEvent, type TuiMouseEventResult, type TuiMouseEventType, type TuiStopOptions, type ViewportTUI, } from "./tui.ts";
37
+ export { type Component, CompositeRevision, Container, CURSOR_MARKER, claimFrameRow, compositeTuiLine, currentRenderRevision, dispatchMouseEvent, type Focusable, type FrameLineBytesTotals, frameLineBytesTotals, frameMode, frameScrollbackRows, isFocusable, isViewportTUI, joinLineArrays, mainScreenHistoryLines, nextRenderRevision, type OverlayAnchor, type OverlayBounds, type OverlayHandle, type OverlayMargin, type OverlayOptions, type OverlayUnfocusOptions, renderAtFrameRow, resetMainScreenHistoryLines, type SizeValue, TUI, type TuiInputListener, type TuiInputListenerResult, type TuiMode, type TuiMouseButton, type TuiMouseDispatchResult, type TuiMouseEvent, type TuiMouseEventResult, type TuiMouseEventType, type TuiStopOptions, type ViewportTUI, } from "./tui.ts";
38
38
  export { TuiAltScreen, type TuiAltScreenOptions } from "./tui-alt-screen.ts";
39
39
  export { TuiMainScreen, type TuiMainScreenRenderState } from "./tui-main-screen.ts";
40
40
  export { getGraphemeSegmenter, getOsc8LinkAtColumn, getWordSegmenter, sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "./utils.ts";
package/dist/index.js CHANGED
@@ -49,7 +49,7 @@ export function calculateImageRows(imageDimensions, targetWidthCells, cellDimens
49
49
  }
50
50
  export { allocateImageId, buildKittyPlaceholderRow, deleteAllKittyImages, deleteKittyImage, detectCapabilities, encodeITerm2, encodeKitty, getCapabilities, getCellDimensions, getGifDimensions, getImageDimensions, getJpegDimensions, getPngDimensions, getTerminalColorMode, getWebpDimensions, hyperlink, imageFallback, KITTY_PLACEHOLDER_MAX, outerKittyGraphicsMode, renderImage, resetCapabilitiesCache, setCapabilities, setCapabilityOverrides, setCellDimensions, wrapTmuxPassthrough, } from "./terminal-image.js";
51
51
  export { sanitizeTerminalLabel, shortenImagePath } from "./terminal-text.js";
52
- export { CompositeRevision, Container, CURSOR_MARKER, claimFrameRow, compositeTuiLine, currentRenderRevision, dispatchMouseEvent, frameMode, frameScrollbackRows, isFocusable, isViewportTUI, joinLineArrays, mainScreenHistoryLines, nextRenderRevision, renderAtFrameRow, resetMainScreenHistoryLines, TUI, } from "./tui.js";
52
+ export { CompositeRevision, Container, CURSOR_MARKER, claimFrameRow, compositeTuiLine, currentRenderRevision, dispatchMouseEvent, frameLineBytesTotals, frameMode, frameScrollbackRows, isFocusable, isViewportTUI, joinLineArrays, mainScreenHistoryLines, nextRenderRevision, renderAtFrameRow, resetMainScreenHistoryLines, TUI, } from "./tui.js";
53
53
  export { TuiAltScreen } from "./tui-alt-screen.js";
54
54
  export { TuiMainScreen } from "./tui-main-screen.js";
55
55
  // Utilities
@@ -28,6 +28,16 @@ export type StdinBufferOptions = {
28
28
  * (default: 10ms). Increase for high-latency Alt+key input (SSH).
29
29
  */
30
30
  escapeTimeout?: number;
31
+ /**
32
+ * Window after the previous input inside which a trailing newline is held
33
+ * as a possible paste fragment instead of being emitted at once
34
+ * (default: 20ms). Keystroke-paced input is unaffected.
35
+ */
36
+ burstWindowMs?: number;
37
+ /**
38
+ * Clock used for burst pacing (default: Date.now). Tests inject a manual clock.
39
+ */
40
+ now?: () => number;
31
41
  };
32
42
  export type StdinBufferEventMap = {
33
43
  data: [string];
@@ -48,8 +58,18 @@ export declare class StdinBuffer extends EventEmitter<StdinBufferEventMap> {
48
58
  private pasteBuffer;
49
59
  private pendingKittyPrintableCodepoint;
50
60
  private decoder;
61
+ private readonly burstWindowMs;
62
+ private readonly clock;
63
+ private lastInputAt;
64
+ private heldNewline;
65
+ private heldNewlineEndsPaste;
66
+ private lastBurstPasteAt;
67
+ private burstTimer;
51
68
  constructor(options?: StdinBufferOptions);
52
69
  process(data: string | Buffer): void;
70
+ private consumeUnbracketedBurst;
71
+ private holdTrailingNewline;
72
+ private releaseHeldNewline;
53
73
  private emitDataSequence;
54
74
  flush(): string[];
55
75
  clear(): void;
@@ -21,8 +21,16 @@ import { EventEmitter } from "events";
21
21
  const ESC = "\x1b";
22
22
  const DEFAULT_SEQUENCE_TIMEOUT_MS = 50;
23
23
  const DEFAULT_ESCAPE_TIMEOUT_MS = 10;
24
+ const DEFAULT_BURST_WINDOW_MS = 20;
24
25
  const BRACKETED_PASTE_START = "\x1b[200~";
25
26
  const BRACKETED_PASTE_END = "\x1b[201~";
27
+ const LINE_BREAK = /\r\n|\r|\n/g;
28
+ const TRAILING_LINE_BREAK_RUN = /(\r\n|\r|\n)+$/;
29
+ const LINE_BREAK_BEFORE_TEXT = /[\r\n][^\r\n]/;
30
+ function countLineBreaks(text) {
31
+ const matches = text.match(LINE_BREAK);
32
+ return matches === null ? 0 : matches.length;
33
+ }
26
34
  /**
27
35
  * Check if a string is a complete escape sequence or needs more data
28
36
  */
@@ -236,8 +244,13 @@ export class StdinBuffer extends EventEmitter {
236
244
  this.pasteMode = false;
237
245
  this.pasteBuffer = "";
238
246
  this.decoder = new StringDecoder("utf8");
247
+ this.heldNewline = "";
248
+ this.heldNewlineEndsPaste = false;
249
+ this.burstTimer = null;
239
250
  this.timeoutMs = options.timeout ?? DEFAULT_SEQUENCE_TIMEOUT_MS;
240
251
  this.escapeTimeoutMs = options.escapeTimeout ?? DEFAULT_ESCAPE_TIMEOUT_MS;
252
+ this.burstWindowMs = options.burstWindowMs ?? DEFAULT_BURST_WINDOW_MS;
253
+ this.clock = options.now ?? Date.now;
241
254
  }
242
255
  process(data) {
243
256
  // Clear any pending timeout
@@ -280,7 +293,30 @@ export class StdinBuffer extends EventEmitter {
280
293
  }
281
294
  this.discardingMouseFragment = false;
282
295
  }
296
+ // Only a read that adds input takes over a held line break. A read that leaves above (an empty
297
+ // decode of half a multibyte character, a dropped mouse fragment) must keep its release timer running.
298
+ if (this.burstTimer) {
299
+ clearTimeout(this.burstTimer);
300
+ this.burstTimer = null;
301
+ }
283
302
  this.buffer += str;
303
+ const chunkAt = str.length > 0 ? this.clock() : undefined;
304
+ const burstGap = chunkAt !== undefined && this.lastInputAt !== undefined ? chunkAt - this.lastInputAt : undefined;
305
+ if (chunkAt !== undefined) {
306
+ this.lastInputAt = chunkAt;
307
+ }
308
+ if (this.heldNewline.length > 0) {
309
+ // The clock decides, not timer delivery: a read outside the window means the held line
310
+ // break was an Enter, even when its release timer has not fired yet (a stalled loop).
311
+ if (burstGap !== undefined && burstGap >= this.burstWindowMs) {
312
+ this.releaseHeldNewline();
313
+ }
314
+ else {
315
+ this.buffer = this.heldNewline + this.buffer;
316
+ this.heldNewline = "";
317
+ this.heldNewlineEndsPaste = false;
318
+ }
319
+ }
284
320
  if (this.pasteMode) {
285
321
  this.pasteBuffer += this.buffer;
286
322
  this.buffer = "";
@@ -326,6 +362,9 @@ export class StdinBuffer extends EventEmitter {
326
362
  }
327
363
  return;
328
364
  }
365
+ if (this.consumeUnbracketedBurst(burstGap)) {
366
+ return;
367
+ }
329
368
  const result = extractCompleteSequences(this.buffer);
330
369
  this.buffer = result.remainder;
331
370
  if (this.buffer.startsWith("\x1b[<")) {
@@ -351,6 +390,71 @@ export class StdinBuffer extends EventEmitter {
351
390
  }, timeoutMs);
352
391
  }
353
392
  }
393
+ consumeUnbracketedBurst(burstGap) {
394
+ const text = this.buffer;
395
+ if (text.length === 0 || text.includes(ESC) || text.includes(BRACKETED_PASTE_START)) {
396
+ return false;
397
+ }
398
+ const breaks = countLineBreaks(text);
399
+ if (breaks === 0) {
400
+ return false;
401
+ }
402
+ // Typing delivers one key per read, so a single read that holds two line breaks, or text after a
403
+ // line break, is pasted. Both carry text: a run of bare Enters stays keystrokes.
404
+ if ((breaks >= 2 || LINE_BREAK_BEFORE_TEXT.test(text)) && /[^\r\n]/.test(text)) {
405
+ this.buffer = "";
406
+ this.lastBurstPasteAt = this.lastInputAt;
407
+ this.emit("paste", text);
408
+ return true;
409
+ }
410
+ const trailing = text.match(TRAILING_LINE_BREAK_RUN)?.[0];
411
+ if (trailing === undefined || burstGap === undefined || burstGap >= this.burstWindowMs) {
412
+ return false;
413
+ }
414
+ const head = text.slice(0, text.length - trailing.length);
415
+ // A newline that lands inside the window right after a burst paste ends that paste: releasing it
416
+ // as Enter would submit the block, which the same text in one read never does.
417
+ const endsPaste = this.lastBurstPasteAt !== undefined &&
418
+ this.lastInputAt !== undefined &&
419
+ this.lastInputAt - this.lastBurstPasteAt < this.burstWindowMs;
420
+ // A read of bare line breaks is a keystroke unless it closes a paste, so it is forwarded at once.
421
+ if (head.length === 0 && !endsPaste) {
422
+ return false;
423
+ }
424
+ for (const chunk of head) {
425
+ this.emitDataSequence(chunk);
426
+ }
427
+ this.buffer = "";
428
+ this.holdTrailingNewline(trailing, endsPaste);
429
+ return true;
430
+ }
431
+ holdTrailingNewline(run, endsPaste) {
432
+ this.heldNewline = run;
433
+ this.heldNewlineEndsPaste = endsPaste;
434
+ if (this.burstTimer) {
435
+ clearTimeout(this.burstTimer);
436
+ }
437
+ this.burstTimer = setTimeout(() => {
438
+ this.burstTimer = null;
439
+ this.releaseHeldNewline();
440
+ }, this.burstWindowMs);
441
+ }
442
+ releaseHeldNewline() {
443
+ if (this.heldNewline.length === 0) {
444
+ return;
445
+ }
446
+ const held = this.heldNewline;
447
+ const endsPaste = this.heldNewlineEndsPaste;
448
+ this.heldNewline = "";
449
+ this.heldNewlineEndsPaste = false;
450
+ if (endsPaste) {
451
+ this.emit("paste", held);
452
+ return;
453
+ }
454
+ for (const chunk of held) {
455
+ this.emitDataSequence(chunk);
456
+ }
457
+ }
354
458
  emitDataSequence(sequence) {
355
459
  const rawCodepoint = sequence.length === 1 ? sequence.codePointAt(0) : undefined;
356
460
  if (rawCodepoint !== undefined && rawCodepoint === this.pendingKittyPrintableCodepoint) {
@@ -365,6 +469,11 @@ export class StdinBuffer extends EventEmitter {
365
469
  clearTimeout(this.timeout);
366
470
  this.timeout = null;
367
471
  }
472
+ if (this.burstTimer) {
473
+ clearTimeout(this.burstTimer);
474
+ this.burstTimer = null;
475
+ }
476
+ this.releaseHeldNewline();
368
477
  if (this.buffer.length === 0) {
369
478
  return [];
370
479
  }
@@ -390,6 +499,13 @@ export class StdinBuffer extends EventEmitter {
390
499
  clearTimeout(this.timeout);
391
500
  this.timeout = null;
392
501
  }
502
+ if (this.burstTimer) {
503
+ clearTimeout(this.burstTimer);
504
+ this.burstTimer = null;
505
+ }
506
+ this.heldNewline = "";
507
+ this.heldNewlineEndsPaste = false;
508
+ this.lastBurstPasteAt = undefined;
393
509
  this.buffer = "";
394
510
  this.pasteMode = false;
395
511
  this.pasteBuffer = "";
@@ -77,6 +77,12 @@ export interface ProcessTerminalOptions {
77
77
  * another byte, so high-latency transports need a longer reassembly window.
78
78
  */
79
79
  export declare function resolveEscapeTimeoutMs(env?: NodeJS.ProcessEnv): number;
80
+ /**
81
+ * Resolve how long a line break that ends a read with text is held as a possible paste fragment
82
+ * when the terminal sends no bracketed-paste markers. Paste chunks over SSH arrive further apart,
83
+ * so the default window is longer there. `PI_TUI_BURST_WINDOW_MS=0` never holds a line break.
84
+ */
85
+ export declare function resolveBurstWindowMs(env?: NodeJS.ProcessEnv): number;
80
86
  /**
81
87
  * Real terminal using process.stdin/stdout
82
88
  */
package/dist/terminal.js CHANGED
@@ -163,6 +163,8 @@ function unsubscribeFromStdinErrors(subscriber) {
163
163
  }
164
164
  const DEFAULT_ESCAPE_TIMEOUT_MS = 10;
165
165
  const DEFAULT_SSH_ESCAPE_TIMEOUT_MS = 100;
166
+ const DEFAULT_BURST_WINDOW_MS = 20;
167
+ const DEFAULT_SSH_BURST_WINDOW_MS = 100;
166
168
  /**
167
169
  * Resolve how long to wait for the rest of an escape sequence before
168
170
  * dispatching a lone ESC as the Escape key. Legacy Alt+key input is ESC plus
@@ -178,6 +180,24 @@ export function resolveEscapeTimeoutMs(env = process.env) {
178
180
  }
179
181
  return DEFAULT_ESCAPE_TIMEOUT_MS;
180
182
  }
183
+ /**
184
+ * Resolve how long a line break that ends a read with text is held as a possible paste fragment
185
+ * when the terminal sends no bracketed-paste markers. Paste chunks over SSH arrive further apart,
186
+ * so the default window is longer there. `PI_TUI_BURST_WINDOW_MS=0` never holds a line break.
187
+ */
188
+ export function resolveBurstWindowMs(env = process.env) {
189
+ const raw = env.PI_TUI_BURST_WINDOW_MS?.trim();
190
+ if (raw !== undefined && raw.length > 0) {
191
+ const configured = Number(raw);
192
+ if (Number.isFinite(configured) && configured >= 0) {
193
+ return configured;
194
+ }
195
+ }
196
+ if (env.SSH_CONNECTION || env.SSH_TTY) {
197
+ return DEFAULT_SSH_BURST_WINDOW_MS;
198
+ }
199
+ return DEFAULT_BURST_WINDOW_MS;
200
+ }
181
201
  /**
182
202
  * Real terminal using process.stdin/stdout
183
203
  */
@@ -392,7 +412,10 @@ export class ProcessTerminal {
392
412
  * to handle the case where the response arrives split across multiple events.
393
413
  */
394
414
  setupStdinBuffer() {
395
- this.stdinBuffer = new StdinBuffer({ escapeTimeout: resolveEscapeTimeoutMs() });
415
+ this.stdinBuffer = new StdinBuffer({
416
+ escapeTimeout: resolveEscapeTimeoutMs(),
417
+ burstWindowMs: resolveBurstWindowMs(),
418
+ });
396
419
  // Forward individual sequences to the input handler
397
420
  this.stdinBuffer.on("data", (sequence) => {
398
421
  if (this.discardingPrivateResponse) {
package/dist/tui.d.ts CHANGED
@@ -257,6 +257,11 @@ export declare function renderAtFrameRow(child: Component, width: number, row: n
257
257
  export declare function nextRenderRevision(): number;
258
258
  /** Current value of the render revision clock (see {@link nextRenderRevision}). */
259
259
  export declare function currentRenderRevision(): number;
260
+ export interface FrameLineBytesTotals {
261
+ readonly previousLinesBytes: number;
262
+ }
263
+ /** Sum of every live TUI's current frame-line bytes; `0` before any frame renders. */
264
+ export declare function frameLineBytesTotals(): FrameLineBytesTotals;
260
265
  /**
261
266
  * Render revision of a component whose output is a pure function of its own state and its children's
262
267
  * output. `bump()` records an own-state change; `read(children)` returns a revision that also changes
@@ -504,7 +509,10 @@ export declare abstract class TuiBase extends Container {
504
509
  */
505
510
  private static readonly FRAME_BEGIN;
506
511
  private static readonly FRAME_END;
512
+ private frameLineBytes;
507
513
  private setPreviousLines;
514
+ /** Releases this TUI's frame from the process total (stop/dispose resets the frame). */
515
+ private dropPreviousLines;
508
516
  /** Image presence of the committed frame, measured once per frame array instead of once per check. */
509
517
  protected previousLinesHaveImage(): boolean;
510
518
  /** Record image presence for a produced array; `undefined` leaves it to a scan when it is committed. */
package/dist/tui.js CHANGED
@@ -385,6 +385,27 @@ export function nextRenderRevision() {
385
385
  export function currentRenderRevision() {
386
386
  return renderRevisionClock;
387
387
  }
388
+ /**
389
+ * Byte cost of the frame the TUI last held, summed over every live TUI in the process (senpi#1960):
390
+ * `previousLines` is the whole frame a terminal keeps for the differential pass, so a long session's
391
+ * transcript cost lives here. The memory report reads the figure structurally through a process-global
392
+ * key; a process with no TUI reports no figure. Estimator: 2 bytes per UTF-16 code unit plus an 8-byte
393
+ * array slot per line - the same estimate the tool-card render cache uses, so the two figures compare.
394
+ */
395
+ const FRAME_LINE_BYTES_KEY = Symbol.for("senpi.tui.frame-line-bytes");
396
+ function frameLineBytesState() {
397
+ const existing = Reflect.get(globalThis, FRAME_LINE_BYTES_KEY);
398
+ if (typeof existing === "object" && existing !== null && typeof Reflect.get(existing, "bytes") === "number") {
399
+ return existing;
400
+ }
401
+ const created = { bytes: 0 };
402
+ Reflect.set(globalThis, FRAME_LINE_BYTES_KEY, created);
403
+ return created;
404
+ }
405
+ /** Sum of every live TUI's current frame-line bytes; `0` before any frame renders. */
406
+ export function frameLineBytesTotals() {
407
+ return { previousLinesBytes: frameLineBytesState().bytes };
408
+ }
388
409
  /**
389
410
  * Render revision of a component whose output is a pure function of its own state and its children's
390
411
  * output. `bump()` records an own-state change; `read(children)` returns a revision that also changes
@@ -654,6 +675,7 @@ export class TuiBase extends Container {
654
675
  this.mouseCommittedLineCount = 0;
655
676
  this.mouseAnchorPending = false;
656
677
  this.mouseExternalWritePending = false;
678
+ this.frameLineBytes = 0;
657
679
  this.terminal = terminal;
658
680
  // Preserve existing positional boolean callers while allowing explicit render-policy overrides.
659
681
  const normalizedOptions = typeof options === "boolean" ? { showHardwareCursor: options } : (options ?? {});
@@ -1272,8 +1294,7 @@ export class TuiBase extends Container {
1272
1294
  this.afterTerminalStop(options);
1273
1295
  this.resetRenderState();
1274
1296
  this.#lastCursorVisibility = undefined;
1275
- this.previousLines = [];
1276
- this.previousRawLines = [];
1297
+ this.dropPreviousLines();
1277
1298
  this.previousKittyImageIds.clear();
1278
1299
  this.previousWidth = 0;
1279
1300
  this.previousHeight = 0;
@@ -1346,8 +1367,7 @@ export class TuiBase extends Container {
1346
1367
  /** Drop every cached frame so the next render repaints from a clean slate. */
1347
1368
  resetForcedRenderState() {
1348
1369
  this.resetRenderState();
1349
- this.previousLines = [];
1350
- this.previousRawLines = [];
1370
+ this.dropPreviousLines();
1351
1371
  this.previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
1352
1372
  this.previousHeight = -1; // -1 triggers heightChanged, forcing a full clear
1353
1373
  this.cursorRow = 0;
@@ -1755,9 +1775,22 @@ export class TuiBase extends Container {
1755
1775
  static { this.FRAME_BEGIN = "\x1b[?2026h\x1b[?7l"; }
1756
1776
  static { this.FRAME_END = "\x1b[?7h\x1b[?2026l"; }
1757
1777
  setPreviousLines(lines, rawLines) {
1778
+ const state = frameLineBytesState();
1779
+ state.bytes -= this.frameLineBytes;
1780
+ let bytes = lines.length * 8;
1781
+ for (const line of lines)
1782
+ bytes += line.length * 2;
1783
+ this.frameLineBytes = bytes;
1784
+ state.bytes += bytes;
1758
1785
  this.previousLines = lines;
1759
1786
  this.previousRawLines = rawLines;
1760
1787
  }
1788
+ /** Releases this TUI's frame from the process total (stop/dispose resets the frame). */
1789
+ dropPreviousLines() {
1790
+ if (this.previousLines.length === 0 && this.previousRawLines.length === 0 && this.frameLineBytes === 0)
1791
+ return;
1792
+ this.setPreviousLines([], []);
1793
+ }
1761
1794
  /** Image presence of the committed frame, measured once per frame array instead of once per check. */
1762
1795
  previousLinesHaveImage() {
1763
1796
  const lines = this.previousLines;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-tui",
3
- "version": "2026.10.2",
3
+ "version": "2026.10.4",
4
4
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",