@aragon-agent/cli 0.5.4 → 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 (100) hide show
  1. package/CHANGELOG.md +747 -577
  2. package/README.md +1163 -995
  3. package/dist/agent/controller.d.ts +71 -1
  4. package/dist/agent/controller.js +135 -1
  5. package/dist/agent/entry-limits.d.ts +65 -0
  6. package/dist/agent/entry-limits.js +133 -0
  7. package/dist/agent/headless.d.ts +29 -0
  8. package/dist/agent/headless.js +86 -1
  9. package/dist/agent/reducer.d.ts +53 -0
  10. package/dist/agent/reducer.js +145 -14
  11. package/dist/agent/system-prompt.d.ts +8 -0
  12. package/dist/agent/system-prompt.js +2 -0
  13. package/dist/cli.js +76 -3
  14. package/dist/commands/builtins.js +171 -0
  15. package/dist/commands/perf.d.ts +45 -0
  16. package/dist/commands/perf.js +78 -0
  17. package/dist/commands/registry.d.ts +27 -1
  18. package/dist/config/env.d.ts +3 -1
  19. package/dist/config/env.js +74 -1
  20. package/dist/config/load.d.ts +41 -0
  21. package/dist/config/load.js +67 -2
  22. package/dist/config/schema.d.ts +108 -8
  23. package/dist/config/schema.js +81 -6
  24. package/dist/config/store.js +10 -1
  25. package/dist/session/persist.d.ts +19 -1
  26. package/dist/session/persist.js +21 -2
  27. package/dist/team/subagent.js +11 -0
  28. package/dist/todo/follow-through.d.ts +117 -0
  29. package/dist/todo/follow-through.js +186 -0
  30. package/dist/todo/limits.d.ts +114 -0
  31. package/dist/todo/limits.js +114 -0
  32. package/dist/todo/normalize.d.ts +33 -0
  33. package/dist/todo/normalize.js +117 -0
  34. package/dist/todo/panel-rows.d.ts +53 -0
  35. package/dist/todo/panel-rows.js +103 -0
  36. package/dist/todo/prompt.d.ts +54 -0
  37. package/dist/todo/prompt.js +76 -0
  38. package/dist/todo/store.d.ts +86 -0
  39. package/dist/todo/store.js +183 -0
  40. package/dist/todo/todo-tool.d.ts +38 -0
  41. package/dist/todo/todo-tool.js +134 -0
  42. package/dist/todo/types.d.ts +62 -0
  43. package/dist/todo/types.js +12 -0
  44. package/dist/tools/index.d.ts +16 -2
  45. package/dist/tools/index.js +13 -0
  46. package/dist/ui/App.js +328 -14
  47. package/dist/ui/Markdown.d.ts +7 -1
  48. package/dist/ui/Markdown.js +15 -48
  49. package/dist/ui/StatusBar.d.ts +31 -0
  50. package/dist/ui/StatusBar.js +7 -2
  51. package/dist/ui/TodoPanel.d.ts +48 -0
  52. package/dist/ui/TodoPanel.js +74 -0
  53. package/dist/ui/TodoStrip.d.ts +36 -0
  54. package/dist/ui/TodoStrip.js +32 -0
  55. package/dist/ui/Transcript.d.ts +56 -4
  56. package/dist/ui/Transcript.js +123 -11
  57. package/dist/ui/entries/AssistantEntry.d.ts +18 -1
  58. package/dist/ui/entries/AssistantEntry.js +33 -3
  59. package/dist/ui/entries/TeamCard.d.ts +9 -1
  60. package/dist/ui/entries/TeamCard.js +22 -1
  61. package/dist/ui/entries/TodoCard.d.ts +39 -0
  62. package/dist/ui/entries/TodoCard.js +57 -0
  63. package/dist/ui/entries/ToolCard.d.ts +13 -1
  64. package/dist/ui/entries/ToolCard.js +39 -5
  65. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  66. package/dist/ui/entries/UserEntry.d.ts +9 -1
  67. package/dist/ui/entries/UserEntry.js +17 -1
  68. package/dist/ui/exit-snapshot.d.ts +7 -0
  69. package/dist/ui/glyphs.d.ts +12 -0
  70. package/dist/ui/glyphs.js +8 -0
  71. package/dist/ui/layout/AppShell.d.ts +34 -1
  72. package/dist/ui/layout/AppShell.js +3 -3
  73. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  74. package/dist/ui/layout/MeasuredEntry.js +30 -0
  75. package/dist/ui/layout/ScrollViewport.d.ts +13 -1
  76. package/dist/ui/layout/ScrollViewport.js +10 -4
  77. package/dist/ui/layout/rail.d.ts +56 -0
  78. package/dist/ui/layout/rail.js +68 -0
  79. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  80. package/dist/ui/layout/viewport-geometry.js +27 -0
  81. package/dist/ui/layout/virtual-window.d.ts +100 -0
  82. package/dist/ui/layout/virtual-window.js +204 -0
  83. package/dist/ui/live-clamp.d.ts +42 -0
  84. package/dist/ui/live-clamp.js +74 -0
  85. package/dist/ui/markdown-blocks.d.ts +25 -1
  86. package/dist/ui/markdown-blocks.js +43 -0
  87. package/dist/ui/overlays/HelpOverlay.js +4 -0
  88. package/dist/ui/render-cache.d.ts +81 -0
  89. package/dist/ui/render-cache.js +195 -0
  90. package/dist/ui/render-governor.d.ts +54 -0
  91. package/dist/ui/render-governor.js +78 -0
  92. package/dist/ui/transcript-text.d.ts +11 -0
  93. package/dist/ui/transcript-text.js +5 -0
  94. package/dist/ui/use-height-store.d.ts +31 -0
  95. package/dist/ui/use-height-store.js +76 -0
  96. package/dist/ui/use-render-governor.d.ts +39 -0
  97. package/dist/ui/use-render-governor.js +57 -0
  98. package/dist/ui/use-startup-notices.d.ts +9 -1
  99. package/dist/ui/use-startup-notices.js +35 -1
  100. package/package.json +3 -2
