@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/dist/cli.js CHANGED
@@ -27,7 +27,7 @@ import { LOG_CONFIG_SET_KEYS, TEAM_CONFIG_SET_KEYS, applyLogConfigSet, applyTeam
27
27
  import { attachAgentEvents, attachTeamEvents, installLogging, setScreenRestore, setSignalTerminator, } from './logging/install.js';
28
28
  import { getLogger } from './logging/logger.js';
29
29
  import { runLogsCommand } from './logging/cli-commands.js';
30
- import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, parseMaxTokensInput, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SKILLS_CONFIG, } from './config/schema.js';
30
+ import { ADAPTER_PROVIDERS, clampDensity, clampTheme, clampThinkingLevel, clampAskRounds, clampHumanTimeout, clampSkillsIntegrity, clampSkillsToolPolicy, clampMaxRenderInterval, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAdapterProvider, parseMaxTokensInput, DEFAULT_MAX_RENDER_INTERVAL_MS, DEFAULT_MAX_TOKENS, DEFAULT_PLAN_HUMAN_TIMEOUT_MS, DEFAULT_PLAN_MAX_ASK_ROUNDS, DEFAULT_SKILLS_CONFIG, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from './config/schema.js';
31
31
  import { makeGetApiKey } from './config/load.js';
32
32
  import { AgentController, DENY_ALL_APPROVAL } from './agent/controller.js';
33
33
  import { runSkillsCommand } from './skills/cli-commands.js';
@@ -102,6 +102,13 @@ function toFlags(opts) {
102
102
  logLevel: opts.logLevel ?? (opts.verbose ? 'debug' : undefined),
103
103
  logFile: opts.logFile,
104
104
  logDir: opts.logDir,
105
+ // Site three of three for the render-performance flags. A flag needs the
106
+ // `.option()` declaration below, a field on `CliFlags`, AND a line here;
107
+ // miss this one and it is inert with no compile error and no runtime error
108
+ // (`mouse` P1-2, `team` P1-4, `todo` C-9 -- four features, same trap).
109
+ transcriptRetain: opts.transcriptRetain,
110
+ renderGovernor: opts.renderGovernor,
111
+ maxRenderInterval: opts.maxRenderInterval,
105
112
  };
106
113
  }
107
114
  // ---------------------------------------------------------------------------
@@ -239,6 +246,10 @@ function runInteractive(flags, extras = {}) {
239
246
  provider: snapshot.provider,
240
247
  model: snapshot.model,
241
248
  elapsedMs: Date.now() - snapshot.startedAt,
249
+ // Names what the retain ring removed, so a long session's replay never
250
+ // starts mid-conversation without saying why (tui-render-performance
251
+ // L1 / K-6).
252
+ ...(snapshot.droppedEntries ? { droppedEntries: snapshot.droppedEntries } : {}),
242
253
  }));
243
254
  };
