@aragon-agent/cli 0.5.6 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/README.md +1429 -1105
  3. package/dist/agent/coalesce.d.ts +13 -0
  4. package/dist/agent/coalesce.js +50 -11
  5. package/dist/agent/controller.d.ts +141 -2
  6. package/dist/agent/controller.js +396 -4
  7. package/dist/agent/entry-limits.d.ts +65 -0
  8. package/dist/agent/entry-limits.js +133 -0
  9. package/dist/agent/headless.d.ts +8 -0
  10. package/dist/agent/headless.js +40 -0
  11. package/dist/agent/reducer.d.ts +253 -3
  12. package/dist/agent/reducer.js +553 -28
  13. package/dist/agent/retry-view.d.ts +68 -0
  14. package/dist/agent/retry-view.js +95 -0
  15. package/dist/agent/system-prompt.d.ts +11 -0
  16. package/dist/agent/system-prompt.js +2 -0
  17. package/dist/cli.js +122 -4
  18. package/dist/commands/builtins.d.ts +1 -1
  19. package/dist/commands/builtins.js +313 -2
  20. package/dist/commands/perf.d.ts +45 -0
  21. package/dist/commands/perf.js +78 -0
  22. package/dist/config/cli-commands.d.ts +53 -2
  23. package/dist/config/cli-commands.js +136 -3
  24. package/dist/config/env.d.ts +6 -1
  25. package/dist/config/env.js +131 -1
  26. package/dist/config/load.d.ts +63 -0
  27. package/dist/config/load.js +125 -2
  28. package/dist/config/schema.d.ts +273 -10
  29. package/dist/config/schema.js +250 -7
  30. package/dist/config/store.js +20 -1
  31. package/dist/fast/critique.d.ts +43 -0
  32. package/dist/fast/critique.js +62 -0
  33. package/dist/fast/digest.d.ts +42 -0
  34. package/dist/fast/digest.js +85 -0
  35. package/dist/fast/frames.d.ts +99 -0
  36. package/dist/fast/frames.js +180 -0
  37. package/dist/fast/limits.d.ts +98 -0
  38. package/dist/fast/limits.js +98 -0
  39. package/dist/fast/prompt.d.ts +72 -0
  40. package/dist/fast/prompt.js +94 -0
  41. package/dist/fast/resolve.d.ts +43 -0
  42. package/dist/fast/resolve.js +105 -0
  43. package/dist/fast/review-call.d.ts +99 -0
  44. package/dist/fast/review-call.js +130 -0
  45. package/dist/fast/reviewer.d.ts +248 -0
  46. package/dist/fast/reviewer.js +664 -0
  47. package/dist/fast/types.d.ts +128 -0
  48. package/dist/fast/types.js +14 -0
  49. package/dist/fast/wiring.d.ts +170 -0
  50. package/dist/fast/wiring.js +316 -0
  51. package/dist/logging/install.d.ts +15 -1
  52. package/dist/logging/install.js +37 -4
  53. package/dist/logging/logger.d.ts +1 -1
  54. package/dist/session/persist.js +57 -1
  55. package/dist/team/normalize.d.ts +26 -1
  56. package/dist/team/normalize.js +23 -3
  57. package/dist/team/prompt.d.ts +11 -0
  58. package/dist/team/prompt.js +10 -0
  59. package/dist/team/report.d.ts +18 -0
  60. package/dist/team/report.js +27 -1
  61. package/dist/team/runtime.d.ts +13 -2
  62. package/dist/team/runtime.js +33 -6
  63. package/dist/team/subagent.d.ts +19 -1
  64. package/dist/team/subagent.js +72 -10
  65. package/dist/team/task-tool.d.ts +26 -0
  66. package/dist/team/task-tool.js +48 -4
  67. package/dist/team/types.d.ts +80 -1
  68. package/dist/tools/bash-tool.js +19 -3
  69. package/dist/tools/diff.d.ts +25 -5
  70. package/dist/tools/diff.js +72 -41
  71. package/dist/tools/file-change-store.d.ts +34 -0
  72. package/dist/tools/file-change-store.js +57 -0
  73. package/dist/tools/fs-tools.d.ts +26 -0
  74. package/dist/tools/fs-tools.js +62 -5
  75. package/dist/tools/index.d.ts +31 -0
  76. package/dist/tools/index.js +7 -1
  77. package/dist/tools/patch.d.ts +115 -0
  78. package/dist/tools/patch.js +453 -0
  79. package/dist/tools/terminal-output.d.ts +107 -0
  80. package/dist/tools/terminal-output.js +198 -0
  81. package/dist/tools/tool-output-store.d.ts +68 -0
  82. package/dist/tools/tool-output-store.js +102 -0
  83. package/dist/ui/ActivityLine.d.ts +48 -0
  84. package/dist/ui/ActivityLine.js +22 -0
  85. package/dist/ui/App.d.ts +1 -4
  86. package/dist/ui/App.js +357 -44
  87. package/dist/ui/BottomStatusRow.d.ts +43 -0
  88. package/dist/ui/BottomStatusRow.js +14 -0
  89. package/dist/ui/Composer.d.ts +1 -6
  90. package/dist/ui/Composer.js +2 -2
  91. package/dist/ui/Markdown.d.ts +7 -1
  92. package/dist/ui/Markdown.js +15 -48
  93. package/dist/ui/PromptInput.d.ts +1 -15
  94. package/dist/ui/PromptInput.js +1 -38
  95. package/dist/ui/StatusBar.d.ts +50 -0
  96. package/dist/ui/StatusBar.js +7 -3
  97. package/dist/ui/TeamPanel.d.ts +5 -4
  98. package/dist/ui/TeamPanel.js +1 -1
  99. package/dist/ui/TodoPanel.d.ts +5 -4
  100. package/dist/ui/Transcript.d.ts +80 -5
  101. package/dist/ui/Transcript.js +179 -11
  102. package/dist/ui/activity-phrases.d.ts +33 -0
  103. package/dist/ui/activity-phrases.js +50 -0
  104. package/dist/ui/entries/AssistantEntry.d.ts +38 -1
  105. package/dist/ui/entries/AssistantEntry.js +34 -3
  106. package/dist/ui/entries/DiffView.d.ts +67 -0
  107. package/dist/ui/entries/DiffView.js +103 -0
  108. package/dist/ui/entries/FastCard.d.ts +53 -0
  109. package/dist/ui/entries/FastCard.js +87 -0
  110. package/dist/ui/entries/RetryCard.d.ts +56 -0
  111. package/dist/ui/entries/RetryCard.js +60 -0
  112. package/dist/ui/entries/TeamCard.d.ts +9 -1
  113. package/dist/ui/entries/TeamCard.js +28 -2
  114. package/dist/ui/entries/TodoCard.d.ts +9 -1
  115. package/dist/ui/entries/TodoCard.js +23 -1
  116. package/dist/ui/entries/ToolCard.d.ts +50 -1
  117. package/dist/ui/entries/ToolCard.js +104 -7
  118. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  119. package/dist/ui/entries/UserEntry.d.ts +9 -1
  120. package/dist/ui/entries/UserEntry.js +17 -1
  121. package/dist/ui/exit-snapshot.d.ts +7 -0
  122. package/dist/ui/glyphs.d.ts +9 -0
  123. package/dist/ui/glyphs.js +2 -0
  124. package/dist/ui/layout/AppShell.d.ts +13 -22
  125. package/dist/ui/layout/AppShell.js +3 -38
  126. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  127. package/dist/ui/layout/MeasuredEntry.js +30 -0
  128. package/dist/ui/layout/ScrollViewport.js +8 -2
  129. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  130. package/dist/ui/layout/viewport-geometry.js +27 -0
  131. package/dist/ui/layout/virtual-window.d.ts +100 -0
  132. package/dist/ui/layout/virtual-window.js +308 -0
  133. package/dist/ui/live-clamp.d.ts +42 -0
  134. package/dist/ui/live-clamp.js +74 -0
  135. package/dist/ui/markdown-blocks.d.ts +25 -1
  136. package/dist/ui/markdown-blocks.js +43 -0
  137. package/dist/ui/overlays/HelpOverlay.js +16 -2
  138. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  139. package/dist/ui/overlays/SettingsScreen.js +147 -1
  140. package/dist/ui/render-cache.d.ts +81 -0
  141. package/dist/ui/render-cache.js +195 -0
  142. package/dist/ui/render-governor.d.ts +54 -0
  143. package/dist/ui/render-governor.js +78 -0
  144. package/dist/ui/transcript-text.d.ts +11 -0
  145. package/dist/ui/transcript-text.js +32 -0
  146. package/dist/ui/use-height-store.d.ts +31 -0
  147. package/dist/ui/use-height-store.js +76 -0
  148. package/dist/ui/use-render-governor.d.ts +39 -0
  149. package/dist/ui/use-render-governor.js +57 -0
  150. package/dist/ui/use-startup-notices.d.ts +9 -1
  151. package/dist/ui/use-startup-notices.js +35 -1
  152. package/dist/ui/use-wheel-routing.d.ts +16 -32
  153. package/dist/ui/use-wheel-routing.js +21 -38
  154. package/package.json +2 -2
  155. package/dist/ui/layout/regions.d.ts +0 -55
  156. package/dist/ui/layout/regions.js +0 -52