package/dist/ui/App.js CHANGED
@@ -11,7 +11,7 @@ import { Box, Text, useApp, useInput, useStdout } from 'ink';
11
11
  import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
12
12
  import { mergeDeltas } from '../agent/coalesce.js';
13
13
  import { computeCost } from '../agent/usage.js';
14
- import { parseMaxTokensInput, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
14
+ import { parseMaxTokensInput, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
15
15
  import { updatePersistedConfig } from '../config/store.js';
16
16
  import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
17
17
  import { bumpSubmitCount, setMouseNoticeSeen } from '../config/ui-state.js';
@@ -29,10 +29,18 @@ import { PromptInput } from './PromptInput.js';
29
29
  import { Composer } from './Composer.js';
30
30
  import { StatusBar } from './StatusBar.js';
31
31
  import { TeamPanel } from './TeamPanel.js';
32
+ import { TodoPanel } from './TodoPanel.js';
33
+ import { TodoStrip } from './TodoStrip.js';
32
34
  import { ToastStack } from './ToastStack.js';
33
35
  import { AppShell } from './layout/AppShell.js';
34
36
  import { ScrollViewport } from './layout/ScrollViewport.js';
37
+ import { todoRailRows, todoRailWidth } from './layout/rail.js';
38
+ import { TODO_LIMITS } from '../todo/limits.js';
39
+ import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
35
40
  import { useTerminalSize } from './layout/useTerminalSize.js';
41
+ import { useHeightStore } from './use-height-store.js';
42
+ import { useRenderGovernor } from './use-render-governor.js';
43
+ import { setPerfResetHook, setPerfSnapshotProvider } from '../commands/perf.js';
36
44
  import { frameHeight, MIN_FULLSCREEN_ROWS } from './layout/frame.js';
37
45
  import { HINT_MIN_ROWS, viewportRows as computeViewportRows } from './layout/budget.js';
38
46
  import { UNMEASURED_GEOMETRY } from './layout/regions.js';
@@ -85,13 +93,23 @@ export function resolveSettingsMaxTokens(raw, previous) {
85
93
  toast: ['warn', 'Max tokens must be a number or "auto".'],
86
94
  };
87
95
  }
88
- /** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
89
- const COALESCE_MS = 33;
90
96
  export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, geometryOverride, }) {
91
97
  const { exit } = useApp();
92
98
  const { stdout } = useStdout();
93
99
  const fullscreen = mode === 'fullscreen';
94
100
  const { rows, cols } = useTerminalSize();
101
+ // --- Render governor (tui-render-performance L4). ------------------------
102
+ //
103
+ // FIRST, BEFORE ANY OTHER WORK IN THIS BODY. The hook stamps
104
+ // `performance.now()` during render and reads it again in a layout effect, so
105
+ // the span it measures is only the whole commit if nothing precedes it here.
106
+ const cfg = controller.getConfig();
107
+ const governor = useRenderGovernor({
108
+ enabled: cfg.renderGovernor,
109
+ maxIntervalMs: cfg.maxRenderIntervalMs,
110
+ });
111
+ /** The transcript height cache, owned here and read by `TranscriptList` (L3). */
112
+ const heights = useHeightStore();
95
113
  const [state, dispatch] = useReducer(viewReducer, undefined, initialViewState);
96
114
  const [elapsedMs, setElapsedMs] = useState(0);
97
115
  const [confirmState, setConfirmState] = useState(null);
@@ -119,7 +137,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
119
137
  // the completion list are memoized against it, so a skill installed mid-session
120
138
  // gets its `/<name>` command and its autocomplete entry immediately.
121
139
  const [skillsNonce, setSkillsNonce] = useState(0);
122
- const cfg = controller.getConfig();
123
140
  const caps = useMemo(() => {
124
141
  const detected = detectCapabilities(process.env, stdout);
125
142
  return {
@@ -144,6 +161,80 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
144
161
  const pendingHuman = useRef(new Set());
145
162
  /** Written by `AppShell`'s layout effect, read at wheel-event time (§4.5). */
146
163
  const geometryRef = useRef(UNMEASURED_GEOMETRY);
164
+ /**
165
+ * What `/perf` reports. Assigned during render, exactly like `stateRef` above,
166
+ * because a slash command runs outside the render pass and a value captured in
167
+ * an effect closure would be one commit stale on every readout.
168
+ */
169
+ const perfRef = useRef(null);
170
+ /**
171
+ * How many entries `TranscriptList` actually mounted this frame.
172
+ *
173
+ * A ref written during the child's render, not state: this is the number
174
+ * `render-budget.test.tsx` gates on (AC-1 / AC-9), and turning it into state
175
+ * would make reading the render budget cost a render.
176
+ */
177
+ const mountedCount = useRef(0);
178
+ // --- Follow-through (todo-plan-followthrough §3.4). ---------------------
179
+ //
180
+ // THREE REFS AND NO VIEW STATE, DELIBERATELY (§5.2). Every field added to
181
+ // `ViewState` is a field `restoreEntries`, `/clear`, `/reset` and the session
182
+ // round-trip each have to have an opinion about, and none of them has an
183
+ // opinion about a 3-second timer.
184
+ const budgetRef = useRef(emptyBudget());
185
+ const followTimer = useRef(null);
186
+ /** True while the run in flight was started by the grace timer, not the user. */
187
+ const autoContinuationRef = useRef(false);
188
+ /**
189
+ * WHY THE END REASON TRAVELS IN A REF THIS FEATURE OWNS (C-11 / D-17).
190
+ *
191
+ * `ViewState.aborted` / `.errorNoticed` are the DEFINITION of the end reason —
192
+ * `runEnd` reads exactly that pair for its own silent-failure guard — but the
193
+ * reducer is not a safe CHANNEL for it, because the answer is needed inside a
194
+ * controller callback rather than during a render.
195
+ *
196
+ * Reading `stateRef.current` there happens to work under Ink 5 only because
197
+ * Ink mounts a LEGACY root (`ink.js`: `createContainer(rootNode, 0 …)`), so a
198
+ * dispatch from a promise context flushes synchronously. Under a concurrent
199
+ * root — an Ink major bump, or its `experimental` renderer — React 18's
200
+ * automatic batching returns, and a provider stream error emitted as
201
+ * `message_update` immediately before `agent_end` (core throws in the loop and
202
+ * emits `agent_end` from `runLoopWithLifecycle`'s `finally`, microtasks apart)
203
+ * would still read `errorNoticed: false`. That auto-continues into a run that
204
+ * just failed authentication: silent, and billable.
205
+ *
206
+ * So this ref is mutated in the SAME SYNCHRONOUS CALLBACK that dispatches the
207
+ * corresponding action, and the reducer fields stay as the cross-check.
208
+ */
209
+ const endReasonRef = useRef({
210
+ aborted: false,
211
+ errored: false,
212
+ });
213
+ /**
214
+ * Disarm an armed continuation. Idempotent; NEVER touches the budget — a
215
+ * cancelled continuation was never issued, so it must not be charged (AC-18).
216
+ *
217
+ * Three callers and there must be no fourth: `Esc` in the idle branch, the
218
+ * first line of `submitMessage`, and the subscription effect's cleanup.
219
+ */
220
+ const cancelFollowThrough = useCallback(() => {
221
+ if (followTimer.current) {
222
+ clearTimeout(followTimer.current);
223
+ followTimer.current = null;
224
+ }
225
+ }, []);
226
+ /**
227
+ * `submitMessage` as of the LATEST render, for the grace timer.
228
+ *
229
+ * The subscription effect below never re-runs (its deps are stable by
230
+ * construction), so a direct call would close over the mount render's binding
231
+ * forever. That binding happens to be functionally correct today because
232
+ * `submitMessage` reads everything mutable through refs — which is exactly the
233
+ * kind of "happens to be correct" this feature has already had to unwind once
234
+ * (P1-1, P1-6). Assigned during render, the same discipline `stateRef` uses
235
+ * twenty lines up.
236
+ */
237
+ const submitRef = useRef(() => { });
147
238
  /**
148
239
  * THE SINGLE SETTLE PATH for a human request. Idempotent per entry, and it
149
240
  * clears the overlay as its last act.
@@ -184,6 +275,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
184
275
  // --- Streaming coalescer + controller subscription. --------------------
185
276
  const pending = useRef([]);
186
277
  const flushTimer = useRef(null);
278
+ /** Alias so the subscription effect below reads one stable ref, not a hook. */
279
+ const governorInterval = governor.intervalMs;
187
280
  const flushPending = useCallback(() => {
188
281
  if (flushTimer.current) {
189
282
  clearTimeout(flushTimer.current);
@@ -200,10 +293,21 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
200
293
  const unsubscribe = controller.subscribe((event) => {
201
294
  const cost = controller.getModelInfo().cost;
202
295
  for (const action of reduceEvent(event, cost)) {
296
+ // MUTATED HERE, ONE INSTANT BEFORE THE ACTION THAT SETS
297
+ // `ViewState.errorNoticed`, rather than read back from the reducer one
298
+ // commit later (C-11). This is the whole of the batching-independence
299
+ // guarantee: `agent_end` can follow this action in the same tick with no
300
+ // render in between, and AC-40 asserts exactly that sequence.
301
+ if (action.type === 'notice' && action.level === 'error') {
302
+ endReasonRef.current.errored = true;
303
+ }
203
304
  if (action.type === 'textDelta' || action.type === 'thinkingDelta') {
204
305
  pending.current.push(action);
205
306
  if (!flushTimer.current) {
206
- flushTimer.current = setTimeout(() => flushPending(), COALESCE_MS);
307
+ // READ FROM THE REF AT ARM TIME (L4). This effect's dependency list
308
+ // never changes by construction, so a captured number would be
309
+ // pinned to the mount render and the ladder would move nothing.
310
+ flushTimer.current = setTimeout(() => flushPending(), governorInterval.current);
207
311
  }
208
312
  }
209
313
  else {
@@ -220,6 +324,69 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
220
324
  // Without it, an aborted run leaves a plan card on screen whose `a` key
221
325
  // silently flips the session to build, with no visible cause.
222
326
  cancelPendingHuman();
327
+ // --- Follow-through (todo-plan-followthrough §3.4). ------------------
328
+ //
329
+ // Round 1 printed one notice here and stopped, under a good argument
330
+ // ("auto-continuation is an unbounded cost loop wearing a helpful hat").
331
+ // That judgement is not overturned: the loop is BOUNDED first, by two
332
+ // structural counters, and only then permitted — opt-in, and off by
333
+ // default, so `followThrough: 'notify'` reproduces the old notice byte for
334
+ // byte (AC-1).
335
+ const todos = controller.getTodoSnapshot();
336
+ budgetRef.current = advanceBudget(budgetRef.current, todos, autoContinuationRef.current);
337
+ autoContinuationRef.current = false;
338
+ const decision = decideFollowThrough({
339
+ // FROM THE CONTROLLER, NOT FROM `cfg` (P1-1). This effect's dependency
340
+ // list never changes, so it closes over the `cfg` object from the render
341
+ // in which it mounted — and `setTodoConfig` builds a NEW object, which
342
+ // that closure will never see. `cfg.todo.followThrough` would therefore
343
+ // be pinned to the launch value for the whole session and
344
+ // `/todo follow auto` would report success and do nothing. Note the
345
+ // asymmetry with `showStrip` further down, which is right to use `cfg`
346
+ // because it runs in render scope where `cfg` is re-read every frame.
347
+ mode: controller.getTodoConfig().followThrough,
348
+ snapshot: todos,
349
+ runEnd: { ...endReasonRef.current },
350
+ budget: budgetRef.current,
351
+ interactive: true,
352
+ });
353
+ endReasonRef.current = { aborted: false, errored: false };
354
+ if (decision.kind === 'notify') {
355
+ dispatch({ type: 'notice', level: decision.level, text: decision.text });
356
+ }
357
+ else if (decision.kind === 'continue') {
358
+ dispatch({ type: 'notice', level: 'info', text: decision.notice });
359
+ // NO LIVE COUNTDOWN (D-10). The notice is written once and does not
360
+ // tick: a 10 Hz re-render of the whole frame to animate a number is the
361
+ // repaint cost this package refuses elsewhere, and Ink's two dedupe
362
+ // gates mean the alternative is not free either.
363
+ cancelFollowThrough(); // I-3: at most one armed continuation, ever
364
+ followTimer.current = setTimeout(() => {
365
+ followTimer.current = null;
366
+ // TWO RE-CHECKS AT FIRE TIME, not only at decision time. The grace
367
+ // window is 3 s and the user can type in it (C-6): without the first,
368
+ // a fired continuation would either be swallowed into
369
+ // `controller.steer()` (the quiet failure) or reach
370
+ // `controller.prompt()`, which REJECTS while running.
371
+ if (controller.isRunning())
372
+ return;
373
+ const live = controller.getTodoSnapshot();
374
+ if (!live || live.doneCount >= live.total)
375
+ return; // the list moved
376
+ budgetRef.current = { ...budgetRef.current, used: budgetRef.current.used + 1 };
377
+ autoContinuationRef.current = true;
378
+ // COUNTS ONLY, NEVER ITEM TEXT (D-19), and only at the fire point —
379
+ // the quiet paths must stay quiet.
380
+ getLogger().debug('agent', 'todo_follow', {
381
+ used: budgetRef.current.used,
382
+ noProgressStreak: budgetRef.current.noProgressStreak,
383
+ total: live.total,
384
+ done: live.doneCount,
385
+ mode: 'auto',
386
+ });
387
+ submitRef.current(decision.message, { userInitiated: false });
388
+ }, decision.graceMs);
389
+ }
223
390
  // A deferred `plan -> build` becomes real here and nowhere else (§3.2).
224
391
  const applied = controller.applyPendingMode();
225
392
  if (applied) {
@@ -233,9 +400,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
233
400
  });
234
401
  return () => {
235
402
  flushPending();
403
+ // An armed continuation must not outlive the tree that armed it (AC-21):
404
+ // the timer would fire into an unmounted App and `submitRef.current` would
405
+ // prompt a controller nobody is rendering.
406
+ cancelFollowThrough();
236
407
  unsubscribe();
237
408
  };
238
- }, [controller, flushPending, cancelPendingHuman]);
409
+ }, [controller, flushPending, cancelPendingHuman, cancelFollowThrough]);
239
410
  // --- Team-mode event stream (team-subagents §5.2 / §3.9). --------------
240
411
  //
241
412
  // A SEPARATE SUBSCRIPTION, not a branch in the one above: `TeamEvent` is
@@ -292,6 +463,34 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
292
463
  controller.dispose();
293
464
  };
294
465
  }, [controller]);
466
+ // --- Todo event stream (todo-plan-execution §3.8 / §5.2). --------------
467
+ //
468
+ // A THIRD SUBSCRIPTION, for the reason the team one records: `TodoEvent` is
469
+ // deliberately not a member of core's `AgentEvent` union (D-11), so it cannot
470
+ // arrive through `controller.subscribe`.
471
+ //
472
+ // NOT ROUTED THROUGH THE STREAMING COALESCER either. That buffer exists to
473
+ // merge text/thinking deltas; a `todo_write` is one deliberate act per step,
474
+ // and delaying the one surface the user is watching a long task through would
475
+ // be exactly backwards.
476
+ useEffect(() => {
477
+ return controller.subscribeTodos((event) => {
478
+ switch (event.type) {
479
+ case 'updated':
480
+ dispatch({ type: 'todoUpdate', snapshot: event.snapshot });
481
+ break;
482
+ case 'cleared':
483
+ dispatch({ type: 'todoCleared' });
484
+ break;
485
+ case 'rejected':
486
+ // THE COMPENSATING PATH FOR SUPPRESSING THE TOOL CARD (§3.8). Without
487
+ // it, the one case where `todo_write` produces nothing visible would
488
+ // be the case that most needs to be visible.
489
+ dispatch({ type: 'notice', level: 'warn', text: event.reason });
490
+ break;
491
+ }
492
+ });
493
+ }, [controller]);
295
494
  // --- Elapsed timer + tokens/sec baseline while running. ----------------
296
495
  useEffect(() => {
297
496
  if (state.status !== 'running') {
@@ -342,7 +541,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
342
541
  // `state.json`, not `config.json`: whether a one-shot notice has been shown
343
542
  // is bookkeeping, and the comment on the old persisted key said so.
344
543
  onSeen: () => setMouseNoticeSeen(true),
345
- });
544
+ },
545
+ // Present only when `loadConfig` had to raise the user's own
546
+ // `transcriptRetain` to `transcriptWindow` (tui-render-performance §5.1).
547
+ cfg.transcriptRetainRequested !== undefined
548
+ ? { requested: cfg.transcriptRetainRequested, resolved: cfg.transcriptRetain }
549
+ : undefined);
346
550
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
347
551
  useEffect(() => {
348
552
  publishExitSnapshot({
@@ -351,8 +555,30 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
351
555
  provider: cfg.provider,
352
556
  model: cfg.model,
353
557
  startedAt: startedAt.current,
558
+ // Carried so the exit replay can say what the retain ring removed. A
559
+ // transcript that silently omits entries is worse than one that names the
560
+ // count (K-6).
561
+ droppedEntries: state.droppedEntries,
562
+ });
563
+ }, [state.entries, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model]);
564
+ // --- `/perf` snapshot channel (tui-render-performance §5.4). ------------
565
+ //
566
+ // The deps are the two `useCallback`d members, NOT the handle objects: both
567
+ // hooks return a fresh literal per render, so depending on them would re-run
568
+ // this effect on every frame.
569
+ const governorReset = governor.reset;
570
+ const heightsClear = heights.clear;
571
+ useEffect(() => {
572
+ setPerfSnapshotProvider(() => perfRef.current);
573
+ setPerfResetHook(() => {
574
+ governorReset();
575
+ heightsClear();
354
576
  });
355
- }, [state.entries, state.usageTotal, cfg.provider, cfg.model]);
577
+ return () => {
578
+ setPerfSnapshotProvider(null);
579
+ setPerfResetHook(null);
580
+ };
581
+ }, [governorReset, heightsClear]);
356
582
  // --- Confirm bridge (confirmTools mode + skill approvals). -------------