244
255
  if (mode === 'fullscreen') {
@@ -393,6 +404,12 @@ const CONFIG_SET_KEYS = new Set([
393
404
  'fullscreen',
394
405
  'exitTranscript',
395
406
  'transcriptWindow',
407
+ // tui-render-performance L1 / L4. Listed here AND cased in the switch
408
+ // below: a key present here but absent there falls through, writes
409
+ // nothing, and still prints `Set <key> = <value>` (P1-2).
410
+ 'transcriptRetain',
411
+ 'renderGovernor',
412
+ 'maxRenderIntervalMs',
396
413
  // Without these two, `density` and `hints` would be settable only as one-shot
397
414
  // flags and never persist -- inconsistent with every other config field.
398
415
  'density',
@@ -509,7 +526,16 @@ function runConfigSet(key, value) {
509
526
  patch.exitTranscript = value === 'true' || value === '1';
510
527
  break;
511
528
  case 'transcriptWindow':
512
- patch.transcriptWindow = clampTranscriptWindow(value, 300);
529
+ patch.transcriptWindow = clampTranscriptWindow(value, DEFAULT_TRANSCRIPT_WINDOW);
530
+ break;
531
+ case 'transcriptRetain':
532
+ patch.transcriptRetain = clampTranscriptRetain(value, DEFAULT_TRANSCRIPT_RETAIN);
533
+ break;
534
+ case 'renderGovernor':
535
+ patch.renderGovernor = value === 'true' || value === '1';
536
+ break;
537
+ case 'maxRenderIntervalMs':
538
+ patch.maxRenderIntervalMs = clampMaxRenderInterval(value, DEFAULT_MAX_RENDER_INTERVAL_MS);
513
539
  break;
514
540
  case 'density':
515
541
  patch.density = clampDensity(value, 'comfortable');
@@ -654,6 +680,14 @@ function buildProgram() {
654
680
  // No short form: `-v` is already `--version`.
655
681
  .option('--verbose', 'Shorthand for --log-level debug')
656
682
  .option('--log-dir <dir>', 'Absolute log directory for this run')
683
+ // Render performance (tui-render-performance §5.2). `--render-governor` is
684
+ // declared BEFORE its negative form for the tri-state reason recorded four
685
+ // times above: `renderGovernor` is persisted, so negative-only would default
686
+ // `opts.renderGovernor` to `true` and silently overwrite a stored `false`.
687
+ .option('--transcript-retain <n>', 'Entries kept in memory (200-20000, default 1000)')
688
+ .option('--render-governor', 'Adapt the frame interval under load (the default)')
689
+ .option('--no-render-governor', 'Pin the frame interval at 33ms however expensive a frame is')
690
+ .option('--max-render-interval <ms>', 'Governor ceiling in ms (33-1000, default 320)')
657
691
  .option('--no-color', 'Disable ANSI color')
658
692
  .option('--quiet', '(print mode) suppress tool/usage lines on stderr')
659
693
  .action(async (prompt, opts) => {
@@ -15,6 +15,7 @@ import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
15
15
  import { loadConfig } from '../config/load.js';
16
16
  import { loadSession, resolveSessionPath, saveSession } from '../session/persist.js';
17
17
  import { getLogger } from '../logging/logger.js';
18
+ import { perfCommand } from './perf.js';
18
19
  const COMMANDS = [
19
20
  {
20
21
  name: 'help',
@@ -535,6 +536,10 @@ const COMMANDS = [
535
536
  ctx.toast('success', `Reloaded config: ${config.provider}:${config.model}.`);
536
537
  },
537
538
  },
539
+ // Lives in its own module because it is the ONLY built-in that reads the
540
+ // render layer, and threading `ui/render-cache.js` into this file would make
541
+ // every other command's import graph pull the renderer in with it.
542
+ perfCommand,
538
543
  {
539
544
  name: 'exit',
540
545
  aliases: ['quit'],
@@ -0,0 +1,45 @@
1
+ /**
2
+ * `/perf` — what the renderer is actually doing (tui-render-performance §5.4).
3
+ *
4
+ * SILENT DEGRADATION IS THE ONE THING A "robust" CLAIM CANNOT AFFORD. The
5
+ * governor makes the stream chunkier under load, the retain ring drops old
6
+ * entries, and virtualisation mounts a fraction of the transcript. Every one of
7
+ * those is invisible by design, so there has to be one place that names them.
8
+ *
9
+ * The snapshot crosses the React boundary through a module-level single slot,
10
+ * the shape `ui/exit-snapshot.ts` already uses and for the same reason: a
11
+ * command runs outside the render pass and has no other way to reach `App`'s
12
+ * refs.
13
+ *
14
+ * READ-ONLY AND ALLOCATION-LIGHT. It renders as a NOTICE ENTRY rather than a
15
+ * live panel, because a performance readout that costs a repaint per second to
16
+ * display would be self-defeating.
17
+ */
18
+ import type { SlashCommand } from './registry.js';
19
+ export interface PerfSnapshot {
20
+ /** Governor rung, its resolved interval, and the last measured commit cost. */
21
+ rung: number;
22
+ intervalMs: number;
23
+ lastCommitMs: number;
24
+ governorEnabled: boolean;
25
+ /** Transcript accounting. */
26
+ totalEntries: number;
27
+ retain: number;
28
+ droppedEntries: number;
29
+ mountedEntries: number;
30
+ /** Height cache. */
31
+ heightsMeasured: number;
32
+ heightsEstimated: number;
33
+ cols: number;
34
+ /** Geometry. */
35
+ mode: 'fullscreen' | 'inline';
36
+ viewportRows: number;
37
+ offset: number;
38
+ }
39
+ /** Called by `App`. Passing `null` detaches. */
40
+ export declare function setPerfSnapshotProvider(fn: (() => PerfSnapshot | null) | null): void;
41
+ export declare function readPerfSnapshot(): PerfSnapshot | null;
42
+ export declare function setPerfResetHook(fn: (() => void) | null): void;
43
+ /** Format a snapshot as the five-line block documented in §5.4. */
44
+ export declare function formatPerfReport(snap: PerfSnapshot): string;
45
+ export declare const perfCommand: SlashCommand;
@@ -0,0 +1,78 @@
1
+ /**
2
+ * `/perf` — what the renderer is actually doing (tui-render-performance §5.4).
3
+ *
4
+ * SILENT DEGRADATION IS THE ONE THING A "robust" CLAIM CANNOT AFFORD. The
5
+ * governor makes the stream chunkier under load, the retain ring drops old
6
+ * entries, and virtualisation mounts a fraction of the transcript. Every one of
7
+ * those is invisible by design, so there has to be one place that names them.
8
+ *
9
+ * The snapshot crosses the React boundary through a module-level single slot,
10
+ * the shape `ui/exit-snapshot.ts` already uses and for the same reason: a
11
+ * command runs outside the render pass and has no other way to reach `App`'s
12
+ * refs.
13
+ *
14
+ * READ-ONLY AND ALLOCATION-LIGHT. It renders as a NOTICE ENTRY rather than a
15
+ * live panel, because a performance readout that costs a repaint per second to
16
+ * display would be self-defeating.
17
+ */
18
+ import { clearRenderCaches, renderCacheStats } from '../ui/render-cache.js';
19
+ /** Cleared on unmount, so `/perf` in a torn-down tree says so instead of lying. */
20
+ let provider = null;
21
+ /** Called by `App`. Passing `null` detaches. */
22
+ export function setPerfSnapshotProvider(fn) {
23
+ provider = fn;
24
+ }
25
+ export function readPerfSnapshot() {
26
+ return provider ? provider() : null;
27
+ }
28
+ /** Reset hooks the App registers alongside the snapshot provider. */
29
+ let resetGovernor = null;
30
+ export function setPerfResetHook(fn) {
31
+ resetGovernor = fn;
32
+ }
33
+ function mb(bytes) {
34
+ return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
35
+ }
36
+ /** Format a snapshot as the five-line block documented in §5.4. */
37
+ export function formatPerfReport(snap) {
38
+ const caches = renderCacheStats();
39
+ const eco = snap.rung > 0 ? ' - eco' : '';
40
+ const governor = snap.governorEnabled ? '' : ' - governor off';
41
+ return [
42
+ `render rung ${snap.rung} - interval ${snap.intervalMs}ms - last commit ` +
43
+ `${Math.round(snap.lastCommitMs)}ms${eco}${governor}`,
44
+ `transcript ${snap.totalEntries} entries - ${snap.retain} retained - ` +
45
+ `${snap.droppedEntries} dropped - ${snap.mountedEntries} mounted`,
46
+ `heights ${snap.heightsMeasured} cached - ${snap.heightsEstimated} estimated - ` +
47
+ `cols ${snap.cols}`,
48
+ `caches md ${caches.markdownEntries} (${mb(caches.markdownBytes)}) - ` +
49
+ `hl ${caches.highlightEntries} (${mb(caches.highlightBytes)}) - ` +
50
+ `lines ${caches.lineEntries}`,
51
+ `mode ${snap.mode} - viewport ${snap.viewportRows} rows - offset ${snap.offset}`,
52
+ ].join('\n');
53
+ }
54
+ export const perfCommand = {
55
+ name: 'perf',
56
+ description: 'Render statistics (/perf reset clears the caches and the governor)',
57
+ run: (ctx) => {
58
+ const arg = ctx.args.trim().toLowerCase();
59
+ if (arg === 'reset') {
60
+ clearRenderCaches();
61
+ resetGovernor?.();
62
+ ctx.toast('success', 'Render caches cleared, governor reset.');
63
+ return;
64
+ }
65
+ if (arg.length > 0) {
66
+ ctx.notify('warn', `Unknown argument "${arg}" - use /perf [reset].`);
67
+ return;
68
+ }
69
+ const snap = readPerfSnapshot();
70
+ if (!snap) {
71
+ // Reachable from a headless / detached tree. Saying so beats printing a
72
+ // block of zeroes that reads like a renderer doing nothing.
73
+ ctx.notify('info', 'No render statistics: the TUI is not mounted.');
74
+ return;
75
+ }
76
+ ctx.notify('info', formatPerfReport(snap));
77
+ },
78
+ };
@@ -7,7 +7,9 @@
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
8
  * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
9
9
  * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
10
- * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
+ * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR,
11
+ * ARAGON_TRANSCRIPT_RETAIN, ARAGON_RENDER_GOVERNOR,
12
+ * ARAGON_MAX_RENDER_INTERVAL_MS.
11
13
  *
12
14
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
13
15
  * evaluated, which is long before `loadDotenv()` runs, so a `.env` can never
@@ -7,7 +7,9 @@
7
7
  * - Overrides: ARAGON_PROVIDER, ARAGON_MODEL, ARAGON_BASE_URL, ARAGON_THINKING,
8
8
  * ARAGON_MAX_TOKENS (a number, or `auto` / `0` for the per-model ceiling),
9
9
  * ARAGON_THEME, ARAGON_MOUSE, ARAGON_PLAN, ARAGON_TEAM,
10
- * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR.
10
+ * ARAGON_TEAM_MAX, ARAGON_LOG_LEVEL, ARAGON_LOG_FILE, ARAGON_LOG_DIR,
11
+ * ARAGON_TRANSCRIPT_RETAIN, ARAGON_RENDER_GOVERNOR,
12
+ * ARAGON_MAX_RENDER_INTERVAL_MS.
11
13
  *
12
14
  * NOT here: `ARAGON_HOME`. It is read when `config/app-paths.ts` is first
13
15
  * evaluated, which is long before `loadDotenv()` runs, so a `.env` can never
@@ -177,6 +179,34 @@ export function readEnvConfig() {
177
179
  if (Object.keys(section).length > 0)
178
180
  partial.todo = section;
179
181
  }
182
+ // Render bounds (tui-render-performance §5.3). Same channel story as
183
+ // ARAGON_FULLSCREEN: a container or a wrapper that owns argv can reach neither
184
+ // a flag nor a config file, and a session that freezes is exactly the case
185
+ // where the user needs a knob they can turn from the outside.
186
+ //
187
+ // ARAGON_TRANSCRIPT_RETAIN / ARAGON_MAX_RENDER_INTERVAL_MS are left ABSENT
188
+ // when unparseable rather than written as a default, so the config file can
189
+ // still win — the rule `ARAGON_TEAM_MAX` above records.
190
+ const retain = process.env.ARAGON_TRANSCRIPT_RETAIN?.trim();
191
+ if (retain !== undefined && retain.length > 0) {
192
+ const parsed = Number.parseInt(retain, 10);
193
+ if (Number.isFinite(parsed) && parsed > 0)
194
+ partial.transcriptRetain = parsed;
195
+ }
196
+ // Parsed by the POSITIVE list, exactly as ARAGON_FULLSCREEN / ARAGON_MOUSE /
197
+ // ARAGON_PLAN / ARAGON_TEAM / ARAGON_TODO are — NOT by `envBool` below, which
198
+ // uses a negative list. The two readers disagree on `ARAGON_RENDER_GOVERNOR=disable`.
199
+ const governor = process.env.ARAGON_RENDER_GOVERNOR?.trim().toLowerCase();
200
+ if (governor !== undefined && governor.length > 0) {
201
+ partial.renderGovernor =
202
+ governor === '1' || governor === 'true' || governor === 'on' || governor === 'yes';
203
+ }
204
+ const renderInterval = process.env.ARAGON_MAX_RENDER_INTERVAL_MS?.trim();
205
+ if (renderInterval !== undefined && renderInterval.length > 0) {
206
+ const parsed = Number.parseInt(renderInterval, 10);
207
+ if (Number.isFinite(parsed) && parsed > 0)
208
+ partial.maxRenderIntervalMs = parsed;
209
+ }
180
210
  const log = readEnvLogConfig();
181
211
  if (log)
182
212
  partial.log = log;
@@ -85,6 +85,20 @@ export interface CliFlags {
85
85
  logFile?: boolean;
86
86
  /** `--log-dir <dir>` — this run only. */
87
87
  logDir?: string;
88
+ /** `--transcript-retain <n>` — the `ViewState` ring cap for this run (L1). */
89
+ transcriptRetain?: string | number;
90
+ /**
91
+ * `--render-governor` ⇒ true, `--no-render-governor` ⇒ false, absent ⇒
92
+ * undefined (L4).
93
+ *
94
+ * DECLARED AS A PAIR for the reason `--todo-panel` records: `renderGovernor`
95
+ * is PERSISTED, so a lone `--no-render-governor` would make commander default
96
+ * `opts.renderGovernor` to `true` and silently overwrite a stored `false` on
97
+ * every run that passed no flag at all.
98
+ */
99
+ renderGovernor?: boolean;
100
+ /** `--max-render-interval <ms>` — the governor ceiling for this run (L4). */
101
+ maxRenderInterval?: string | number;
88
102
  }
89
103
  /**
90
104
  * Resolve the effective config by merging all layers, then derive the runtime
@@ -6,11 +6,12 @@
6
6
  * legitimately long single tool run can never be watchdog-killed.
7
7
  */
8
8
  import process from 'node:process';
9
- import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, } from './schema.js';
9
+ import { DEFAULT_CONFIG, IDLE_TIMEOUT_MARGIN_MS, clampAskRounds, clampDensity, clampHumanTimeout, clampLogConfig, clampSkillsConfig, clampTeamConfig, clampTodoConfig, clampTheme, clampThinkingLevel, clampMaxTokens, clampMaxRenderInterval, clampTranscriptRetain, clampTranscriptWindow, coercePositiveInt, isAutoToken, isSkillsToolPolicyMode, } from './schema.js';
10
10
  import { loadDotenv, readEnvConfig, readEnvDisabledSkills } from './env.js';
11
11
  import { readConfigFile } from './store.js';
12
12
  import { getConfigPath } from './app-paths.js';
13
13
  import { setHistoryEnabled } from './prompt-history.js';
14
+ import { setEntryRetain } from '../agent/entry-limits.js';
14
15
  import { readSubmitCount } from './ui-state.js';
15
16
  import { getLogger } from '../logging/logger.js';
16
17
  import { registerSecret, registerSecretsFrom } from '../logging/secret-registry.js';
@@ -120,6 +121,25 @@ function resolveTodoConfig(flags, env, file) {
120
121
  ...(flags.todoFollow !== undefined ? { followThrough: flags.todoFollow } : {}),
121
122
  });
122
123
  }
124
+ /**
125
+ * Resolve the adaptive render governor: flag › env › file › default `true`
126
+ * (tui-render-performance L4).
127
+ *
128
+ * Shaped like `resolveMouse` rather than like `resolveFullscreen`: there are no
129
+ * heuristics for an explicit `true` to override, so a persisted `true` is just a
130
+ * value and not an opinion that has to be told apart from silence. `!==
131
+ * undefined` on the flag is what makes `--no-render-governor` beat a config file
132
+ * that turned it on (P1-4's rule, one feature later).
133
+ */
134
+ function resolveRenderGovernor(flags, env, file) {
135
+ if (flags.renderGovernor !== undefined)
136
+ return flags.renderGovernor;
137
+ if (env.renderGovernor !== undefined)
138
+ return env.renderGovernor;
139
+ if (file.renderGovernor !== undefined)
140
+ return file.renderGovernor;
141
+ return DEFAULT_CONFIG.renderGovernor;
142
+ }
123
143
  /** Merge the `log` section across all four layers (defaults › file › env › flags). */
124
144
  function resolveLogConfig(flags, env, file) {
125
145
  const merged = clampLogConfig({
@@ -268,6 +288,23 @@ export function loadConfig(flags = {}) {
268
288
  // The prompt-history store is a module-level singleton, so the resolved value
269
289
  // has to be pushed into it — this is the ONLY place that happens.
270
290
  setHistoryEnabled(historyEnabled);
291
+ // Transcript bounds (tui-render-performance L1 / L3).
292
+ //
293
+ // `transcriptRetain` is RAISED TO `transcriptWindow` when the two conflict:
294
+ // retaining fewer entries than the window can scroll to would silently make
295
+ // part of the window unreachable, and the user would see a horizon they can
296
+ // never actually reach.
297
+ //
298
+ // The raise is REPORTED, not just performed (§5.1). Overriding a value the
299
+ // user typed and saying nothing is the one thing this feature is not allowed
300
+ // to add — every other adjustment it makes is announced somewhere, and a
301
+ // config key is the last place to start making exceptions.
302
+ const transcriptWindow = clampTranscriptWindow(pick(env.partial.transcriptWindow, file.transcriptWindow), DEFAULT_CONFIG.transcriptWindow);
303
+ const requestedRetain = clampTranscriptRetain(pick(flags.transcriptRetain, env.partial.transcriptRetain, file.transcriptRetain), DEFAULT_CONFIG.transcriptRetain);
304
+ const transcriptRetain = Math.max(transcriptWindow, requestedRetain);
305
+ // `viewReducer` is pure and takes no config, so the resolved ring cap reaches
306
+ // it through the same module-singleton channel `setHistoryEnabled` uses.
307
+ setEntryRetain(transcriptRetain);
271
308
  const config = {
272
309
  provider,
273
310
  model,
@@ -280,7 +317,11 @@ export function loadConfig(flags = {}) {
280
317
  exitTranscript: flags.exitTranscript !== undefined
281
318
  ? flags.exitTranscript
282
319
  : file.exitTranscript ?? DEFAULT_CONFIG.exitTranscript,
283
- transcriptWindow: clampTranscriptWindow(file.transcriptWindow, DEFAULT_CONFIG.transcriptWindow),
320
+ transcriptWindow,
321
+ transcriptRetain,
322
+ ...(requestedRetain < transcriptRetain ? { transcriptRetainRequested: requestedRetain } : {}),
323
+ renderGovernor: resolveRenderGovernor(flags, env.partial, file),
324
+ maxRenderIntervalMs: clampMaxRenderInterval(pick(flags.maxRenderInterval, env.partial.maxRenderIntervalMs, file.maxRenderIntervalMs), DEFAULT_CONFIG.maxRenderIntervalMs),
284
325
  confirmTools,
285
326
  toolTimeoutMs,
286
327
  idleTimeoutMs,
@@ -64,15 +64,40 @@ export declare const DEFAULT_IDLE_TIMEOUT_MS: number;
64
64
  */
65
65
  export declare const PROMPT_HISTORY_CAP = 100;
66
66
  /**
67
- * How many trailing entries the full-screen viewport renders (invariant I-3).
68
- * Yoga lays out every child even when `overflow: hidden` clips it, so the cost
69
- * of a long session is linear in the entry count regardless of visibility. The
70
- * reducer and `/save` keep the full history — this is a render-layer bound only.
67
+ * The SCROLL HORIZON: entries older than this are not reachable inside the app
68
+ * and render as the "N earlier entries collapsed" line instead (invariant I-3).
69
+ *
70
+ * It used to be a rendering budget as well, because yoga laid out every child
71
+ * even when `overflow: hidden` clipped it. The viewport is virtualised now
72
+ * (tui-render-performance L3) — off-screen entries are not mounted at all — so
73
+ * the horizon costs O(entries) of arithmetic rather than O(characters) of layout
74
+ * and the default rises 300 -> 1000 with a 20000 ceiling. An existing persisted
75
+ * `300` is still valid and still clamps; no config migration is required.
71
76
  */
72
- export declare const DEFAULT_TRANSCRIPT_WINDOW = 300;
77
+ export declare const DEFAULT_TRANSCRIPT_WINDOW = 1000;
73
78
  export declare const MIN_TRANSCRIPT_WINDOW = 50;
74
- export declare const MAX_TRANSCRIPT_WINDOW = 2000;
79
+ export declare const MAX_TRANSCRIPT_WINDOW = 20000;
75
80
  export declare function clampTranscriptWindow(v: unknown, fallback: number): number;
81
+ /**
82
+ * Entries kept in `ViewState` at all (tui-render-performance L1 / R5).
83
+ *
84
+ * A DIFFERENT BOUND FROM `transcriptWindow`, and the two must not be merged:
85
+ * the window is what the user can scroll to, this is what the process keeps in
86
+ * memory and what `/save` can export. Dropped entries are COUNTED and reported
87
+ * (`/perf`, and a leading line in the exported transcript) — never silent.
88
+ */
89
+ export declare const DEFAULT_TRANSCRIPT_RETAIN = 1000;
90
+ export declare const MIN_TRANSCRIPT_RETAIN = 200;
91
+ export declare const MAX_TRANSCRIPT_RETAIN = 20000;
92
+ export declare function clampTranscriptRetain(v: unknown, fallback: number): number;
93
+ /** Adaptive coalescing under load. On by default; `--no-render-governor` kills it. */
94
+ export declare const DEFAULT_RENDER_GOVERNOR = true;
95
+ /**
96
+ * Ceiling of the governor ladder, in ms. `33` flattens the ladder to a single
97
+ * rung, which is the pre-feature behaviour without needing the kill switch.
98
+ */
99
+ export declare const DEFAULT_MAX_RENDER_INTERVAL_MS = 320;
100
+ export declare function clampMaxRenderInterval(v: unknown, fallback: number): number;
76
101
  /** `ask_user` calls allowed per user turn. */
77
102
  export declare const DEFAULT_PLAN_MAX_ASK_ROUNDS = 4;
78
103
  /**
@@ -300,8 +325,14 @@ export interface PersistedConfig {
300
325
  fullscreen: boolean;
301
326
  /** Replay a plain-text session summary after leaving the alternate screen. */
302
327
  exitTranscript: boolean;
303
- /** Trailing entries the full-screen viewport renders (I-3). */
328
+ /** Entries reachable by scrolling; older ones show the "collapsed" line (I-3). */
304
329
  transcriptWindow: number;
330
+ /** Entries kept in `ViewState`; older ones are dropped and counted (L1). */
331
+ transcriptRetain: number;
332
+ /** Adaptive coalescing under load (L4). */
333
+ renderGovernor: boolean;
334
+ /** Ceiling of the governor ladder in ms; `33` flattens it (L4). */
335
+ maxRenderIntervalMs: number;
305
336
  confirmTools: boolean;
306
337
  toolTimeoutMs: number;
307
338
  idleTimeoutMs: number;
@@ -390,8 +421,30 @@ export interface CliConfig {
390
421
  fullscreen?: boolean;
391
422
  /** Replay a plain-text summary on exit (full-screen only — see §4.4). */
392
423
  exitTranscript: boolean;
393
- /** Trailing entries the full-screen viewport renders (I-3). */
424
+ /** Entries reachable by scrolling; older ones show the "collapsed" line (I-3). */
394
425
  transcriptWindow: number;
426
+ /**
427
+ * Entries kept in `ViewState` (L1).
428
+ *
429
+ * Resolved to be `>= transcriptWindow`: retaining fewer entries than the
430
+ * window can scroll to would silently make part of the window unreachable.
431
+ */
432
+ transcriptRetain: number;
433
+ /**
434
+ * What the user actually asked `transcriptRetain` to be, present ONLY when
435
+ * that value had to be raised (§5.1).
436
+ *
437
+ * A setting ignored without a word is the one failure mode this feature is
438
+ * not allowed to add: the eco chip, `/perf` and the dropped-entry line all
439
+ * exist so that nothing it does happens unannounced, and a config key
440
+ * overwritten in silence would be the exception. `undefined` means nothing
441
+ * was overridden, which is every default session.
442
+ */
443
+ transcriptRetainRequested?: number;
444
+ /** Adaptive coalescing under load (L4). */
445
+ renderGovernor: boolean;
446
+ /** Ceiling of the governor ladder in ms; `33` flattens it (L4). */
447
+ maxRenderIntervalMs: number;
395
448
  confirmTools: boolean;
396
449
  toolTimeoutMs: number;
397
450
  idleTimeoutMs: number;
@@ -98,18 +98,52 @@ export const DEFAULT_IDLE_TIMEOUT_MS = DEFAULT_TOOL_TIMEOUT_MS + IDLE_TIMEOUT_MA
98
98
  */
99
99
  export const PROMPT_HISTORY_CAP = 100;
100
100
  /**
101
- * How many trailing entries the full-screen viewport renders (invariant I-3).
102
- * Yoga lays out every child even when `overflow: hidden` clips it, so the cost
103
- * of a long session is linear in the entry count regardless of visibility. The
104
- * reducer and `/save` keep the full history — this is a render-layer bound only.
101
+ * The SCROLL HORIZON: entries older than this are not reachable inside the app
102
+ * and render as the "N earlier entries collapsed" line instead (invariant I-3).
103
+ *
104
+ * It used to be a rendering budget as well, because yoga laid out every child
105
+ * even when `overflow: hidden` clipped it. The viewport is virtualised now
106
+ * (tui-render-performance L3) — off-screen entries are not mounted at all — so
107
+ * the horizon costs O(entries) of arithmetic rather than O(characters) of layout
108
+ * and the default rises 300 -> 1000 with a 20000 ceiling. An existing persisted
109
+ * `300` is still valid and still clamps; no config migration is required.
105
110
  */
106
- export const DEFAULT_TRANSCRIPT_WINDOW = 300;
111
+ export const DEFAULT_TRANSCRIPT_WINDOW = 1000;
107
112
  export const MIN_TRANSCRIPT_WINDOW = 50;
108
- export const MAX_TRANSCRIPT_WINDOW = 2000;
113
+ export const MAX_TRANSCRIPT_WINDOW = 20_000;
109
114
  export function clampTranscriptWindow(v, fallback) {
110
115
  const n = coercePositiveInt(v, fallback);
111
116
  return Math.min(MAX_TRANSCRIPT_WINDOW, Math.max(MIN_TRANSCRIPT_WINDOW, n));
112
117
  }
118
+ /**
119
+ * Entries kept in `ViewState` at all (tui-render-performance L1 / R5).
120
+ *
121
+ * A DIFFERENT BOUND FROM `transcriptWindow`, and the two must not be merged:
122
+ * the window is what the user can scroll to, this is what the process keeps in
123
+ * memory and what `/save` can export. Dropped entries are COUNTED and reported
124
+ * (`/perf`, and a leading line in the exported transcript) — never silent.
125
+ */
126
+ export const DEFAULT_TRANSCRIPT_RETAIN = 1000;
127
+ export const MIN_TRANSCRIPT_RETAIN = 200;
128
+ export const MAX_TRANSCRIPT_RETAIN = 20_000;
129
+ export function clampTranscriptRetain(v, fallback) {
130
+ const n = coercePositiveInt(v, fallback);
131
+ return Math.min(MAX_TRANSCRIPT_RETAIN, Math.max(MIN_TRANSCRIPT_RETAIN, n));
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // Adaptive render governor (tui-render-performance L4 / R7)
135
+ // ---------------------------------------------------------------------------
136
+ /** Adaptive coalescing under load. On by default; `--no-render-governor` kills it. */
137
+ export const DEFAULT_RENDER_GOVERNOR = true;
138
+ /**
139
+ * Ceiling of the governor ladder, in ms. `33` flattens the ladder to a single
140
+ * rung, which is the pre-feature behaviour without needing the kill switch.
141
+ */
142
+ export const DEFAULT_MAX_RENDER_INTERVAL_MS = 320;
143
+ const RENDER_INTERVAL_RANGE = { min: 33, max: 1000 };
144
+ export function clampMaxRenderInterval(v, fallback) {
145
+ return clampInt(v, fallback, RENDER_INTERVAL_RANGE);
146
+ }
113
147
  // ---------------------------------------------------------------------------
114
148
  // Plan mode (plan-mode §4.4)
115
149
  //
@@ -360,6 +394,9 @@ export const DEFAULT_CONFIG = {
360
394
  fullscreen: true,
361
395
  exitTranscript: true,
362
396
  transcriptWindow: DEFAULT_TRANSCRIPT_WINDOW,
397
+ transcriptRetain: DEFAULT_TRANSCRIPT_RETAIN,
398
+ renderGovernor: DEFAULT_RENDER_GOVERNOR,
399
+ maxRenderIntervalMs: DEFAULT_MAX_RENDER_INTERVAL_MS,
363
400
  confirmTools: false,
364
401
  toolTimeoutMs: DEFAULT_TOOL_TIMEOUT_MS,
365
402
  idleTimeoutMs: DEFAULT_IDLE_TIMEOUT_MS,