@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.
Files changed (59) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +60 -2
  3. package/dist/agent/entry-limits.d.ts +65 -0
  4. package/dist/agent/entry-limits.js +133 -0
  5. package/dist/agent/reducer.d.ts +8 -0
  6. package/dist/agent/reducer.js +67 -15
  7. package/dist/cli.js +36 -2
  8. package/dist/commands/builtins.js +5 -0
  9. package/dist/commands/perf.d.ts +45 -0
  10. package/dist/commands/perf.js +78 -0
  11. package/dist/config/env.d.ts +3 -1
  12. package/dist/config/env.js +31 -1
  13. package/dist/config/load.d.ts +14 -0
  14. package/dist/config/load.js +43 -2
  15. package/dist/config/schema.d.ts +61 -8
  16. package/dist/config/schema.js +43 -6
  17. package/dist/ui/App.js +86 -9
  18. package/dist/ui/Markdown.d.ts +7 -1
  19. package/dist/ui/Markdown.js +15 -48
  20. package/dist/ui/StatusBar.d.ts +13 -0
  21. package/dist/ui/StatusBar.js +4 -2
  22. package/dist/ui/Transcript.d.ts +56 -4
  23. package/dist/ui/Transcript.js +112 -10
  24. package/dist/ui/entries/AssistantEntry.d.ts +18 -1
  25. package/dist/ui/entries/AssistantEntry.js +33 -3
  26. package/dist/ui/entries/TeamCard.d.ts +9 -1
  27. package/dist/ui/entries/TeamCard.js +22 -1
  28. package/dist/ui/entries/TodoCard.d.ts +9 -1
  29. package/dist/ui/entries/TodoCard.js +23 -1
  30. package/dist/ui/entries/ToolCard.d.ts +13 -1
  31. package/dist/ui/entries/ToolCard.js +39 -5
  32. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  33. package/dist/ui/entries/UserEntry.d.ts +9 -1
  34. package/dist/ui/entries/UserEntry.js +17 -1
  35. package/dist/ui/exit-snapshot.d.ts +7 -0
  36. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  37. package/dist/ui/layout/MeasuredEntry.js +30 -0
  38. package/dist/ui/layout/ScrollViewport.js +8 -2
  39. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  40. package/dist/ui/layout/viewport-geometry.js +27 -0
  41. package/dist/ui/layout/virtual-window.d.ts +100 -0
  42. package/dist/ui/layout/virtual-window.js +204 -0
  43. package/dist/ui/live-clamp.d.ts +42 -0
  44. package/dist/ui/live-clamp.js +74 -0
  45. package/dist/ui/markdown-blocks.d.ts +25 -1
  46. package/dist/ui/markdown-blocks.js +43 -0
  47. package/dist/ui/render-cache.d.ts +81 -0
  48. package/dist/ui/render-cache.js +195 -0
  49. package/dist/ui/render-governor.d.ts +54 -0
  50. package/dist/ui/render-governor.js +78 -0
  51. package/dist/ui/transcript-text.d.ts +11 -0
  52. package/dist/ui/transcript-text.js +5 -0
  53. package/dist/ui/use-height-store.d.ts +31 -0
  54. package/dist/ui/use-height-store.js +76 -0
  55. package/dist/ui/use-render-governor.d.ts +39 -0
  56. package/dist/ui/use-render-governor.js +57 -0
  57. package/dist/ui/use-startup-notices.d.ts +9 -1
  58. package/dist/ui/use-startup-notices.js +35 -1
  59. package/package.json +2 -2
package/CHANGELOG.md CHANGED
@@ -7,8 +7,85 @@ were written.
7
7
 
8
8
  ## Unreleased
9
9
 