357
583
  useEffect(() => {
358
584
  if (!confirmBridge)
@@ -495,9 +721,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
495
721
  toast,
496
722
  persistConfig,
497
723
  exit: doExit,
498
- submit: (text) => submitMessage(text),
724
+ submit: (text, opts) => submitMessage(text, opts),
499
725
  refreshSkills: () => setSkillsNonce((n) => n + 1),
500
726
  applyAgentMode: applyMode,
727
+ // Read at command time, so `/todo status` reports the live count rather than
728
+ // whatever it was when this context was built.
729
+ followBudget: budgetRef.current,
501
730
  });
502
731
  /**
503
732
  * Submitting a prompt no longer touches `config.json` at all — it appends one
@@ -527,13 +756,26 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
527
756
  * `handleSubmit` would re-parse it as input and, for a body that happens to
528
757
  * start with `/`, recurse into command dispatch.
529
758
  */
530
- const submitMessage = (message) => {
759
+ const submitMessage = (message, opts = {}) => {
760
+ // A USER MESSAGE IS A NEW INTENT AND OUTRANKS A QUEUED ONE (AC-19). First
761
+ // line, before the steering branch: a message typed during the grace window
762
+ // must be the one that runs, whichever branch it takes.
763
+ cancelFollowThrough();
531
764
  if (stateRef.current.status === 'running') {
532
765
  controller.steer(message);
533
766
  toast('info', 'Steering queued.');
534
767
  return;
535
768
  }
536
- recordPrompt(message);
769
+ // `userInitiated: false` skips TWO things and both are wanted: the
770
+ // prompt-history append, and the `submitCount` bump that drives the
771
+ // composer's hint fade. An auto-continuation demonstrates nothing about what
772
+ // the user has learned, so suppressing the fade is correct rather than
773
+ // incidental — which is why the parameter is named for the CAUSE (P2-8).
774
+ if (opts.userInitiated !== false)
775
+ recordPrompt(message);
776
+ // Mirrors where the reducer clears its own copies, in the same action
777
+ // (C-11): a new turn's end reason must not inherit the previous one's.
778
+ endReasonRef.current = { aborted: false, errored: false };
537
779
  dispatch({ type: 'submit', text: message });
538
780
  const pre = controller.preflight();
539
781
  if (!pre.ok) {
@@ -544,6 +786,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
544
786
  // Fire-and-forget: events drive the UI. prompt() never rejects.
545
787
  void controller.prompt(message);
546
788
  };
789
+ submitRef.current = submitMessage;
547
790
  const handleSubmit = async (raw) => {
548
791
  // Submitting is an unconditional "take me to the newest output" (§4.5).
549
792
  setPinToBottomNonce((n) => n + 1);
@@ -782,8 +1025,24 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
782
1025
  }
783
1026
  if (stateRef.current.status === 'running') {
784
1027
  controller.abort();
1028
+ // Next to `abortMark`, in the same callback, for the reason
1029
+ // `endReasonRef`'s own comment gives (C-11). D-5 turns this into
1030
+ // silence at `agent_end`: the user's Esc is the statement, and
1031
+ // restating what they interrupted is the CLI arguing.
1032
+ endReasonRef.current.aborted = true;
785
1033
  dispatch({ type: 'abortMark' });
786
1034
  toast('info', 'Run aborted.');
1035
+ return;
1036
+ }
1037
+ // IDLE. Today this branch does nothing at all, which is what makes it free
1038
+ // to take (R-4). OVERLAY PRECEDENCE IS UNCHANGED AND DELIBERATE (P2-9):
1039
+ // the block above returns while an overlay is open, so with one open the
1040
+ // first Esc closes the overlay and the second cancels the continuation.
1041
+ // Reaching past it would make "close this dialog" silently also mean
1042
+ // "abandon the plan".
1043
+ if (followTimer.current) {
1044
+ cancelFollowThrough();
1045
+ toast('info', 'Auto-continue cancelled.');
787
1046
  }
788
1047
  return;
789
1048
  }
@@ -802,6 +1061,40 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
802
1061
  // tells `OverlayFrame` to render everything and show no position indicator.
803
1062
  const viewportBudget = computeViewportRows(rows);
804
1063
  const overlayMaxRows = fullscreen ? viewportBudget : Number.POSITIVE_INFINITY;
1064
+ // --- The todo rail (todo-plan-execution §3.9). --------------------------
1065
+ //
1066
+ // ONE NUMBER FOR THE GATE AND FOR THE LAYOUT (I-10). `railRows` is both what
1067
+ // decides whether the panel mounts and what the panel lays out against; a
1068
+ // panel that mounts on one number and renders against another shows nothing
1069
+ // and reports nothing. `todoRailRows` is NOT `viewportBudget`: the live team
1070
+ // roster costs up to 8 rows inside `AppShell`'s measured bottom box that the
1071
+ // budget knows nothing about (P1-3).
1072
+ const railRows = todoRailRows(viewportBudget, state.team !== null);
1073
+ const railWidth = todoRailWidth(cols);
1074
+ const showRail = fullscreen && // non-goal 4: inline mode has no fixed frame
1075
+ cfg.todo.panel && // `/todo panel off`, persisted
1076
+ overlay === null && // a modal owns the screen (D-7)
1077
+ state.todos !== null && // no list -> no furniture, zero layout cost
1078
+ railWidth > 0 && // too narrow (under 80 columns)
1079
+ railRows >= TODO_LIMITS.panelMinRows; // too short to say anything useful
1080
+ // The inline plan strip (todo-plan-followthrough §3.7 / W2).
1081
+ //
1082
+ // DELIBERATELY NOT `!showRail`, which is true for FOUR different reasons and
1083
+ // three of them mean "do not draw this". `cfg.todo.panel` is included because
1084
+ // `--no-todo-panel` means "keep the planning, drop the display" and a strip is
1085
+ // a display. Overlays are NOT consulted: inline mode's overlays are part of
1086
+ // the document flow, not a modal that owns the screen.
1087
+ //
1088
+ // `cfg` is the right read here and `controller.getTodoConfig()` would be
1089
+ // needless — this runs in RENDER scope, where `cfg` is re-read every frame.
1090
+ // The asymmetry with the `agent_end` handler above is the whole of P1-1.
1091
+ const showStrip = !fullscreen && cfg.todo.panel && state.todos !== null;
1092
+ // Every consumer INSIDE the viewport slot switches to this; the header,
1093
+ // composer, status bar, toasts and every overlay keep the full frame width
1094
+ // (C-7). Overlays keep it because `showRail` already includes `overlay ===
1095
+ // null`: splitting the screen with a status panel behind a modal is both worse
1096
+ // design and a second source of width-contract bugs in six components.
1097
+ const contentCols = cols - (showRail ? railWidth : 0);
805
1098
  const overlayNode = overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: cols, rows: helpRows(theme, caps), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { initial: {
806
1099
  provider: cfg.provider,
807
1100
  model: cfg.model,
@@ -834,15 +1127,34 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
834
1127
  // The wordmark + getting-started card now live INSIDE the viewport, as its
835
1128
  // first block of content, so they scroll away instead of being yanked out of
836
1129
  // the header the moment the first message lands.
837
- const opener = empty ? (_jsx(SessionOpener, { variant: fullscreen ? pickOpenerVariant(viewportBudget, cols, caps) : 'none', version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: fullscreen ? viewportBudget : Number.POSITIVE_INFINITY, theme: theme, caps: caps })) : null;
1130
+ const opener = empty ? (_jsx(SessionOpener, { variant: fullscreen ? pickOpenerVariant(viewportBudget, contentCols, caps) : 'none', version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: fullscreen ? viewportBudget : Number.POSITIVE_INFINITY, theme: theme, caps: caps })) : null;
838
1131
  const density = cfg.density;
839
- const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, showScrollIndicator: true, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, windowSize: cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }), overlayNode] }));
1132
+ const transcriptWindow = cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW;
1133
+ // Assigned during render so `/perf` reads the CURRENT frame (see `perfRef`).
1134
+ // `mountedEntries` is the same number `render-budget.test.tsx` asserts (AC-9).
1135
+ perfRef.current = {
1136
+ rung: governor.rung,
1137
+ intervalMs: governor.intervalMs.current,
1138
+ lastCommitMs: governor.lastCommitMs(),
1139
+ governorEnabled: cfg.renderGovernor,
1140
+ totalEntries: state.entries.length,
1141
+ retain: cfg.transcriptRetain ?? DEFAULT_TRANSCRIPT_RETAIN,
1142
+ droppedEntries: state.droppedEntries,
1143
+ mountedEntries: mountedCount.current,
1144
+ heightsMeasured: heights.stats().measured,
1145
+ heightsEstimated: heights.stats().estimated,
1146
+ cols: contentCols,
1147
+ mode: fullscreen ? 'fullscreen' : 'inline',
1148
+ viewportRows: fullscreen ? viewportBudget : rows,
1149
+ offset: scrolledLines,
1150
+ };
1151
+ const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: reducedMotion, density: density, theme: theme, caps: caps }), overlayNode] }));
840
1152
  // The completion popup lives inside the bottom chrome and can add up to 9
