@prometheus-ai/tui 0.5.3 → 0.5.8

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.
Files changed (55) hide show
  1. package/dist/types/autocomplete.d.ts +3 -1
  2. package/dist/types/components/box.d.ts +1 -1
  3. package/dist/types/components/editor.d.ts +35 -2
  4. package/dist/types/components/image.d.ts +22 -3
  5. package/dist/types/components/input.d.ts +6 -1
  6. package/dist/types/components/loader.d.ts +9 -2
  7. package/dist/types/components/markdown.d.ts +3 -1
  8. package/dist/types/components/scroll-view.d.ts +23 -1
  9. package/dist/types/components/select-list.d.ts +19 -1
  10. package/dist/types/components/settings-list.d.ts +87 -7
  11. package/dist/types/components/spacer.d.ts +1 -1
  12. package/dist/types/components/tab-bar.d.ts +37 -4
  13. package/dist/types/components/text.d.ts +2 -2
  14. package/dist/types/components/truncated-text.d.ts +1 -1
  15. package/dist/types/fuzzy.d.ts +10 -1
  16. package/dist/types/index.d.ts +1 -0
  17. package/dist/types/keybindings.d.ts +5 -3
  18. package/dist/types/keys.d.ts +1 -1
  19. package/dist/types/kill-ring.d.ts +0 -7
  20. package/dist/types/kitty-graphics.d.ts +16 -31
  21. package/dist/types/loop-watchdog.d.ts +39 -0
  22. package/dist/types/mouse.d.ts +41 -0
  23. package/dist/types/stdin-buffer.d.ts +17 -0
  24. package/dist/types/terminal-capabilities.d.ts +74 -18
  25. package/dist/types/terminal.d.ts +34 -36
  26. package/dist/types/tui.d.ts +191 -79
  27. package/dist/types/utils.d.ts +5 -2
  28. package/package.json +4 -4
  29. package/src/autocomplete.ts +79 -65
  30. package/src/components/box.ts +43 -63
  31. package/src/components/editor.ts +471 -136
  32. package/src/components/image.ts +85 -9
  33. package/src/components/input.ts +12 -3
  34. package/src/components/loader.ts +35 -21
  35. package/src/components/markdown.ts +174 -53
  36. package/src/components/scroll-view.ts +63 -2
  37. package/src/components/select-list.ts +233 -38
  38. package/src/components/settings-list.ts +626 -64
  39. package/src/components/spacer.ts +9 -5
  40. package/src/components/tab-bar.ts +153 -28
  41. package/src/components/text.ts +6 -2
  42. package/src/components/truncated-text.ts +10 -2
  43. package/src/fuzzy.ts +214 -59
  44. package/src/index.ts +3 -1
  45. package/src/keybindings.ts +72 -14
  46. package/src/keys.ts +1 -1
  47. package/src/kill-ring.ts +5 -0
  48. package/src/kitty-graphics.ts +2 -101
  49. package/src/loop-watchdog.ts +106 -0
  50. package/src/mouse.ts +55 -0
  51. package/src/stdin-buffer.ts +291 -81
  52. package/src/terminal-capabilities.ts +206 -168
  53. package/src/terminal.ts +367 -110
  54. package/src/tui.ts +2102 -1729
  55. package/src/utils.ts +92 -60
