@code-yeongyu/senpi-tui 2026.10.1 → 2026.10.3

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.
@@ -3,7 +3,7 @@ export interface AutocompleteItem {
3
3
  label: string;
4
4
  description?: string;
5
5
  /**
6
- * The command declares an argument hint: confirming the row completes `/name ` and waits for
6
+ * The command requires arguments: confirming the row completes `/name ` and waits for
7
7
  * arguments instead of submitting.
8
8
  */
9
9
  awaitsArguments?: boolean;
@@ -13,6 +13,8 @@ export interface SlashCommand {
13
13
  name: string;
14
14
  description?: string;
15
15
  argumentHint?: string;
16
+ /** Whether picker Enter must wait for arguments. Omitted means: wait only when `argumentHint` is set. */
17
+ requiresArguments?: boolean;
16
18
  getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
17
19
  }
18
20
  export interface AutocompleteSuggestions {
@@ -240,16 +240,17 @@ export class CombinedAutocompleteProvider {
240
240
  prefix: dollarContext.prefix,
241
241
  };
242
242
  }
243
- if (!options.force && textBeforeCursor.startsWith("/")) {
244
- const spaceIndex = textBeforeCursor.indexOf(" ");
243
+ const commandText = textBeforeCursor.trimStart();
244
+ if (!options.force && commandText.startsWith("/")) {
245
+ const spaceIndex = commandText.indexOf(" ");
245
246
  if (spaceIndex === -1) {
246
- const prefix = textBeforeCursor.slice(1);
247
+ const prefix = commandText.slice(1);
247
248
  const filtered = getSlashCommandSuggestions(this.commands, prefix);
248
249
  if (filtered.length === 0)
249
250
  return null;
250
251
  return {
251
252
  items: filtered,
252
- prefix: textBeforeCursor,
253
+ prefix: commandText,
253
254
  };
254
255
  }
255
256
  const tokenStart = textBeforeCursor.search(/\S+$/);
@@ -268,8 +269,8 @@ export class CombinedAutocompleteProvider {
268
269
  prefix: currentToken,
269
270
  };
270
271
  }
271
- const commandName = textBeforeCursor.slice(1, spaceIndex);
272
- const argumentText = textBeforeCursor.slice(spaceIndex + 1);
272
+ const commandName = commandText.slice(1, spaceIndex);
273
+ const argumentText = commandText.slice(spaceIndex + 1);
273
274
  const command = this.commands.find((cmd) => {
274
275
  const name = "name" in cmd ? cmd.name : cmd.value;
275
276
  return name === commandName;
@@ -10,6 +10,7 @@ export declare class Box implements Component {
10
10
  private disposed;
11
11
  private cache?;
12
12
  private mouseLayout?;
13
+ private readonly composite;
13
14
  constructor(paddingX?: number, paddingY?: number, bgFn?: (text: string) => string);
14
15
  addChild(component: Component): void;
15
16
  removeChild(component: Component): void;
@@ -20,6 +21,10 @@ export declare class Box implements Component {
20
21
  private invalidateCache;
21
22
  private matchCache;
22
23
  invalidate(): void;
24
+ /** Padding plus background over the children: exact `Box` instances change only with them (see `Container`). */
25
+ getRenderRevision(): number | undefined;
26
+ protected childRenderRevision(): number | undefined;
27
+ protected bumpRenderRevision(): void;
23
28
  handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined;
24
29
  render(width: number): string[];
25
30
  private applyBg;
@@ -1,5 +1,5 @@
1
- import { dispatchMouseEvent } from "../tui.js";
2
- import { applyBackgroundToLine, visibleWidth } from "../utils.js";
1
+ import { CompositeRevision, dispatchMouseEvent, } from "../tui.js";
2
+ import { applyBackgroundToLine, flattenLines, visibleWidth } from "../utils.js";
3
3
  /**
4
4
  * Box component - a container that applies padding and background to all children
5
5
  */
@@ -7,6 +7,7 @@ export class Box {
7
7
  constructor(paddingX = 1, paddingY = 1, bgFn) {
8
8
  this.children = [];
9
9
  this.disposed = false;
10
+ this.composite = new CompositeRevision();
10
11
  this.paddingX = paddingX;
11
12
  this.paddingY = paddingY;
12
13
  this.bgFn = bgFn;
@@ -45,10 +46,12 @@ export class Box {
45
46
  }
46
47
  setBgFn(bgFn) {
47
48
  this.bgFn = bgFn;
49
+ this.composite.bump();
48
50
  // Don't invalidate here - we'll detect bgFn changes by sampling output
49
51
  }
50
52
  invalidateCache() {
51
53
  this.cache = undefined;
54
+ this.composite.bump();
52
55
  }
53
56
  matchCache(width, childLines, bgSample) {
54
57
  const cache = this.cache;
@@ -60,10 +63,21 @@ export class Box {
60
63
  }
61
64
  invalidate() {
62
65
  this.invalidateCache();
66
+ this.composite.bump();
63
67
  for (const child of this.children) {
64
68
  child.invalidate?.();
65
69
  }
66
70
  }
71
+ /** Padding plus background over the children: exact `Box` instances change only with them (see `Container`). */
72
+ getRenderRevision() {
73
+ return Object.getPrototypeOf(this) === Box.prototype ? this.childRenderRevision() : undefined;
74
+ }
75
+ childRenderRevision() {
76
+ return this.composite.read(this.children);
77
+ }
78
+ bumpRenderRevision() {
79
+ this.composite.bump();
80
+ }
67
81
  handleMouse(event) {
68
82
  const contentWidth = Math.max(1, event.width - this.paddingX * 2);
69
83
  const contentY = event.y - this.paddingY;
@@ -131,6 +145,7 @@ export class Box {
131
145
  result.push(this.applyBg("", width));
132
146
  }
133
147
  // Update cache
148
+ flattenLines(result);
134
149
  this.cache = { childLines, width, bgSample, lines: result };
135
150
  return result;
136
151
  }
@@ -1,4 +1,4 @@
1
- import type { Component } from "../tui.ts";
1
+ import { type Component } from "../tui.ts";
2
2
  export declare function clearRenderCache(): void;
3
3
  export declare function getMarkdownHighlightCallCount(): number;
4
4
  export declare function resetMarkdownHighlightCallCount(): void;
@@ -64,9 +64,14 @@ export declare class Markdown implements Component {
64
64
  private cachedText?;
65
65
  private cachedWidth?;
66
66
  private cachedLines?;
67
+ private revision;
67
68
  constructor(text: string, paddingX: number, paddingY: number, theme: MarkdownTheme, defaultTextStyle?: DefaultTextStyle, options?: MarkdownOptions);
68
69
  setText(text: string): void;
69
70
  invalidate(): void;
71
+ /** Instances that expose a revision advance the shared clock; others (e.g. an animated subclass) stay local. */
72
+ private markRevision;
73
+ /** Exact `Markdown` instances only; a subclass may render more than its source text. */
74
+ getRenderRevision(): number | undefined;
70
75
  render(width: number): string[];
71
76
  /**
72
77
  * Apply default text style to a string.
@@ -1,7 +1,8 @@
1
1
  // allow: SIZE_OK - existing markdown renderer is oversized; this merge only preserves behavior and cache-key correctness.
2
2
  import { Marked, Tokenizer } from "marked";
3
3
  import { getCapabilities, hyperlink, isImageLine } from "../terminal-image.js";
4
- import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
4
+ import { nextRenderRevision } from "../tui.js";
5
+ import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.js";
5
6
  import { latexToUnicode } from "./latex.js";
6
7
  const STRICT_STRIKETHROUGH_REGEX = /^(~~)(?=[^\s~])((?:\\.|[^\\])*?(?:\\.|[^\s~\\]))\1(?=[^~]|$)/;
7
8
  class StrictStrikethroughTokenizer extends Tokenizer {
@@ -326,6 +327,7 @@ export function resetMarkdownHighlightCallCount() {
326
327
  }
327
328
  export class Markdown {
328
329
  constructor(text, paddingX, paddingY, theme, defaultTextStyle, options) {
330
+ this.revision = nextRenderRevision();
329
331
  this.text = text;
330
332
  this.paddingX = paddingX;
331
333
  this.paddingY = paddingY;
@@ -341,6 +343,15 @@ export class Markdown {
341
343
  this.cachedText = undefined;
342
344
  this.cachedWidth = undefined;
343
345
  this.cachedLines = undefined;
346
+ this.markRevision();
347
+ }
348
+ /** Instances that expose a revision advance the shared clock; others (e.g. an animated subclass) stay local. */
349
+ markRevision() {
350
+ this.revision = this.getRenderRevision() === undefined ? this.revision + 1 : nextRenderRevision();
351
+ }
352
+ /** Exact `Markdown` instances only; a subclass may render more than its source text. */
353
+ getRenderRevision() {
354
+ return Object.getPrototypeOf(this) === Markdown.prototype ? this.revision : undefined;
344
355
  }
345
356
  render(width) {
346
357
  // Check cache
@@ -448,6 +459,7 @@ export class Markdown {
448
459
  }
449
460
  // Combine top padding, content, and bottom padding
450
461
  const result = emptyLines.concat(contentLines, emptyLines);
462
+ flattenLines(result);
451
463
  // Update cache
452
464
  this.cachedText = this.text;
453
465
  this.cachedWidth = width;
@@ -6,6 +6,8 @@ export declare class MouseRegion implements Component {
6
6
  private readonly onMouse;
7
7
  constructor(child: Component, onMouse: MouseRegionHandler);
8
8
  render(width: number): string[];
9
+ /** Exact regions render their child unchanged; a subclass may add state and opts in itself. */
10
+ getRenderRevision(): number | undefined;
9
11
  handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | TuiMouseEventResult | undefined;
10
12
  invalidate(): void;
11
13
  }
@@ -8,6 +8,10 @@ export class MouseRegion {
8
8
  render(width) {
9
9
  return this.child.render(width);
10
10
  }
11
+ /** Exact regions render their child unchanged; a subclass may add state and opts in itself. */
12
+ getRenderRevision() {
13
+ return Object.getPrototypeOf(this) === MouseRegion.prototype ? this.child.getRenderRevision?.() : undefined;
14
+ }
11
15
  handleMouse(event) {
12
16
  const childResult = dispatchMouseEvent(this.child, event);
13
17
  return childResult ?? this.onMouse(event);
@@ -1,12 +1,16 @@
1
- import type { Component } from "../tui.ts";
1
+ import { type Component } from "../tui.ts";
2
2
  /**
3
3
  * Spacer component that renders empty lines
4
4
  */
5
5
  export declare class Spacer implements Component {
6
6
  private lines;
7
+ private revision;
7
8
  constructor(lines?: number);
8
9
  setLines(lines: number): void;
9
10
  invalidate(): void;
11
+ /** Instances that expose a revision advance the shared clock; others (e.g. an animated subclass) stay local. */
12
+ private markRevision;
13
+ getRenderRevision(): number | undefined;
10
14
  render(_width: number): string[];
11
15
  }
12
16
  //# sourceMappingURL=spacer.d.ts.map
@@ -1,15 +1,25 @@
1
+ import { nextRenderRevision } from "../tui.js";
1
2
  /**
2
3
  * Spacer component that renders empty lines
3
4
  */
4
5
  export class Spacer {
5
6
  constructor(lines = 1) {
7
+ this.revision = nextRenderRevision();
6
8
  this.lines = lines;
7
9
  }
8
10
  setLines(lines) {
9
11
  this.lines = lines;
12
+ this.markRevision();
10
13
  }
11
14
  invalidate() {
12
- // No cached state to invalidate currently
15
+ this.markRevision();
16
+ }
17
+ /** Instances that expose a revision advance the shared clock; others (e.g. an animated subclass) stay local. */
18
+ markRevision() {
19
+ this.revision = this.getRenderRevision() === undefined ? this.revision + 1 : nextRenderRevision();
20
+ }
21
+ getRenderRevision() {
22
+ return Object.getPrototypeOf(this) === Spacer.prototype ? this.revision : undefined;
13
23
  }
14
24
  render(_width) {
15
25
  const result = [];
@@ -1,4 +1,4 @@
1
- import type { Component } from "../tui.ts";
1
+ import { type Component } from "../tui.ts";
2
2
  /**
3
3
  * Text component - displays multi-line text with word wrapping
4
4
  */
@@ -10,10 +10,17 @@ export declare class Text implements Component {
10
10
  private cachedText?;
11
11
  private cachedWidth?;
12
12
  private cachedLines?;
13
+ private revision;
13
14
  constructor(text?: string, paddingX?: number, paddingY?: number, customBgFn?: (text: string) => string);
14
15
  setText(text: string): void;
15
16
  setCustomBgFn(customBgFn?: (text: string) => string): void;
16
17
  invalidate(): void;
18
+ /** Instances that expose a revision advance the shared clock; others (e.g. an animated subclass) stay local. */
19
+ private markRevision;
20
+ /** Exact `Text` instances only: a subclass may render more than its text and opts in with {@link textRenderRevision}. */
21
+ getRenderRevision(): number | undefined;
22
+ /** Revision of the text, padding and background state this class renders. */
23
+ protected textRenderRevision(): number;
17
24
  render(width: number): string[];
18
25
  }
19
26
  //# sourceMappingURL=text.d.ts.map
@@ -1,15 +1,19 @@
1
- import { applyBackgroundToLine, visibleWidth, wrapTextWithAnsi } from "../utils.js";
1
+ import { nextRenderRevision } from "../tui.js";
2
+ import { applyBackgroundToLine, flattenLines, visibleWidth, wrapTextWithAnsi } from "../utils.js";
2
3
  /**
3
4
  * Text component - displays multi-line text with word wrapping
4
5
  */
5
6
  export class Text {
6
7
  constructor(text = "", paddingX = 1, paddingY = 1, customBgFn) {
8
+ this.revision = nextRenderRevision();
7
9
  this.text = text;
8
10
  this.paddingX = paddingX;
9
11
  this.paddingY = paddingY;
10
12
  this.customBgFn = customBgFn;
11
13
  }
12
14
  setText(text) {
15
+ if (text !== this.text)
16
+ this.markRevision();
13
17
  this.text = text;
14
18
  this.cachedText = undefined;
15
19
  this.cachedWidth = undefined;
@@ -17,6 +21,7 @@ export class Text {
17
21
  }
18
22
  setCustomBgFn(customBgFn) {
19
23
  this.customBgFn = customBgFn;
24
+ this.markRevision();
20
25
  this.cachedText = undefined;
21
26
  this.cachedWidth = undefined;
22
27
  this.cachedLines = undefined;
@@ -25,6 +30,19 @@ export class Text {
25
30
  this.cachedText = undefined;
26
31
  this.cachedWidth = undefined;
27
32
  this.cachedLines = undefined;
33
+ this.markRevision();
34
+ }
35
+ /** Instances that expose a revision advance the shared clock; others (e.g. an animated subclass) stay local. */
36
+ markRevision() {
37
+ this.revision = this.getRenderRevision() === undefined ? this.revision + 1 : nextRenderRevision();
38
+ }
39
+ /** Exact `Text` instances only: a subclass may render more than its text and opts in with {@link textRenderRevision}. */
40
+ getRenderRevision() {
41
+ return Object.getPrototypeOf(this) === Text.prototype ? this.revision : undefined;
42
+ }
43
+ /** Revision of the text, padding and background state this class renders. */
44
+ textRenderRevision() {
45
+ return this.revision;
28
46
  }
29
47
  render(width) {
30
48
  // Check cache
@@ -72,6 +90,7 @@ export class Text {
72
90
  emptyLines.push(line);
73
91
  }
74
92
  const result = [...emptyLines, ...contentLines, ...emptyLines];
93
+ flattenLines(result);
75
94
  // Update cache
76
95
  this.cachedText = this.text;
77
96
  this.cachedWidth = width;
package/dist/index.d.ts CHANGED
@@ -29,12 +29,12 @@ export { getNativeClipboard, type NativeClipboard } from "./native-platform.ts";
29
29
  export { oklabToOkhslLightness } from "./oklab.ts";
30
30
  export { type EditorPasteState, expandPasteMarkers } from "./paste-markers.ts";
31
31
  export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts";
32
- export { type CursorPosition, ProcessTerminal, type ProcessTerminalOptions, type Terminal } from "./terminal.ts";
32
+ export { type CursorPosition, isAppleTerminalSession, isWarpWslSession, ProcessTerminal, type ProcessTerminalOptions, type Terminal, } from "./terminal.ts";
33
33
  export { parseTerminalColorSchemeReport, type RgbColor, type TerminalColorScheme, type TerminalColors, } from "./terminal-colors.ts";
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, Container, CURSOR_MARKER, compositeTuiLine, type Focusable, isFocusable, isViewportTUI, type OverlayAnchor, type OverlayBounds, type OverlayHandle, type OverlayMargin, type OverlayOptions, type OverlayUnfocusOptions, type SizeValue, TUI, type TuiInputListener, type TuiInputListenerResult, type TuiMode, type TuiMouseButton, 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, 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
@@ -40,7 +40,7 @@ export { expandPasteMarkers } from "./paste-markers.js";
40
40
  // Input buffering for batch splitting
41
41
  export { StdinBuffer } from "./stdin-buffer.js";
42
42
  // Terminal interface and implementations
43
- export { ProcessTerminal } from "./terminal.js";
43
+ export { isAppleTerminalSession, isWarpWslSession, ProcessTerminal, } from "./terminal.js";
44
44
  // Terminal colors
45
45
  export { parseTerminalColorSchemeReport, } from "./terminal-colors.js";
46
46
  // Terminal image support
@@ -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 { Container, CURSOR_MARKER, compositeTuiLine, isFocusable, isViewportTUI, TUI, } from "./tui.js";
52
+ export { CompositeRevision, Container, CURSOR_MARKER, claimFrameRow, compositeTuiLine, currentRenderRevision, dispatchMouseEvent, 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
@@ -38,6 +38,8 @@ export function getSlashCommandSuggestions(commands, prefix) {
38
38
  return [];
39
39
  }
40
40
  const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
41
+ // A hint without an explicit `requiresArguments` means the command expects input.
42
+ const requiresArguments = ("requiresArguments" in cmd ? cmd.requiresArguments : undefined) ?? hint !== undefined;
41
43
  const desc = cmd.description ?? "";
42
44
  const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
43
45
  return [
@@ -46,7 +48,7 @@ export function getSlashCommandSuggestions(commands, prefix) {
46
48
  label: name,
47
49
  description: fullDesc || undefined,
48
50
  searchText: isSkill && !explicitSkillNamespace ? skillName : name,
49
- awaitsArguments: hint !== undefined,
51
+ awaitsArguments: requiresArguments || ("awaitsArguments" in cmd && cmd.awaitsArguments === true),
50
52
  },
51
53
  ];
52
54
  });
@@ -23,6 +23,7 @@ export declare function isAppleTerminalSession(): boolean;
23
23
  export declare function refreshTerminalDimensions(): void;
24
24
  export declare function normalizeNativeShiftEnterInput(data: string, shouldDetectNativeShiftEnter: boolean, isShiftPressed: boolean): string;
25
25
  export declare function normalizeAppleTerminalInput(data: string, isAppleTerminal: boolean, isShiftPressed: boolean): string;
26
+ export declare function isWarpWslSession(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, socketExists?: (socketPath: string) => boolean): boolean;
26
27
  export declare function normalizeWarpWslShiftEnterInput(data: string, env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, socketExists?: (socketPath: string) => boolean): string;
27
28
  export declare function keyboardEnhancementEnabled(): boolean;
28
29
  export declare function __stdinErrorSubscriberCountForTests(): number;
package/dist/terminal.js CHANGED
@@ -77,7 +77,7 @@ export function normalizeNativeShiftEnterInput(data, shouldDetectNativeShiftEnte
77
77
  export function normalizeAppleTerminalInput(data, isAppleTerminal, isShiftPressed) {
78
78
  return normalizeNativeShiftEnterInput(data, isAppleTerminal, isShiftPressed);
79
79
  }
80
- export function normalizeWarpWslShiftEnterInput(data, env = process.env, platform = process.platform, socketExists = (socketPath) => {
80
+ export function isWarpWslSession(env = process.env, platform = process.platform, socketExists = (socketPath) => {
81
81
  try {
82
82
  return fs.statSync(socketPath).isSocket();
83
83
  }
@@ -85,15 +85,18 @@ export function normalizeWarpWslShiftEnterInput(data, env = process.env, platfor
85
85
  return false;
86
86
  }
87
87
  }) {
88
- if (data !== "\n" || platform !== "linux")
89
- return data;
88
+ if (platform !== "linux")
89
+ return false;
90
90
  if (isMultiplexerSession(env) || env.SSH_CONNECTION?.trim() || env.SSH_CLIENT?.trim() || env.SSH_TTY?.trim()) {
91
- return data;
91
+ return false;
92
92
  }
93
93
  const isWarp = Boolean(env.WARP_SESSION_ID?.trim() || env.WARP_TERMINAL_SESSION_UUID?.trim());
94
94
  const interopPath = env.WSL_INTEROP?.trim();
95
95
  const isWsl = isWarp && interopPath !== undefined && /^\/run\/WSL\/\d+_interop$/.test(interopPath) && socketExists(interopPath);
96
- return isWarp && isWsl ? NATIVE_SHIFT_ENTER_SEQUENCE : data;
96
+ return isWarp && isWsl;
97
+ }
98
+ export function normalizeWarpWslShiftEnterInput(data, env = process.env, platform = process.platform, socketExists) {
99
+ return data === "\n" && isWarpWslSession(env, platform, socketExists) ? NATIVE_SHIFT_ENTER_SEQUENCE : data;
97
100
  }
98
101
  export function keyboardEnhancementEnabled() {
99
102
  const value = process.env.PI_TUI_KEYBOARD_PROTOCOL;
@@ -90,6 +90,8 @@ export declare class TuiAltScreen extends TuiBase implements ViewportTUI {
90
90
  hasActiveSelection(): boolean;
91
91
  /** Copy the active fullscreen text selection, if any, using the configured selection clipboard path. */
92
92
  copyActiveSelectionToClipboard(): Promise<boolean>;
93
+ /** The lines of the last rendered frame, one per terminal row, as written to the terminal. */
94
+ getScreenLines(): string[];
93
95
  setLayoutRoot(component: Component | undefined): void;
94
96
  render(width: number): string[];
95
97
  protected getMountedRoots(): readonly Component[];
@@ -102,6 +102,10 @@ export class TuiAltScreen extends TuiBase {
102
102
  return false;
103
103
  return this.copyTextToClipboard(text);
104
104
  }
105
+ /** The lines of the last rendered frame, one per terminal row, as written to the terminal. */
106
+ getScreenLines() {
107
+ return [...this.previousScreen];
108
+ }
105
109
  setLayoutRoot(component) {
106
110
  if (this.layoutRoot === component)
107
111
  return;
@@ -13,14 +13,17 @@ export declare class TuiMainScreen extends TuiBase {
13
13
  readonly mode: "regular";
14
14
  private trackingEnabled;
15
15
  private readonly clicks;
16
+ /**
17
+ * A press stays clickable only while the committed frame and component tree it hit are unchanged.
18
+ * Both are captured at press time and compared at release, so ordinary frames pay nothing for it.
19
+ */
16
20
  private mousePress?;
17
- private layoutRevision;
18
- private committedMouseLines;
19
- private committedMouseComponents;
20
21
  constructor(...args: ConstructorParameters<typeof TuiBase>);
21
22
  protected applyMouseTracking(enabled: boolean): void;
22
23
  protected beforeTerminalStop(): void;
23
24
  protected doRender(): void;
25
+ private collectMouseComponents;
26
+ private pressLayoutUnchanged;
24
27
  private applyMouseResult;
25
28
  private handleMouseInput;
26
29
  captureRenderState(): TuiMainScreenRenderState;
@@ -8,9 +8,6 @@ export class TuiMainScreen extends TuiBase {
8
8
  this.mode = "regular";
9
9
  this.trackingEnabled = false;
10
10
  this.clicks = new MouseClickSynthesizer();
11
- this.layoutRevision = 0;
12
- this.committedMouseLines = [];
13
- this.committedMouseComponents = [];
14
11
  this.addInputListener((data) => this.handleMouseInput(data));
15
12
  }
16
13
  applyMouseTracking(enabled) {
@@ -51,6 +48,9 @@ export class TuiMainScreen extends TuiBase {
51
48
  }
52
49
  super.doRender();
53
50
  this.noteCommittedMouseFrame();
51
+ this.calibrateMouseAnchor();
52
+ }
53
+ collectMouseComponents() {
54
54
  const components = [];
55
55
  const visit = (component) => {
56
56
  components.push(component);
@@ -60,15 +60,20 @@ export class TuiMainScreen extends TuiBase {
60
60
  };
61
61
  for (const root of this.getMouseLayoutRoots())
62
62
  visit(root);
63
- if (this.previousLines.length !== this.committedMouseLines.length ||
64
- this.previousLines.some((line, index) => line !== this.committedMouseLines[index]) ||
65
- components.length !== this.committedMouseComponents.length ||
66
- components.some((component, index) => component !== this.committedMouseComponents[index])) {
67
- this.layoutRevision++;
63
+ return components;
64
+ }
65
+ pressLayoutUnchanged(press) {
66
+ const lines = this.previousLines;
67
+ if (press.frame !== lines) {
68
+ if (press.frame.length !== lines.length)
69
+ return false;
70
+ for (let index = 0; index < lines.length; index++)
71
+ if (press.frame[index] !== lines[index])
72
+ return false;
68
73
  }
69
- this.committedMouseLines = [...this.previousLines];
70
- this.committedMouseComponents = components;
71
- this.calibrateMouseAnchor();
74
+ const components = this.collectMouseComponents();
75
+ return (components.length === press.components.length &&
76
+ components.every((component, index) => component === press.components[index]));
72
77
  }
73
78
  applyMouseResult(result) {
74
79
  if (!result?.focus)
@@ -105,7 +110,8 @@ export class TuiMainScreen extends TuiBase {
105
110
  this.mousePress = {
106
111
  target: result.target,
107
112
  epoch: this.placementEpoch,
108
- revision: this.layoutRevision,
113
+ frame: this.previousLines,
114
+ components: this.collectMouseComponents(),
109
115
  x: raw.x,
110
116
  y: raw.y,
111
117
  };
@@ -120,7 +126,7 @@ export class TuiMainScreen extends TuiBase {
120
126
  this.mousePress = undefined;
121
127
  if (!press ||
122
128
  press.epoch !== this.placementEpoch ||
123
- press.revision !== this.layoutRevision ||
129
+ !this.pressLayoutUnchanged(press) ||
124
130
  press.x !== raw.x ||
125
131
  press.y !== raw.y) {
126
132
  this.clicks.cancel();
package/dist/tui.d.ts CHANGED
@@ -86,6 +86,15 @@ export interface Component {
86
86
  * Called when theme changes or when component needs to re-render from scratch.
87
87
  */
88
88
  invalidate(): void;
89
+ /**
90
+ * Optional render revision for containers that cache child output.
91
+ *
92
+ * A number promises that `render(width)` returns the same lines for the same width, terminal
93
+ * capabilities and theme until the number changes; the component must change it whenever its
94
+ * state changes (including in `invalidate()`). `undefined` means the output may change at any
95
+ * time (streaming, animation, unknown dependencies), so the component is rendered every frame.
96
+ */
97
+ getRenderRevision?(): number | undefined;
89
98
  dispose?(): void;
90
99
  }
91
100
  export type TuiInputListenerResult = {
@@ -217,12 +226,55 @@ type TuiConstructorOptions = {
217
226
  showHardwareCursor?: boolean;
218
227
  muxDetector?: () => boolean;
219
228
  };
229
+ /** Mode of the renderer drawing the current frame, or `undefined` outside a frame. */
230
+ export declare function frameMode(): TuiMode | undefined;
220
231
  /**
221
- * Container - a component that contains other components
232
+ * Lines of transcript history a main-screen frame keeps above the live area: the terminal's own
233
+ * scrollback size where it can be read (tmux `history-limit`, or `PI_TUI_HISTORY_LINES`), else
234
+ * 2,000; never less than two screens, never more than 5,000 so a resume or repaint stays instant.
222
235
  */
236
+ export declare function mainScreenHistoryLines(rows?: number): number;
237
+ /** Forget the measured terminal scrollback size, e.g. after the environment changed in a test. */
238
+ export declare function resetMainScreenHistoryLines(): void;
239
+ /**
240
+ * Rows at the top of the last committed frame that now live in the terminal's native scrollback
241
+ * (main-screen renderer, same terminal size). Changing any of them forces a full scrollback replay.
242
+ * 0 outside such a frame.
243
+ */
244
+ export declare function frameScrollbackRows(): number;
245
+ /**
246
+ * The absolute frame row where `component` starts, when its parent rendered it through
247
+ * {@link renderAtFrameRow}; `undefined` when the position is unknown (any other parent).
248
+ */
249
+ export declare function claimFrameRow(component: Component): number | undefined;
250
+ /** Render `child` as starting at absolute frame row `row`, so it can {@link claimFrameRow} it. */
251
+ export declare function renderAtFrameRow(child: Component, width: number, row: number | undefined): string[];
252
+ /**
253
+ * Draw a new value from the process-wide render revision clock. Every revisioned state change uses
254
+ * one, so "the clock has not moved" proves no revisioned component changed and caches may skip
255
+ * re-reading their children's revisions.
256
+ */
257
+ export declare function nextRenderRevision(): number;
258
+ /** Current value of the render revision clock (see {@link nextRenderRevision}). */
259
+ export declare function currentRenderRevision(): number;
260
+ /**
261
+ * Render revision of a component whose output is a pure function of its own state and its children's
262
+ * output. `bump()` records an own-state change; `read(children)` returns a revision that also changes
263
+ * whenever a child is replaced or a child's revision changes, and `undefined` while any child is live.
264
+ * Child revisions are re-read only after the clock moved, so an unchanged subtree costs one identity pass.
265
+ */
266
+ export declare class CompositeRevision {
267
+ private revision;
268
+ private children;
269
+ private childRevisions;
270
+ private checkedAt;
271
+ bump(): void;
272
+ read(children: readonly Component[]): number | undefined;
273
+ }
223
274
  export declare class Container implements Component {
224
275
  children: Component[];
225
276
  private disposed;
277
+ private readonly composite;
226
278
  private mouseLayout?;
227
279
  addChild(component: Component): void;
228
280
  removeChild(component: Component): void;
@@ -231,9 +283,25 @@ export declare class Container implements Component {
231
283
  detachAll(): void;
232
284
  dispose(): void;
233
285
  invalidate(): void;
286
+ /**
287
+ * A plain `Container` only concatenates its children, so its output changes exactly when a child
288
+ * changes. Subclasses may render more than their children and therefore opt in explicitly by
289
+ * overriding this (usually via {@link childRenderRevision}); an inherited revision would let a
290
+ * cache keep their stale output.
291
+ */
292
+ getRenderRevision(): number | undefined;
293
+ /** Revision of this container's children, for subclasses whose output depends only on them and `bump()`ed state. */
294
+ protected childRenderRevision(): number | undefined;
295
+ /** Record an own-state change for {@link childRenderRevision}. */
296
+ protected bumpRenderRevision(): void;
234
297
  handleMouse(event: TuiMouseEvent): TuiMouseDispatchResult | undefined;
235
298
  render(width: number): string[];
236
299
  }
300
+ /**
301
+ * Concatenate rendered line arrays into one new array. Native `concat` copies whole arrays at once,
302
+ * which keeps a frame over a long transcript from paying a per-line iterator and push.
303
+ */
304
+ export declare function joinLineArrays(chunks: readonly (readonly string[])[]): string[];
237
305
  /** Composite overlay content into a terminal line at a fixed column. */
238
306
  export declare function compositeTuiLine(baseLine: string, overlayLine: string, startCol: number, overlayWidth: number, totalWidth: number): string;
239
307
  export type TuiMode = "regular" | "fullscreen";
@@ -260,6 +328,9 @@ export declare abstract class TuiBase extends Container {
260
328
  terminal: Terminal;
261
329
  protected previousLines: string[];
262
330
  private previousRawLines;
331
+ private previousImageScan;
332
+ /** Image presence the normalization pass already measured for the array it produced. */
333
+ private normalizedImageHint;
263
334
  private normalizeMemo;
264
335
  protected previousKittyImageIds: Set<number>;
265
336
  protected previousWidth: number;
@@ -419,6 +490,7 @@ export declare abstract class TuiBase extends Container {
419
490
  /** Composite all overlays into content lines (sorted by focusOrder, higher = on top). */
420
491
  protected compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[];
421
492
  private static readonly SEGMENT_RESET;
493
+ private static readonly NORMALIZE_MEMO_MIN;
422
494
  /**
423
495
  * Every frame write is bracketed by synchronized output (DECSET 2026) and
424
496
  * disables autowrap (DECAWM, DECRST 7) while rows are painted. Differential
@@ -433,10 +505,21 @@ export declare abstract class TuiBase extends Container {
433
505
  private static readonly FRAME_BEGIN;
434
506
  private static readonly FRAME_END;
435
507
  private setPreviousLines;
508
+ /** Image presence of the committed frame, measured once per frame array instead of once per check. */
509
+ protected previousLinesHaveImage(): boolean;
510
+ /** Record image presence for a produced array; `undefined` leaves it to a scan when it is committed. */
511
+ private hintNormalizedImages;
436
512
  private normalizeLine;
437
513
  protected applyLineResets(lines: string[]): string[];
438
514
  private applyLineResetResult;
439
515
  private applyViewportLineResets;
516
+ /**
517
+ * A frame whose line count changed (an append, a growing editor, a removed row) keeps every
518
+ * leading line that is unchanged since the last frame. Normalization is a pure function of the
519
+ * raw line, so the previous normalized prefix is reused and only the changed tail is normalized;
520
+ * the diff then starts where the raw lines first differ.
521
+ */
522
+ private applyResizedLineResets;
440
523
  private collectKittyImageIds;
441
524
  private deleteKittyImages;
442
525
  private getKittyImageReservedRows;
package/dist/tui.js CHANGED
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Minimal TUI implementation with differential rendering
3
3
  */
4
+ import { execFile } from "node:child_process";
4
5
  import * as fs from "node:fs";
5
6
  import * as os from "node:os";
6
7
  import * as path from "node:path";
@@ -274,18 +275,179 @@ function parseSizeValue(value, referenceSize) {
274
275
  /**
275
276
  * Container - a component that contains other components
276
277
  */
278
+ /**
279
+ * Facts about the frame being rendered, published by the main-screen renderer for containers that
280
+ * can skip work for rows the terminal cannot repaint cheaply.
281
+ */
282
+ const renderFrame = {
283
+ scrollbackRows: 0,
284
+ offset: 0,
285
+ next: undefined,
286
+ mode: undefined,
287
+ rows: 0,
288
+ };
289
+ /** Mode of the renderer drawing the current frame, or `undefined` outside a frame. */
290
+ export function frameMode() {
291
+ return renderFrame.mode;
292
+ }
293
+ const DEFAULT_HISTORY_LINES = 2000;
294
+ /** Writing this many lines into a terminal takes ~0.2 s, the most a resume or repaint may spend on history. */
295
+ const MAX_HISTORY_LINES = 5000;
296
+ let terminalScrollbackLines;
297
+ /** Bumped by {@link resetMainScreenHistoryLines}, so a tmux answer for an older environment is dropped. */
298
+ let scrollbackLookupGeneration = 0;
299
+ /**
300
+ * The override is read at once; tmux is asked in the background (a process run inside the first
301
+ * render would hold input and painting while tmux answers), and the default applies until it does.
302
+ */
303
+ function readTerminalScrollbackLines() {
304
+ const override = Number(process.env.PI_TUI_HISTORY_LINES);
305
+ if (Number.isFinite(override) && override > 0)
306
+ return Math.floor(override);
307
+ if (!process.env.TMUX)
308
+ return null;
309
+ const generation = scrollbackLookupGeneration;
310
+ try {
311
+ execFile("tmux", ["display-message", "-p", "#{history_limit}"], { encoding: "utf8", timeout: 500 }, (error, stdout) => {
312
+ if (generation !== scrollbackLookupGeneration)
313
+ return;
314
+ const limit = Number(stdout.trim());
315
+ if (!error && Number.isFinite(limit) && limit > 0)
316
+ terminalScrollbackLines = limit;
317
+ });
318
+ }
319
+ catch {
320
+ // tmux is not runnable: keep the default.
321
+ }
322
+ return null;
323
+ }
324
+ /**
325
+ * Lines of transcript history a main-screen frame keeps above the live area: the terminal's own
326
+ * scrollback size where it can be read (tmux `history-limit`, or `PI_TUI_HISTORY_LINES`), else
327
+ * 2,000; never less than two screens, never more than 5,000 so a resume or repaint stays instant.
328
+ */
329
+ export function mainScreenHistoryLines(rows = renderFrame.rows) {
330
+ if (terminalScrollbackLines === undefined)
331
+ terminalScrollbackLines = readTerminalScrollbackLines();
332
+ const preferred = Math.min(MAX_HISTORY_LINES, terminalScrollbackLines ?? DEFAULT_HISTORY_LINES);
333
+ return Math.min(MAX_HISTORY_LINES, Math.max(2 * Math.max(1, rows), preferred));
334
+ }
335
+ /** Forget the measured terminal scrollback size, e.g. after the environment changed in a test. */
336
+ export function resetMainScreenHistoryLines() {
337
+ terminalScrollbackLines = undefined;
338
+ scrollbackLookupGeneration += 1;
339
+ }
340
+ /**
341
+ * Rows at the top of the last committed frame that now live in the terminal's native scrollback
342
+ * (main-screen renderer, same terminal size). Changing any of them forces a full scrollback replay.
343
+ * 0 outside such a frame.
344
+ */
345
+ export function frameScrollbackRows() {
346
+ return renderFrame.scrollbackRows;
347
+ }
348
+ /**
349
+ * The absolute frame row where `component` starts, when its parent rendered it through
350
+ * {@link renderAtFrameRow}; `undefined` when the position is unknown (any other parent).
351
+ */
352
+ export function claimFrameRow(component) {
353
+ if (renderFrame.next !== component)
354
+ return undefined;
355
+ renderFrame.next = undefined;
356
+ return renderFrame.offset;
357
+ }
358
+ /** Render `child` as starting at absolute frame row `row`, so it can {@link claimFrameRow} it. */
359
+ export function renderAtFrameRow(child, width, row) {
360
+ if (row === undefined)
361
+ return child.render(width);
362
+ const previousOffset = renderFrame.offset;
363
+ const previousNext = renderFrame.next;
364
+ renderFrame.offset = row;
365
+ renderFrame.next = child;
366
+ try {
367
+ return child.render(width);
368
+ }
369
+ finally {
370
+ renderFrame.offset = previousOffset;
371
+ renderFrame.next = previousNext;
372
+ }
373
+ }
374
+ let renderRevisionClock = 0;
375
+ /**
376
+ * Draw a new value from the process-wide render revision clock. Every revisioned state change uses
377
+ * one, so "the clock has not moved" proves no revisioned component changed and caches may skip
378
+ * re-reading their children's revisions.
379
+ */
380
+ export function nextRenderRevision() {
381
+ renderRevisionClock += 1;
382
+ return renderRevisionClock;
383
+ }
384
+ /** Current value of the render revision clock (see {@link nextRenderRevision}). */
385
+ export function currentRenderRevision() {
386
+ return renderRevisionClock;
387
+ }
388
+ /**
389
+ * Render revision of a component whose output is a pure function of its own state and its children's
390
+ * output. `bump()` records an own-state change; `read(children)` returns a revision that also changes
391
+ * whenever a child is replaced or a child's revision changes, and `undefined` while any child is live.
392
+ * Child revisions are re-read only after the clock moved, so an unchanged subtree costs one identity pass.
393
+ */
394
+ export class CompositeRevision {
395
+ constructor() {
396
+ this.revision = nextRenderRevision();
397
+ this.children = [];
398
+ this.childRevisions = [];
399
+ this.checkedAt = -1;
400
+ }
401
+ bump() {
402
+ this.revision = nextRenderRevision();
403
+ }
404
+ read(children) {
405
+ let replaced = children.length !== this.children.length;
406
+ for (let index = 0; !replaced && index < children.length; index++) {
407
+ if (children[index] !== this.children[index])
408
+ replaced = true;
409
+ }
410
+ if (!replaced && this.checkedAt === renderRevisionClock)
411
+ return this.revision;
412
+ const revisions = [];
413
+ for (const child of children) {
414
+ const revision = child.getRenderRevision?.();
415
+ if (revision === undefined) {
416
+ // Unrevisioned: nothing to compare against next time, and removed children must not stay referenced.
417
+ this.children = [];
418
+ this.childRevisions = [];
419
+ this.checkedAt = -1;
420
+ return undefined;
421
+ }
422
+ revisions.push(revision);
423
+ }
424
+ const changed = replaced || revisions.some((revision, index) => revision !== this.childRevisions[index]);
425
+ if (changed) {
426
+ this.revision = nextRenderRevision();
427
+ this.children = [...children];
428
+ }
429
+ this.childRevisions = revisions;
430
+ this.checkedAt = renderRevisionClock;
431
+ return this.revision;
432
+ }
433
+ }
277
434
  export class Container {
278
435
  constructor() {
279
436
  this.children = [];
280
437
  this.disposed = false;
438
+ this.composite = new CompositeRevision();
281
439
  }
440
+ // Every structural change moves the render revision clock, so a cache that saw the clock stand
441
+ // still may trust that no revisioned subtree gained, lost or swapped a child.
282
442
  addChild(component) {
283
443
  this.children.push(component);
444
+ this.composite.bump();
284
445
  }
285
446
  removeChild(component) {
286
447
  const index = this.children.indexOf(component);
287
448
  if (index !== -1) {
288
449
  this.children.splice(index, 1);
450
+ this.composite.bump();
289
451
  component.dispose?.();
290
452
  }
291
453
  }
@@ -293,6 +455,7 @@ export class Container {
293
455
  const index = this.children.indexOf(component);
294
456
  if (index !== -1) {
295
457
  this.children.splice(index, 1);
458
+ this.composite.bump();
296
459
  }
297
460
  }
298
461
  clear() {
@@ -300,9 +463,11 @@ export class Container {
300
463
  child.dispose?.();
301
464
  }
302
465
  this.children = [];
466
+ this.composite.bump();
303
467
  }
304
468
  detachAll() {
305
469
  this.children = [];
470
+ this.composite.bump();
306
471
  }
307
472
  dispose() {
308
473
  if (this.disposed)
@@ -313,10 +478,28 @@ export class Container {
313
478
  }
314
479
  }
315
480
  invalidate() {
481
+ this.composite.bump();
316
482
  for (const child of this.children) {
317
483
  child.invalidate?.();
318
484
  }
319
485
  }
486
+ /**
487
+ * A plain `Container` only concatenates its children, so its output changes exactly when a child
488
+ * changes. Subclasses may render more than their children and therefore opt in explicitly by
489
+ * overriding this (usually via {@link childRenderRevision}); an inherited revision would let a
490
+ * cache keep their stale output.
491
+ */
492
+ getRenderRevision() {
493
+ return Object.getPrototypeOf(this) === Container.prototype ? this.childRenderRevision() : undefined;
494
+ }
495
+ /** Revision of this container's children, for subclasses whose output depends only on them and `bump()`ed state. */
496
+ childRenderRevision() {
497
+ return this.composite.read(this.children);
498
+ }
499
+ /** Record an own-state change for {@link childRenderRevision}. */
500
+ bumpRenderRevision() {
501
+ this.composite.bump();
502
+ }
320
503
  handleMouse(event) {
321
504
  if (event.y < 0 || event.y >= event.height)
322
505
  return undefined;
@@ -340,12 +523,13 @@ export class Container {
340
523
  return undefined;
341
524
  }
342
525
  render(width) {
343
- const lines = [];
526
+ const chunks = [];
344
527
  const mouseChildren = [];
528
+ let row = claimFrameRow(this);
345
529
  for (const child of this.children) {
346
530
  let childLines;
347
531
  try {
348
- childLines = child.render(width);
532
+ childLines = renderAtFrameRow(child, width, row);
349
533
  }
350
534
  catch (error) {
351
535
  logRenderErrorOnce(child, error);
@@ -354,14 +538,28 @@ export class Container {
354
538
  childLines = [`[render error: ${componentName}]`];
355
539
  }
356
540
  mouseChildren.push({ component: child, height: childLines.length });
357
- for (const line of childLines) {
358
- lines.push(line);
359
- }
541
+ chunks.push(childLines);
542
+ if (row !== undefined)
543
+ row += childLines.length;
360
544
  }
361
545
  this.mouseLayout = { width, children: mouseChildren };
362
- return lines;
546
+ return joinLineArrays(chunks);
363
547
  }
364
548
  }
549
+ const JOIN_BATCH = 1024;
550
+ /**
551
+ * Concatenate rendered line arrays into one new array. Native `concat` copies whole arrays at once,
552
+ * which keeps a frame over a long transcript from paying a per-line iterator and push.
553
+ */
554
+ export function joinLineArrays(chunks) {
555
+ if (chunks.length <= JOIN_BATCH)
556
+ return [].concat(...chunks);
557
+ const batches = [];
558
+ for (let start = 0; start < chunks.length; start += JOIN_BATCH) {
559
+ batches.push([].concat(...chunks.slice(start, start + JOIN_BATCH)));
560
+ }
561
+ return [].concat(...batches);
562
+ }
365
563
  /**
366
564
  * TUI - Main class for managing terminal UI with differential rendering
367
565
  */
@@ -528,7 +726,7 @@ export class TuiBase extends Container {
528
726
  if (this.mouseCommittedLineCount !== this.previousLines.length)
529
727
  this.placementEpoch++;
530
728
  this.mouseCommittedLineCount = this.previousLines.length;
531
- if (this.previousLines.some(isImageLine)) {
729
+ if (this.previousLinesHaveImage()) {
532
730
  this.placementEpoch++;
533
731
  this.anchor.kind = "unknown";
534
732
  return;
@@ -560,7 +758,7 @@ export class TuiBase extends Container {
560
758
  this.mouseExternalWritePending ||
561
759
  !this.terminal.queryCursorPosition ||
562
760
  this.previousLines.length === 0 ||
563
- this.previousLines.some(isImageLine))
761
+ this.previousLinesHaveImage())
564
762
  return;
565
763
  if (this.anchor.kind !== "unknown" &&
566
764
  this.anchor.epoch === this.placementEpoch &&
@@ -1542,6 +1740,7 @@ export class TuiBase extends Container {
1542
1740
  return result;
1543
1741
  }
1544
1742
  static { this.SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07"; }
1743
+ static { this.NORMALIZE_MEMO_MIN = 4096; }
1545
1744
  /**
1546
1745
  * Every frame write is bracketed by synchronized output (DECSET 2026) and
1547
1746
  * disables autowrap (DECAWM, DECRST 7) while rows are painted. Differential
@@ -1559,6 +1758,19 @@ export class TuiBase extends Container {
1559
1758
  this.previousLines = lines;
1560
1759
  this.previousRawLines = rawLines;
1561
1760
  }
1761
+ /** Image presence of the committed frame, measured once per frame array instead of once per check. */
1762
+ previousLinesHaveImage() {
1763
+ const lines = this.previousLines;
1764
+ if (this.previousImageScan?.lines !== lines) {
1765
+ const hint = this.normalizedImageHint?.lines === lines ? this.normalizedImageHint.hasImage : undefined;
1766
+ this.previousImageScan = { lines, hasImage: hint ?? lines.some(isImageLine) };
1767
+ }
1768
+ return this.previousImageScan.hasImage;
1769
+ }
1770
+ /** Record image presence for a produced array; `undefined` leaves it to a scan when it is committed. */
1771
+ hintNormalizedImages(lines, hasImage) {
1772
+ this.normalizedImageHint = hasImage === undefined ? undefined : { lines, hasImage };
1773
+ }
1562
1774
  normalizeLine(line) {
1563
1775
  if (isImageLine(line)) {
1564
1776
  return { line, normalized: false };
@@ -1568,6 +1780,16 @@ export class TuiBase extends Container {
1568
1780
  return { line: cached, normalized: false };
1569
1781
  }
1570
1782
  const normalized = normalizeTerminalOutput(line) + TUI.SEGMENT_RESET;
1783
+ // The windowed path only ever adds: a long run of distinct lines (spinners, streamed text)
1784
+ // grew the memo without bound. Past a bound the oldest half goes; full passes rebuild it.
1785
+ if (this.normalizeMemo.size >= Math.max(TUI.NORMALIZE_MEMO_MIN, this.previousRawLines.length * 2)) {
1786
+ let drop = this.normalizeMemo.size >> 1;
1787
+ for (const key of this.normalizeMemo.keys()) {
1788
+ if (drop-- <= 0)
1789
+ break;
1790
+ this.normalizeMemo.delete(key);
1791
+ }
1792
+ }
1571
1793
  this.normalizeMemo.set(line, normalized);
1572
1794
  return { line: normalized, normalized: true };
1573
1795
  }
@@ -1579,9 +1801,11 @@ export class TuiBase extends Container {
1579
1801
  const nextMemo = new Map();
1580
1802
  const normalizedLines = [];
1581
1803
  let normalizedCount = 0;
1804
+ let hasImage = false;
1582
1805
  for (let i = 0; i < lines.length; i++) {
1583
1806
  const line = lines[i];
1584
1807
  if (isImageLine(line)) {
1808
+ hasImage = true;
1585
1809
  normalizedLines.push(line);
1586
1810
  continue;
1587
1811
  }
@@ -1594,6 +1818,7 @@ export class TuiBase extends Container {
1594
1818
  normalizedLines.push(normalized);
1595
1819
  }
1596
1820
  this.normalizeMemo = nextMemo;
1821
+ this.hintNormalizedImages(normalizedLines, hasImage);
1597
1822
  recordViewportRenderStats(normalizedCount, mode);
1598
1823
  return {
1599
1824
  lines: normalizedLines,
@@ -1606,10 +1831,12 @@ export class TuiBase extends Container {
1606
1831
  if (!viewportRenderEnabled() ||
1607
1832
  !stableDimensions ||
1608
1833
  this.previousLines.length === 0 ||
1609
- this.previousLines.length !== rawLines.length ||
1610
- this.previousRawLines.length !== rawLines.length) {
1834
+ this.previousRawLines.length !== this.previousLines.length) {
1611
1835
  return this.applyLineResetResult(rawLines);
1612
1836
  }
1837
+ if (this.previousRawLines.length !== rawLines.length) {
1838
+ return this.applyResizedLineResets(rawLines);
1839
+ }
1613
1840
  const windowStart = Math.max(0, viewportTop - VIEWPORT_RENDER_OVERSCAN);
1614
1841
  const windowEnd = Math.min(rawLines.length, viewportTop + height + VIEWPORT_RENDER_OVERSCAN);
1615
1842
  let firstRawChanged = -1;
@@ -1624,20 +1851,27 @@ export class TuiBase extends Container {
1624
1851
  return this.applyLineResetResult(rawLines, "escaped");
1625
1852
  }
1626
1853
  }
1854
+ const previousHadImage = this.previousLinesHaveImage();
1627
1855
  const lines = this.previousLines.slice();
1628
1856
  let normalizedCount = 0;
1857
+ let changedImage = false;
1629
1858
  if (firstRawChanged !== -1) {
1630
1859
  for (let i = windowStart; i < windowEnd; i++) {
1631
1860
  if (rawLines[i] === this.previousRawLines[i]) {
1632
1861
  continue;
1633
1862
  }
1634
- const normalized = this.normalizeLine(rawLines[i] ?? "");
1863
+ const raw = rawLines[i] ?? "";
1864
+ if (isImageLine(raw))
1865
+ changedImage = true;
1866
+ const normalized = this.normalizeLine(raw);
1635
1867
  lines[i] = normalized.line;
1636
1868
  if (normalized.normalized) {
1637
1869
  normalizedCount += 1;
1638
1870
  }
1639
1871
  }
1640
1872
  }
1873
+ // A frame that had an image may have replaced it; only an image-free frame can be updated in place.
1874
+ this.hintNormalizedImages(lines, previousHadImage ? undefined : changedImage);
1641
1875
  recordViewportRenderStats(normalizedCount, "bounded");
1642
1876
  return {
1643
1877
  lines,
@@ -1646,6 +1880,46 @@ export class TuiBase extends Container {
1646
1880
  bounded: true,
1647
1881
  };
1648
1882
  }
1883
+ /**
1884
+ * A frame whose line count changed (an append, a growing editor, a removed row) keeps every
1885
+ * leading line that is unchanged since the last frame. Normalization is a pure function of the
1886
+ * raw line, so the previous normalized prefix is reused and only the changed tail is normalized;
1887
+ * the diff then starts where the raw lines first differ.
1888
+ */
1889
+ applyResizedLineResets(rawLines) {
1890
+ const previousRaw = this.previousRawLines;
1891
+ const sharedLength = Math.min(rawLines.length, previousRaw.length);
1892
+ let firstRawChanged = 0;
1893
+ while (firstRawChanged < sharedLength && rawLines[firstRawChanged] === previousRaw[firstRawChanged]) {
1894
+ firstRawChanged++;
1895
+ }
1896
+ const previousHadImage = this.previousLinesHaveImage();
1897
+ const lines = this.previousLines.slice(0, firstRawChanged);
1898
+ let normalizedCount = 0;
1899
+ let tailImage = false;
1900
+ for (let i = firstRawChanged; i < rawLines.length; i++) {
1901
+ const line = rawLines[i] ?? "";
1902
+ if (isImageLine(line)) {
1903
+ tailImage = true;
1904
+ lines.push(line);
1905
+ continue;
1906
+ }
1907
+ let normalized = this.normalizeMemo.get(line);
1908
+ if (normalized === undefined) {
1909
+ normalized = normalizeTerminalOutput(line) + TUI.SEGMENT_RESET;
1910
+ normalizedCount += 1;
1911
+ }
1912
+ lines.push(normalized);
1913
+ }
1914
+ this.hintNormalizedImages(lines, previousHadImage ? undefined : tailImage);
1915
+ recordViewportRenderStats(normalizedCount, "bounded");
1916
+ return {
1917
+ lines,
1918
+ firstRawChanged,
1919
+ compareEndExclusive: Math.max(rawLines.length, this.previousLines.length),
1920
+ bounded: true,
1921
+ };
1922
+ }
1649
1923
  collectKittyImageIds(lines) {
1650
1924
  recordKittyImageScanStats(lines.length);
1651
1925
  const ids = new Set();
@@ -1960,8 +2234,22 @@ export class TuiBase extends Container {
1960
2234
  const targetScreenRow = targetRow - viewportTop;
1961
2235
  return targetScreenRow - currentScreenRow;
1962
2236
  };
1963
- // Render all components to get new lines
1964
- let newLines = this.render(width);
2237
+ // Render all components to get new lines. The main screen tells containers which rows of the
2238
+ // last frame are in native scrollback, so live content there can stay as the terminal shows it.
2239
+ renderFrame.scrollbackRows =
2240
+ this.mode === "regular" && !widthChanged && !heightChanged && this.previousLines.length > 0
2241
+ ? prevViewportTop
2242
+ : 0;
2243
+ renderFrame.mode = this.mode;
2244
+ renderFrame.rows = height;
2245
+ let newLines;
2246
+ try {
2247
+ newLines = renderAtFrameRow(this, width, 0);
2248
+ }
2249
+ finally {
2250
+ renderFrame.scrollbackRows = 0;
2251
+ renderFrame.mode = undefined;
2252
+ }
1965
2253
  // Composite overlays into the rendered lines (before differential compare)
1966
2254
  if (this.overlayStack.length > 0) {
1967
2255
  newLines = this.compositeOverlays(newLines, width, height);
package/dist/utils.d.ts CHANGED
@@ -54,6 +54,12 @@ export declare function getActiveBackgroundAnsi(text: string): string;
54
54
  * @param width - Maximum visible width per line
55
55
  * @returns Array of wrapped lines (NOT padded to width)
56
56
  */
57
+ /**
58
+ * Flatten cached lines. V8 keeps a string built by concatenation as a tree of its parts until something reads it
59
+ * whole, and a cached line kept as such a tree retains several times its own size. Converting a string to a number
60
+ * reads it whole, so V8 flattens it in place; the strings' values do not change.
61
+ */
62
+ export declare function flattenLines(lines: readonly string[]): void;
57
63
  export declare function wrapTextWithAnsi(text: string, width: number): string[];
58
64
  export declare const PUNCTUATION_REGEX: RegExp;
59
65
  /**
package/dist/utils.js CHANGED
@@ -872,6 +872,15 @@ function splitIntoTokensWithAnsi(text) {
872
872
  * @param width - Maximum visible width per line
873
873
  * @returns Array of wrapped lines (NOT padded to width)
874
874
  */
875
+ /**
876
+ * Flatten cached lines. V8 keeps a string built by concatenation as a tree of its parts until something reads it
877
+ * whole, and a cached line kept as such a tree retains several times its own size. Converting a string to a number
878
+ * reads it whole, so V8 flattens it in place; the strings' values do not change.
879
+ */
880
+ export function flattenLines(lines) {
881
+ for (const line of lines)
882
+ Number(line);
883
+ }
875
884
  export function wrapTextWithAnsi(text, width) {
876
885
  if (!text) {
877
886
  return [""];
@@ -1260,8 +1269,11 @@ export function sliceWithWidth(line, startCol, length, strict = false) {
1260
1269
  while (i < line.length) {
1261
1270
  const ansi = extractAnsiCode(line, i);
1262
1271
  if (ansi) {
1263
- if (currentCol >= startCol && currentCol < endCol)
1264
- result += ansi.code;
1272
+ if (currentCol >= startCol && currentCol < endCol) {
1273
+ // Keep original order: codes from before the range must precede codes at the boundary
1274
+ result += pendingAnsi + ansi.code;
1275
+ pendingAnsi = "";
1276
+ }
1265
1277
  else if (currentCol < startCol)
1266
1278
  pendingAnsi += ansi.code;
1267
1279
  i += ansi.length;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@code-yeongyu/senpi-tui",
3
- "version": "2026.10.1",
3
+ "version": "2026.10.3",
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",