@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,195 @@
|
|
|
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 { highlight } from 'cli-highlight';
|
|
25
|
+
import { parseMarkdownBlocks } from './markdown-blocks.js';
|
|
26
|
+
import { pickGlyphs } from './glyphs.js';
|
|
27
|
+
export const RENDER_CACHE_LIMITS = {
|
|
28
|
+
highlightEntries: 128,
|
|
29
|
+
highlightBytes: 2 * 1024 * 1024,
|
|
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
|
+
highlightMaxChars: 20_000,
|
|
39
|
+
markdownEntries: 256,
|
|
40
|
+
markdownBytes: 4 * 1024 * 1024,
|
|
41
|
+
linesEntries: 512,
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Insertion-ordered LRU with both an entry-count and an approximate byte budget.
|
|
45
|
+
*
|
|
46
|
+
* TWO BUDGETS, NOT ONE. An entry count alone is unbounded in memory (one 5 MB
|
|
47
|
+
* answer), and a byte budget alone is unbounded in Map overhead (a hundred
|
|
48
|
+
* thousand one-character keys). Every cache here can be fed by the model, so
|
|
49
|
+
* both directions have to be closed.
|
|
50
|
+
*/
|
|
51
|
+
class LruCache {
|
|
52
|
+
maxEntries;
|
|
53
|
+
maxBytes;
|
|
54
|
+
map = new Map();
|
|
55
|
+
total = 0;
|
|
56
|
+
hits = 0;
|
|
57
|
+
misses = 0;
|
|
58
|
+
constructor(maxEntries, maxBytes) {
|
|
59
|
+
this.maxEntries = maxEntries;
|
|
60
|
+
this.maxBytes = maxBytes;
|
|
61
|
+
}
|
|
62
|
+
get(key) {
|
|
63
|
+
const hit = this.map.get(key);
|
|
64
|
+
if (hit === undefined) {
|
|
65
|
+
this.misses += 1;
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
// Re-insert to move the key to the young end of the insertion order.
|
|
69
|
+
this.map.delete(key);
|
|
70
|
+
this.map.set(key, hit);
|
|
71
|
+
this.hits += 1;
|
|
72
|
+
return hit.value;
|
|
73
|
+
}
|
|
74
|
+
set(key, value, bytes) {
|
|
75
|
+
const existing = this.map.get(key);
|
|
76
|
+
if (existing)
|
|
77
|
+
this.total -= existing.bytes;
|
|
78
|
+
this.map.delete(key);
|
|
79
|
+
this.map.set(key, { value, bytes });
|
|
80
|
+
this.total += bytes;
|
|
81
|
+
while (this.map.size > this.maxEntries || this.total > this.maxBytes) {
|
|
82
|
+
const oldest = this.map.keys().next();
|
|
83
|
+
if (oldest.done)
|
|
84
|
+
break;
|
|
85
|
+
const entry = this.map.get(oldest.value);
|
|
86
|
+
if (entry)
|
|
87
|
+
this.total -= entry.bytes;
|
|
88
|
+
this.map.delete(oldest.value);
|
|
89
|
+
}
|
|
90
|
+
return value;
|
|
91
|
+
}
|
|
92
|
+
clear() {
|
|
93
|
+
this.map.clear();
|
|
94
|
+
this.total = 0;
|
|
95
|
+
this.hits = 0;
|
|
96
|
+
this.misses = 0;
|
|
97
|
+
}
|
|
98
|
+
get size() {
|
|
99
|
+
return this.map.size;
|
|
100
|
+
}
|
|
101
|
+
get bytes() {
|
|
102
|
+
return this.total;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const highlightCache = new LruCache(RENDER_CACHE_LIMITS.highlightEntries, RENDER_CACHE_LIMITS.highlightBytes);
|
|
106
|
+
const markdownCache = new LruCache(RENDER_CACHE_LIMITS.markdownEntries, RENDER_CACHE_LIMITS.markdownBytes);
|
|
107
|
+
/**
|
|
108
|
+
* `text.split('\n')` results.
|
|
109
|
+
*
|
|
110
|
+
* Bounded by ENTRY COUNT ONLY, and the byte budget is set to the markdown one
|
|
111
|
+
* because the arrays are views over strings the transcript already holds — they
|
|
112
|
+
* add pointer overhead, not a second copy of the text.
|
|
113
|
+
*/
|
|
114
|
+
const lineCache = new LruCache(RENDER_CACHE_LIMITS.linesEntries, RENDER_CACHE_LIMITS.markdownBytes);
|
|
115
|
+
/**
|
|
116
|
+
* Syntax-highlight `code`, memoised on `(lang, code)`.
|
|
117
|
+
*
|
|
118
|
+
* `cli-highlight` emits chalk-level ANSI that does not depend on `theme.*`, so
|
|
119
|
+
* unlike a rendered element this string IS safe to cache across a `/theme`
|
|
120
|
+
* switch — the same property it has had since before this cache existed.
|
|
121
|
+
*/
|
|
122
|
+
export function highlightCached(code, lang) {
|
|
123
|
+
if (code.length === 0)
|
|
124
|
+
return code;
|
|
125
|
+
if (code.length > RENDER_CACHE_LIMITS.highlightMaxChars)
|
|
126
|
+
return code;
|
|
127
|
+
// The delimiter is written as an ESCAPE and never as a literal control
|
|
128
|
+
// character: a raw NUL byte makes the whole file "binary" to grep and
|
|
129
|
+
// ripgrep, which silently hides every definition in it from the tool this
|
|
130
|
+
// codebase navigates itself with. NUL rather than a space because a space
|
|
131
|
+
// still lets ("a b", "ts") and ("b", "ts a") collide on one key.
|
|
132
|
+
const key = `${lang}\u0000${code}`;
|
|
133
|
+
const hit = highlightCache.get(key);
|
|
134
|
+
if (hit !== undefined)
|
|
135
|
+
return hit;
|
|
136
|
+
let rendered = code;
|
|
137
|
+
try {
|
|
138
|
+
rendered = highlight(code, { language: lang || undefined, ignoreIllegals: true });
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
rendered = code;
|
|
142
|
+
}
|
|
143
|
+
return highlightCache.set(key, rendered, key.length + rendered.length);
|
|
144
|
+
}
|
|
145
|
+
/** Parse a markdown document into blocks, memoised on the whole text. */
|
|
146
|
+
export function parseMarkdownCached(text) {
|
|
147
|
+
const hit = markdownCache.get(text);
|
|
148
|
+
if (hit !== undefined)
|
|
149
|
+
return hit;
|
|
150
|
+
return markdownCache.set(text, parseMarkdownBlocks(text), text.length * 2);
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* `text.split('\n')`, memoised.
|
|
154
|
+
*
|
|
155
|
+
* READ-ONLY BY CONTRACT. The returned array is shared with every other holder of
|
|
156
|
+
* the same string, so a caller that sorts or splices it in place corrupts an
|
|
157
|
+
* unrelated card. The `readonly string[]` return type is the enforcement.
|
|
158
|
+
*/
|
|
159
|
+
export function splitLinesCached(text) {
|
|
160
|
+
if (text.length === 0)
|
|
161
|
+
return EMPTY_LINES;
|
|
162
|
+
const hit = lineCache.get(text);
|
|
163
|
+
if (hit !== undefined)
|
|
164
|
+
return hit;
|
|
165
|
+
return lineCache.set(text, text.split('\n'), text.length);
|
|
166
|
+
}
|
|
167
|
+
const EMPTY_LINES = [];
|
|
168
|
+
/**
|
|
169
|
+
* The glyph set for a terminal.
|
|
170
|
+
*
|
|
171
|
+
* `pickGlyphs` already returns one of two module constants, so this adds no
|
|
172
|
+
* caching of its own — it exists so that every render-layer memoisation the
|
|
173
|
+
* transcript depends on is reachable from one module, and so the components can
|
|
174
|
+
* stop calling `pickGlyphs` once per entry per frame through six import paths.
|
|
175
|
+
*/
|
|
176
|
+
export function glyphsFor(caps) {
|
|
177
|
+
return pickGlyphs(caps);
|
|
178
|
+
}
|
|
179
|
+
/** Drop everything. `/perf reset`, and the test suite between cases. */
|
|
180
|
+
export function clearRenderCaches() {
|
|
181
|
+
highlightCache.clear();
|
|
182
|
+
markdownCache.clear();
|
|
183
|
+
lineCache.clear();
|
|
184
|
+
}
|
|
185
|
+
export function renderCacheStats() {
|
|
186
|
+
return {
|
|
187
|
+
markdownEntries: markdownCache.size,
|
|
188
|
+
markdownBytes: markdownCache.bytes,
|
|
189
|
+
highlightEntries: highlightCache.size,
|
|
190
|
+
highlightBytes: highlightCache.bytes,
|
|
191
|
+
lineEntries: lineCache.size,
|
|
192
|
+
hits: markdownCache.hits + highlightCache.hits + lineCache.hits,
|
|
193
|
+
misses: markdownCache.misses + highlightCache.misses + lineCache.misses,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive render governor (tui-render-performance L4 / R7). Pure, React-free.
|
|
3
|
+
*
|
|
4
|
+
* Neither the streaming coalescer nor Ink adapts when a frame overruns its
|
|
5
|
+
* budget: the coalescer keeps its 33 ms timer, Ink keeps its 32 ms throttle, and
|
|
6
|
+
* the event loop saturates. Renders then queue behind each other on Node's
|
|
7
|
+
* single thread and KEYSTROKES QUEUE BEHIND THE RENDERS — which is what turns a
|
|
8
|
+
* slow frame into an unresponsive terminal.
|
|
9
|
+
*
|
|
10
|
+
* The ladder is the safety net under L1/L2/L3, not a substitute for them: it
|
|
11
|
+
* cannot make a frame cheaper, only rarer. Its job is to turn any residual
|
|
12
|
+
* pathology from a freeze into a visibly slower but still interactive UI.
|
|
13
|
+
*
|
|
14
|
+
* I-L4-1 — the governor changes only HOW OFTEN the view updates. Never what is
|
|
15
|
+
* dispatched, never the order, never the final state. `mergeDeltas` already
|
|
16
|
+
* guarantees that a longer window is simply a larger merge and that any
|
|
17
|
+
* non-delta action flushes the pending run first, so the same event sequence
|
|
18
|
+
* must produce the same final `ViewState` at every rung
|
|
19
|
+
* (`render-governor.test.ts`).
|
|
20
|
+
*/
|
|
21
|
+
/** Coalescing intervals, in ms. Rung 0 is the pre-feature `COALESCE_MS`. */
|
|
22
|
+
export declare const GOVERNOR_LADDER: readonly [33, 50, 80, 125, 200, 320];
|
|
23
|
+
/** Step up when a commit costs more than this fraction of the current interval. */
|
|
24
|
+
export declare const GOVERNOR_UP_RATIO = 0.7;
|
|
25
|
+
/**
|
|
26
|
+
* Consecutive cheap frames required before stepping back down.
|
|
27
|
+
*
|
|
28
|
+
* Hysteresis, and it has to be asymmetric: stepping up on one expensive frame
|
|
29
|
+
* and down on one cheap one would oscillate through the whole ladder while a
|
|
30
|
+
* long answer streams past a couple of code blocks.
|
|
31
|
+
*/
|
|
32
|
+
export declare const GOVERNOR_DOWN_STREAK = 8;
|
|
33
|
+
export interface GovernorState {
|
|
34
|
+
rung: number;
|
|
35
|
+
cheapStreak: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function initialGovernor(): GovernorState;
|
|
38
|
+
/**
|
|
39
|
+
* The coalescing interval for a state.
|
|
40
|
+
*
|
|
41
|
+
* Clamped to `maxIntervalMs` in BOTH directions: `maxRenderIntervalMs = 33`
|
|
42
|
+
* flattens the ladder to one rung, which is the documented way to get the
|
|
43
|
+
* pre-feature cadence without reaching for the kill switch.
|
|
44
|
+
*/
|
|
45
|
+
export declare function intervalOf(state: GovernorState, maxIntervalMs: number): number;
|
|
46
|
+
/**
|
|
47
|
+
* Fold one commit's cost into the ladder.
|
|
48
|
+
*
|
|
49
|
+
* `lastCommitMs` covers React reconciliation AND Ink's yoga layout, because Ink
|
|
50
|
+
* computes layout in `onRender`, which React runs before layout effects. It does
|
|
51
|
+
* NOT cover Ink's serialisation and the stdout write, which happen after. In
|
|
52
|
+
* practice layout dominates, and the ladder's job is DIRECTION, not accuracy.
|
|
53
|
+
*/
|
|
54
|
+
export declare function stepGovernor(state: GovernorState, lastCommitMs: number, maxIntervalMs: number): GovernorState;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adaptive render governor (tui-render-performance L4 / R7). Pure, React-free.
|
|
3
|
+
*
|
|
4
|
+
* Neither the streaming coalescer nor Ink adapts when a frame overruns its
|
|
5
|
+
* budget: the coalescer keeps its 33 ms timer, Ink keeps its 32 ms throttle, and
|
|
6
|
+
* the event loop saturates. Renders then queue behind each other on Node's
|
|
7
|
+
* single thread and KEYSTROKES QUEUE BEHIND THE RENDERS — which is what turns a
|
|
8
|
+
* slow frame into an unresponsive terminal.
|
|
9
|
+
*
|
|
10
|
+
* The ladder is the safety net under L1/L2/L3, not a substitute for them: it
|
|
11
|
+
* cannot make a frame cheaper, only rarer. Its job is to turn any residual
|
|
12
|
+
* pathology from a freeze into a visibly slower but still interactive UI.
|
|
13
|
+
*
|
|
14
|
+
* I-L4-1 — the governor changes only HOW OFTEN the view updates. Never what is
|
|
15
|
+
* dispatched, never the order, never the final state. `mergeDeltas` already
|
|
16
|
+
* guarantees that a longer window is simply a larger merge and that any
|
|
17
|
+
* non-delta action flushes the pending run first, so the same event sequence
|
|
18
|
+
* must produce the same final `ViewState` at every rung
|
|
19
|
+
* (`render-governor.test.ts`).
|
|
20
|
+
*/
|
|
21
|
+
/** Coalescing intervals, in ms. Rung 0 is the pre-feature `COALESCE_MS`. */
|
|
22
|
+
export const GOVERNOR_LADDER = [33, 50, 80, 125, 200, 320];
|
|
23
|
+
/** Step up when a commit costs more than this fraction of the current interval. */
|
|
24
|
+
export const GOVERNOR_UP_RATIO = 0.7;
|
|
25
|
+
/**
|
|
26
|
+
* Consecutive cheap frames required before stepping back down.
|
|
27
|
+
*
|
|
28
|
+
* Hysteresis, and it has to be asymmetric: stepping up on one expensive frame
|
|
29
|
+
* and down on one cheap one would oscillate through the whole ladder while a
|
|
30
|
+
* long answer streams past a couple of code blocks.
|
|
31
|
+
*/
|
|
32
|
+
export const GOVERNOR_DOWN_STREAK = 8;
|
|
33
|
+
export function initialGovernor() {
|
|
34
|
+
return { rung: 0, cheapStreak: 0 };
|
|
35
|
+
}
|
|
36
|
+
/** Highest rung reachable under `maxIntervalMs`. Always at least 0. */
|
|
37
|
+
function topRung(maxIntervalMs) {
|
|
38
|
+
let top = 0;
|
|
39
|
+
for (let i = 1; i < GOVERNOR_LADDER.length; i += 1) {
|
|
40
|
+
if (GOVERNOR_LADDER[i] <= maxIntervalMs)
|
|
41
|
+
top = i;
|
|
42
|
+
}
|
|
43
|
+
return top;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The coalescing interval for a state.
|
|
47
|
+
*
|
|
48
|
+
* Clamped to `maxIntervalMs` in BOTH directions: `maxRenderIntervalMs = 33`
|
|
49
|
+
* flattens the ladder to one rung, which is the documented way to get the
|
|
50
|
+
* pre-feature cadence without reaching for the kill switch.
|
|
51
|
+
*/
|
|
52
|
+
export function intervalOf(state, maxIntervalMs) {
|
|
53
|
+
const ceiling = Number.isFinite(maxIntervalMs) ? maxIntervalMs : GOVERNOR_LADDER[0];
|
|
54
|
+
const rung = Math.min(Math.max(0, Math.floor(state.rung)), topRung(ceiling));
|
|
55
|
+
return Math.min(ceiling, GOVERNOR_LADDER[rung] ?? GOVERNOR_LADDER[0]);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Fold one commit's cost into the ladder.
|
|
59
|
+
*
|
|
60
|
+
* `lastCommitMs` covers React reconciliation AND Ink's yoga layout, because Ink
|
|
61
|
+
* computes layout in `onRender`, which React runs before layout effects. It does
|
|
62
|
+
* NOT cover Ink's serialisation and the stdout write, which happen after. In
|
|
63
|
+
* practice layout dominates, and the ladder's job is DIRECTION, not accuracy.
|
|
64
|
+
*/
|
|
65
|
+
export function stepGovernor(state, lastCommitMs, maxIntervalMs) {
|
|
66
|
+
const top = topRung(Number.isFinite(maxIntervalMs) ? maxIntervalMs : GOVERNOR_LADDER[0]);
|
|
67
|
+
const rung = Math.min(Math.max(0, Math.floor(state.rung)), top);
|
|
68
|
+
const interval = intervalOf({ rung, cheapStreak: state.cheapStreak }, maxIntervalMs);
|
|
69
|
+
const cost = Number.isFinite(lastCommitMs) ? lastCommitMs : 0;
|
|
70
|
+
if (cost > interval * GOVERNOR_UP_RATIO) {
|
|
71
|
+
return { rung: Math.min(top, rung + 1), cheapStreak: 0 };
|
|
72
|
+
}
|
|
73
|
+
const streak = state.cheapStreak + 1;
|
|
74
|
+
if (streak >= GOVERNOR_DOWN_STREAK) {
|
|
75
|
+
return { rung: Math.max(0, rung - 1), cheapStreak: 0 };
|
|
76
|
+
}
|
|
77
|
+
return { rung, cheapStreak: streak };
|
|
78
|
+
}
|
|
@@ -22,6 +22,17 @@ export interface TranscriptTextOptions {
|
|
|
22
22
|
elapsedMs: number;
|
|
23
23
|
/** Entries beyond this count collapse into a single "… N omitted" line. */
|
|
24
24
|
maxEntries?: number;
|
|
25
|
+
/**
|
|
26
|
+
* Entries the retain ring already removed from `ViewState`
|
|
27
|
+
* (tui-render-performance L1 / K-6).
|
|
28
|
+
*
|
|
29
|
+
* A DIFFERENT LOSS FROM `maxEntries`, and both have to be reported: that one
|
|
30
|
+
* omits the middle of a transcript this replay still holds in full, while this
|
|
31
|
+
* one names entries the process no longer has. A replay that silently starts
|
|
32
|
+
* mid-session is exactly the failure the retain ring would otherwise
|
|
33
|
+
* introduce.
|
|
34
|
+
*/
|
|
35
|
+
droppedEntries?: number;
|
|
25
36
|
}
|
|
26
37
|
/** Render the whole session (summary line + entries) as plain text. */
|
|
27
38
|
export declare function renderTranscriptText(entries: Entry[], opts: TranscriptTextOptions): string;
|
|
@@ -111,6 +111,11 @@ export function renderTranscriptText(entries, opts) {
|
|
|
111
111
|
].join(` ${g.midDot} `);
|
|
112
112
|
const max = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
|
|
113
113
|
const lines = [summary];
|
|
114
|
+
const dropped = opts.droppedEntries ?? 0;
|
|
115
|
+
if (dropped > 0) {
|
|
116
|
+
lines.push(`${g.ellipsis} ${dropped} earlier ${dropped === 1 ? 'entry' : 'entries'} dropped by ` +
|
|
117
|
+
`transcriptRetain ${g.midDot} raise it or /save sooner`);
|
|
118
|
+
}
|
|
114
119
|
if (entries.length > max && max > 1) {
|
|
115
120
|
const head = Math.ceil(max / 2);
|
|
116
121
|
const tail = max - head;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript height cache (tui-render-performance L3).
|
|
3
|
+
*
|
|
4
|
+
* Virtualisation needs a height for entries it is NOT mounting. Measured
|
|
5
|
+
* heights are authoritative; entries that have never been on screen fall back to
|
|
6
|
+
* `estimateEntryRows`, and both are memoised on the same `heightKey` so a
|
|
7
|
+
* `cols` change or a content change invalidates them together (V-5).
|
|
8
|
+
*
|
|
9
|
+
* THE MAP LIVES IN A REF, NOT IN STATE, and the version counter is bumped at
|
|
10
|
+
* most once per frame through a microtask and ONLY when a value actually
|
|
11
|
+
* changed. K-2 is the failure this shape exists to prevent: a naive
|
|
12
|
+
* measure -> setState -> re-measure store never converges, and the symptom is a
|
|
13
|
+
* hung terminal rather than an error. In steady state — settled entries at a
|
|
14
|
+
* fixed width — this produces zero extra renders.
|
|
15
|
+
*/
|
|
16
|
+
import { type HeightKey } from './layout/virtual-window.js';
|
|
17
|
+
export interface HeightStoreStats {
|
|
18
|
+
measured: number;
|
|
19
|
+
estimated: number;
|
|
20
|
+
}
|
|
21
|
+
export interface HeightStore {
|
|
22
|
+
/** Bumped at most once per frame, and only when a measurement changed. */
|
|
23
|
+
version: number;
|
|
24
|
+
/** Measured rows, else the memoised estimate, computing it once if needed. */
|
|
25
|
+
resolve: (key: HeightKey, estimate: () => number) => number;
|
|
26
|
+
/** Called from `MeasuredEntry`'s layout effect. Idempotent per (key, value). */
|
|
27
|
+
report: (key: HeightKey, rows: number) => void;
|
|
28
|
+
stats: () => HeightStoreStats;
|
|
29
|
+
clear: () => void;
|
|
30
|
+
}
|
|
31
|
+
export declare function useHeightStore(): HeightStore;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The transcript height cache (tui-render-performance L3).
|
|
3
|
+
*
|
|
4
|
+
* Virtualisation needs a height for entries it is NOT mounting. Measured
|
|
5
|
+
* heights are authoritative; entries that have never been on screen fall back to
|
|
6
|
+
* `estimateEntryRows`, and both are memoised on the same `heightKey` so a
|
|
7
|
+
* `cols` change or a content change invalidates them together (V-5).
|
|
8
|
+
*
|
|
9
|
+
* THE MAP LIVES IN A REF, NOT IN STATE, and the version counter is bumped at
|
|
10
|
+
* most once per frame through a microtask and ONLY when a value actually
|
|
11
|
+
* changed. K-2 is the failure this shape exists to prevent: a naive
|
|
12
|
+
* measure -> setState -> re-measure store never converges, and the symptom is a
|
|
13
|
+
* hung terminal rather than an error. In steady state — settled entries at a
|
|
14
|
+
* fixed width — this produces zero extra renders.
|
|
15
|
+
*/
|
|
16
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
17
|
+
import { VIRTUAL_LIMITS } from './layout/virtual-window.js';
|
|
18
|
+
/** Drop the oldest keys once a map passes the ceiling (insertion-ordered LRU). */
|
|
19
|
+
function trim(map) {
|
|
20
|
+
while (map.size > VIRTUAL_LIMITS.heightEntries) {
|
|
21
|
+
const oldest = map.keys().next();
|
|
22
|
+
if (oldest.done)
|
|
23
|
+
break;
|
|
24
|
+
map.delete(oldest.value);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export function useHeightStore() {
|
|
28
|
+
const measured = useRef(new Map());
|
|
29
|
+
const estimated = useRef(new Map());
|
|
30
|
+
const [version, setVersion] = useState(0);
|
|
31
|
+
const scheduled = useRef(false);
|
|
32
|
+
const mounted = useRef(true);
|
|
33
|
+
useEffect(() => () => {
|
|
34
|
+
mounted.current = false;
|
|
35
|
+
}, []);
|
|
36
|
+
const report = useCallback((key, rows) => {
|
|
37
|
+
const value = Number.isFinite(rows) && rows > 0 ? Math.floor(rows) : 0;
|
|
38
|
+
if (value <= 0)
|
|
39
|
+
return;
|
|
40
|
+
if (measured.current.get(key) === value)
|
|
41
|
+
return;
|
|
42
|
+
measured.current.set(key, value);
|
|
43
|
+
trim(measured.current);
|
|
44
|
+
// An estimate that has been superseded is dead weight; dropping it here is
|
|
45
|
+
// what keeps `stats()` honest for `/perf`.
|
|
46
|
+
estimated.current.delete(key);
|
|
47
|
+
if (scheduled.current)
|
|
48
|
+
return;
|
|
49
|
+
scheduled.current = true;
|
|
50
|
+
queueMicrotask(() => {
|
|
51
|
+
scheduled.current = false;
|
|
52
|
+
if (mounted.current)
|
|
53
|
+
setVersion((v) => v + 1);
|
|
54
|
+
});
|
|
55
|
+
}, []);
|
|
56
|
+
const resolve = useCallback((key, estimate) => {
|
|
57
|
+
const hit = measured.current.get(key);
|
|
58
|
+
if (hit !== undefined)
|
|
59
|
+
return hit;
|
|
60
|
+
const cached = estimated.current.get(key);
|
|
61
|
+
if (cached !== undefined)
|
|
62
|
+
return cached;
|
|
63
|
+
const value = Math.max(1, Math.floor(estimate()));
|
|
64
|
+
estimated.current.set(key, value);
|
|
65
|
+
trim(estimated.current);
|
|
66
|
+
return value;
|
|
67
|
+
}, []);
|
|
68
|
+
const stats = useCallback(() => ({ measured: measured.current.size, estimated: estimated.current.size }), []);
|
|
69
|
+
const clear = useCallback(() => {
|
|
70
|
+
measured.current.clear();
|
|
71
|
+
estimated.current.clear();
|
|
72
|
+
if (mounted.current)
|
|
73
|
+
setVersion((v) => v + 1);
|
|
74
|
+
}, []);
|
|
75
|
+
return { version, resolve, report, stats, clear };
|
|
76
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit-cost timing for the render governor (tui-render-performance L4).
|
|
3
|
+
*
|
|
4
|
+
* `performance.now()` is taken at the TOP of the `App` render body (this hook
|
|
5
|
+
* must therefore be called early, before any other work) and again in a layout
|
|
6
|
+
* effect. Ink computes yoga layout in `onRender`, which React runs BEFORE layout
|
|
7
|
+
* effects, so that span covers React reconciliation AND the Ink layout pass —
|
|
8
|
+
* the same mechanism `ScrollViewport.tsx:87-88` and `AppShell.tsx:100-102`
|
|
9
|
+
* already document and rely on.
|
|
10
|
+
*
|
|
11
|
+
* ONLY THE RUNG IS REACT STATE. The measured cost and the resolved interval live
|
|
12
|
+
* in refs: putting a millisecond count in state would re-render the whole tree
|
|
13
|
+
* on every frame to record how expensive rendering the whole tree was.
|
|
14
|
+
*/
|
|
15
|
+
export interface RenderGovernorOptions {
|
|
16
|
+
/** `false` pins rung 0 forever (`--no-render-governor`, `ARAGON_RENDER_GOVERNOR=0`). */
|
|
17
|
+
enabled: boolean;
|
|
18
|
+
/** Ceiling of the ladder; `33` flattens it. */
|
|
19
|
+
maxIntervalMs: number;
|
|
20
|
+
}
|
|
21
|
+
export interface RenderGovernorHandle {
|
|
22
|
+
/**
|
|
23
|
+
* The coalescing interval to arm the next flush timer with.
|
|
24
|
+
*
|
|
25
|
+
* A REF, read at `setTimeout` time. The coalescer's callbacks are `useCallback`d
|
|
26
|
+
* with stable deps and must stay that way (the controller subscription effect
|
|
27
|
+
* never re-runs), so a plain value would be pinned to the mount render.
|
|
28
|
+
*/
|
|
29
|
+
intervalMs: {
|
|
30
|
+
current: number;
|
|
31
|
+
};
|
|
32
|
+
/** Rendered as the `eco` chip when > 0 (I-L4-2). */
|
|
33
|
+
rung: number;
|
|
34
|
+
/** Most recent commit cost in ms, for `/perf`. Not state. */
|
|
35
|
+
lastCommitMs: () => number;
|
|
36
|
+
/** `/perf reset` — back to rung 0. */
|
|
37
|
+
reset: () => void;
|
|
38
|
+
}
|
|
39
|
+
export declare function useRenderGovernor(opts: RenderGovernorOptions): RenderGovernorHandle;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit-cost timing for the render governor (tui-render-performance L4).
|
|
3
|
+
*
|
|
4
|
+
* `performance.now()` is taken at the TOP of the `App` render body (this hook
|
|
5
|
+
* must therefore be called early, before any other work) and again in a layout
|
|
6
|
+
* effect. Ink computes yoga layout in `onRender`, which React runs BEFORE layout
|
|
7
|
+
* effects, so that span covers React reconciliation AND the Ink layout pass —
|
|
8
|
+
* the same mechanism `ScrollViewport.tsx:87-88` and `AppShell.tsx:100-102`
|
|
9
|
+
* already document and rely on.
|
|
10
|
+
*
|
|
11
|
+
* ONLY THE RUNG IS REACT STATE. The measured cost and the resolved interval live
|
|
12
|
+
* in refs: putting a millisecond count in state would re-render the whole tree
|
|
13
|
+
* on every frame to record how expensive rendering the whole tree was.
|
|
14
|
+
*/
|
|
15
|
+
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
16
|
+
import { performance } from 'node:perf_hooks';
|
|
17
|
+
import { GOVERNOR_LADDER, initialGovernor, intervalOf, stepGovernor, } from './render-governor.js';
|
|
18
|
+
export function useRenderGovernor(opts) {
|
|
19
|
+
const { enabled, maxIntervalMs } = opts;
|
|
20
|
+
const stateRef = useRef(initialGovernor());
|
|
21
|
+
const intervalMs = useRef(GOVERNOR_LADDER[0]);
|
|
22
|
+
const lastCommit = useRef(0);
|
|
23
|
+
const startedAt = useRef(0);
|
|
24
|
+
const mounted = useRef(true);
|
|
25
|
+
const [rung, setRung] = useState(0);
|
|
26
|
+
// Stamped during render, which is the whole point of calling this hook first.
|
|
27
|
+
startedAt.current = performance.now();
|
|
28
|
+
useEffect(() => () => {
|
|
29
|
+
mounted.current = false;
|
|
30
|
+
}, []);
|
|
31
|
+
useLayoutEffect(() => {
|
|
32
|
+
const cost = performance.now() - startedAt.current;
|
|
33
|
+
lastCommit.current = cost;
|
|
34
|
+
if (!enabled) {
|
|
35
|
+
stateRef.current = initialGovernor();
|
|
36
|
+
intervalMs.current = Math.min(maxIntervalMs, GOVERNOR_LADDER[0]);
|
|
37
|
+
if (rung !== 0)
|
|
38
|
+
setRung(0);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const next = stepGovernor(stateRef.current, cost, maxIntervalMs);
|
|
42
|
+
stateRef.current = next;
|
|
43
|
+
intervalMs.current = intervalOf(next, maxIntervalMs);
|
|
44
|
+
// React bails out when the value is unchanged, so this is a no-op on every
|
|
45
|
+
// frame that did not move the ladder — which is almost all of them.
|
|
46
|
+
if (mounted.current && next.rung !== rung)
|
|
47
|
+
setRung(next.rung);
|
|
48
|
+
});
|
|
49
|
+
const reset = useCallback(() => {
|
|
50
|
+
stateRef.current = initialGovernor();
|
|
51
|
+
intervalMs.current = Math.min(maxIntervalMs, GOVERNOR_LADDER[0]);
|
|
52
|
+
if (mounted.current)
|
|
53
|
+
setRung(0);
|
|
54
|
+
}, [maxIntervalMs]);
|
|
55
|
+
const lastCommitMs = useCallback(() => lastCommit.current, []);
|
|
56
|
+
return { intervalMs, rung, lastCommitMs, reset };
|
|
57
|
+
}
|
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
* not otherwise see — rather than belonging to the view.
|
|
13
13
|
*/
|
|
14
14
|
import type { ViewAction } from '../agent/reducer.js';
|
|
15
|
+
/**
|
|
16
|
+
* `{ requested, resolved }` when the raise happened, `undefined` otherwise —
|
|
17
|
+
* i.e. `cfg.transcriptRetainRequested` and `cfg.transcriptRetain`.
|
|
18
|
+
*/
|
|
19
|
+
export interface RetainNoticeOptions {
|
|
20
|
+
requested: number;
|
|
21
|
+
resolved: number;
|
|
22
|
+
}
|
|
15
23
|
export interface MouseNoticeOptions {
|
|
16
24
|
/**
|
|
17
25
|
* Whether reporting is ACTUALLY in effect — i.e. a filter was installed, not
|
|
@@ -22,4 +30,4 @@ export interface MouseNoticeOptions {
|
|
|
22
30
|
/** Record that it was shown (`state.json`). Called once, after dispatch. */
|
|
23
31
|
onSeen: () => void;
|
|
24
32
|
}
|
|
25
|
-
export declare function useStartupNotices(dispatch: (action: ViewAction) => void, mouseNotice?: MouseNoticeOptions): void;
|
|
33
|
+
export declare function useStartupNotices(dispatch: (action: ViewAction) => void, mouseNotice?: MouseNoticeOptions, retainNotice?: RetainNoticeOptions): void;
|
|
@@ -25,7 +25,21 @@ import { getLogger } from '../logging/logger.js';
|
|
|
25
25
|
*/
|
|
26
26
|
const MOUSE_SELECTION_NOTICE = 'Mouse wheel scrolls the transcript. Hold Shift to select text, ' +
|
|
27
27
|
'or turn it off with `aragon config set mouse false`.';
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The §5.1 notice (tui-render-performance). Shown once per run, and only when
|
|
30
|
+
* the user's own `transcriptRetain` was below `transcriptWindow`.
|
|
31
|
+
*
|
|
32
|
+
* The raise itself is not negotiable — retaining fewer entries than the horizon
|
|
33
|
+
* can scroll to makes part of that horizon permanently unreachable — but doing
|
|
34
|
+
* it in silence would leave a user staring at a `/perf` line that disagrees with
|
|
35
|
+
* the number in their own config file and no way to connect the two.
|
|
36
|
+
*/
|
|
37
|
+
function retainRaisedNotice(requested, resolved) {
|
|
38
|
+
return (`transcriptRetain ${requested} is below transcriptWindow ${resolved}; using ${resolved} ` +
|
|
39
|
+
'so the whole scroll horizon stays reachable. Lower transcriptWindow too if you meant ' +
|
|
40
|
+
'to cap memory.');
|
|
41
|
+
}
|
|
42
|
+
export function useStartupNotices(dispatch, mouseNotice, retainNotice) {
|
|
29
43
|
// The sink gives up after repeated write failures (a read-only log directory,
|
|
30
44
|
// a full disk). Silently logging nothing is exactly the state a user must not
|
|
31
45
|
// discover later, from an empty file, while trying to explain a bug.
|
|
@@ -62,4 +76,24 @@ export function useStartupNotices(dispatch, mouseNotice) {
|
|
|
62
76
|
dispatch({ type: 'notice', level: 'info', text: MOUSE_SELECTION_NOTICE });
|
|
63
77
|
noticeRef.current?.onSeen();
|
|
64
78
|
}, [mouseEnabled, dispatch]);
|
|
79
|
+
// A `warn` notice rather than a toast: it is about a value the user typed, so
|
|
80
|
+
// it belongs in the transcript where it can be scrolled back to, not in a row
|
|
81
|
+
// that expires. DEPENDS ON THE TWO SCALARS, never on the options object —
|
|
82
|
+
// `App` builds that literal during render, and depending on it would re-run
|
|
83
|
+
// this effect on every frame of the session.
|
|
84
|
+
const retainRequested = retainNotice?.requested;
|
|
85
|
+
const retainResolved = retainNotice?.resolved;
|
|
86
|
+
const retainNoticeShown = useRef(false);
|
|
87
|
+
useEffect(() => {
|
|
88
|
+
if (retainRequested === undefined || retainResolved === undefined)
|
|
89
|
+
return;
|
|
90
|
+
if (retainNoticeShown.current)
|
|
91
|
+
return;
|
|
92
|
+
retainNoticeShown.current = true;
|
|
93
|
+
dispatch({
|
|
94
|
+
type: 'notice',
|
|
95
|
+
level: 'warn',
|
|
96
|
+
text: retainRaisedNotice(retainRequested, retainResolved),
|
|
97
|
+
});
|
|
98
|
+
}, [retainRequested, retainResolved, dispatch]);
|
|
65
99
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aragon-agent/cli",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.8",
|
|
4
4
|
"description": "AragonAgent CLI — a Claude-Code / Codex-style interactive terminal UI (TUI) on top of the @aragon-agent/core engine. Installable and runnable via npm / npx.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "AragonAgent contributors",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"prepublishOnly": "npm run build"
|
|
53
53
|
},
|
|
54
54
|
"dependencies": {
|
|
55
|
-
"@aragon-agent/core": "^0.2.
|
|
55
|
+
"@aragon-agent/core": "^0.2.8",
|
|
56
56
|
"chalk": "^5.6.2",
|
|
57
57
|
"cli-highlight": "^2.0.0",
|
|
58
58
|
"commander": "^12.1.0",
|