841
1153
  // rows; unbounded, it pushes the status bar past the frame height where
842
1154
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
843
1155
  const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
844
1156
  const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, popupMaxRows: popupMaxRows, historyIntent: historyIntent, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
845
- return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, geometryRef: geometryRef, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, rows: rows, cols: cols, reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ...(state.team
1157
+ return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, geometryRef: geometryRef, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, rows: rows, cols: cols, reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, rail: showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: state.status === 'running', reducedMotion: reducedMotion, theme: theme, caps: caps })) : null, strip: showStrip && state.todos ? (_jsx(TodoStrip, { snapshot: state.todos, cols: cols, theme: theme, caps: caps })) : null, toast: _jsx(ToastStack, { toasts: state.toasts, theme: theme, mode: mode }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ecoRung: governor.rung, ...(state.team
846
1158
  ? {
847
1159
  teamActive: {
848
1160
  running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
@@ -850,5 +1162,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
850
1162
  total: state.team.runs.length,
851
1163
  },
852
1164
  }
1165
+ : {}), ...(state.todos && !showRail
1166
+ ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
853
1167
  : {}) }) }));
854
1168
  }
@@ -7,7 +7,13 @@
7
7
  * *italic* / `code` / ~~strike~~. Anything else renders as plain text. Still
8
8
  * intentionally small — not a CommonMark implementation.