10
+ ### Changed
11
+
12
+ - **A long session stays as fast as a short one.** The TUI used to get slower as
13
+ a session grew and, past a threshold, stopped making progress at all. That was
14
+ structural rather than incidental: Ink has no output caching, so every frame
15
+ re-walked the entire mounted tree, re-measured every text node and
16
+ re-serialised the frame. The per-frame cost was therefore proportional to every
17
+ character the session had ever produced — and `transcriptWindow` bounded the
18
+ entry *count*, which bounds nothing when one entry can be fifty thousand lines.
19
+
20
+ Entries outside the visible band are no longer mounted. The viewport renders
21
+ what you can see plus a couple either side and replaces the rest with two
22
+ spacers of the right height, so the frame costs what the screen costs.
23
+ Scrolling, the `↑N` readout and the wheel routing are untouched. Because the
24
+ horizon is no longer a rendering budget, `transcriptWindow` rises from 300 to
25
+ 1000 and its ceiling from 2000 to 20000; an existing setting is still valid
26
+ and still clamps.
27
+
28
+ Syntax highlighting and markdown parsing are memoised on their input, so a
29
+ 200-line code block is tokenised once rather than thirty times a second for as
30
+ long as it stays on screen. Only the *parsed* form is cached and never a themed
31
+ one, so `/theme` still takes effect on the next frame.
32
+
33
+ Under genuine load the streaming interval widens — 33 ms up to a 320 ms
34
+ ceiling — so keystrokes and `Ctrl+C` do not queue behind renders. This is
35
+ visible rather than silent: a muted **`eco`** chip appears in the status bar
36
+ while it is in effect, because a user who cannot see why the stream got
37
+ chunkier will conclude the model got slower. `--no-render-governor`,
38
+ `renderGovernor: false` or `ARAGON_RENDER_GOVERNOR=0` turns it off;
39
+ `--max-render-interval 33` flattens the ladder without disabling the mechanism.
40
+
41
+ - **A live thinking block no longer grows without limit.** While an answer is
42
+ streaming, visible thinking is clamped to its newest 24 rows behind a
43
+ `... N earlier lines` marker, and the whole block renders again the moment the
44
+ answer settles. Thinking is scratch reasoning you read from its newest end, and
45
+ an unbounded one was the easiest way for a single live entry to fill the
46
+ viewport on its own.
47
+
48
+ ### Fixed
49
+
50
+ - **Inline mode could hard-freeze the terminal, and now cannot.** Once a live
51
+ entry grew taller than the terminal, Ink took a branch that writes
52
+ `clearTerminal` plus the *entire accumulated session history* on every
53
+ subsequent frame — bypassing both of its own dedupe gates, and growing without
54
+ bound for the rest of the session. Any inline user (`--no-fullscreen`,
55
+ `TERM=dumb`, CI, a terminal under 12 rows or 40 columns) whose model wrote a
56
+ long answer could reach it. The live region is now clamped below that
57
+ threshold, with a `... N earlier lines` marker naming what is deferred. Nothing
58
+ is lost: the entry prints in full into the terminal's own scrollback the moment
59
+ it settles.
60
+
61
+ - **The view state no longer grows without a ceiling.** A single assistant answer
62
+ is capped at 256 KiB in the view (thinking at 64 KiB, streamed tool arguments
63
+ at 16 KiB) by eliding the MIDDLE, so both the opening and the live cursor
64
+ survive; a marker states how many characters went. `transcriptRetain`
65
+ (default 1000) caps the entries kept in memory, and what it removed is counted
66
+ and reported both by `/perf` and at the top of the exit replay. A model that
67
+ emits five megabytes no longer makes every later frame a five-megabyte scan.
68
+
10
69
  ### Added
11
70
 
71
+ - **`/perf`.** Reports what the renderer is actually doing: the frame interval
72
+ and the last commit cost, how many entries exist / are retained / were dropped
73
+ / are mounted, height-cache and render-cache occupancy, and the viewport
74
+ geometry. `/perf reset` clears the caches and returns the governor to its
75
+ fastest rung. Silent degradation is the one thing a claim of robustness cannot
76
+ afford, and this is the place that names it.
77
+
78
+ - **Three configuration keys**, each with a flag and an environment override:
79
+ `transcriptRetain` (`--transcript-retain`, `ARAGON_TRANSCRIPT_RETAIN`),
80
+ `renderGovernor` (`--render-governor` / `--no-render-governor`,
81
+ `ARAGON_RENDER_GOVERNOR`) and `maxRenderIntervalMs`
82
+ (`--max-render-interval`, `ARAGON_MAX_RENDER_INTERVAL_MS`). No migration and no
83
+ config version bump: a file written before they existed loads unchanged.
84
+ `transcriptRetain` is raised to `transcriptWindow` when the two conflict —
85
+ retaining less than you can scroll to would make part of the horizon
86
+ unreachable — and a startup notice names both numbers, because overriding a
87
+ value you typed and saying nothing is the one thing none of this may do.
88
+
12
89
  - **Following the plan through, on a budget you can do arithmetic on.** A run
13
90
  that ended with steps outstanding used to print one sentence and stop. It now
14
91
  asks three questions the CLI already knew the answers to — did you abort it,
package/README.md CHANGED
@@ -80,6 +80,9 @@ aragon --help | -h Print help
80
80
  | `--no-skills` | Disable the skill system entirely (no catalog, no skill tools) |
81
81
  | `--skill <name>` | Force-load a skill's full body for this run (repeatable) |
82
82
  | `--skills-yes` | Approve skill installs for this run (CI / headless) |
83
+ | `--transcript-retain <n>` | Entries kept in memory, clamped to `[200, 20000]`. Older ones are dropped and counted |
84
+ | `--render-governor` / `--no-render-governor` | Adapt the frame interval under load, or pin it at 33 ms |
85
+ | `--max-render-interval <ms>` | Governor ceiling, clamped to `[33, 1000]`. `33` flattens the ladder |
83
86
  | `--no-color` | Disable ANSI color |
84
87
  | `--quiet` | (print mode) suppress tool/usage lines on stderr |
85
88
 