@@ -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;
@@ -7,6 +7,7 @@
7
7
  * simply print everything twice.
8
8
  */
9
9
  import { formatCost, formatDuration, formatTokens } from '../agent/usage.js';
10
+ import { formatRetryLine } from '../agent/retry-view.js';
10
11
  const DEFAULT_MAX_ENTRIES = 200;
11
12
  const CONTINUATION_INDENT = ' ';
12
13
  const TOOL_INDENT = ' ';
@@ -84,6 +85,32 @@ function renderEntry(entry, glyphs) {
84
85
  ];
85
86
  return lines;
86
87
  }
88
+ case 'retry': {
89
+ // A turn that spent three minutes retrying is exactly the kind of cost an
90
+ // exported transcript is supposed to account for — the same argument the
91
+ // `team` case above makes in its own words. This file's `default: return []`
92
+ // means the addition is a CHOICE rather than a compile requirement, and it is
93
+ // worth making: without it a replay of a session that lost two minutes to a
94
+ // 529 shows a gap and no reason for it.
95
+ const glyph = entry.phase === 'exhausted' ? glyphs.error : glyphs.retry;
96
+ return [
97
+ `${TOOL_INDENT}${glyph} ${formatRetryLine(entry, glyphs, Date.now())}`,
98
+ ];
99
+ }
100
+ case 'fast': {
101
+ // Reviews are real spend and real advice, and an exported transcript that
102
+ // omitted them would be unaccountable for both — the argument the `team`
103
+ // and `retry` cases above each make in their own words.
104
+ const glyph = entry.status === 'failed' ? glyphs.toolError : glyphs.toolDone;
105
+ const duration = entry.durationMs === undefined ? '' : ` (${entry.durationMs}ms)`;
106
+ const head = `${TOOL_INDENT}${glyph} fast review #${entry.reviewIndex} ${entry.model} ` +
107
+ `turn ${entry.turn}${duration}`;
108
+ if (entry.status === 'advice' && entry.text) {
109
+ return [head, ...entry.text.split('\n').map((l) => `${TOOL_INDENT}${CONTINUATION_INDENT}${l}`)];
110
+ }
111
+ const detail = entry.detail ? `: ${entry.detail}` : '';
112
+ return [`${head} ${entry.status}${detail}`];
113
+ }
87
114
  case 'notice': {
88
115
  const glyph = entry.level === 'error'
89
116
  ? glyphs.error
@@ -111,6 +138,11 @@ export function renderTranscriptText(entries, opts) {
111
138
  ].join(` ${g.midDot} `);
112
139
  const max = opts.maxEntries ?? DEFAULT_MAX_ENTRIES;
113
140
  const lines = [summary];
141
+ const dropped = opts.droppedEntries ?? 0;
142
+ if (dropped > 0) {
143
+ lines.push(`${g.ellipsis} ${dropped} earlier ${dropped === 1 ? 'entry' : 'entries'} dropped by ` +
144
+ `transcriptRetain ${g.midDot} raise it or /save sooner`);
145
+ }
114
146
  if (entries.length > max && max > 1) {
115
147
  const head = Math.ceil(max / 2);
116
148
  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
- export function useStartupNotices(dispatch, mouseNotice) {
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
  }
@@ -1,31 +1,26 @@
1
1
  /**
2
- * Wheel routing (mouse-wheel-region-routing §4.6 / §4.7).
2
+ * Wheel routing (wheel-scrolls-transcript-only §4.1).
3
3
  *
4
- * Subscribes to the mouse source, turns each wheel notch into a band via
5
- * `hitTestWheel`, and dispatches it to the transcript, an open overlay, or the
6
- * prompt-history stepper. Lives outside `App.tsx` because that file is exactly
7
- * at the 1000-line ceiling `CLAUDE.md` sets, and because the routing table is
8
- * the most testable part of this feature and does not want to be inside a
9
- * 1000-line component (D-13).
4
+ * Subscribes to the mouse source and dispatches every notch to THE ONE
5
+ * SCROLLABLE REGION ON SCREEN — the transcript, or the controlled overlay
6
+ * covering it. Lives outside `App.tsx` because the routing table is the most
7
+ * testable part of this feature and a table wants to be readable on its own,
8
+ * away from a large component (D-13).
10
9
  *
11
- * THE TWO BANDS HAVE DIFFERENT PHYSICS, and that is the requirement rather than
12
- * a tuning choice (§4.7). A physical notch produces ONE SGR report, whereas
13
- * alternate-scroll used to produce three arrow keys:
10
+ * THE POINTER ROW IS NOT CONSULTED, and that is the requirement rather than a
11
+ * simplification (§1). A wheel notch is a VIEWPORT gesture: the pointer in a
12
+ * TUI rests wherever the user last left it — in a chat-shaped app, next to the
13
+ * thing they type into — so routing by row meant the ordinary "scroll back to
14
+ * see what the agent just did" gesture landed on the composer and replaced the
15
+ * draft with an old prompt. Prompt history belongs to `↑` / `↓` alone (G1 / G3),
16
+ * which is the contract Claude Code, Codex and every full-screen terminal app
17
+ * the user brings muscle memory from already follow.
14
18
  *
15
- * - transcript: 3 rows per notch, matching the terminal's own convention and
16
- * the muscle memory users bring from every other full-screen app;
17
- * - composer: exactly ONE history step per notch. Multiplying here would make
18
- * a single flick jump three prompts back — unusable, and destructive when a
19
- * draft is in play.
20
- *
21
- * Bursts are handled differently for the same reason: transcript events are
22
- * coalesced into one intent carrying `repeat`, composer events never are (they
23
- * are rate-limited by the human, and each must produce its own visible step).
19
+ * Every notch is coalesced into one intent carrying `repeat`, no matter how fast
20
+ * the wheel spins: one gesture, one `setState`, one render.
24
21
  */
25
- import { type MutableRefObject } from 'react';
26
22
  import type { Overlay } from '../agent/reducer.js';
27
23
  import type { MouseSource } from '../input/stdin-mouse-filter.js';
28
- import { type FrameGeometry } from './layout/regions.js';
29
24
  import type { ScrollIntent } from './layout/scroll.js';
30
25
  /**
31
26
  * Rows a PgUp/PgDn — and a Shift+wheel notch — moves inside a controlled
@@ -37,22 +32,11 @@ export declare const OVERLAY_PAGE = 8;
37
32
  export interface WheelRoutingOptions {
38
33
  /** Absent when mouse support is off, in inline mode, or on a non-TTY. */
39
34
  mouseSource?: MouseSource;
40
- /** Live frame geometry, written by `AppShell`'s layout effect (§4.5). */
41
- geometryRef: MutableRefObject<FrameGeometry>;
42
- /**
43
- * Test-only seam (§8.2): when set, the router reads this instead of the
44
- * measured ref, so a routing test never depends on `measureElement` working
45
- * under `ink-testing-library` — a dependency no other test in this package
46
- * exercises, and one that would make a routing regression present as a
47
- * measurement flake.
48
- */
49
- geometryOverride?: FrameGeometry;
50
35
  /** Read at EVENT time for the routing branch, and again at FLUSH time (P1-3). */
51
36
  getOverlay: () => Overlay | null;
52
37
  onScroll: (kind: ScrollIntent, repeat: number) => void;
53
38
  /** Signed row delta; the caller clamps at 0 the way the keyboard branch does. */
54
39
  onOverlayScroll: (delta: number) => void;
55
- onHistoryStep: (dir: 'up' | 'down') => void;
56
40
  }
57
41
  export interface WheelRouting {
58
42
  /**
@@ -1,29 +1,25 @@
1
1
  /**
2
- * Wheel routing (mouse-wheel-region-routing §4.6 / §4.7).
2
+ * Wheel routing (wheel-scrolls-transcript-only §4.1).
3
3
  *
4
- * Subscribes to the mouse source, turns each wheel notch into a band via
5
- * `hitTestWheel`, and dispatches it to the transcript, an open overlay, or the
6
- * prompt-history stepper. Lives outside `App.tsx` because that file is exactly
7
- * at the 1000-line ceiling `CLAUDE.md` sets, and because the routing table is
8
- * the most testable part of this feature and does not want to be inside a
9
- * 1000-line component (D-13).
4
+ * Subscribes to the mouse source and dispatches every notch to THE ONE
5
+ * SCROLLABLE REGION ON SCREEN — the transcript, or the controlled overlay
6
+ * covering it. Lives outside `App.tsx` because the routing table is the most
7
+ * testable part of this feature and a table wants to be readable on its own,
8
+ * away from a large component (D-13).
10
9
  *
11
- * THE TWO BANDS HAVE DIFFERENT PHYSICS, and that is the requirement rather than
12
- * a tuning choice (§4.7). A physical notch produces ONE SGR report, whereas
13
- * alternate-scroll used to produce three arrow keys:
10
+ * THE POINTER ROW IS NOT CONSULTED, and that is the requirement rather than a
11
+ * simplification (§1). A wheel notch is a VIEWPORT gesture: the pointer in a
12
+ * TUI rests wherever the user last left it — in a chat-shaped app, next to the
13
+ * thing they type into — so routing by row meant the ordinary "scroll back to
14
+ * see what the agent just did" gesture landed on the composer and replaced the
15
+ * draft with an old prompt. Prompt history belongs to `↑` / `↓` alone (G1 / G3),
16
+ * which is the contract Claude Code, Codex and every full-screen terminal app
17
+ * the user brings muscle memory from already follow.
14
18
  *
15
- * - transcript: 3 rows per notch, matching the terminal's own convention and
16
- * the muscle memory users bring from every other full-screen app;
17
- * - composer: exactly ONE history step per notch. Multiplying here would make
18
- * a single flick jump three prompts back — unusable, and destructive when a
19
- * draft is in play.
20
- *
21
- * Bursts are handled differently for the same reason: transcript events are
22
- * coalesced into one intent carrying `repeat`, composer events never are (they
23
- * are rate-limited by the human, and each must produce its own visible step).
19
+ * Every notch is coalesced into one intent carrying `repeat`, no matter how fast
20
+ * the wheel spins: one gesture, one `setState`, one render.
24
21
  */
25
22
  import { useCallback, useEffect, useRef } from 'react';
26
- import { hitTestWheel } from './layout/regions.js';
27
23
  /**
28
24
  * Rows a PgUp/PgDn — and a Shift+wheel notch — moves inside a controlled
29
25
  * overlay. Declared here rather than in `App.tsx` because both the keyboard
@@ -89,42 +85,29 @@ export function useWheelRouting(options) {
89
85
  }, [flush]);
90
86
  const route = useCallback((event) => {
91
87
  const current = optionsRef.current;
92
- const geometry = current.geometryOverride ?? current.geometryRef.current;
93
- const band = hitTestWheel(event.y, geometry);
94
88
  const overlay = current.getOverlay();
95
- // THE OVERLAY BRANCH IS EVALUATED FIRST AND RETURNS FOR EVERY BAND IT
89
+ // THE OVERLAY BRANCH IS EVALUATED FIRST AND RETURNS FOR EVERY OVERLAY IT
96
90
  // RECOGNIZES — the same two ordering rules the keyboard handler follows,
97
91
  // and for the same reason (R-P1-7).
98
92
  if (overlay) {
99
- // The composer is inactive behind an overlay; stepping a hidden draft
100
- // is worse than doing nothing.
101
- if (band !== 'content')
102
- return;
103
93
  // Modes A only. `model` / `confirm` / `question` own their own keys and
104
94
  // scrolling them would leave an inconsistent cursor (R-11).
105
95
  const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan';
106
96
  if (!controlled)
107
97
  return;
98
+ // D-3: the row is not consulted here either. Behind an overlay the
99
+ // composer is inactive and the overlay is the only scrollable thing on
100
+ // screen, so a notch anywhere scrolls it.
108
101
  const step = event.shift ? OVERLAY_PAGE : OVERLAY_LINES_PER_NOTCH;
109
102
  current.onOverlayScroll(event.dir === 'up' ? -step : step);
110
103
  return;
111
104
  }
112
- if (band === 'composer') {
113
- // §4.7: the accumulator flushes when the BAND changes, not only when
114
- // the direction does. The composer path is never coalesced, so without
115
- // this the transcript scroll that preceded the pointer moving down
116
- // lands ~16 ms AFTER the history step it happened before — the two
117
- // halves of one gesture, applied out of order.
118
- flush();
119
- current.onHistoryStep(event.dir);
120
- return;
121
- }
122
105
  if (event.shift) {
123
106
  accumulate(event.dir === 'up' ? 'pageUp' : 'pageDown', 1);
124
107
  return;
125
108
  }
126
109
  accumulate(event.dir === 'up' ? 'lineUp' : 'lineDown', CONTENT_LINES_PER_NOTCH);
127
- }, [accumulate, flush]);
110
+ }, [accumulate]);
128
111
  const { mouseSource } = options;
129
112
  useEffect(() => {
130
113
  if (!mouseSource)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aragon-agent/cli",
3
- "version": "0.5.6",
3
+ "version": "0.5.9",
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.6",
55
+ "@aragon-agent/core": "^0.2.9",
56
56
  "chalk": "^5.6.2",
57
57
  "cli-highlight": "^2.0.0",
58
58
  "commander": "^12.1.0",