9
9
  *
10
- * The grammar lives in `markdown-blocks.ts`; this file only renders.
10
+ * The grammar lives in `markdown-blocks.ts`; this file only renders. That was
11
+ * true of the file header long before it was true of the file
12
+ * (tui-render-performance R2): the block grammar used to run here, in the render
13
+ * body, on every frame. Both expensive halves are now memoised on the input
14
+ * string — `parseMarkdownCached` for the AST, `highlightCached` for the ANSI —
15
+ * while theme and glyphs stay render-time, so `/theme` still takes effect
16
+ * immediately (K-5).
11
17
  */
12
18
  import React from 'react';
13
19
  import type { Theme } from './theme.js';
@@ -1,54 +1,27 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
- import { highlight } from 'cli-highlight';
4
3
  import { pickGlyphs } from './glyphs.js';
5
4
  import { railBorderProps } from './layout/Gutter.js';
6
- import { formatTable, isRule, isTableDivider, parseHeading, parseInline, parseTableRow, tableAlignments, } from './markdown-blocks.js';
7
- const FENCE = /^```(\w*)\s*$/;
5
+ import { highlightCached, parseMarkdownCached } from './render-cache.js';
6
+ import { formatTable, isRule, parseHeading, parseInline, } from './markdown-blocks.js';
8
7
  /** Width of the `---` rule. Fixed so it cannot force a wrap on narrow frames. */
9
8
  const RULE_WIDTH = 24;
10
9
  export function Markdown({ text, theme, caps }) {
11
10
  const glyphs = pickGlyphs(caps);
12
- const lines = text.split('\n');
13
- const blocks = [];
14
- let i = 0;
15
- let key = 0;
16
- while (i < lines.length) {
17
- const line = lines[i] ?? '';
18
- const fence = FENCE.exec(line.trim());
19
- if (fence) {
20
- const lang = fence[1] || '';
21
- const codeLines = [];
22
- i += 1;
23
- while (i < lines.length && !FENCE.test((lines[i] ?? '').trim())) {
24
- codeLines.push(lines[i] ?? '');
25
- i += 1;
26
- }
27
- i += 1; // skip closing fence
28
- blocks.push(_jsx(CodeBlock, { code: codeLines.join('\n'), lang: lang, theme: theme, glyphs: glyphs }, `b${key++}`));
29
- continue;
30
- }
31
- // A pipe row followed by a divider row starts a table; consume the run.
32
- const headerCells = parseTableRow(line);
33
- if (headerCells && i + 1 < lines.length && isTableDivider(lines[i + 1] ?? '')) {
34
- const align = tableAlignments(lines[i + 1] ?? '');
35
- const rows = [headerCells];
36
- i += 2;
37
- while (i < lines.length) {
38
- const cells = parseTableRow(lines[i] ?? '');
39
- if (!cells)
40
- break;
41
- rows.push(cells);
42
- i += 1;
43
- }
44
- const formatted = formatTable(rows, align);
45
- blocks.push(_jsx(Box, { flexDirection: "column", children: formatted.map((row, r) => (_jsx(Text, { wrap: "truncate", bold: r === 0, color: r === 0 ? theme.accent : undefined, children: row }, r))) }, `b${key++}`));
46
- continue;
11
+ const blocks = parseMarkdownCached(text);
12
+ return (_jsx(Box, { flexDirection: "column", children: blocks.map((block, i) => renderBlock(block, i, theme, glyphs)) }));
13
+ }
14
+ function renderBlock(block, key, theme, glyphs) {
15
+ switch (block.kind) {
16
+ case 'code':
17
+ return (_jsx(CodeBlock, { code: block.code, lang: block.lang, theme: theme, glyphs: glyphs }, `b${key}`));
18
+ case 'table': {
19
+ const formatted = formatTable(block.rows, block.align);
20
+ return (_jsx(Box, { flexDirection: "column", children: formatted.map((row, r) => (_jsx(Text, { wrap: "truncate", bold: r === 0, color: r === 0 ? theme.accent : undefined, children: row }, r))) }, `b${key}`));
47
21
  }
48
- blocks.push(_jsx(InlineLine, { line: line, theme: theme, glyphs: glyphs }, `b${key++}`));
49
- i += 1;
22
+ default:
23
+ return _jsx(InlineLine, { line: block.text, theme: theme, glyphs: glyphs }, `b${key}`);
50
24
  }
51
- return _jsx(Box, { flexDirection: "column", children: blocks });
52
25
  }
53
26
  /**
54
27
  * Fenced code. The round border is gone (§4.3): nested inside a tool card it
@@ -57,13 +30,7 @@ export function Markdown({ text, theme, caps }) {
57
30
  * code" boundary for 1 column and 0 rows.
58
31
  */
59
32
  function CodeBlock({ code, lang, theme, glyphs, }) {
60
- let rendered = code;
61
- try {
62
- rendered = highlight(code, { language: lang || undefined, ignoreIllegals: true });
63
- }
64
- catch {
65
- rendered = code;
66
- }
33
+ const rendered = highlightCached(code, lang);
67
34
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [lang.length > 0 && (_jsx(Text, { color: theme.chip.fg, backgroundColor: theme.chip.bg, children: ` ${lang} ` })), _jsx(Text, { children: rendered })] }));
68
35
  }
69
36
  /** Six heading levels used to render identically; now they carry three weights. */
@@ -70,6 +70,37 @@ interface StatusBarProps {
70
70
  running: number;
71
71
  total: number;
72
72
  };
73
+ /**
74
+ * Live todo counts (todo-plan-execution §6.3), or absent.
75
+ *
76
+ * `App` sends this ONLY when a list exists AND the rail does not: with the
77
+ * rail on screen the numbers are already there in a larger typeface, and
78
+ * duplicating them costs the context gauge columns it cannot spare. This is
79
+ * therefore the INLINE-MODE and NARROW-TERMINAL fallback for R-d, which is
80
+ * exactly why it is here rather than being folded into the rail's own gate.
81
+ *
82
+ * Degrades below `TODO_LIMITS.statusCompactCols` from `todo 2/7` to `[2/7]`,
83
+ * the same treatment and the same justification as the team counter's
84
+ * `agents 3/5` -> `[3]`. See the note on `agentMode` for why that rule does
85
+ * NOT apply to the mode word.
86
+ */
87
+ todoActive?: {
88
+ done: number;
89
+ total: number;
90
+ };
91
+ /**
92
+ * Render-governor rung (tui-render-performance L4). `> 0` renders a muted
93
+ * `eco` chip.
94
+ *
95
+ * I-L4-2 — THE GOVERNOR IS USER-VISIBLE, and this is where. A user who cannot
96
+ * see why the stream got chunkier will conclude the model got slower, and
97
+ * degrading silently is the one thing a claim of robustness cannot afford.
98
+ * It sits in the RIGHT cluster next to the other derived numbers and is
99
+ * suppressed on a narrow terminal, which is the `teamActive` treatment rather
100
+ * than the `agentMode` one: the mode must never be unreportable, whereas an
101
+ * eco chip on a 50-column terminal costs the context gauge more than it says.
102
+ */
103
+ ecoRung?: number;
73
104
  }
74
105
  export declare function StatusBar(props: StatusBarProps): React.ReactElement;
75
106
  export {};