@@ -27,6 +27,9 @@ export interface ImageOptions {
27
27
 
28
28
  const EMPTY_IDS: readonly number[] = [];
29
29
  const EMPTY_TRANSMITS: readonly string[] = [];
30
+ // Direct placements reserve height with leading zero-width rows. Keep them
31
+ // non-plain so transcript blank-edge trimming does not collapse image-only blocks.
32
+ const RESERVED_IMAGE_ROW = "\x1b[0m";
30
33
 
31
34
  /** Default count of inline images kept as live graphics before older ones fall back to text. */
32
35
  export const DEFAULT_MAX_INLINE_IMAGES = 8;
@@ -73,6 +76,16 @@ export class ImageBudget {
73
76
  #transmitted = new Set<number>();
74
77
  /** Transmit sequences (full base64) to write once, before this frame's placements. */
75
78
  #pendingTransmits: string[] = [];
79
+ // True while the in-flight pass is a partial/throwaway pass (the
80
+ // non-multiplexer resize viewport fast path) that walks only the visible
81
+ // tail, bottom-up. Such a pass cannot derive display order from observe()
82
+ // call order, so its suppression decisions replay the committed split below.
83
+ #stablePass = false;
84
+ // Image ids shown as text in the frame currently on the terminal: the
85
+ // display-order prefix [0, #onTerminal) of the last full pass, snapshotted by
86
+ // id so a partial pass reproduces the on-screen live/text split without a
87
+ // full, correctly-ordered walk.
88
+ #suppressedIds = new Set<number>();
76
89
 
77
90
  constructor(cap: number = DEFAULT_MAX_INLINE_IMAGES, requestRender: () => void = () => {}) {
78
91
  this.#cap = normalizeCap(cap);
@@ -114,18 +127,32 @@ export class ImageBudget {
114
127
  return this.#nextId++;
115
128
  }
116
129
 
117
- /** Begin a render pass. Called by the renderer before composing the frame. */
118
- beginPass(): void {
130
+ /**
131
+ * Begin a render pass. Called by the renderer before composing the frame.
132
+ * Pass `stable: true` for a partial/throwaway pass that does not walk the
133
+ * whole tree in display order (the resize viewport fast path): {@link observe}
134
+ * then replays the last committed per-id decision instead of one derived from
135
+ * call order, and the pass must NOT be closed with {@link endPass}.
136
+ */
137
+ beginPass(stable = false): void {
119
138
  this.#passIds.length = 0;
120
- this.#applyingReset = this.#cap > 0 && this.#planned > this.#onTerminal;
139
+ this.#stablePass = stable;
140
+ this.#applyingReset = !stable && this.#cap > 0 && this.#planned > this.#onTerminal;
121
141
  }
122
142
 
123
143
  /**
124
144
  * Record an image in display order and report whether it must render its text
125
145
  * fallback this frame. Called by every {@link Image} during render — including
126
146
  * on a cache hit, so the image keeps its display-order slot.
147
+ *
148
+ * During a `stable` pass ({@link beginPass}) the call order and visible subset
149
+ * are not authoritative, so the decision is the committed on-terminal split
150
+ * (`#suppressedIds`) keyed by id — order- and partiality-independent.
127
151
  */
128
152
  observe(imageId: number): boolean {
153
+ if (this.#stablePass) {
154
+ return this.#cap > 0 && this.#suppressedIds.has(imageId);
155
+ }
129
156
  const index = this.#passIds.length;
130
157
  this.#passIds.push(imageId);
131
158
  return this.#cap > 0 && index < this.#planned;
@@ -152,6 +179,11 @@ export class ImageBudget {
152
179
  reset = true;
153
180
  }
154
181
  this.#reconcile(total);
182
+ // Snapshot the committed display-order suppression by id: the prefix
183
+ // [0, #onTerminal) is what the terminal currently shows as text. Partial
184
+ // passes replay this per id (see #stablePass) instead of re-deriving it
185
+ // from a reversed, tail-only walk.
186
+ this.#suppressedIds = new Set(this.#passIds.slice(0, this.#onTerminal));
155
187
  return reset;
156
188
  }
157
189
 
@@ -188,6 +220,26 @@ export class ImageBudget {
188
220
  this.#pendingTransmits.push(sequence);
189
221
  }
190
222
 
223
+ /** Whether a frame has image data queued but not yet written to the terminal. */
224
+ hasPendingTransmits(): boolean {
225
+ return this.#pendingTransmits.length > 0;
226
+ }
227
+
228
+ /**
229
+ * True when the budget has nothing in flight: no live images observed on
230
+ * the last pass, no queued transmits, no pending purges, and no stricter
231
+ * threshold left to apply. A component-scoped frame may skip the observe
232
+ * pass only then — a partial tree walk would under-count display order.
233
+ */
234
+ get quiescent(): boolean {
235
+ return (
236
+ this.#lastTotal === 0 &&
237
+ this.#pendingTransmits.length === 0 &&
238
+ this.#purgeIds.length === 0 &&
239
+ this.#planned === this.#onTerminal
240
+ );
241
+ }
242
+
191
243
  /** Transmit sequences to write before this frame's placements; clears the queue. */
192
244
  takeTransmits(): readonly string[] {
193
245
  if (this.#pendingTransmits.length === 0) return EMPTY_TRANSMITS;
@@ -232,6 +284,10 @@ export class Image implements Component {
232
284
  #cachedLines?: string[];
233
285
  #cachedWidth?: number;
234
286
  #cachedSuppressed = false;
287
+ // Tallest graphic placement this image has rendered. The text fallback
288
+ // pads itself to this height so a budget demotion never shrinks the block
289
+ // (its rows may already be committed to native scrollback).
290
+ #renderedGraphicRows = 0;
235
291
 
236
292
  constructor(
237
293
  base64Data: string,
@@ -254,7 +310,7 @@ export class Image implements Component {
254
310
  this.#cachedWidth = undefined;
255
311
  }
256
312
 
257
- render(width: number): string[] {
313
+ render(width: number): readonly string[] {
258
314
  const hasProtocol = TERMINAL.imageProtocol != null;
259
315
  // observe() must run on every pass — even a cache hit — so the image keeps
260
316
  // its display-order slot in the budget. Only graphics-capable frames count
@@ -296,17 +352,16 @@ export class Image implements Component {
296
352
  // moves the cursor back up, then emits the image sequence.
297
353
  lines = [];
298
354
  for (let i = 0; i < result.rows - 1; i++) {
299
- lines.push("");
355
+ lines.push(RESERVED_IMAGE_ROW);
300
356
  }
301
357
  const moveUp = result.rows > 1 ? `\x1b[${result.rows - 1}A` : "";
302
358
  lines.push(moveUp + (result.sequence ?? ""));
303
359
  } else {
304
- lines = [
305
- this.#theme.fallbackColor(imageFallback(this.#mimeType, this.#dimensions, this.#options.filename)),
306
- ];
360
+ lines = this.#fallbackLines();
307
361
  }
362
+ this.#renderedGraphicRows = Math.max(this.#renderedGraphicRows, lines.length);
308
363
  } else {
309
- lines = [this.#theme.fallbackColor(imageFallback(this.#mimeType, this.#dimensions, this.#options.filename))];
364
+ lines = this.#fallbackLines();
310
365
  }
311
366
 
312
367
  this.#cachedLines = lines;
@@ -315,4 +370,25 @@ export class Image implements Component {
315
370
 
316
371
  return lines;
317
372
  }
373
+
374
+ /**
375
+ * Text fallback, height-preserving once a graphic has rendered: a demoted
376
+ * image must keep occupying the rows its placement used, because those
377
+ * rows may already be committed to native scrollback — shrinking the block
378
+ * would shift everything below it and force the renderer's commit-resync
379
+ * (stale band + recommit). Reserved rows stay non-plain so blank-edge
380
+ * trimming cannot collapse the block either.
381
+ */
382
+ #fallbackLines(): string[] {
383
+ const fallback = this.#theme.fallbackColor(
384
+ imageFallback(this.#mimeType, this.#dimensions, this.#options.filename),
385
+ );
386
+ if (this.#renderedGraphicRows <= 1) return [fallback];
387
+ const lines: string[] = [];
388
+ for (let i = 0; i < this.#renderedGraphicRows - 1; i++) {
389
+ lines.push(RESERVED_IMAGE_ROW);
390
+ }
391
+ lines.push(fallback);
392
+ return lines;
393
+ }
318
394
  }
@@ -28,6 +28,8 @@ export class Input implements Component, Focusable {
28
28
  #value: string = "";
29
29
  #cursor: number = 0; // Cursor position in the value
30
30
  #useTerminalCursor = false;
31
+ /** Rendered before the editable area; set to "" for chrome-less embedding. */
32
+ prompt = "> ";
31
33
  onSubmit?: (value: string) => void;
32
34
  onEscape?: () => void;
33
35
 
@@ -50,6 +52,7 @@ export class Input implements Component, Focusable {
50
52
 
51
53
  setValue(value: string): void {
52
54
  this.#value = value;
55
+ // Callers seed or replace the value wholesale; typing continues at the end.
53
56
  this.#cursor = value.length;
54
57
  }
55
58
 
@@ -187,6 +190,12 @@ export class Input implements Component, Focusable {
187
190
  }
188
191
  }
189
192
 
193
+ /** Apply terminal paste semantics to text from non-bracketed paste transports
194
+ * (e.g. kitty's OSC 5522 enhanced clipboard read). Mirrors `Editor.pasteText`. */
195
+ pasteText(text: string): void {
196
+ this.#handlePaste(text);
197
+ }
198
+
190
199
  #insertCharacter(text: string): void {
191
200
  const isWordChunk = [...segmenter.segment(text)].every(seg => getWordNavKind(seg.segment) !== "whitespace");
192
201
  // Undo coalescing: consecutive word typing coalesces into one undo unit.
@@ -391,10 +400,10 @@ export class Input implements Component, Focusable {
391
400
  // No cached state to invalidate currently
392
401
  }
393
402
 
394
- render(width: number): string[] {
403
+ render(width: number): readonly string[] {
395
404
  // Calculate visible window
396
- const prompt = "> ";
397
- const availableWidth = width - prompt.length;
405
+ const prompt = this.prompt;
406
+ const availableWidth = width - visibleWidth(prompt);
398
407
 
399
408
  if (availableWidth <= 0) {
400
409
  return [prompt];
@@ -3,21 +3,20 @@ import { sliceByColumn, visibleWidth } from "../utils";
3
3
  import { Text } from "./text";
4
4
 
5
5
  /**
6
- * Loader component that drives display refresh at ~60fps so callers whose
7
- * message colorizer is time-dependent (e.g. shimmer/KITT) animate smoothly.
6
+ * Loader component. Spinner frames advance at `SPINNER_ADVANCE_MS`.
8
7
  *
9
- * Two cadences are interleaved on a single timer:
10
- * - **Render tick** (every `RENDER_INTERVAL_MS`) asks the TUI to redraw.
11
- * The TUI already throttles at 16ms (`MIN_RENDER_INTERVAL_MS`), so this
12
- * is the natural upper bound; static messageColorFns produce identical
13
- * output and the differ drops the no-op redraw at ~zero cost.
14
- * - **Spinner advance** (every `SPINNER_ADVANCE_MS`) → bumps the spinner
15
- * frame index. Decoupled from the render cadence so the spinner keeps
16
- * its classic ~12.5fps step pace regardless of shimmer state.
8
+ * Message colorizers that are time-dependent can opt into 30fps redraws by
9
+ * setting `animated` to `true` on the function object.
17
10
  */
18
- const RENDER_INTERVAL_MS = 16;
11
+ const RENDER_INTERVAL_MS = 1000 / 30;
19
12
  const SPINNER_ADVANCE_MS = 80;
20
13
 
14
+ type ColorFn = (str: string) => string;
15
+
16
+ export type LoaderMessageColorFn = ColorFn & {
17
+ readonly animated?: true;
18
+ };
19
+
21
20
  export class Loader extends Text {
22
21
  #frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
23
22
  #currentFrame = 0;
@@ -27,8 +26,8 @@ export class Loader extends Text {
27
26
 
28
27
  constructor(
29
28
  ui: TUI,
30
- private spinnerColorFn: (str: string) => string,
31
- private messageColorFn: (str: string) => string,
29
+ private spinnerColorFn: ColorFn,
30
+ private messageColorFn: LoaderMessageColorFn,
32
31
  private message: string = "Loading...",
33
32
  spinnerFrames?: string[],
34
33
  ) {
@@ -40,7 +39,7 @@ export class Loader extends Text {
40
39
  this.start();
41
40
  }
42
41
 
43
- render(width: number): string[] {
42
+ render(width: number): readonly string[] {
44
43
  const lines = ["", ...super.render(width)];
45
44
  for (let i = 0; i < lines.length; i++) {
46
45
  const line = lines[i];
@@ -54,14 +53,17 @@ export class Loader extends Text {
54
53
  start() {
55
54
  this.#lastSpinnerTick = performance.now();
56
55
  this.#updateDisplay();
56
+ const intervalMs = this.messageColorFn.animated === true ? RENDER_INTERVAL_MS : SPINNER_ADVANCE_MS;
57
57
  this.#intervalId = setInterval(() => {
58
58
  const now = performance.now();
59
- if (now - this.#lastSpinnerTick >= SPINNER_ADVANCE_MS) {
60
- this.#currentFrame = (this.#currentFrame + 1) % this.#frames.length;
61
- this.#lastSpinnerTick = now;
59
+ const elapsed = now - this.#lastSpinnerTick;
60
+ if (elapsed >= SPINNER_ADVANCE_MS) {
61
+ const steps = Math.floor(elapsed / SPINNER_ADVANCE_MS);
62
+ this.#currentFrame = (this.#currentFrame + steps) % this.#frames.length;
63
+ this.#lastSpinnerTick += steps * SPINNER_ADVANCE_MS;
62
64
  }
63
65
  this.#updateDisplay();
64
- }, RENDER_INTERVAL_MS);
66
+ }, intervalMs);
65
67
  }
66
68
 
67
69
  stop() {
@@ -71,16 +73,28 @@ export class Loader extends Text {
71
73
  }
72
74
  }
73
75
 
76
+ /** Lifecycle teardown: stop the animation timer. Idempotent. */
77
+ dispose() {
78
+ this.stop();
79
+ }
80
+
74
81
  setMessage(message: string) {
82
+ if (message === this.message) {
83
+ return;
84
+ }
75
85
  this.message = message;
76
86
  this.#updateDisplay();
77
87
  }
78
88
 
79
89
  #updateDisplay() {
80
90
  const frame = this.#frames[this.#currentFrame];
81
- this.setText(`${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`);
82
- if (this.#ui) {
83
- this.#ui.requestRender();
91
+ const text = `${this.spinnerColorFn(frame)} ${this.messageColorFn(this.message)}`;
92
+ if (this.setText(text) && this.#ui) {
93
+ // Component-scoped: a spinner tick changes only this component, so
94
+ // the TUI may reuse every other root subtree instead of re-walking
95
+ // the whole tree (full repaints at 12.5 Hz made huge transcripts
96
+ // lag as soon as the loader appeared).
97
+ this.#ui.requestComponentRender(this);
84
98
  }
85
99
  }
86
100
  }
@@ -58,7 +58,15 @@ markdownParser.setOptions({
58
58
  // (Rust FFI) work for content/layout combinations already seen this session.
59
59
 
60
60
  const RENDER_CACHE_MAX = 256; // sane cap: ~256 distinct message × width combos
61
- const renderCache = new LRUCache<string, string[]>({ max: RENDER_CACHE_MAX });
61
+ const EMPTY_RENDER_LINES: readonly string[] = [];
62
+ const renderCache = new LRUCache<string, readonly string[]>({ max: RENDER_CACHE_MAX });
63
+
64
+ // A reference-link definition (`[label]: dest`) resolves across the whole
65
+ // document, so a split lex cannot reproduce it — disable the streaming fast path
66
+ // when one is present (rare in streamed output). The label may contain
67
+ // backslash-escaped characters (`[a\]b]: x`), so escapes are matched explicitly;
68
+ // over-matching is safe (it only costs the fast path), under-matching is not.
69
+ const HAS_REF_DEF = /^ {0,3}\[(?:\\.|[^\]\\])+\]:/m;
62
70
 
63
71
  /** Drop all L2 cache entries. Call on theme change to prevent stale styled output. */
64
72
  export function clearRenderCache(): void {
@@ -288,10 +296,23 @@ export class Markdown implements Component {
288
296
  /** Number of spaces used to indent code block content. */
289
297
  #codeBlockIndent: number;
290
298
 
291
- // Cache for rendered output
299
+ // Cache for rendered output. Cached arrays are shared and returned by
300
+ // reference (render contract: results are component-owned and immutable to
301
+ // callers); the L2 LRU may hand the same array to multiple instances.
292
302
  #cachedText?: string;
293
303
  #cachedWidth?: number;
294
- #cachedLines?: string[];
304
+ #cachedLines?: readonly string[];
305
+ #transientRenderCache = false;
306
+
307
+ // Streaming-lex cache: the largest blank-line-bounded prefix of #text whose
308
+ // block tokens are frozen, plus those tokens. marked has no resumable lexer,
309
+ // but block tokenization is local across a "\n\n" boundary with balanced
310
+ // fences, so lex(prefix) ++ lex(tail) === lex(prefix+tail). On append-only
311
+ // growth (the streaming path) this re-lexes only the grown tail instead of the
312
+ // whole buffer, turning O(N^2) reveal cost into O(N). Width/theme do not affect
313
+ // tokenization, so this cache is independent of the render caches above.
314
+ #streamPrefixText?: string;
315
+ #streamPrefixTokens?: Token[];
295
316
 
296
317
  constructor(
297
318
  text: string,
@@ -311,6 +332,13 @@ export class Markdown implements Component {
311
332
 
312
333
  setText(text: string): void {
313
334
  this.#text = text;
335
+ if (!text.trim()) {
336
+ // Blank replacement: render() early-returns before #lexTokens can see
337
+ // the non-append edit, so drop the frozen stream state here or it
338
+ // outlives the content it indexed.
339
+ this.#streamPrefixText = undefined;
340
+ this.#streamPrefixTokens = undefined;
341
+ }
314
342
  this.invalidate();
315
343
  }
316
344
 
@@ -319,10 +347,92 @@ export class Markdown implements Component {
319
347
  this.#cachedWidth = undefined;
320
348
  this.#cachedLines = undefined;
321
349
  }
350
+ get transientRenderCache(): boolean {
351
+ return this.#transientRenderCache;
352
+ }
353
+
354
+ set transientRenderCache(value: boolean) {
355
+ const next = value === true;
356
+ if (this.#transientRenderCache === next) return;
357
+ this.#transientRenderCache = next;
358
+ this.invalidate();
359
+ }
360
+
361
+ // Lex `text` into block tokens, reusing the frozen stable prefix when the text
362
+ // only grew (the streaming path). Falls back to a full lex whenever the prefix
363
+ // is no longer a prefix (non-append edit), the text carries reference-link
364
+ // definitions, or it contains CR (marked normalizes CRLF, which would desync
365
+ // raw-span offsets). Every fallback is correctness-preserving — only speed
366
+ // differs; the render loop sees the identical token list either way.
367
+ #lexTokens(text: string): Token[] {
368
+ const canStream = !HAS_REF_DEF.test(text) && !text.includes("\r");
369
+ const prefix = this.#streamPrefixText;
370
+ const prefixTokens = this.#streamPrefixTokens;
371
+ if (
372
+ canStream &&
373
+ prefix !== undefined &&
374
+ prefixTokens !== undefined &&
375
+ text.length > prefix.length &&
376
+ text.startsWith(prefix)
377
+ ) {
378
+ const tailTokens = markdownParser.lexer(text.slice(prefix.length));
379
+ const tokens = [...prefixTokens, ...tailTokens];
380
+ this.#freezeStablePrefix(text, tokens);
381
+ return tokens;
382
+ }
383
+ const tokens = markdownParser.lexer(text);
384
+ if (canStream) {
385
+ this.#freezeStablePrefix(text, tokens);
386
+ } else {
387
+ this.#streamPrefixText = undefined;
388
+ this.#streamPrefixTokens = undefined;
389
+ }
390
+ return tokens;
391
+ }
392
+
393
+ // Freeze the largest run of leading blocks that end on a hard "\n\n" boundary
394
+ // (complete and immutable under append-only growth) so the next streaming
395
+ // render re-lexes only the unfrozen tail. Caller guarantees no CR / no
396
+ // reference definitions, so each token's `raw` is a verbatim slice of `text`
397
+ // and the summed offsets address `text` exactly.
398
+ #freezeStablePrefix(text: string, tokens: Token[]): void {
399
+ let pos = 0;
400
+ let frozenEnd = 0;
401
+ let frozenCount = 0;
402
+ for (let i = 0; i < tokens.length; i++) {
403
+ const raw = tokens[i].raw;
404
+ const end = pos + raw.length;
405
+ // A `space` token ending in "\n\n" closes the preceding block, but a
406
+ // `list` before it can still be extended by a following same-marker
407
+ // item across the blank line (CommonMark loose-list continuation),
408
+ // which marked merges into one renumbered loose list. Freezing across
409
+ // such a cut would keep the lists separate. Never freeze right after a
410
+ // list — it stays in the re-lexed tail.
411
+ if (raw.endsWith("\n\n") && tokens[i - 1]?.type !== "list") {
412
+ frozenEnd = end;
413
+ frozenCount = i + 1;
414
+ }
415
+ pos = end;
416
+ }
417
+ // Freeze only when the tail begins with real block content. If the next
418
+ // char is whitespace (an extra blank line, or an indented continuation),
419
+ // the block separator straddles the cut and lex(prefix)++lex(tail) would
420
+ // desync from a full lex — e.g. a fence followed by "\n\n\n- list". When
421
+ // frozenEnd is at end-of-text the next char is unknown, so defer.
422
+ if (frozenCount > 0 && frozenEnd < text.length) {
423
+ const next = text.charCodeAt(frozenEnd);
424
+ if (next !== 0x20 /* space */ && next !== 0x0a /* \n */) {
425
+ this.#streamPrefixText = text.slice(0, frozenEnd);
426
+ this.#streamPrefixTokens = tokens.slice(0, frozenCount);
427
+ }
428
+ }
429
+ }
322
430
 
323
- render(width: number): string[] {
431
+ render(width: number): readonly string[] {
324
432
  // L1: per-instance cache — fastest path for repeated renders of the same
325
433
  // instance at the same width (e.g. resize debounce, repeated redraws).
434
+ // Returning the cached reference is load-bearing: parents memoize their
435
+ // concatenation on reference equality.
326
436
  if (this.#cachedLines && this.#cachedText === this.#text && this.#cachedWidth === width) {
327
437
  return this.#cachedLines;
328
438
  }
@@ -332,12 +442,10 @@ export class Markdown implements Component {
332
442
 
333
443
  // Don't render anything if there's no actual text
334
444
  if (!this.#text || this.#text.trim() === "") {
335
- const result: string[] = [];
336
- // Update per-instance cache
337
445
  this.#cachedText = this.#text;
338
446
  this.#cachedWidth = width;
339
- this.#cachedLines = result;
340
- return result;
447
+ this.#cachedLines = EMPTY_RENDER_LINES;
448
+ return EMPTY_RENDER_LINES;
341
449
  }
342
450
 
343
451
  // Replace tabs with 3 spaces for consistent rendering
@@ -355,20 +463,23 @@ export class Markdown implements Component {
355
463
  // risk of clashing with a function that returns text verbatim.
356
464
  // theme.heading is used as the representative theme probe — it's required
357
465
  // by MarkdownTheme and is one of the most styling-sensitive entries.
358
- const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
359
- const headingProbe = this.#theme.heading("");
360
- const cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${TERMINAL.textSizing ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
361
- const cached = renderCache.get(cacheKey);
362
- if (cached !== undefined) {
363
- // Populate L1 so subsequent calls from this instance are O(1) map lookup.
364
- this.#cachedText = this.#text;
365
- this.#cachedWidth = width;
366
- this.#cachedLines = cached;
367
- return cached;
466
+ let cacheKey: string | undefined;
467
+ if (!this.transientRenderCache) {
468
+ const bgColorProbe = this.#defaultTextStyle?.bgColor ? this.#defaultTextStyle.bgColor("\x01") : "";
469
+ const headingProbe = this.#theme.heading("");
470
+ cacheKey = `${normalizedText}\x00${width}\x00${this.#paddingX}\x00${this.#paddingY}\x00${this.#codeBlockIndent}\x00${objectId(this.#theme)}\x00${this.#defaultTextStyle ? objectId(this.#defaultTextStyle) : -1}\x00${TERMINAL.imageProtocol ?? ""}\x00${TERMINAL.hyperlinks ? 1 : 0}\x00${TERMINAL.textSizing ? 1 : 0}\x00${bgColorProbe}\x00${headingProbe}`;
471
+ const cached = renderCache.get(cacheKey);
472
+ if (cached !== undefined) {
473
+ // Populate L1 so subsequent calls from this instance are O(1) map lookup.
474
+ this.#cachedText = this.#text;
475
+ this.#cachedWidth = width;
476
+ this.#cachedLines = cached;
477
+ return cached;
478
+ }
368
479
  }
369
480
 
370
481
  // Parse markdown to HTML-like tokens
371
- const tokens = markdownParser.lexer(normalizedText);
482
+ const tokens = this.#lexTokens(normalizedText);
372
483
 
373
484
  // Convert tokens to styled terminal output
374
485
  const renderedLines: string[] = [];
@@ -445,14 +556,18 @@ export class Markdown implements Component {
445
556
  const rawResult = [...emptyLines, ...contentLines, ...emptyLines];
446
557
  const result = rawResult.length > 0 ? rawResult : [""];
447
558
 
448
- // Update L1 per-instance cache
559
+ // Update caches and hand the array out by reference. Callers must not
560
+ // mutate it (Component render contract); the L2 entry is shared across
561
+ // instances keyed on identical inputs.
449
562
  this.#cachedText = this.#text;
450
563
  this.#cachedWidth = width;
451
564
  this.#cachedLines = result;
452
565
 
453
566
  // Update L2 module-level LRU so future instances with the same key skip
454
567
  // the marked.lexer + highlightCode (Rust FFI) work entirely.
455
- renderCache.set(cacheKey, result);
568
+ if (cacheKey !== undefined) {
569
+ renderCache.set(cacheKey, result);
570
+ }
456
571
 
457
572
  return result;
458
573
  }
@@ -604,7 +719,7 @@ export class Markdown implements Component {
604
719
 
605
720
  const codeIndent = padding(this.#codeBlockIndent);
606
721
  lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
607
- if (this.#theme.highlightCode) {
722
+ if (this.#theme.highlightCode && !this.transientRenderCache) {
608
723
  const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
609
724
  for (const hlLine of highlightedLines) {
610
725
  lines.push(`${codeIndent}${hlLine}`);
@@ -822,35 +937,33 @@ export class Markdown implements Component {
822
937
  for (let i = 0; i < token.items.length; i++) {
823
938
  const item = token.items[i];
824
939
  const bullet = token.ordered ? `${startNumber + i}. ` : "- ";
940
+ // Continuation rows align under the item text, so the hang matches the
941
+ // actual bullet width (`10. ` is 4 cells, not 2).
942
+ const continuationIndent = indent + padding(bullet.length);
825
943
 
826
- // Process item tokens to handle nested lists
944
+ // Process item tokens; nested-list lines arrive structurally tagged and
945
+ // already carry their own full indent.
827
946
  const itemLines = this.#renderListItem(item.tokens || [], depth, styleContext);
828
947
 
829
948
  if (itemLines.length > 0) {
830
- // First line - check if it's a nested list
831
- // A nested list will start with indent (spaces) followed by cyan bullet
832
- const firstLine = itemLines[0];
833
- const isNestedList = /^\s+\x1b\[36m[-\d]/.test(firstLine); // starts with spaces + cyan + bullet char
834
-
835
- if (isNestedList) {
836
- // This is a nested list, just add it as-is (already has full indent)
837
- lines.push(firstLine);
949
+ const firstLine = itemLines[0]!;
950
+ if (firstLine.nested) {
951
+ // Nested list first - keep as-is (already has full indent)
952
+ lines.push(firstLine.text);
838
953
  } else {
839
954
  // Regular text content - add indent and bullet
840
- lines.push(indent + this.#theme.listBullet(bullet) + firstLine);
955
+ lines.push(indent + this.#theme.listBullet(bullet) + firstLine.text);
841
956
  }
842
957
 
843
958
  // Rest of the lines
844
959
  for (let j = 1; j < itemLines.length; j++) {
845
- const line = itemLines[j];
846
- const isNestedListLine = /^\s+\x1b\[36m[-\d]/.test(line); // starts with spaces + cyan + bullet char
847
-
848
- if (isNestedListLine) {
960
+ const line = itemLines[j]!;
961
+ if (line.nested) {
849
962
  // Nested list line - already has full indent
850
- lines.push(line);
963
+ lines.push(line.text);
851
964
  } else {
852
- // Regular content - add parent indent + 2 spaces for continuation
853
- lines.push(`${indent} ${line}`);
965
+ // Regular content - hang under the item text
966
+ lines.push(continuationIndent + line.text);
854
967
  }
855
968
  }
856
969
  } else {
@@ -862,50 +975,58 @@ export class Markdown implements Component {
862
975
  }
863
976
 
864
977
  /**
865
- * Render list item tokens, handling nested lists
866
- * Returns lines WITHOUT the parent indent (renderList will add it)
978
+ * Render list item tokens, handling nested lists.
979
+ * Returns lines WITHOUT the parent indent (renderList adds it); lines that
980
+ * belong to a nested list are tagged `nested` so the caller never has to
981
+ * sniff theme-dependent ANSI bytes to recognize them.
867
982
  */
868
- #renderListItem(tokens: Token[], parentDepth: number, styleContext?: InlineStyleContext): string[] {
869
- const lines: string[] = [];
983
+ #renderListItem(
984
+ tokens: Token[],
985
+ parentDepth: number,
986
+ styleContext?: InlineStyleContext,
987
+ ): Array<{ text: string; nested: boolean }> {
988
+ const lines: Array<{ text: string; nested: boolean }> = [];
870
989
 
871
990
  for (const token of tokens) {
872
991
  if (token.type === "list") {
873
992
  // Nested list - render with one additional indent level
874
- // These lines will have their own indent, so we just add them as-is
993
+ // These lines carry their own indent, so tag them for pass-through
875
994
  const nestedLines = this.#renderList(token as ListToken, parentDepth + 1, styleContext);
876
- lines.push(...nestedLines);
995
+ for (const nestedLine of nestedLines) {
996
+ lines.push({ text: nestedLine, nested: true });
997
+ }
877
998
  } else if (token.type === "text") {
878
999
  // Text content (may have inline tokens)
879
1000
  const text =
880
1001
  token.tokens && token.tokens.length > 0
881
1002
  ? this.#renderInlineTokens(token.tokens, styleContext)
882
1003
  : token.text || "";
883
- lines.push(text);
1004
+ lines.push({ text, nested: false });
884
1005
  } else if (token.type === "paragraph") {
885
1006
  // Paragraph in list item
886
1007
  const text = this.#renderInlineTokens(token.tokens || [], styleContext);
887
- lines.push(text);
1008
+ lines.push({ text, nested: false });
888
1009
  } else if (token.type === "code") {
889
1010
  // Code block in list item
890
1011
  const codeIndent = padding(this.#codeBlockIndent);
891
- lines.push(this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`));
892
- if (this.#theme.highlightCode) {
1012
+ lines.push({ text: this.#theme.codeBlockBorder(`\`\`\`${token.lang || ""}`), nested: false });
1013
+ if (this.#theme.highlightCode && !this.transientRenderCache) {
893
1014
  const highlightedLines = this.#theme.highlightCode(token.text, token.lang);
894
1015
  for (const hlLine of highlightedLines) {
895
- lines.push(`${codeIndent}${hlLine}`);
1016
+ lines.push({ text: `${codeIndent}${hlLine}`, nested: false });
896
1017
  }
897
1018
  } else {
898
1019
  const codeLines = token.text.split("\n");
899
1020
  for (const codeLine of codeLines) {
900
- lines.push(`${codeIndent}${this.#theme.codeBlock(codeLine)}`);
1021
+ lines.push({ text: `${codeIndent}${this.#theme.codeBlock(codeLine)}`, nested: false });
901
1022
  }
902
1023
  }
903
- lines.push(this.#theme.codeBlockBorder("```"));
1024
+ lines.push({ text: this.#theme.codeBlockBorder("```"), nested: false });
904
1025
  } else {
905
1026
  // Other token types - try to render as inline
906
1027
  const text = this.#renderInlineTokens([token], styleContext);
907
1028
  if (text) {
908
- lines.push(text);
1029
+ lines.push({ text, nested: false });
909
1030
  }
910
1031
  }
911
1032
  }