@aragon-agent/cli 0.5.6 → 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/CHANGELOG.md +77 -0
- package/README.md +60 -2
- package/dist/agent/entry-limits.d.ts +65 -0
- package/dist/agent/entry-limits.js +133 -0
- package/dist/agent/reducer.d.ts +8 -0
- package/dist/agent/reducer.js +67 -15
- package/dist/cli.js +36 -2
- package/dist/commands/builtins.js +5 -0
- package/dist/commands/perf.d.ts +45 -0
- package/dist/commands/perf.js +78 -0
- package/dist/config/env.d.ts +3 -1
- package/dist/config/env.js +31 -1
- package/dist/config/load.d.ts +14 -0
- package/dist/config/load.js +43 -2
- package/dist/config/schema.d.ts +61 -8
- package/dist/config/schema.js +43 -6
- package/dist/ui/App.js +86 -9
- package/dist/ui/Markdown.d.ts +7 -1
- package/dist/ui/Markdown.js +15 -48
- package/dist/ui/StatusBar.d.ts +13 -0
- package/dist/ui/StatusBar.js +4 -2
- package/dist/ui/Transcript.d.ts +56 -4
- package/dist/ui/Transcript.js +112 -10
- package/dist/ui/entries/AssistantEntry.d.ts +18 -1
- package/dist/ui/entries/AssistantEntry.js +33 -3
- package/dist/ui/entries/TeamCard.d.ts +9 -1
- package/dist/ui/entries/TeamCard.js +22 -1
- package/dist/ui/entries/TodoCard.d.ts +9 -1
- package/dist/ui/entries/TodoCard.js +23 -1
- package/dist/ui/entries/ToolCard.d.ts +13 -1
- package/dist/ui/entries/ToolCard.js +39 -5
- package/dist/ui/entries/ToolPreview.d.ts +2 -2
- package/dist/ui/entries/UserEntry.d.ts +9 -1
- package/dist/ui/entries/UserEntry.js +17 -1
- package/dist/ui/exit-snapshot.d.ts +7 -0
- package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
- package/dist/ui/layout/MeasuredEntry.js +30 -0
- package/dist/ui/layout/ScrollViewport.js +8 -2
- package/dist/ui/layout/viewport-geometry.d.ts +28 -0
- package/dist/ui/layout/viewport-geometry.js +27 -0
- package/dist/ui/layout/virtual-window.d.ts +100 -0
- package/dist/ui/layout/virtual-window.js +204 -0
- package/dist/ui/live-clamp.d.ts +42 -0
- package/dist/ui/live-clamp.js +74 -0
- package/dist/ui/markdown-blocks.d.ts +25 -1
- package/dist/ui/markdown-blocks.js +43 -0
- package/dist/ui/render-cache.d.ts +81 -0
- package/dist/ui/render-cache.js +195 -0
- package/dist/ui/render-governor.d.ts +54 -0
- package/dist/ui/render-governor.js +78 -0
- package/dist/ui/transcript-text.d.ts +11 -0
- package/dist/ui/transcript-text.js +5 -0
- package/dist/ui/use-height-store.d.ts +31 -0
- package/dist/ui/use-height-store.js +76 -0
- package/dist/ui/use-render-governor.d.ts +39 -0
- package/dist/ui/use-render-governor.js +57 -0
- package/dist/ui/use-startup-notices.d.ts +9 -1
- package/dist/ui/use-startup-notices.js +35 -1
- package/package.json +2 -2
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Viewport virtualisation maths (tui-render-performance L3 / R1). Pure,
|
|
3
|
+
* React-free, unit-tested.
|
|
4
|
+
*
|
|
5
|
+
* THE STRUCTURAL FIX. Ink's `renderNodeToOutput` has no early-out for clipped
|
|
6
|
+
* subtrees: for every `ink-text` node it runs `squashTextNodes`, `widestLine`
|
|
7
|
+
* and possibly `wrapText` — all O(chars) — and `Output.get()` then evaluates
|
|
8
|
+
* `widestLine` a SECOND time before it is allowed to skip an out-of-clip
|
|
9
|
+
* operation. A clipped 50 000-character entry therefore costs two full string
|
|
10
|
+
* scans per frame and buys nothing. Bounding the ENTRY COUNT (`transcriptWindow`)
|
|
11
|
+
* never bounded that, because one entry can be fifty thousand lines.
|
|
12
|
+
*
|
|
13
|
+
* So entries outside the visible band are not mounted at all. They are replaced
|
|
14
|
+
* by two spacer boxes whose heights come from a height cache, which makes the
|
|
15
|
+
* per-frame cost O(visible rows) instead of O(characters ever produced).
|
|
16
|
+
*
|
|
17
|
+
* V-4 — `offset` counts rows FROM THE BOTTOM (`layout/scroll.ts:1-13`), and that
|
|
18
|
+
* is what makes virtualisation safe here for free: a height correction on an
|
|
19
|
+
* entry ABOVE the viewport changes the content height and therefore
|
|
20
|
+
* `overflowLines`, while `offset` — and hence what the user is looking at — is
|
|
21
|
+
* unchanged. No scroll-anchoring compensation pass is required. Do NOT
|
|
22
|
+
* "simplify" `offset` to count from the top.
|
|
23
|
+
*/
|
|
24
|
+
import { GUTTER_WIDTH } from './Gutter.js';
|
|
25
|
+
export const VIRTUAL_LIMITS = {
|
|
26
|
+
/**
|
|
27
|
+
* Entries kept mounted on each side of the visible band.
|
|
28
|
+
*
|
|
29
|
+
* V-2 — must stay `>= 2`, so a one-row scroll can never expose an entry whose
|
|
30
|
+
* height has never been measured at the very edge of the viewport.
|
|
31
|
+
*/
|
|
32
|
+
overscan: 2,
|
|
33
|
+
/** Height-cache ceiling (LRU). Four thousand numbers, not four thousand strings. */
|
|
34
|
+
heightEntries: 4096,
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* A cheap content version: it changes exactly when the rendered content does.
|
|
38
|
+
*
|
|
39
|
+
* DERIVED, NEVER HASHED. Hashing a 256 KiB string once per entry per frame would
|
|
40
|
+
* recreate the very cost this module exists to remove, so every field below is
|
|
41
|
+
* an O(1) read of a length or a scalar.
|
|
42
|
+
*
|
|
43
|
+
* I-L3-1 — this must change whenever the rendered output changes. The only lossy
|
|
44
|
+
* case is a same-LENGTH text replacement, which the reducer cannot produce: all
|
|
45
|
+
* text fields are append-only (`appendBounded` only ever elides the middle,
|
|
46
|
+
* which changes the length). A future NON-APPEND mutation MUST extend the
|
|
47
|
+
* revision, or the entry freezes at a stale height AND a stale rendered subtree
|
|
48
|
+
* with nothing anywhere reporting it. `virtual-window.test.ts` enumerates every
|
|
49
|
+
* kind against every mutating action.
|
|
50
|
+
*/
|
|
51
|
+
export function entryRevision(entry) {
|
|
52
|
+
switch (entry.kind) {
|
|
53
|
+
case 'user':
|
|
54
|
+
return 'u';
|
|
55
|
+
case 'notice':
|
|
56
|
+
return 'n';
|
|
57
|
+
case 'assistant':
|
|
58
|
+
return `a${entry.text.length}.${entry.thinking?.length ?? 0}.${entry.streaming ? 1 : 0}.${entry.aborted ? 1 : 0}`;
|
|
59
|
+
case 'tool':
|
|
60
|
+
return `t${entry.status}.${entry.argsRaw.length}.${entry.preview?.length ?? 0}.${entry.durationMs ?? -1}`;
|
|
61
|
+
case 'team':
|
|
62
|
+
return `m${entry.active ? 1 : 0}.${entry.runs.length}.${entry.runs
|
|
63
|
+
.map((r) => r.phase[0])
|
|
64
|
+
.join('')}.${entry.runs.reduce((acc, r) => acc + r.toolCalls, 0)}`;
|
|
65
|
+
case 'todo':
|
|
66
|
+
return `d${entry.live ? 1 : 0}.${entry.doneCount}.${entry.total}.${entry.items.length}`;
|
|
67
|
+
default:
|
|
68
|
+
return 'x';
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The height-cache key.
|
|
73
|
+
*
|
|
74
|
+
* V-5 — `cols` is part of the key, so a resize invalidates every entry. The
|
|
75
|
+
* frame after a resize renders from estimates and re-measures lazily: one
|
|
76
|
+
* reflow, no crash, no stale layout.
|
|
77
|
+
*
|
|
78
|
+
* `flags` carries the render-affecting switches that are neither per-entry nor
|
|
79
|
+
* part of `density` — today only `thinkingVisible` (Ctrl+T). It is a parameter
|
|
80
|
+
* rather than a fifth positional scalar so a future switch costs one character
|
|
81
|
+
* at the call site instead of a signature change at four.
|
|
82
|
+
*/
|
|
83
|
+
export function heightKey(entry, cols, expanded, density, flags = '') {
|
|
84
|
+
return `${entry.id}|${entryRevision(entry)}|${cols}|${expanded ? 1 : 0}|${density}|${flags}`;
|
|
85
|
+
}
|
|
86
|
+
/** Rows a string occupies at `usable` columns, wrapping included. At least 1. */
|
|
87
|
+
function wrappedRows(text, usable) {
|
|
88
|
+
const width = Math.max(1, usable);
|
|
89
|
+
let rows = 0;
|
|
90
|
+
let lineLen = 0;
|
|
91
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
92
|
+
if (text.charCodeAt(i) === 10) {
|
|
93
|
+
rows += Math.max(1, Math.ceil(lineLen / width));
|
|
94
|
+
lineLen = 0;
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
lineLen += 1;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return rows + Math.max(1, Math.ceil(lineLen / width));
|
|
101
|
+
}
|
|
102
|
+
/** Collapsed preview rows in `ToolCard`; duplicated as a NUMBER, not imported. */
|
|
103
|
+
const TOOL_COLLAPSED_LINES = 8;
|
|
104
|
+
/**
|
|
105
|
+
* Pure UPPER-BOUND estimate of an entry's height, used until a real measurement
|
|
106
|
+
* exists.
|
|
107
|
+
*
|
|
108
|
+
* Upper bound rather than best guess, and deliberately so: an over-estimate
|
|
109
|
+
* makes the scroll position slightly conservative and self-corrects the frame
|
|
110
|
+
* after the entry is measured, while an under-estimate can put the newest output
|
|
111
|
+
* off the bottom of a viewport the user believes is pinned.
|
|
112
|
+
*/
|
|
113
|
+
export function estimateEntryRows(entry, cols, density, expanded) {
|
|
114
|
+
const usable = Math.max(1, cols - GUTTER_WIDTH);
|
|
115
|
+
// `separationRows` needs the PRECEDING entry, which a per-entry estimate does
|
|
116
|
+
// not have. One row is its maximum, so charging it unconditionally keeps this
|
|
117
|
+
// an upper bound (`compact` never spends it at all).
|
|
118
|
+
const separation = density === 'comfortable' ? 1 : 0;
|
|
119
|
+
switch (entry.kind) {
|
|
120
|
+
case 'user':
|
|
121
|
+
return separation + wrappedRows(entry.text, usable);
|
|
122
|
+
case 'notice':
|
|
123
|
+
return separation + wrappedRows(entry.text, usable);
|
|
124
|
+
case 'assistant': {
|
|
125
|
+
const thinking = entry.thinking ? 1 + wrappedRows(entry.thinking, usable) : 0;
|
|
126
|
+
const body = entry.text.length > 0 ? wrappedRows(entry.text, usable) : 1;
|
|
127
|
+
return separation + thinking + body + (entry.aborted ? 1 : 0);
|
|
128
|
+
}
|
|
129
|
+
case 'tool': {
|
|
130
|
+
if (!entry.preview || (entry.status !== 'done' && entry.status !== 'error')) {
|
|
131
|
+
return separation + 1;
|
|
132
|
+
}
|
|
133
|
+
const lines = wrappedRows(entry.preview, usable);
|
|
134
|
+
const shown = expanded ? lines : Math.min(lines, TOOL_COLLAPSED_LINES);
|
|
135
|
+
// header + body + the `+N lines (Ctrl+O)` / `(Ctrl+O to collapse)` footer
|
|
136
|
+
return separation + 1 + shown + 1;
|
|
137
|
+
}
|
|
138
|
+
case 'team':
|
|
139
|
+
return separation + 1 + entry.runs.length + (expanded ? entry.runs.length * 3 + 1 : 1);
|
|
140
|
+
case 'todo':
|
|
141
|
+
return separation + 1 + entry.items.length;
|
|
142
|
+
default:
|
|
143
|
+
return separation + 1;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/** Choose which entries to mount, and how tall the two spacers must be. */
|
|
147
|
+
export function selectWindow(input) {
|
|
148
|
+
const { entries, heightOf } = input;
|
|
149
|
+
const n = entries.length;
|
|
150
|
+
if (n === 0) {
|
|
151
|
+
return { startIndex: 0, endIndex: 0, leadingRows: 0, trailingRows: 0, totalRows: 0 };
|
|
152
|
+
}
|
|
153
|
+
const heights = new Array(n);
|
|
154
|
+
let totalRows = 0;
|
|
155
|
+
for (let i = 0; i < n; i += 1) {
|
|
156
|
+
const h = heightOf(i);
|
|
157
|
+
const rows = Number.isFinite(h) && h > 0 ? Math.floor(h) : 1;
|
|
158
|
+
heights[i] = rows;
|
|
159
|
+
totalRows += rows;
|
|
160
|
+
}
|
|
161
|
+
// A viewport of 0 is the FIRST FRAME, before `measureElement` has run. Falling
|
|
162
|
+
// back to 1 keeps the live tail mounted through it, so the first paint is real
|
|
163
|
+
// content rather than a blank rectangle that only fills in on the second.
|
|
164
|
+
const viewport = Math.max(1, Math.floor(input.viewportRows) || 0);
|
|
165
|
+
const offset = Math.max(0, Math.floor(input.offset) || 0);
|
|
166
|
+
const overscan = Math.max(VIRTUAL_LIMITS.overscan, Math.floor(input.overscan) || 0);
|
|
167
|
+
const bottom = totalRows - offset;
|
|
168
|
+
const top = bottom - viewport;
|
|
169
|
+
let start = n - 1;
|
|
170
|
+
let acc = 0;
|
|
171
|
+
for (let i = 0; i < n; i += 1) {
|
|
172
|
+
const next = acc + heights[i];
|
|
173
|
+
if (next > top) {
|
|
174
|
+
start = i;
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
acc = next;
|
|
178
|
+
}
|
|
179
|
+
let end = n;
|
|
180
|
+
acc = 0;
|
|
181
|
+
for (let i = 0; i < n; i += 1) {
|
|
182
|
+
if (acc >= bottom) {
|
|
183
|
+
end = i;
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
acc += heights[i];
|
|
187
|
+
}
|
|
188
|
+
start = Math.max(0, start - overscan);
|
|
189
|
+
end = Math.min(n, end + overscan);
|
|
190
|
+
// V-3 — while pinned, the LAST entry is always inside the window, whatever its
|
|
191
|
+
// height. The live tail must be real content and never a spacer, or a
|
|
192
|
+
// streaming answer renders as a growing blank rectangle.
|
|
193
|
+
if (offset <= 0)
|
|
194
|
+
end = n;
|
|
195
|
+
if (end <= start)
|
|
196
|
+
end = Math.min(n, start + 1);
|
|
197
|
+
let leadingRows = 0;
|
|
198
|
+
for (let i = 0; i < start; i += 1)
|
|
199
|
+
leadingRows += heights[i];
|
|
200
|
+
let trailingRows = 0;
|
|
201
|
+
for (let i = end; i < n; i += 1)
|
|
202
|
+
trailingRows += heights[i];
|
|
203
|
+
return { startIndex: start, endIndex: end, leadingRows, trailingRows, totalRows };
|
|
204
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-region clamp for INLINE render mode (tui-render-performance L5 / R6).
|
|
3
|
+
*
|
|
4
|
+
* Inline mode has no fixed frame, so nothing kept its live (non-`<Static>`)
|
|
5
|
+
* region shorter than the terminal. When one live entry grew taller than
|
|
6
|
+
* `stdout.rows`, Ink took the branch at `ink.js:118-123`:
|
|
7
|
+
*
|
|
8
|
+
* if (outputHeight >= this.options.stdout.rows) {
|
|
9
|
+
* stdout.write(clearTerminal + this.fullStaticOutput + output);
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* `fullStaticOutput` accumulates EVERY `<Static>` frame of the whole session and
|
|
13
|
+
* is never trimmed, so once that branch is taken it is taken on every subsequent
|
|
14
|
+
* frame — bypassing both the `output !== lastOutput` dedupe and `throttledLog` —
|
|
15
|
+
* and the terminal receives the entire session history thirty times a second.
|
|
16
|
+
* That is a genuine hard freeze, and it is reachable today by any inline user
|
|
17
|
+
* (`--no-fullscreen`, `TERM=dumb`, CI, a short or narrow terminal) whose model
|
|
18
|
+
* writes a long answer.
|
|
19
|
+
*
|
|
20
|
+
* I-L5-1 — keeping the live region strictly below `stdout.rows` makes that
|
|
21
|
+
* branch UNREACHABLE, which is the inline analogue of `frame.ts`'s
|
|
22
|
+
* `frameHeight(r) < r` and deserves the same standing. We cannot bound Ink's
|
|
23
|
+
* `fullStaticOutput` growth (it is private and append-only); preventing the
|
|
24
|
+
* branch that WRITES it is the entire mitigation, and nothing about the memory
|
|
25
|
+
* it holds is fixed by this module.
|
|
26
|
+
*
|
|
27
|
+
* Nothing is lost, only deferred: when the entry settles it moves into
|
|
28
|
+
* `<Static>` and is printed in full.
|
|
29
|
+
*/
|
|
30
|
+
export interface LiveClamp {
|
|
31
|
+
text: string;
|
|
32
|
+
/** Rows dropped off the FRONT. `0` means the text was returned untouched. */
|
|
33
|
+
hiddenRows: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Keep the LAST `maxRows` rows of `text`; report how many were dropped.
|
|
37
|
+
*
|
|
38
|
+
* Rows, not wrapped display lines: this runs per frame on a streaming entry, and
|
|
39
|
+
* a wrap-aware count would need the column width and a second full scan of a
|
|
40
|
+
* string this function exists to avoid scanning twice.
|
|
41
|
+
*/
|
|
42
|
+
export declare function clampLiveText(text: string, maxRows: number): LiveClamp;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live-region clamp for INLINE render mode (tui-render-performance L5 / R6).
|
|
3
|
+
*
|
|
4
|
+
* Inline mode has no fixed frame, so nothing kept its live (non-`<Static>`)
|
|
5
|
+
* region shorter than the terminal. When one live entry grew taller than
|
|
6
|
+
* `stdout.rows`, Ink took the branch at `ink.js:118-123`:
|
|
7
|
+
*
|
|
8
|
+
* if (outputHeight >= this.options.stdout.rows) {
|
|
9
|
+
* stdout.write(clearTerminal + this.fullStaticOutput + output);
|
|
10
|
+
* }
|
|
11
|
+
*
|
|
12
|
+
* `fullStaticOutput` accumulates EVERY `<Static>` frame of the whole session and
|
|
13
|
+
* is never trimmed, so once that branch is taken it is taken on every subsequent
|
|
14
|
+
* frame — bypassing both the `output !== lastOutput` dedupe and `throttledLog` —
|
|
15
|
+
* and the terminal receives the entire session history thirty times a second.
|
|
16
|
+
* That is a genuine hard freeze, and it is reachable today by any inline user
|
|
17
|
+
* (`--no-fullscreen`, `TERM=dumb`, CI, a short or narrow terminal) whose model
|
|
18
|
+
* writes a long answer.
|
|
19
|
+
*
|
|
20
|
+
* I-L5-1 — keeping the live region strictly below `stdout.rows` makes that
|
|
21
|
+
* branch UNREACHABLE, which is the inline analogue of `frame.ts`'s
|
|
22
|
+
* `frameHeight(r) < r` and deserves the same standing. We cannot bound Ink's
|
|
23
|
+
* `fullStaticOutput` growth (it is private and append-only); preventing the
|
|
24
|
+
* branch that WRITES it is the entire mitigation, and nothing about the memory
|
|
25
|
+
* it holds is fixed by this module.
|
|
26
|
+
*
|
|
27
|
+
* Nothing is lost, only deferred: when the entry settles it moves into
|
|
28
|
+
* `<Static>` and is printed in full.
|
|
29
|
+
*/
|
|
30
|
+
/**
|
|
31
|
+
* Keep the LAST `maxRows` rows of `text`; report how many were dropped.
|
|
32
|
+
*
|
|
33
|
+
* Rows, not wrapped display lines: this runs per frame on a streaming entry, and
|
|
34
|
+
* a wrap-aware count would need the column width and a second full scan of a
|
|
35
|
+
* string this function exists to avoid scanning twice.
|
|
36
|
+
*/
|
|
37
|
+
export function clampLiveText(text, maxRows) {
|
|
38
|
+
if (!Number.isFinite(maxRows) || maxRows <= 0)
|
|
39
|
+
return { text: '', hiddenRows: rowCount(text) };
|
|
40
|
+
const keep = Math.floor(maxRows);
|
|
41
|
+
// Cheap reject: a string with fewer than `keep` newlines cannot have more than
|
|
42
|
+
// `keep` rows, and this is the path every ordinary entry takes.
|
|
43
|
+
let newlines = 0;
|
|
44
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
45
|
+
if (text.charCodeAt(i) === 10)
|
|
46
|
+
newlines += 1;
|
|
47
|
+
}
|
|
48
|
+
if (newlines + 1 <= keep)
|
|
49
|
+
return { text, hiddenRows: 0 };
|
|
50
|
+
// Walk back from the end to the `keep`-th newline rather than splitting the
|
|
51
|
+
// whole string: the point of the clamp is to stop touching the head at all.
|
|
52
|
+
let cut = text.length;
|
|
53
|
+
let seen = 0;
|
|
54
|
+
for (let i = text.length - 1; i >= 0; i -= 1) {
|
|
55
|
+
if (text.charCodeAt(i) === 10) {
|
|
56
|
+
seen += 1;
|
|
57
|
+
if (seen === keep) {
|
|
58
|
+
cut = i + 1;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { text: text.slice(cut), hiddenRows: newlines + 1 - keep };
|
|
64
|
+
}
|
|
65
|
+
function rowCount(text) {
|
|
66
|
+
if (text.length === 0)
|
|
67
|
+
return 0;
|
|
68
|
+
let n = 1;
|
|
69
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
70
|
+
if (text.charCodeAt(i) === 10)
|
|
71
|
+
n += 1;
|
|
72
|
+
}
|
|
73
|
+
return n;
|
|
74
|
+
}
|
|
@@ -15,11 +15,13 @@ export interface HeadingBlock {
|
|
|
15
15
|
export interface RuleBlock {
|
|
16
16
|
kind: 'rule';
|
|
17
17
|
}
|
|
18
|
+
/** Column alignment carried by a table's `|---|:--:|` divider row. */
|
|
19
|
+
export type Align = 'left' | 'right' | 'center';
|
|
18
20
|
export interface TableBlock {
|
|
19
21
|
kind: 'table';
|
|
20
22
|
/** Already column-aligned; render as plain monospace rows. */
|
|
21
23
|
rows: string[][];
|
|
22
|
-
align:
|
|
24
|
+
align: Align[];
|
|
23
25
|
}
|
|
24
26
|
export declare function parseHeading(line: string): HeadingBlock | null;
|
|
25
27
|
export declare function isRule(line: string): boolean;
|
|
@@ -34,6 +36,28 @@ export declare function tableAlignments(dividerLine: string): TableBlock['align'
|
|
|
34
36
|
* on any realistic terminal.
|
|
35
37
|
*/
|
|
36
38
|
export declare function formatTable(rows: string[][], align: TableBlock['align']): string[];
|
|
39
|
+
/**
|
|
40
|
+
* One parsed top-level block.
|
|
41
|
+
*
|
|
42
|
+
* `line` is deliberately UNPARSED: heading / rule / quote / bullet detection is
|
|
43
|
+
* a handful of regexes over one short string and is done at render time, where
|
|
44
|
+
* the theme and the glyph set live. Caching a themed element instead would make
|
|
45
|
+
* `/theme` a no-op until the cache evicted (K-5).
|
|
46
|
+
*/
|
|
47
|
+
export type MdBlock = {
|
|
48
|
+
kind: 'code';
|
|
49
|
+
code: string;
|
|
50
|
+
lang: string;
|
|
51
|
+
} | {
|
|
52
|
+
kind: 'table';
|
|
53
|
+
rows: string[][];
|
|
54
|
+
align: Align[];
|
|
55
|
+
} | {
|
|
56
|
+
kind: 'line';
|
|
57
|
+
text: string;
|
|
58
|
+
};
|
|
59
|
+
/** Split a document into fenced code, pipe tables, and everything else. */
|
|
60
|
+
export declare function parseMarkdownBlocks(text: string): MdBlock[];
|
|
37
61
|
export type InlineSpan = {
|
|
38
62
|
kind: 'text';
|
|
39
63
|
text: string;
|
|
@@ -73,6 +73,49 @@ export function formatTable(rows, align) {
|
|
|
73
73
|
.join(' ')
|
|
74
74
|
.trimEnd());
|
|
75
75
|
}
|
|
76
|
+
/** A fence line: ```` ```lang ```` or a bare ```` ``` ````. */
|
|
77
|
+
const FENCE_RE = /^```(\w*)\s*$/;
|
|
78
|
+
/** Split a document into fenced code, pipe tables, and everything else. */
|
|
79
|
+
export function parseMarkdownBlocks(text) {
|
|
80
|
+
const lines = text.split('\n');
|
|
81
|
+
const blocks = [];
|
|
82
|
+
let i = 0;
|
|
83
|
+
while (i < lines.length) {
|
|
84
|
+
const line = lines[i] ?? '';
|
|
85
|
+
const fence = FENCE_RE.exec(line.trim());
|
|
86
|
+
if (fence) {
|
|
87
|
+
const lang = fence[1] || '';
|
|
88
|
+
const codeLines = [];
|
|
89
|
+
i += 1;
|
|
90
|
+
while (i < lines.length && !FENCE_RE.test((lines[i] ?? '').trim())) {
|
|
91
|
+
codeLines.push(lines[i] ?? '');
|
|
92
|
+
i += 1;
|
|
93
|
+
}
|
|
94
|
+
i += 1; // skip the closing fence
|
|
95
|
+
blocks.push({ kind: 'code', code: codeLines.join('\n'), lang });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// A pipe row followed by a divider row starts a table; consume the run.
|
|
99
|
+
const headerCells = parseTableRow(line);
|
|
100
|
+
if (headerCells && i + 1 < lines.length && isTableDivider(lines[i + 1] ?? '')) {
|
|
101
|
+
const align = tableAlignments(lines[i + 1] ?? '');
|
|
102
|
+
const rows = [headerCells];
|
|
103
|
+
i += 2;
|
|
104
|
+
while (i < lines.length) {
|
|
105
|
+
const cells = parseTableRow(lines[i] ?? '');
|
|
106
|
+
if (!cells)
|
|
107
|
+
break;
|
|
108
|
+
rows.push(cells);
|
|
109
|
+
i += 1;
|
|
110
|
+
}
|
|
111
|
+
blocks.push({ kind: 'table', rows, align });
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
blocks.push({ kind: 'line', text: line });
|
|
115
|
+
i += 1;
|
|
116
|
+
}
|
|
117
|
+
return blocks;
|
|
118
|
+
}
|
|
76
119
|
const CODE_RE = /`[^`]+`/g;
|
|
77
120
|
const EMPHASIS_RE = /(\*\*[^*]+\*\*|~~[^~]+~~|\*[^*]+\*)/g;
|
|
78
121
|
/**
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render caches (tui-render-performance L2 / R2 + R3).
|
|
3
|
+
*
|
|
4
|
+
* Ink has no output caching: every frame re-walks the whole mounted tree,
|
|
5
|
+
* re-measures every text node and re-serialises the frame. The two most
|
|
6
|
+
* expensive PURE computations in that walk were being redone from scratch on
|
|
7
|
+
* every one of them:
|
|
8
|
+
*
|
|
9
|
+
* - `cli-highlight`'s `highlight()` ran INSIDE `CodeBlock`'s render body, so a
|
|
10
|
+
* 200-line fenced block was a complete highlight.js tokenise, 30 times a
|
|
11
|
+
* second, for as long as it stayed mounted;
|
|
12
|
+
* - `Markdown` re-split and re-regexed the whole answer into one React element
|
|
13
|
+
* per line on every render.
|
|
14
|
+
*
|
|
15
|
+
* Everything cached here is a function of its INPUT STRING ALONE. Theme and
|
|
16
|
+
* glyphs are applied at render time and are never cached, because a themed
|
|
17
|
+
* React element in a cache makes `/theme` a no-op until the entry evicts (K-5) —
|
|
18
|
+
* exactly the class of silent bug this package writes comments to avoid.
|
|
19
|
+
*
|
|
20
|
+
* Caches are MODULE-LEVEL on purpose: settled transcript text is identical
|
|
21
|
+
* across component instances and across mount/unmount cycles, so per-instance
|
|
22
|
+
* caches would miss precisely when a scroll brings an old entry back.
|
|
23
|
+
*/
|
|
24
|
+
import { type MdBlock } from './markdown-blocks.js';
|
|
25
|
+
import { type Glyphs } from './glyphs.js';
|
|
26
|
+
import type { TermCapabilities } from './capabilities.js';
|
|
27
|
+
export declare const RENDER_CACHE_LIMITS: {
|
|
28
|
+
readonly highlightEntries: 128;
|
|
29
|
+
readonly highlightBytes: number;
|
|
30
|
+
/**
|
|
31
|
+
* Above this, skip highlighting entirely.
|
|
32
|
+
*
|
|
33
|
+
* highlight.js is superlinear in the length of a single block, and a
|
|
34
|
+
* multi-megabyte "code block" is almost always a pasted log rather than
|
|
35
|
+
* source. Rendering it plain costs the user nothing they would have been able
|
|
36
|
+
* to read; tokenising it costs the whole frame budget.
|
|
37
|
+
*/
|
|
38
|
+
readonly highlightMaxChars: 20000;
|
|
39
|
+
readonly markdownEntries: 256;
|
|
40
|
+
readonly markdownBytes: number;
|
|
41
|
+
readonly linesEntries: 512;
|
|
42
|
+
};
|
|
43
|
+
export interface RenderCacheStats {
|
|
44
|
+
markdownEntries: number;
|
|
45
|
+
markdownBytes: number;
|
|
46
|
+
highlightEntries: number;
|
|
47
|
+
highlightBytes: number;
|
|
48
|
+
lineEntries: number;
|
|
49
|
+
hits: number;
|
|
50
|
+
misses: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Syntax-highlight `code`, memoised on `(lang, code)`.
|
|
54
|
+
*
|
|
55
|
+
* `cli-highlight` emits chalk-level ANSI that does not depend on `theme.*`, so
|
|
56
|
+
* unlike a rendered element this string IS safe to cache across a `/theme`
|
|
57
|
+
* switch — the same property it has had since before this cache existed.
|
|
58
|
+
*/
|
|
59
|
+
export declare function highlightCached(code: string, lang: string): string;
|
|
60
|
+
/** Parse a markdown document into blocks, memoised on the whole text. */
|
|
61
|
+
export declare function parseMarkdownCached(text: string): MdBlock[];
|
|
62
|
+
/**
|
|
63
|
+
* `text.split('\n')`, memoised.
|
|
64
|
+
*
|
|
65
|
+
* READ-ONLY BY CONTRACT. The returned array is shared with every other holder of
|
|
66
|
+
* the same string, so a caller that sorts or splices it in place corrupts an
|
|
67
|
+
* unrelated card. The `readonly string[]` return type is the enforcement.
|
|
68
|
+
*/
|
|
69
|
+
export declare function splitLinesCached(text: string): readonly string[];
|
|
70
|
+
/**
|
|
71
|
+
* The glyph set for a terminal.
|
|
72
|
+
*
|
|
73
|
+
* `pickGlyphs` already returns one of two module constants, so this adds no
|
|
74
|
+
* caching of its own — it exists so that every render-layer memoisation the
|
|
75
|
+
* transcript depends on is reachable from one module, and so the components can
|
|
76
|
+
* stop calling `pickGlyphs` once per entry per frame through six import paths.
|
|
77
|
+
*/
|
|
78
|
+
export declare function glyphsFor(caps: TermCapabilities): Glyphs;
|
|
79
|
+
/** Drop everything. `/perf reset`, and the test suite between cases. */
|
|
80
|
+
export declare function clearRenderCaches(): void;
|
|
81
|
+
export declare function renderCacheStats(): RenderCacheStats;
|