@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.
- package/dist/types/autocomplete.d.ts +3 -1
- package/dist/types/components/box.d.ts +1 -1
- package/dist/types/components/editor.d.ts +35 -2
- package/dist/types/components/image.d.ts +22 -3
- package/dist/types/components/input.d.ts +6 -1
- package/dist/types/components/loader.d.ts +9 -2
- package/dist/types/components/markdown.d.ts +3 -1
- package/dist/types/components/scroll-view.d.ts +23 -1
- package/dist/types/components/select-list.d.ts +19 -1
- package/dist/types/components/settings-list.d.ts +87 -7
- package/dist/types/components/spacer.d.ts +1 -1
- package/dist/types/components/tab-bar.d.ts +37 -4
- package/dist/types/components/text.d.ts +2 -2
- package/dist/types/components/truncated-text.d.ts +1 -1
- package/dist/types/fuzzy.d.ts +10 -1
- package/dist/types/index.d.ts +1 -0
- package/dist/types/keybindings.d.ts +5 -3
- package/dist/types/keys.d.ts +1 -1
- package/dist/types/kill-ring.d.ts +0 -7
- package/dist/types/kitty-graphics.d.ts +16 -31
- package/dist/types/loop-watchdog.d.ts +39 -0
- package/dist/types/mouse.d.ts +41 -0
- package/dist/types/stdin-buffer.d.ts +17 -0
- package/dist/types/terminal-capabilities.d.ts +74 -18
- package/dist/types/terminal.d.ts +34 -36
- package/dist/types/tui.d.ts +191 -79
- package/dist/types/utils.d.ts +5 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +79 -65
- package/src/components/box.ts +43 -63
- package/src/components/editor.ts +471 -136
- package/src/components/image.ts +85 -9
- package/src/components/input.ts +12 -3
- package/src/components/loader.ts +35 -21
- package/src/components/markdown.ts +174 -53
- package/src/components/scroll-view.ts +63 -2
- package/src/components/select-list.ts +233 -38
- package/src/components/settings-list.ts +626 -64
- package/src/components/spacer.ts +9 -5
- package/src/components/tab-bar.ts +153 -28
- package/src/components/text.ts +6 -2
- package/src/components/truncated-text.ts +10 -2
- package/src/fuzzy.ts +214 -59
- package/src/index.ts +3 -1
- package/src/keybindings.ts +72 -14
- package/src/keys.ts +1 -1
- package/src/kill-ring.ts +5 -0
- package/src/kitty-graphics.ts +2 -101
- package/src/loop-watchdog.ts +106 -0
- package/src/mouse.ts +55 -0
- package/src/stdin-buffer.ts +291 -81
- package/src/terminal-capabilities.ts +206 -168
- package/src/terminal.ts +367 -110
- package/src/tui.ts +2102 -1729
- package/src/utils.ts +92 -60
package/src/components/image.ts
CHANGED
|
@@ -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
|
-
/**
|
|
118
|
-
|
|
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.#
|
|
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 =
|
|
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
|
}
|
package/src/components/input.ts
CHANGED
|
@@ -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
|
|
405
|
+
const prompt = this.prompt;
|
|
406
|
+
const availableWidth = width - visibleWidth(prompt);
|
|
398
407
|
|
|
399
408
|
if (availableWidth <= 0) {
|
|
400
409
|
return [prompt];
|
package/src/components/loader.ts
CHANGED
|
@@ -3,21 +3,20 @@ import { sliceByColumn, visibleWidth } from "../utils";
|
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
|
-
* Loader component
|
|
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
|
-
*
|
|
10
|
-
*
|
|
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 =
|
|
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:
|
|
31
|
-
private messageColorFn:
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
},
|
|
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
|
-
|
|
82
|
-
if (this.#ui) {
|
|
83
|
-
this
|
|
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
|
|
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 =
|
|
340
|
-
return
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
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
|
-
|
|
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 -
|
|
853
|
-
lines.push(
|
|
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
|
|
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(
|
|
869
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|