@@ -107,6 +110,7 @@ Exit codes: `0` success · `1` agent/runtime error · `2` config/usage error ·
107
110
  | `/theme <auto\|warm\|cool\|light>` | Switch the color theme live (persisted) |
108
111
  | `/expand [n]` | Expand / collapse the n-th-from-last (default last) tool card |
109
112
  | `/copy` | Copy the last answer to the clipboard |
113
+ | `/perf` | What the renderer is doing: frame interval, mounted entries, dropped entries, cache occupancy. `/perf reset` clears the caches and the governor |
110
114
  | `/logs` | Show where this session is being logged, and at what level |
111
115
  | `/reload` | Re-read `config.json` after editing it in another window (refused mid-run) |
112
116
  | `/skills [sub]` | List and manage skills (see below) |
@@ -220,6 +224,55 @@ this mode, and mouse tracking is deliberately left off because enabling it costs
220
224
  text selection and copy in most terminals. If a crash ever strands your terminal
221
225
  on the alternate screen, `reset` restores it.
222
226
 
227
+ ## Render performance
228
+
229
+ A long session used to get slower and slower, and past a point it stopped
230
+ responding at all. The cause was structural rather than incidental: Ink has no
231
+ output caching, so every frame re-walked the whole mounted tree, re-measured
232
+ every text node and re-serialised the frame. The cost was therefore proportional
233
+ to **every character the session had ever produced**, not to the characters
234
+ actually on screen — and `transcriptWindow` bounded the entry *count*, which is
235
+ not the same thing when one entry can be fifty thousand lines.
236
+
237
+ Four things changed, and you should not have to think about any of them:
238
+
239
+ - **Off-screen entries are not mounted.** The viewport renders the band you can
240
+ see plus a couple of entries either side, and replaces the rest with two
241
+ spacers of the right height. Scrolling and the `↑N` readout are unchanged.
242
+ - **The expensive pure work is cached.** Syntax highlighting and markdown
243
+ parsing now run once per distinct block instead of once per frame. Themes are
244
+ still applied at render time, so `/theme` takes effect immediately.
245
+ - **The transcript is bounded.** A single answer cannot exceed 256 KiB in the
246
+ view, and `transcriptRetain` caps how many entries are kept. Both bounds
247
+ report what they removed rather than dropping it silently.
248
+ - **The frame interval adapts.** If a frame is genuinely expensive, updates
249
+ coalesce into a wider window — 33 ms up to a 320 ms ceiling — so the terminal
250
+ stays responsive to typing and to `Ctrl+C`. A muted **`eco`** chip appears in
251
+ the status bar whenever this is in effect: the stream getting chunkier is
252
+ something you should be able to see a reason for.
253
+
254
+ `/perf` reports exactly what the renderer is doing:
255
+
256
+ ```
257
+ render rung 2 - interval 80ms - last commit 61ms - eco
258
+ transcript 4213 entries - 1000 retained - 3213 dropped - 38 mounted
259
+ heights 412 cached - 6 estimated - cols 132
260
+ caches md 178 (1.9MB) - hl 96 (1.4MB) - lines 340
261
+ mode fullscreen - viewport 44 rows - offset 0
262
+ ```
263
+
264
+ `/perf reset` clears the render caches and returns the governor to its fastest
265
+ rung. `--no-render-governor` (or `renderGovernor: false`, or
266
+ `ARAGON_RENDER_GOVERNOR=0`) turns the adaptation off entirely; the output is
267
+ identical, just heavier under load.
268
+
269
+ **Inline mode** (`--no-fullscreen`, `TERM=dumb`, CI, a very small terminal) gets
270
+ one extra guarantee. Ink repaints the *entire session history* on every frame
271
+ once a live entry grows taller than the terminal, which is a genuine freeze
272
+ rather than a slowdown. The live region is now clamped to stay below that
273
+ threshold, with a `... N earlier lines` marker naming what is deferred; the
274
+ entry prints **in full** into your scrollback the moment it finishes.
275
+
223
276
  ## Plan mode
224
277
 
225
278
  `aragon` has two postures, and `Shift+Tab` switches between them. `/plan` does
@@ -789,7 +842,9 @@ env / `.env` → CLI flags**.
789
842
  `ARAGON_TODO_FOLLOW` (`notify` / `auto` / `off`),
790
843
  `ARAGON_SKILLS` (`0` disables), `ARAGON_SKILLS_PATH`,
791
844
  `ARAGON_SKILLS_DISABLED` (comma-separated names), `ARAGON_LOG_LEVEL`,
792
- `ARAGON_LOG_FILE` (`0` disables), `ARAGON_LOG_DIR`.
845
+ `ARAGON_LOG_FILE` (`0` disables), `ARAGON_LOG_DIR`,
846
+ `ARAGON_TRANSCRIPT_RETAIN`, `ARAGON_RENDER_GOVERNOR` (`0` disables),
847
+ `ARAGON_MAX_RENDER_INTERVAL_MS`.
793
848
  `ARAGON_HOME` is the exception that **cannot** come from `.env`: it is read
794
849
  before any `.env` is loaded. See [Files & logs](#files--logs).
795
850
  - **User config file** (`aragon config path`): JSON, written `0600` on POSIX.
@@ -805,7 +860,10 @@ env / `.env` → CLI flags**.
805
860
  | `maxTokens` | `64000` | Output token cap, clamped to `[256, 200000]`. `null` means **auto** — use each model's own ceiling, never above 64000. Absent means the default. See [Output token limits](#output-token-limits). |
806
861
  | `fullscreen` | `true` | Use the full-screen TUI (still subject to the automatic downgrades). Setting it to `false` opts out permanently; leaving it `true` is *not* a force — only `--fullscreen` / `ARAGON_FULLSCREEN=1` override the heuristics. |
807
862
  | `exitTranscript` | `true` | Replay a plain-text session summary after exiting (full-screen only). |
808
- | `transcriptWindow` | `300` | How many trailing entries the full-screen viewport renders, clamped to `[50, 2000]`. The full history stays in memory and in `/save`. |
863
+ | `transcriptWindow` | `1000` | How far back you can scroll, clamped to `[50, 20000]`. Entries beyond it collapse into one line. It is no longer a rendering budget: the viewport is virtualised, so off-screen entries are not laid out at all. |
864
+ | `transcriptRetain` | `1000` | Entries kept in memory, clamped to `[200, 20000]` and raised to `transcriptWindow` if the two conflict — with a startup notice saying so, because retaining less than you can scroll to would otherwise make part of the horizon quietly unreachable. Older entries are dropped, counted, and reported by `/perf` and by the exit replay. |
865
+ | `renderGovernor` | `true` | Widen the streaming frame interval when a frame is genuinely expensive. See [Render performance](#render-performance). |
866
+ | `maxRenderIntervalMs` | `320` | Ceiling of the governor ladder, clamped to `[33, 1000]`. `33` flattens it to the pre-0.6 cadence without disabling the mechanism. |
809
867
  | `planModeDefault` | `false` | Start every session in plan mode. `--no-plan` still overrides it for a single run. |
810
868
  | `planModeMaxAskRounds` | `4` | `ask_user` rounds allowed per user message, clamped to `[1, 10]`. The counter resets on your next message; steering does **not** reset it. |
811
869
  | `planModeHumanTimeoutMs` | `1800000` | Ceiling on a single question / plan wait, clamped to `[60000, 7200000]`. Thirty minutes is far outside "went to get coffee", but a ceiling has to exist so an unattended terminal cannot wedge a CI job forever. |
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Bounded view state (tui-render-performance L1 / R5).
3
+ *
4
+ * `ViewState.entries` used to grow without any ceiling: `argsRaw`, assistant
5
+ * `text` and `thinking` all concatenated forever, and the entry array was never
6
+ * trimmed. That makes the per-frame cost of the renderer proportional to the
7
+ * total number of characters the session has ever produced, because Ink's
8
+ * `renderNodeToOutput` walks and measures every mounted text node whether or not
9
+ * `overflow: hidden` clips it.
10
+ *
11
+ * Two bounds live here, both pure and React-free:
12
+ * - `appendBounded` caps one field by eliding its MIDDLE, and
13
+ * - `trimEntries` ring-trims the entry array itself.
14
+ *
15
+ * ASCII ONLY. This module is produced outside `src/ui/**`, where no terminal
16
+ * capabilities are available, so the elision marker has to be safe on a legacy
17
+ * `cmd.exe` -- the same rule `PREVIEW_TRUNCATION_MARK` in `reducer.ts` records.
18
+ */
19
+ import type { Entry } from './reducer.js';
20
+ export interface EntryLimits {
21
+ /** Assistant answer text ceiling, in characters. */
22
+ text: number;
23
+ /** Assistant thinking ceiling. */
24
+ thinking: number;
25
+ /** Streamed tool-argument JSON ceiling. */
26
+ argsRaw: number;
27
+ }
28
+ /**
29
+ * The per-field ceilings.
30
+ *
31
+ * `text` is the generous one on purpose: a 256 KiB answer is already far past
32
+ * anything a terminal can usefully show, and the middle is what gets elided, so
33
+ * both the opening and the live cursor survive. `argsRaw` is the tight one --
34
+ * it is a streamed JSON fragment rendered as a 60-character card summary, so
35
+ * anything past a few KiB is invisible by construction.
36
+ */
37
+ export declare const ENTRY_LIMITS: EntryLimits;
38
+ /** Marker inserted at the elision point. ASCII -- produced outside `src/ui/**`. */
39
+ export declare const ELISION_MARK_PREFIX = "\n[... ";
40
+ export declare const ELISION_MARK_SUFFIX = " characters elided ...]\n";
41
+ /**
42
+ * Append `delta` to `prev`, keeping the result at or below `cap` by eliding the
43
+ * MIDDLE. Head and tail are both preserved: the head is the answer's opening,
44
+ * the tail is where the cursor is. Idempotent -- re-eliding an already elided
45
+ * string collapses the two markers into one.
46
+ *
47
+ * The returned string is NEVER longer than `cap` (AC-7). The marker's own digit
48
+ * count is bounded before the tail budget is computed, so the arithmetic cannot
49
+ * overshoot by a digit the way a naive `cap - marker.length` would.
50
+ */
51
+ export declare function appendBounded(prev: string, delta: string, cap: number): string;
52
+ export interface TrimResult {
53
+ entries: Entry[];
54
+ dropped: number;
55
+ }
56
+ /**
57
+ * Ring-trim to `retain` entries; returns the SAME array reference when no-op.
58
+ *
59
+ * The identity return is load-bearing: the reducer spreads this result into a
60
+ * new `ViewState` on every append, and a fresh array on every action would
61
+ * defeat the `React.memo` boundaries L2 installs on the transcript.
62
+ */
63
+ export declare function trimEntries(entries: Entry[], retain: number): TrimResult;
64
+ export declare function setEntryRetain(value: number): void;
65
+ export declare function entryRetain(): number;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Bounded view state (tui-render-performance L1 / R5).
3
+ *
4
+ * `ViewState.entries` used to grow without any ceiling: `argsRaw`, assistant
5
+ * `text` and `thinking` all concatenated forever, and the entry array was never
6
+ * trimmed. That makes the per-frame cost of the renderer proportional to the
7
+ * total number of characters the session has ever produced, because Ink's
8
+ * `renderNodeToOutput` walks and measures every mounted text node whether or not
9
+ * `overflow: hidden` clips it.
10
+ *
11
+ * Two bounds live here, both pure and React-free:
12
+ * - `appendBounded` caps one field by eliding its MIDDLE, and
13
+ * - `trimEntries` ring-trims the entry array itself.
14
+ *
15
+ * ASCII ONLY. This module is produced outside `src/ui/**`, where no terminal
16
+ * capabilities are available, so the elision marker has to be safe on a legacy
17
+ * `cmd.exe` -- the same rule `PREVIEW_TRUNCATION_MARK` in `reducer.ts` records.
18
+ */
19
+ import { DEFAULT_TRANSCRIPT_RETAIN } from '../config/schema.js';
20
+ /**
21
+ * The per-field ceilings.
22
+ *
23
+ * `text` is the generous one on purpose: a 256 KiB answer is already far past
24
+ * anything a terminal can usefully show, and the middle is what gets elided, so
25
+ * both the opening and the live cursor survive. `argsRaw` is the tight one --
26
+ * it is a streamed JSON fragment rendered as a 60-character card summary, so
27
+ * anything past a few KiB is invisible by construction.
28
+ */
29
+ export const ENTRY_LIMITS = {
30
+ text: 262_144,
31
+ thinking: 65_536,
32
+ argsRaw: 16_384,
33
+ };
34
+ /** Marker inserted at the elision point. ASCII -- produced outside `src/ui/**`. */
35
+ export const ELISION_MARK_PREFIX = '\n[... ';
36
+ export const ELISION_MARK_SUFFIX = ' characters elided ...]\n';
37
+ /** Matches a marker this module wrote, so re-elision collapses rather than nests. */
38
+ const ELISION_RE = /\n\[\.\.\. (\d+) characters elided \.\.\.\]\n/;
39
+ /** Fixed cost of the marker, before the digits of the count. */
40
+ const MARKER_FIXED = ELISION_MARK_PREFIX.length + ELISION_MARK_SUFFIX.length;
41
+ /**
42
+ * Fraction of the cap reserved for the HEAD. The tail gets the rest because that
43
+ * is where the cursor is; the head exists so the answer still starts where the
44
+ * model started it.
45
+ */
46
+ const HEAD_FRACTION = 4;
47
+ /** Undo one elision, so a second one merges into it rather than nesting. */
48
+ function splitElided(prev) {
49
+ const m = ELISION_RE.exec(prev);
50
+ if (!m)
51
+ return { head: '', elided: 0, tail: prev };
52
+ const index = m.index;
53
+ return {
54
+ head: prev.slice(0, index),
55
+ elided: Number.parseInt(m[1], 10) || 0,
56
+ tail: prev.slice(index + m[0].length),
57
+ };
58
+ }
59
+ /**
60
+ * Append `delta` to `prev`, keeping the result at or below `cap` by eliding the
61
+ * MIDDLE. Head and tail are both preserved: the head is the answer's opening,
62
+ * the tail is where the cursor is. Idempotent -- re-eliding an already elided
63
+ * string collapses the two markers into one.
64
+ *
65
+ * The returned string is NEVER longer than `cap` (AC-7). The marker's own digit
66
+ * count is bounded before the tail budget is computed, so the arithmetic cannot
67
+ * overshoot by a digit the way a naive `cap - marker.length` would.
68
+ */
69
+ export function appendBounded(prev, delta, cap) {
70
+ if (!Number.isFinite(cap) || cap <= 0)
71
+ return '';
72
+ const split = splitElided(prev);
73
+ let head = split.head;
74
+ let tail = split.tail + delta;
75
+ let elided = split.elided;
76
+ // Fast path: nothing has ever been elided and it still fits. This is the path
77
+ // every ordinary answer takes, so it must allocate nothing beyond the concat.
78
+ if (elided === 0 && tail.length <= cap)
79
+ return tail;
80
+ const headBudget = Math.max(0, Math.min(Math.floor(cap / HEAD_FRACTION), cap - MARKER_FIXED - 1));
81
+ if (elided === 0) {
82
+ // First elision: the head has to be carved out of the accumulated text.
83
+ head = tail.slice(0, headBudget);
84
+ tail = tail.slice(headBudget);
85
+ }
86
+ else if (head.length > headBudget) {
87
+ // A later `cap` reduction can leave the stored head oversized; charge the
88
+ // difference to the elision count rather than silently keeping it.
89
+ elided += head.length - headBudget;
90
+ head = head.slice(0, headBudget);
91
+ }
92
+ if (head.length + MARKER_FIXED >= cap) {
93
+ // Degenerate cap (only reachable from a hand-written test): keep the tail.
94
+ return tail.slice(Math.max(0, tail.length - cap));
95
+ }
96
+ // `elided + tail.length` is an UPPER BOUND on the final count, so its digit
97
+ // count is an upper bound on the marker's width. Budgeting against the bound
98
+ // is what makes the `<= cap` guarantee hold without a second pass.
99
+ const digitBound = String(elided + tail.length).length;
100
+ const markerBound = MARKER_FIXED + digitBound;
101
+ const tailKeep = Math.max(0, Math.min(tail.length, cap - head.length - markerBound));
102
+ const total = elided + (tail.length - tailKeep);
103
+ return `${head}${ELISION_MARK_PREFIX}${total}${ELISION_MARK_SUFFIX}${tail.slice(tail.length - tailKeep)}`;
104
+ }
105
+ /**
106
+ * Ring-trim to `retain` entries; returns the SAME array reference when no-op.
107
+ *
108
+ * The identity return is load-bearing: the reducer spreads this result into a
109
+ * new `ViewState` on every append, and a fresh array on every action would
110
+ * defeat the `React.memo` boundaries L2 installs on the transcript.
111
+ */
112
+ export function trimEntries(entries, retain) {
113
+ const keep = Math.max(1, Math.floor(retain));
114
+ if (entries.length <= keep)
115
+ return { entries, dropped: 0 };
116
+ return { entries: entries.slice(entries.length - keep), dropped: entries.length - keep };
117
+ }
118
+ // ---------------------------------------------------------------------------
119
+ // The resolved retain ceiling
120
+ // ---------------------------------------------------------------------------
121
+ /**
122
+ * `viewReducer` is pure and takes no config, so the resolved `transcriptRetain`
123
+ * reaches it through a module-level singleton -- the shape `prompt-history.ts`
124
+ * already uses for `setHistoryEnabled`. `load.ts` is the ONLY writer.
125
+ */
126
+ let resolvedRetain = DEFAULT_TRANSCRIPT_RETAIN;
127
+ export function setEntryRetain(value) {
128
+ if (Number.isFinite(value) && value > 0)
129
+ resolvedRetain = Math.floor(value);
130
+ }
131
+ export function entryRetain() {
132
+ return resolvedRetain;
133
+ }
@@ -160,6 +160,14 @@ export interface ViewState {
160
160
  errorNoticed: boolean;
161
161
  /** The current run was aborted by the user (suppresses the failure guard). */
162
162
  aborted: boolean;
163
+ /**
164
+ * Entries removed by `transcriptRetain` (tui-render-performance L1).
165
+ *
166
+ * Rendered by `transcript-text` and reported by `/perf`; NEVER PERSISTED —
167
+ * `SavedSession` keeps `{ model, messages, entries, todos }` exactly as it is,
168
+ * so `/save` and `/resume` need no format change and old files load unchanged.
169
+ */
170
+ droppedEntries: number;
163
171
  }
164
172
  export declare function initialViewState(): ViewState;
165
173
  export type ViewAction = {
@@ -10,7 +10,9 @@
10
10
  * - `agent_end` after a turn that produced no `turn_end` and no error notice
11
11
  * synthesizes a generic error notice (a swallowed throw is never a no-op).
12
12
  */
13
+ import process from 'node:process';
13
14
  import { computeCost } from './usage.js';
15
+ import { ENTRY_LIMITS, appendBounded, entryRetain, trimEntries } from './entry-limits.js';
14
16
  /** Default auto-dismiss window for a toast. */
15
17
  export const DEFAULT_TOAST_TTL_MS = 2500;
16
18
  export function initialViewState() {
@@ -36,6 +38,7 @@ export function initialViewState() {
36
38
  turnProduced: false,
37
39
  errorNoticed: false,
38
40
  aborted: false,
41
+ droppedEntries: 0,
39
42
  };
40
43
  }
41
44
  // ---------------------------------------------------------------------------
@@ -201,6 +204,35 @@ function nextId(state) {
201
204
  function mapEntry(entries, id, fn) {
202
205
  return entries.map((e) => (e.id === id ? fn(e) : e));
203
206
  }
207
+ /**
208
+ * Append one entry and ring-trim the result (tui-render-performance L1).
209
+ *
210
+ * TRIMMING HAPPENS ONLY AT APPEND POINTS, and that is the whole reason this is a
211
+ * helper rather than a line in `viewReducer`. A mid-turn trim triggered by an
212
+ * UPDATE action could drop the entry `streamingId` / `teamEntryId` /
213
+ * `todoEntryId` points at while it is still being written to; appending is the
214
+ * only moment at which the tail is by definition the newest thing.
215
+ *
216
+ * I-L1-1 — the trim must never drop a live target. With `retain >= 200` and a
217
+ * trim that only ever removes from the head this is structurally true, but a
218
+ * future clamp change would break it SILENTLY (the `mapEntry` calls would simply
219
+ * stop matching), so development builds assert it.
220
+ */
221
+ function appendEntry(state, entries, entry) {
222
+ const trimmed = trimEntries([...entries, entry], entryRetain());
223
+ if (trimmed.dropped > 0 && process.env.NODE_ENV !== 'production') {
224
+ const live = [state.streamingId, state.teamEntryId, state.todoEntryId].filter((id) => typeof id === 'string');
225
+ for (const id of live) {
226
+ if (!trimmed.entries.some((e) => e.id === id)) {
227
+ throw new Error(`trimEntries dropped a live entry (${id}) - see I-L1-1`);
228
+ }
229
+ }
230
+ }
231
+ return {
232
+ entries: trimmed.entries,
233
+ droppedEntries: state.droppedEntries + trimmed.dropped,
234
+ };
235
+ }
204
236
  function findToolEntryId(state, toolCallId) {
205
237
  for (let i = state.entries.length - 1; i >= 0; i -= 1) {
206
238
  const e = state.entries[i];
@@ -217,7 +249,7 @@ export function viewReducer(state, action) {
217
249
  return {
218
250
  ...state,
219
251
  seq,
220
- entries: [...state.entries, entry],
252
+ ...appendEntry(state, state.entries, entry),
221
253
  streamingId: undefined,
222
254
  // A new turn gets its own card, in that turn's position in the
223
255
  // transcript. The PREVIOUS card stays exactly where it is — it is
@@ -246,7 +278,7 @@ export function viewReducer(state, action) {
246
278
  thinkingOpen: false,
247
279
  streaming: true,
248
280
  };
249
- return { ...state, seq, entries: [...state.entries, entry], streamingId: id };
281
+ return { ...state, seq, ...appendEntry(state, state.entries, entry), streamingId: id };
250
282
  }
251
283
  case 'thinkingStart': {
252
284
  if (!state.streamingId)
@@ -262,7 +294,7 @@ export function viewReducer(state, action) {
262
294
  return {
263
295
  ...state,
264
296
  entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
265
- ? { ...e, thinking: (e.thinking ?? '') + action.delta }
297
+ ? { ...e, thinking: appendBounded(e.thinking ?? '', action.delta, ENTRY_LIMITS.thinking) }
266
298
  : e),
267
299
  };
268
300
  }
@@ -271,7 +303,9 @@ export function viewReducer(state, action) {
271
303
  return state;
272
304
  return {
273
305
  ...state,
274
- entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, text: e.text + action.delta } : e),
306
+ entries: mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant'
307
+ ? { ...e, text: appendBounded(e.text, action.delta, ENTRY_LIMITS.text) }
308
+ : e),
275
309
  };
276
310
  }
277
311
  case 'toolCallStart': {
@@ -285,7 +319,7 @@ export function viewReducer(state, action) {
285
319
  argsRaw: '',
286
320
  status: 'pending',
287
321
  };
288
- return { ...state, seq, entries: [...state.entries, entry] };
322
+ return { ...state, seq, ...appendEntry(state, state.entries, entry) };
289
323
  }
290
324
  case 'toolCallDelta': {
291
325
  const id = findToolEntryId(state, action.toolCallId);
@@ -293,7 +327,9 @@ export function viewReducer(state, action) {
293
327
  return state;
294
328
  return {
295
329
  ...state,
296
- entries: mapEntry(state.entries, id, (e) => e.kind === 'tool' ? { ...e, argsRaw: e.argsRaw + action.argsDelta } : e),
330
+ entries: mapEntry(state.entries, id, (e) => e.kind === 'tool'
331
+ ? { ...e, argsRaw: appendBounded(e.argsRaw, action.argsDelta, ENTRY_LIMITS.argsRaw) }
332
+ : e),
297
333
  };
298
334
  }
299
335
  case 'toolCallEnd': {
@@ -358,22 +394,27 @@ export function viewReducer(state, action) {
358
394
  ? mapEntry(state.entries, state.streamingId, (e) => e.kind === 'assistant' ? { ...e, streaming: false } : e)
359
395
  : state.entries;
360
396
  let seq = state.seq;
397
+ let droppedEntries = state.droppedEntries;
361
398
  // Silent-failure guard (R2): a swallowed throw shows nothing otherwise.
362
399
  // A user abort is an expected empty run, not a failure — skip the guard.
363
400
  if (!state.turnProduced && !state.errorNoticed && !state.aborted) {
364
401
  const next = nextId({ ...state, seq });
365
402
  seq = next.seq;
366
- entries = [
367
- ...entries,
368
- { id: next.id, kind: 'notice', level: 'error', text: GENERIC_RUN_FAILURE },
369
- ];
403
+ const appended = appendEntry(state, entries, {
404
+ id: next.id,
405
+ kind: 'notice',
406
+ level: 'error',
407
+ text: GENERIC_RUN_FAILURE,
408
+ });
409
+ entries = appended.entries;
410
+ droppedEntries = appended.droppedEntries;
370
411
  }
371
412
  // The card may now reach `<Static>` (C-5 / I-6): the turn that owns it is
372
413
  // over, so nothing will rewrite it.
373
414
  if (state.todoEntryId) {
374
415
  entries = mapEntry(entries, state.todoEntryId, (e) => e.kind === 'todo' ? { ...e, live: false } : e);
375
416
  }
376
- return { ...state, entries, seq, status: 'idle', streamingId: undefined };
417
+ return { ...state, entries, seq, droppedEntries, status: 'idle', streamingId: undefined };
377
418
  }
378
419
  case 'notice': {
379
420
  const { id, seq } = nextId(state);
@@ -381,7 +422,7 @@ export function viewReducer(state, action) {
381
422
  return {
382
423
  ...state,
383
424
  seq,
384
- entries: [...state.entries, entry],
425
+ ...appendEntry(state, state.entries, entry),
385
426
  errorNoticed: action.level === 'error' ? true : state.errorNoticed,
386
427
  };
387
428
  }
@@ -403,6 +444,11 @@ export function viewReducer(state, action) {
403
444
  return {
404
445
  ...state,
405
446
  entries: [],
447
+ // The count describes what the RETAIN RING removed from the visible
448
+ // transcript. `/clear` empties it by intent, so carrying the number
449
+ // over would make the exported transcript claim a loss that did not
450
+ // happen.
451
+ droppedEntries: 0,
406
452
  streamingId: undefined,
407
453
  expandedToolIds: {},
408
454
  teamEntryId: undefined,
@@ -416,6 +462,7 @@ export function viewReducer(state, action) {
416
462
  return {
417
463
  ...state,
418
464
  entries: [],
465
+ droppedEntries: 0,
419
466
  streamingId: undefined,
420
467
  expandedToolIds: {},
421
468
  contextTokens: 0,
@@ -481,7 +528,7 @@ export function viewReducer(state, action) {
481
528
  return {
482
529
  ...state,
483
530
  seq,
484
- entries: [...state.entries, entry],
531
+ ...appendEntry(state, state.entries, entry),
485
532
  teamEntryId: id,
486
533
  team: {
487
534
  dispatchId: action.dispatchId,
@@ -558,7 +605,7 @@ export function viewReducer(state, action) {
558
605
  ...state,
559
606
  seq,
560
607
  todos: action.snapshot,
561
- entries: [...state.entries, entry(id)],
608
+ ...appendEntry(state, state.entries, entry(id)),
562
609
  todoEntryId: id,
563
610
  };
564
611
  }
@@ -572,9 +619,14 @@ export function viewReducer(state, action) {
572
619
  const n = Number.parseInt(e.id.replace(/^e/, ''), 10);
573
620
  return Number.isFinite(n) ? Math.max(acc, n) : acc;
574
621
  }, state.seq);
622
+ // A session file can be longer than the retain ring; trimming it here is
623
+ // the one append-free place the bound still has to hold, and the count
624
+ // starts from this restore rather than inheriting the previous session's.
625
+ const restored = trimEntries([...action.entries], entryRetain());
575
626
  return {
576
627
  ...state,
577
- entries: [...action.entries],
628
+ entries: restored.entries,
629
+ droppedEntries: restored.dropped,
578
630
  seq: maxSeq,
579
631
  teamEntryId: undefined,
580
632
  // As for `team`: the restored cards are history and no turn owns them.