@aragon-agent/cli 0.5.6 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +356 -0
  2. package/README.md +1429 -1105
  3. package/dist/agent/coalesce.d.ts +13 -0
  4. package/dist/agent/coalesce.js +50 -11
  5. package/dist/agent/controller.d.ts +141 -2
  6. package/dist/agent/controller.js +396 -4
  7. package/dist/agent/entry-limits.d.ts +65 -0
  8. package/dist/agent/entry-limits.js +133 -0
  9. package/dist/agent/headless.d.ts +8 -0
  10. package/dist/agent/headless.js +40 -0
  11. package/dist/agent/reducer.d.ts +253 -3
  12. package/dist/agent/reducer.js +553 -28
  13. package/dist/agent/retry-view.d.ts +68 -0
  14. package/dist/agent/retry-view.js +95 -0
  15. package/dist/agent/system-prompt.d.ts +11 -0
  16. package/dist/agent/system-prompt.js +2 -0
  17. package/dist/cli.js +122 -4
  18. package/dist/commands/builtins.d.ts +1 -1
  19. package/dist/commands/builtins.js +313 -2
  20. package/dist/commands/perf.d.ts +45 -0
  21. package/dist/commands/perf.js +78 -0
  22. package/dist/config/cli-commands.d.ts +53 -2
  23. package/dist/config/cli-commands.js +136 -3
  24. package/dist/config/env.d.ts +6 -1
  25. package/dist/config/env.js +131 -1
  26. package/dist/config/load.d.ts +63 -0
  27. package/dist/config/load.js +125 -2
  28. package/dist/config/schema.d.ts +273 -10
  29. package/dist/config/schema.js +250 -7
  30. package/dist/config/store.js +20 -1
  31. package/dist/fast/critique.d.ts +43 -0
  32. package/dist/fast/critique.js +62 -0
  33. package/dist/fast/digest.d.ts +42 -0
  34. package/dist/fast/digest.js +85 -0
  35. package/dist/fast/frames.d.ts +99 -0
  36. package/dist/fast/frames.js +180 -0
  37. package/dist/fast/limits.d.ts +98 -0
  38. package/dist/fast/limits.js +98 -0
  39. package/dist/fast/prompt.d.ts +72 -0
  40. package/dist/fast/prompt.js +94 -0
  41. package/dist/fast/resolve.d.ts +43 -0
  42. package/dist/fast/resolve.js +105 -0
  43. package/dist/fast/review-call.d.ts +99 -0
  44. package/dist/fast/review-call.js +130 -0
  45. package/dist/fast/reviewer.d.ts +248 -0
  46. package/dist/fast/reviewer.js +664 -0
  47. package/dist/fast/types.d.ts +128 -0
  48. package/dist/fast/types.js +14 -0
  49. package/dist/fast/wiring.d.ts +170 -0
  50. package/dist/fast/wiring.js +316 -0
  51. package/dist/logging/install.d.ts +15 -1
  52. package/dist/logging/install.js +37 -4
  53. package/dist/logging/logger.d.ts +1 -1
  54. package/dist/session/persist.js +57 -1
  55. package/dist/team/normalize.d.ts +26 -1
  56. package/dist/team/normalize.js +23 -3
  57. package/dist/team/prompt.d.ts +11 -0
  58. package/dist/team/prompt.js +10 -0
  59. package/dist/team/report.d.ts +18 -0
  60. package/dist/team/report.js +27 -1
  61. package/dist/team/runtime.d.ts +13 -2
  62. package/dist/team/runtime.js +33 -6
  63. package/dist/team/subagent.d.ts +19 -1
  64. package/dist/team/subagent.js +72 -10
  65. package/dist/team/task-tool.d.ts +26 -0
  66. package/dist/team/task-tool.js +48 -4
  67. package/dist/team/types.d.ts +80 -1
  68. package/dist/tools/bash-tool.js +19 -3
  69. package/dist/tools/diff.d.ts +25 -5
  70. package/dist/tools/diff.js +72 -41
  71. package/dist/tools/file-change-store.d.ts +34 -0
  72. package/dist/tools/file-change-store.js +57 -0
  73. package/dist/tools/fs-tools.d.ts +26 -0
  74. package/dist/tools/fs-tools.js +62 -5
  75. package/dist/tools/index.d.ts +31 -0
  76. package/dist/tools/index.js +7 -1
  77. package/dist/tools/patch.d.ts +115 -0
  78. package/dist/tools/patch.js +453 -0
  79. package/dist/tools/terminal-output.d.ts +107 -0
  80. package/dist/tools/terminal-output.js +198 -0
  81. package/dist/tools/tool-output-store.d.ts +68 -0
  82. package/dist/tools/tool-output-store.js +102 -0
  83. package/dist/ui/ActivityLine.d.ts +48 -0
  84. package/dist/ui/ActivityLine.js +22 -0
  85. package/dist/ui/App.d.ts +1 -4
  86. package/dist/ui/App.js +357 -44
  87. package/dist/ui/BottomStatusRow.d.ts +43 -0
  88. package/dist/ui/BottomStatusRow.js +14 -0
  89. package/dist/ui/Composer.d.ts +1 -6
  90. package/dist/ui/Composer.js +2 -2
  91. package/dist/ui/Markdown.d.ts +7 -1
  92. package/dist/ui/Markdown.js +15 -48
  93. package/dist/ui/PromptInput.d.ts +1 -15
  94. package/dist/ui/PromptInput.js +1 -38
  95. package/dist/ui/StatusBar.d.ts +50 -0
  96. package/dist/ui/StatusBar.js +7 -3
  97. package/dist/ui/TeamPanel.d.ts +5 -4
  98. package/dist/ui/TeamPanel.js +1 -1
  99. package/dist/ui/TodoPanel.d.ts +5 -4
  100. package/dist/ui/Transcript.d.ts +80 -5
  101. package/dist/ui/Transcript.js +179 -11
  102. package/dist/ui/activity-phrases.d.ts +33 -0
  103. package/dist/ui/activity-phrases.js +50 -0
  104. package/dist/ui/entries/AssistantEntry.d.ts +38 -1
  105. package/dist/ui/entries/AssistantEntry.js +34 -3
  106. package/dist/ui/entries/DiffView.d.ts +67 -0
  107. package/dist/ui/entries/DiffView.js +103 -0
  108. package/dist/ui/entries/FastCard.d.ts +53 -0
  109. package/dist/ui/entries/FastCard.js +87 -0
  110. package/dist/ui/entries/RetryCard.d.ts +56 -0
  111. package/dist/ui/entries/RetryCard.js +60 -0
  112. package/dist/ui/entries/TeamCard.d.ts +9 -1
  113. package/dist/ui/entries/TeamCard.js +28 -2
  114. package/dist/ui/entries/TodoCard.d.ts +9 -1
  115. package/dist/ui/entries/TodoCard.js +23 -1
  116. package/dist/ui/entries/ToolCard.d.ts +50 -1
  117. package/dist/ui/entries/ToolCard.js +104 -7
  118. package/dist/ui/entries/ToolPreview.d.ts +2 -2
  119. package/dist/ui/entries/UserEntry.d.ts +9 -1
  120. package/dist/ui/entries/UserEntry.js +17 -1
  121. package/dist/ui/exit-snapshot.d.ts +7 -0
  122. package/dist/ui/glyphs.d.ts +9 -0
  123. package/dist/ui/glyphs.js +2 -0
  124. package/dist/ui/layout/AppShell.d.ts +13 -22
  125. package/dist/ui/layout/AppShell.js +3 -38
  126. package/dist/ui/layout/MeasuredEntry.d.ts +21 -0
  127. package/dist/ui/layout/MeasuredEntry.js +30 -0
  128. package/dist/ui/layout/ScrollViewport.js +8 -2
  129. package/dist/ui/layout/viewport-geometry.d.ts +28 -0
  130. package/dist/ui/layout/viewport-geometry.js +27 -0
  131. package/dist/ui/layout/virtual-window.d.ts +100 -0
  132. package/dist/ui/layout/virtual-window.js +308 -0
  133. package/dist/ui/live-clamp.d.ts +42 -0
  134. package/dist/ui/live-clamp.js +74 -0
  135. package/dist/ui/markdown-blocks.d.ts +25 -1
  136. package/dist/ui/markdown-blocks.js +43 -0
  137. package/dist/ui/overlays/HelpOverlay.js +16 -2
  138. package/dist/ui/overlays/SettingsScreen.d.ts +82 -1
  139. package/dist/ui/overlays/SettingsScreen.js +147 -1
  140. package/dist/ui/render-cache.d.ts +81 -0
  141. package/dist/ui/render-cache.js +195 -0
  142. package/dist/ui/render-governor.d.ts +54 -0
  143. package/dist/ui/render-governor.js +78 -0
  144. package/dist/ui/transcript-text.d.ts +11 -0
  145. package/dist/ui/transcript-text.js +32 -0
  146. package/dist/ui/use-height-store.d.ts +31 -0
  147. package/dist/ui/use-height-store.js +76 -0
  148. package/dist/ui/use-render-governor.d.ts +39 -0
  149. package/dist/ui/use-render-governor.js +57 -0
  150. package/dist/ui/use-startup-notices.d.ts +9 -1
  151. package/dist/ui/use-startup-notices.js +35 -1
  152. package/dist/ui/use-wheel-routing.d.ts +16 -32
  153. package/dist/ui/use-wheel-routing.js +21 -38
  154. package/package.json +2 -2
  155. package/dist/ui/layout/regions.d.ts +0 -55
  156. package/dist/ui/layout/regions.js +0 -52
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';
@@ -28,26 +28,30 @@ import { Transcript, TranscriptList } from './Transcript.js';
28
28
  import { PromptInput } from './PromptInput.js';
29
29
  import { Composer } from './Composer.js';
30
30
  import { StatusBar } from './StatusBar.js';
31
+ import { secondsLeft } from '../agent/retry-view.js';
31
32
  import { TeamPanel } from './TeamPanel.js';
32
33
  import { TodoPanel } from './TodoPanel.js';
33
34
  import { TodoStrip } from './TodoStrip.js';
34
- import { ToastStack } from './ToastStack.js';
35
+ import { ActivityLine } from './ActivityLine.js';
36
+ import { BottomStatusRow } from './BottomStatusRow.js';
35
37
  import { AppShell } from './layout/AppShell.js';
36
38
  import { ScrollViewport } from './layout/ScrollViewport.js';
37
39
  import { todoRailRows, todoRailWidth } from './layout/rail.js';
38
40
  import { TODO_LIMITS } from '../todo/limits.js';
39
41
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
40
42
  import { useTerminalSize } from './layout/useTerminalSize.js';
43
+ import { useHeightStore } from './use-height-store.js';
44
+ import { useRenderGovernor } from './use-render-governor.js';
45
+ import { setPerfResetHook, setPerfSnapshotProvider } from '../commands/perf.js';
41
46
  import { frameHeight, MIN_FULLSCREEN_ROWS } from './layout/frame.js';
42
47
  import { HINT_MIN_ROWS, viewportRows as computeViewportRows } from './layout/budget.js';
43
- import { UNMEASURED_GEOMETRY } from './layout/regions.js';
44
48
  import { OVERLAY_PAGE, useWheelRouting } from './use-wheel-routing.js';
45
49
  import { installConsoleBridge } from './console-bridge.js';
46
50
  import { publishExitSnapshot } from './exit-snapshot.js';
47
51
  import { OverlayFrame } from './layout/OverlayFrame.js';
48
52
  import { helpRows } from './overlays/HelpOverlay.js';
49
53
  import { ModelPicker } from './overlays/ModelPicker.js';
50
- import { SettingsScreen } from './overlays/SettingsScreen.js';
54
+ import { SettingsScreen, fastSettingsFrom, readFastSettings, } from './overlays/SettingsScreen.js';
51
55
  import { ConfirmDialog } from './overlays/ConfirmDialog.js';
52
56
  import { QuestionOverlay } from './overlays/QuestionOverlay.js';
53
57
  import { PlanReviewOverlay } from './overlays/PlanReviewOverlay.js';
@@ -90,14 +94,35 @@ export function resolveSettingsMaxTokens(raw, previous) {
90
94
  toast: ['warn', 'Max tokens must be a number or "auto".'],
91
95
  };
92
96
  }
93
- /** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
94
- const COALESCE_MS = 33;
95
- export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, geometryOverride, }) {
97
+ /**
98
+ * The lazy `useReducer` initializer. Module scope, so the reference is stable
99
+ * across renders and React never re-runs it.
100
+ */
101
+ function seedViewState(thinkingVisible) {
102
+ return initialViewState({ thinkingVisible });
103
+ }
104
+ export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, }) {
96
105
  const { exit } = useApp();
97
106
  const { stdout } = useStdout();
98
107
  const fullscreen = mode === 'fullscreen';
99
108
  const { rows, cols } = useTerminalSize();
100
- const [state, dispatch] = useReducer(viewReducer, undefined, initialViewState);
109
+ // --- Render governor (tui-render-performance L4). ------------------------
110
+ //
111
+ // FIRST, BEFORE ANY OTHER WORK IN THIS BODY. The hook stamps
112
+ // `performance.now()` during render and reads it again in a layout effect, so
113
+ // the span it measures is only the whole commit if nothing precedes it here.
114
+ const cfg = controller.getConfig();
115
+ const governor = useRenderGovernor({
116
+ enabled: cfg.renderGovernor,
117
+ maxIntervalMs: cfg.maxRenderIntervalMs,
118
+ });
119
+ /** The transcript height cache, owned here and read by `TranscriptList` (L3). */
120
+ const heights = useHeightStore();
121
+ // SEEDED FROM CONFIG, not dispatched after mount (§3.1.1): a post-mount
122
+ // `toggleThinking` would let `/resume` flash a restored thinking block on
123
+ // frame 1. `seedViewState` is module-scope so the lazy initializer is a stable
124
+ // function reference.
125
+ const [state, dispatch] = useReducer(viewReducer, cfg.showThinking, seedViewState);
101
126
  const [elapsedMs, setElapsedMs] = useState(0);
102
127
  const [confirmState, setConfirmState] = useState(null);
103
128
  /** The request the question / plan overlay is currently rendering. */
@@ -108,8 +133,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
108
133
  const [promptHistory, setPromptHistory] = useState(() => loadPromptHistory());
109
134
  // Scroll plumbing: intent goes down, one derived display number comes back.
110
135
  const [scrollIntent, setScrollIntent] = useState();
111
- /** One prompt-history step per wheel notch over the composer band (§4.7). */
112
- const [historyIntent, setHistoryIntent] = useState();
113
136
  const [pinToBottomNonce, setPinToBottomNonce] = useState(0);
114
137
  const [scrolledLines, setScrolledLines] = useState(0);
115
138
  // I-5 (§4.13): Ctrl+L cannot repaint by writing escapes — Ink dedupes
@@ -124,7 +147,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
124
147
  // the completion list are memoized against it, so a skill installed mid-session
125
148
  // gets its `/<name>` command and its autocomplete entry immediately.
126
149
  const [skillsNonce, setSkillsNonce] = useState(0);
127
- const cfg = controller.getConfig();
128
150
  const caps = useMemo(() => {
129
151
  const detected = detectCapabilities(process.env, stdout);
130
152
  return {
@@ -143,12 +165,35 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
143
165
  const ctrlCArmed = useRef(false);
144
166
  const ctrlCTimer = useRef(null);
145
167
  const runBaselineOut = useRef(0);
168
+ /**
169
+ * Epoch ms the current run began — the activity phrase's seed.
170
+ *
171
+ * ASSIGNED IN RENDER SCOPE, NOT IN THE ELAPSED EFFECT (P1-7). That effect runs
172
+ * AFTER the render that first sees `status === 'running'`, so a ref it wrote
173
+ * would be one frame late — and the first frame of a run is exactly when the
174
+ * phrase is chosen. `Transcript.tsx` updates `highWater` / `prevLen` during
175
+ * render for the same reason: a value derived from a transition is needed by
176
+ * the very render that observes the transition.
177
+ */
178
+ const runStartedAt = useRef(0);
146
179
  const toastTimers = useRef(new Map());
147
180
  /** Every outstanding `gate.request()`. Held in a ref, never in state: it is
148
181
  * bookkeeping for promises, and re-rendering on it would be noise. */
149
182
  const pendingHuman = useRef(new Set());
150
- /** Written by `AppShell`'s layout effect, read at wheel-event time (§4.5). */
151
- const geometryRef = useRef(UNMEASURED_GEOMETRY);
183
+ /**
184
+ * What `/perf` reports. Assigned during render, exactly like `stateRef` above,
185
+ * because a slash command runs outside the render pass and a value captured in
186
+ * an effect closure would be one commit stale on every readout.
187
+ */
188
+ const perfRef = useRef(null);
189
+ /**
190
+ * How many entries `TranscriptList` actually mounted this frame.
191
+ *
192
+ * A ref written during the child's render, not state: this is the number
193
+ * `render-budget.test.tsx` gates on (AC-1 / AC-9), and turning it into state
194
+ * would make reading the render budget cost a render.
195
+ */
196
+ const mountedCount = useRef(0);
152
197
  // --- Follow-through (todo-plan-followthrough §3.4). ---------------------
153
198
  //
154
199
  // THREE REFS AND NO VIEW STATE, DELIBERATELY (§5.2). Every field added to
@@ -249,6 +294,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
249
294
  // --- Streaming coalescer + controller subscription. --------------------
250
295
  const pending = useRef([]);
251
296
  const flushTimer = useRef(null);
297
+ /** Alias so the subscription effect below reads one stable ref, not a hook. */
298
+ const governorInterval = governor.intervalMs;
252
299
  const flushPending = useCallback(() => {
253
300
  if (flushTimer.current) {
254
301
  clearTimeout(flushTimer.current);
@@ -261,10 +308,16 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
261
308
  for (const action of merged)
262
309
  dispatch(action);
263
310
  }, []);
311
+ /**
312
+ * The CLI-local diff side channel (§3.3.7). Memoized once, beside the `cost`
313
+ * lookup below, because `reduceEvent` TAKES a patch rather than reading one:
314
+ * the store is consume-once, so this object must not be rebuilt per event.
315
+ */
316
+ const patchSource = useMemo(() => ({ take: (id) => controller.takeFilePatch(id) }), [controller]);
264
317
  useEffect(() => {
265
318
  const unsubscribe = controller.subscribe((event) => {
266
319
  const cost = controller.getModelInfo().cost;
267
- for (const action of reduceEvent(event, cost)) {
320
+ for (const action of reduceEvent(event, cost, patchSource)) {
268
321
  // MUTATED HERE, ONE INSTANT BEFORE THE ACTION THAT SETS
269
322
  // `ViewState.errorNoticed`, rather than read back from the reducer one
270
323
  // commit later (C-11). This is the whole of the batching-independence
@@ -276,7 +329,10 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
276
329
  if (action.type === 'textDelta' || action.type === 'thinkingDelta') {
277
330
  pending.current.push(action);
278
331
  if (!flushTimer.current) {
279
- flushTimer.current = setTimeout(() => flushPending(), COALESCE_MS);
332
+ // READ FROM THE REF AT ARM TIME (L4). This effect's dependency list
333
+ // never changes by construction, so a captured number would be
334
+ // pinned to the mount render and the ladder would move nothing.
335
+ flushTimer.current = setTimeout(() => flushPending(), governorInterval.current);
280
336
  }
281
337
  }
282
338
  else {
@@ -375,7 +431,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
375
431
  cancelFollowThrough();
376
432
  unsubscribe();
377
433
  };
378
- }, [controller, flushPending, cancelPendingHuman, cancelFollowThrough]);
434
+ }, [controller, patchSource, flushPending, cancelPendingHuman, cancelFollowThrough]);
379
435
  // --- Team-mode event stream (team-subagents §5.2 / §3.9). --------------
380
436
  //
381
437
  // A SEPARATE SUBSCRIPTION, not a branch in the one above: `TeamEvent` is
@@ -409,16 +465,32 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
409
465
  dispatch({ type: 'teamUpdate', snapshot });
410
466
  break;
411
467
  }
412
- case 'usage':
468
+ case 'usage': {
413
469
  // Child spend is REAL SPEND and must reach the status bar, or the
414
470
  // session cost readout under-reports by however much the team consumed
415
471
  // — the single most misleading possible failure of this feature (R-5).
416
- dispatch({
417
- type: 'teamUsage',
418
- usage: event.usage,
419
- costDelta: computeCost(event.usage, controller.getModelInfo().cost),
420
- });
472
+ //
473
+ // THE COST TABLE IS SELECTED BY TIER (fast-model-tier §3.6 / R-7).
474
+ // Always using the lead's would over-report a Haiku child under a
475
+ // Sonnet lead by roughly an order of magnitude, which is a different
476
+ // way of being wrong about the same number.
477
+ // FROM THE CONTROLLER, NOT FROM `cfg` (the rule the `agent_end`
478
+ // handler above records): this effect's dependency list never changes,
479
+ // so it closes over the `cfg` object of the render it mounted in, and
480
+ // `setModel` builds a NEW one.
481
+ const live = controller.getConfig();
482
+ const fastTier = event.tier === 'fast' ? controller.getFastStatus().tier : null;
483
+ const ref = fastTier && fastTier.ok
484
+ ? fastTier.ref
485
+ : { providerId: live.provider, modelId: live.model };
486
+ // Unknown pricing contributes ZERO rather than a fabricated figure
487
+ // (C-11 / RV-4); `/fast status` and the dispatch report say `unknown`.
488
+ const costDelta = controller.isPricedModel(ref)
489
+ ? computeCost(event.usage, controller.getModelInfoFor(ref).cost)
490
+ : 0;
491
+ dispatch({ type: 'teamUsage', usage: event.usage, costDelta });
421
492
  break;
493
+ }
422
494
  case 'dispatch_end':
423
495
  dispatch({ type: 'teamEnd', outcome: event.outcome });
424
496
  break;
@@ -460,6 +532,83 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
460
532
  }
461
533
  });
462
534
  }, [controller]);
535
+ // --- Fast-tier event stream (fast-model-tier §5.3). --------------------
536
+ //
537
+ // A FOURTH SUBSCRIPTION, for the reason the team and todo ones record:
538
+ // `FastEvent` is deliberately not a member of core's `AgentEvent` union
539
+ // (D-10), so it cannot arrive through `controller.subscribe`.
540
+ //
541
+ // `subscribeFast` returns a no-op unsubscribe when the tier was off at
542
+ // construction, so this can subscribe unconditionally and an ordinary session
543
+ // pays one closure for the whole run.
544
+ useEffect(() => {
545
+ // Seed the chip from the tier resolved at construction, so a fast-enabled
546
+ // session shows it on the first frame rather than after the first review.
547
+ const initial = controller.getFastStatus();
548
+ if (initial.registered)
549
+ dispatch({ type: 'fastTier', snapshot: initial.snapshot });
550
+ return controller.subscribeFast((event) => {
551
+ switch (event.type) {
552
+ case 'review_start':
553
+ dispatch({
554
+ type: 'fastStart',
555
+ index: event.index,
556
+ turn: event.turn,
557
+ model: controller.getFastStatus().snapshot.model,
558
+ });
559
+ break;
560
+ case 'review_end':
561
+ dispatch({ type: 'fastEnd', review: event.review });
562
+ // The card settled, so the chip's `inFlight` and the session totals
563
+ // both moved. One dispatch rather than two: `fastTier` carries them.
564
+ dispatch({ type: 'fastTier', snapshot: controller.getFastStatus().snapshot });
565
+ break;
566
+ case 'usage': {
567
+ // Review spend is REAL SPEND at the FAST model's price (§3.6 / AC-18).
568
+ const ref = controller.getFastStatus().tier;
569
+ const costDelta = ref.ok && controller.isPricedModel(ref.ref)
570
+ ? computeCost(event.usage, controller.getModelInfoFor(ref.ref).cost)
571
+ : 0;
572
+ dispatch({ type: 'fastUsage', usage: event.usage, costDelta });
573
+ break;
574
+ }
575
+ case 'tier_changed':
576
+ dispatch({ type: 'fastTier', snapshot: event.snapshot });
577
+ break;
578
+ }
579
+ });
580
+ }, [controller]);
581
+ // --- Live tool output (agent-activity-presentation-live §3.3.2). --------
582
+ //
583
+ // A FIFTH SUBSCRIPTION, and THE ONE THAT DOES GO THROUGH THE STREAMING
584
+ // COALESCER (D-34 / P0-2). The three above each carry a discrete act at human
585
+ // frequency — a roster change, a `todo_write`, a review verdict — and each has
586
+ // a comment refusing the buffer for that reason. This one carries a STREAM at
587
+ // pipe speed: the producer is a child process's stdout, so dispatching per
588
+ // chunk would be one React commit per `read()`.
589
+ //
590
+ // THE EFFECT PUSHES INTO `pending.current` AND ARMS `flushTimer` ITSELF.
591
+ // There is no "buffered path for every other action" to fall into —
592
+ // `App.tsx:436-447` buffers exactly `textDelta` / `thinkingDelta` and
593
+ // dispatches everything else immediately — so writing this the way its three
594
+ // siblings are written leaves `mergeDeltas`'s new clause dead code and R-1
595
+ // with one bound instead of two. AC-38 is the pin, mutation-checked against
596
+ // the direct-dispatch form.
597
+ //
598
+ // `Date.now()` IS READ HERE, in the listener, not in the reducer, which is
599
+ // what keeps `viewReducer` pure.
600
+ useEffect(() => {
601
+ return controller.subscribeToolOutput(({ toolCallId, rows }) => {
602
+ pending.current.push({ type: 'toolOutputDelta', toolCallId, rows, at: Date.now() });
603
+ if (!flushTimer.current) {
604
+ // READ FROM THE REF AT ARM TIME, for the reason `App.tsx:439-441`
605
+ // records: this effect's dependency list never changes, so a captured
606
+ // number would be pinned to the mount render and the governor's ladder
607
+ // would move nothing.
608
+ flushTimer.current = setTimeout(() => flushPending(), governorInterval.current);
609
+ }
610
+ });
611
+ }, [controller, flushPending, governorInterval]);
463
612
  // --- Elapsed timer + tokens/sec baseline while running. ----------------
464
613
  useEffect(() => {
465
614
  if (state.status !== 'running') {
@@ -472,6 +621,19 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
472
621
  const id = setInterval(() => setElapsedMs(Date.now() - startedAt), 200);
473
622
  return () => clearInterval(id);
474
623
  }, [state.status]);
624
+ // --- Retry countdown tick (llm-api-retry-backoff §6.5). ----------------
625
+ //
626
+ // ONLY WHILE A BACKOFF IS ACTUALLY WAITING, and the effect's teardown clears it —
627
+ // so an idle session, and even a session that is retrying but has already fired
628
+ // its next attempt, has NO timer at all. 1 Hz rather than the elapsed timer's
629
+ // 200 ms because the card shows whole seconds and a five-times-finer tick would
630
+ // buy four identical frames.
631
+ useEffect(() => {
632
+ if (state.retry?.phase !== 'waiting')
633
+ return undefined;
634
+ const id = setInterval(() => dispatch({ type: 'retryTick' }), 1000);
635
+ return () => clearInterval(id);
636
+ }, [state.retry?.phase]);
475
637
  // --- Toast auto-dismiss (single effect keyed on the toast list). -------
476
638
  useEffect(() => {
477
639
  for (const t of state.toasts) {
@@ -510,7 +672,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
510
672
  // `state.json`, not `config.json`: whether a one-shot notice has been shown
511
673
  // is bookkeeping, and the comment on the old persisted key said so.
512
674
  onSeen: () => setMouseNoticeSeen(true),
513
- });
675
+ },
676
+ // Present only when `loadConfig` had to raise the user's own
677
+ // `transcriptRetain` to `transcriptWindow` (tui-render-performance §5.1).
678
+ cfg.transcriptRetainRequested !== undefined
679
+ ? { requested: cfg.transcriptRetainRequested, resolved: cfg.transcriptRetain }
680
+ : undefined);
514
681
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
515
682
  useEffect(() => {
516
683
  publishExitSnapshot({
@@ -519,8 +686,30 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
519
686
  provider: cfg.provider,
520
687
  model: cfg.model,
521
688
  startedAt: startedAt.current,
689
+ // Carried so the exit replay can say what the retain ring removed. A
690
+ // transcript that silently omits entries is worse than one that names the
691
+ // count (K-6).
692
+ droppedEntries: state.droppedEntries,
693
+ });
694
+ }, [state.entries, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model]);
695
+ // --- `/perf` snapshot channel (tui-render-performance §5.4). ------------
696
+ //
697
+ // The deps are the two `useCallback`d members, NOT the handle objects: both
698
+ // hooks return a fresh literal per render, so depending on them would re-run
699
+ // this effect on every frame.
700
+ const governorReset = governor.reset;
701
+ const heightsClear = heights.clear;
702
+ useEffect(() => {
703
+ setPerfSnapshotProvider(() => perfRef.current);
704
+ setPerfResetHook(() => {
705
+ governorReset();
706
+ heightsClear();
522
707
  });
523
- }, [state.entries, state.usageTotal, cfg.provider, cfg.model]);
708
+ return () => {
709
+ setPerfSnapshotProvider(null);
710
+ setPerfResetHook(null);
711
+ };
712
+ }, [governorReset, heightsClear]);
524
713
  // --- Confirm bridge (confirmTools mode + skill approvals). -------------
525
714
  useEffect(() => {
526
715
  if (!confirmBridge)
@@ -745,6 +934,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
745
934
  controller.setModel(values.provider, values.model, values.baseUrl || undefined);
746
935
  controller.setThinkingLevel(values.thinkingLevel);
747
936
  controller.setMaxTokens(cap.applied);
937
+ // THE SETTER AND THE PERSIST ARE BOTH REQUIRED, the pair `/todo panel`
938
+ // documents: persisting alone reports success and changes nothing until the
939
+ // next launch. `thinkingVisible` lives in `ViewState`, not in the config the
940
+ // controller holds, so the live half is a dispatch rather than a setter —
941
+ // and it is a no-op when the row already agrees with the current state.
942
+ if ((values.showThinking === 'on') !== stateRef.current.thinkingVisible) {
943
+ dispatch({ type: 'toggleThinking' });
944
+ }
748
945
  const key = values.apiKey.trim();
749
946
  if (key.length > 0) {
750
947
  // Registration site 4 (§4.4.3), and the one that matters most: this is how
@@ -759,6 +956,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
759
956
  model: values.model,
760
957
  baseUrl: values.baseUrl.trim() ? values.baseUrl.trim() : null,
761
958
  thinkingLevel: values.thinkingLevel,
959
+ showThinking: values.showThinking === 'on',
960
+ // PERSIST ONLY, with no paired live setter — the store is allocated in
961
+ // `AgentController`'s constructor, so this row takes effect on the next
962
+ // launch. That is what manual row 8 asks for, and it is why the seed above
963
+ // reads `cfg` rather than `ViewState`.
964
+ liveToolOutput: values.liveToolOutput === 'on',
762
965
  // Omitted entirely when the field was unusable, so the previous value
763
966
  // survives instead of being overwritten with a guess.
764
967
  ...(cap.persist !== 'skip' ? { maxTokens: cap.persist } : {}),
@@ -769,6 +972,20 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
769
972
  // merges it — a shallow merge here would reset `redactSecrets` every time
770
973
  // someone touched the level.
771
974
  patch.log = { level: values.logLevel };
975
+ // --- Fast tier (fast-model-tier §4.5) ---------------------------------
976
+ //
977
+ // THE SETTER AND THE PERSIST ARE BOTH REQUIRED, and this is the pair `/todo
978
+ // panel` documents (P1-2): `App` reads `controller.getConfig()` at render
979
+ // time, so persisting alone reports success and changes nothing until the
980
+ // next launch, while the setter alone forgets by morning.
981
+ //
982
+ // `setFastConfig` also re-resolves the tier, which matters HERE more than
983
+ // anywhere: this same Enter press may have changed the main provider, the
984
+ // main model or the API key, and `fast.provider: ''` inherits from all
985
+ // three (§3.2 rule 8 / RV-3).
986
+ const fastPatch = readFastSettings(values);
987
+ controller.setFastConfig(fastPatch);
988
+ patch.fast = fastPatch;
772
989
  persistConfig(patch);
773
990
  getLogger().reconfigure({ ...controller.getConfig().log, level: values.logLevel });
774
991
  dispatch({ type: 'setOverlay', overlay: null });
@@ -806,26 +1023,24 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
806
1023
  }
807
1024
  resolveHuman({ kind: 'planDecision', decision: verdict.decision, feedback: verdict.feedback });
808
1025
  };
809
- // --- Mouse wheel region routing (§4.6). Table and coalescer live in the
810
- // hook (D-13); only the wiring it cannot own stays here. Inline arrows are
811
- // fine — the hook re-reads its options through a ref every render. The
812
- // `Math.max(0, …)` mirrors the keyboard branch below, and for the same
813
- // reason: `OverlayFrame` only ever reports a clamp DOWNWARD.
1026
+ // --- Mouse wheel routing (wheel-scrolls-transcript-only §4.1). Table and
1027
+ // coalescer live in the hook (D-13); only the wiring it cannot own stays here.
1028
+ // Inline arrows are fine — the hook re-reads its options through a ref every
1029
+ // render. The `Math.max(0, …)` mirrors the keyboard branch below, and for the
1030
+ // same reason: `OverlayFrame` only ever reports a clamp DOWNWARD.
814
1031
  const { clearCoalescer } = useWheelRouting({
815
1032
  mouseSource,
816
- geometryRef,
817
- geometryOverride,
818
1033
  getOverlay: () => stateRef.current.overlay,
819
1034
  onScroll: scrollBy,
820
1035
  onOverlayScroll: (delta) => setOverlayScroll((n) => Math.max(0, n + delta)),
821
- onHistoryStep: (dir) => setHistoryIntent((prev) => ({ dir, nonce: (prev?.nonce ?? 0) + 1 })),
822
1036
  });
823
1037
  // A fresh overlay always opens at the top; carrying the previous overlay's
824
1038
  // offset over would open Help mid-list for no reason the user can see.
825
1039
  //
826
1040
  // `clearCoalescer()` rides along here rather than adding a second subscriber
827
- // to the same transition (§4.7 rule 2): an accumulated-but-unflushed
828
- // transcript scroll must not survive an overlay opening.
1041
+ // to the same transition (wheel-scrolls-transcript-only §4.3): an
1042
+ // accumulated-but-unflushed transcript scroll must not survive an overlay
1043
+ // opening.
829
1044
  useEffect(() => {
830
1045
  setOverlayScroll(0);
831
1046
  clearCoalescer();
@@ -933,7 +1148,19 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
933
1148
  }
934
1149
  }
935
1150
  if (key.ctrl && (input === 't' || input === 'T')) {
1151
+ const revealing = !stateRef.current.thinkingVisible;
936
1152
  dispatch({ type: 'toggleThinking' });
1153
+ // THE ACK IS MODE-AWARE FOR THE SAME REASON THE MARKER IS (D-16 / P0-2).
1154
+ // Inline mode cannot repaint what `<Static>` has already printed, so the
1155
+ // toast says what it DID rather than what the user hoped. The hide
1156
+ // direction needs no qualifier: hiding applies to everything drawn from
1157
+ // here on, and nothing is claimed about scrollback either way.
1158
+ if (!revealing)
1159
+ toast('info', 'Thinking hidden.');
1160
+ else if (fullscreen)
1161
+ toast('info', 'Thinking shown.');
1162
+ else
1163
+ toast('info', 'Thinking shown for new output.');
937
1164
  return;
938
1165
  }
939
1166
  if (key.ctrl && (input === 'o' || input === 'O')) {
@@ -998,6 +1225,40 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
998
1225
  const tokPerSec = state.status === 'running' && elapsedMs > 500
999
1226
  ? Math.max(0, Math.round((state.usageTotal.outputTokens - runBaselineOut.current) / (elapsedMs / 1000)))
1000
1227
  : 0;
1228
+ // --- The activity line's seed (§3.2.3 / P1-7). --------------------------
1229
+ //
1230
+ // RENDER SCOPE. Without this the ref holds `0` on the first run (an arbitrary
1231
+ // word) and the PREVIOUS run's start on every run after, so every run opens on
1232
+ // a wrong phrase for one frame and then jumps — a flicker at the start of
1233
+ // every single turn, which is the one moment this feature exists to make feel
1234
+ // calm.
1235
+ const running = state.status === 'running';
1236
+ if (running && runStartedAt.current === 0)
1237
+ runStartedAt.current = Date.now();
1238
+ if (!running && runStartedAt.current !== 0)
1239
+ runStartedAt.current = 0;
1240
+ // --- The running tool (agent-activity-presentation-live §3.4 / L4). -----
1241
+ //
1242
+ // A REVERSE LOOP WITH AN EARLY EXIT, NOT `.filter(...).pop()` (P2-3).
1243
+ // `state.entries` is bounded by `transcriptRetain`, which DEFAULTS TO 1000,
1244
+ // and this runs in render scope at up to 30 fps — an allocating full scan
1245
+ // there is the exact cost `tui-render-performance` exists to remove. The
1246
+ // running tool, when there is one, is within a few entries of the end.
1247
+ let runningTool;
1248
+ if (running) {
1249
+ for (let i = state.entries.length - 1; i >= 0; i -= 1) {
1250
+ const e = state.entries[i];
1251
+ if (e.kind === 'tool' && e.status === 'running') {
1252
+ runningTool = e.name;
1253
+ break;
1254
+ }
1255
+ }
1256
+ }
1257
+ // WALL-CLOCK SECONDS, and the 200 ms ticker above is what causes the render
1258
+ // that re-reads it — so no timer is added (D-36 / E-15). Seconds rather than
1259
+ // milliseconds because `ToolCard` is `React.memo`'d with the default
1260
+ // comparator; `Transcript` passes it on only to running tool entries.
1261
+ const nowSec = running ? Math.floor(Date.now() / 1000) : undefined;
1001
1262
  // Row budget for the viewport and everything sized against it (§4.2a). The
1002
1263
  // inline path has no fixed frame, hence no height to fit into: `Infinity`
1003
1264
  // tells `OverlayFrame` to render everything and show no position indicator.
@@ -1042,18 +1303,33 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1042
1303
  model: cfg.model,
1043
1304
  baseUrl: cfg.baseUrl ?? '',
1044
1305
  thinkingLevel: cfg.thinkingLevel,
1306
+ // SEEDED AND READ BACK, both halves (§4.3): omitting either is the
1307
+ // documented silent half of adding a row — the field renders empty and
1308
+ // an untouched save writes a value the user never chose.
1309
+ //
1310
+ // FROM `ViewState`, NOT FROM `cfg`. `thinkingVisible` is session state
1311
+ // that `Ctrl+T` also writes, and there is no controller setter to keep
1312
+ // `cfg` in step — so seeding from the config would show the LAUNCH
1313
+ // value under a transcript that has been toggled since, and an
1314
+ // untouched save would then quietly toggle it back.
1315
+ showThinking: state.thinkingVisible ? 'on' : 'off',
1316
+ // FROM `cfg`, unlike its neighbour, and the difference is real: the
1317
+ // live tail is decided at construction (the store is allocated there
1318
+ // and there is no setter), so the config IS the current value and
1319
+ // nothing can have toggled it since launch.
1320
+ liveToolOutput: cfg.liveToolOutput ? 'on' : 'off',
1045
1321
  // `auto`, not an empty string: an empty field reads as "unset", which
1046
1322
  // is indistinguishable from AUTO and is not a state this can be in.
1047
1323
  maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : 'auto',
1048
1324
  apiKey: '',
1049
1325
  logLevel: cfg.log.level,
1050
- }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
1051
- // A MEASUREMENT MUST NEVER OUTLIVE THE FRAME IT DESCRIBED (P1-5): neither
1052
- // path below renders the full-screen `AppShell`, so nothing else would
1053
- // overwrite a stale `bottom`. See `UNMEASURED_GEOMETRY` for why there is no
1054
- // arithmetic fallback.
1055
- if (!fullscreen || rows < MIN_FULLSCREEN_ROWS)
1056
- geometryRef.current = UNMEASURED_GEOMETRY;
1326
+ // The four fast rows, seeded through the pure mapper so the screen and
1327
+ // the save handler cannot disagree about what `(inherit)` means.
1328
+ ...fastSettingsFrom(cfg.fast),
1329
+ }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave,
1330
+ // Read from `cfg` (render scope, re-read every frame), so `/retry off`
1331
+ // while the screen is open shows the new value on the next paint.
1332
+ retrySummary: cfg.retry.enabled && cfg.retry.maxRetries > 0 ? String(cfg.retry.maxRetries) : 'off' })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
1057
1333
  // A resize can drop below the usable floor. Do NOT leave the alt-screen —
1058
1334
  // entering and leaving it on every drag is worse than a placeholder — and take
1059
1335
  // the height from `frameHeight()` like everything else, because this is the
@@ -1071,13 +1347,39 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1071
1347
  // the header the moment the first message lands.
1072
1348
  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;
1073
1349
  const density = cfg.density;
1074
- 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: 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] }));
1350
+ const transcriptWindow = cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW;
1351
+ // Assigned during render so `/perf` reads the CURRENT frame (see `perfRef`).
1352
+ // `mountedEntries` is the same number `render-budget.test.tsx` asserts (AC-9).
1353
+ perfRef.current = {
1354
+ rung: governor.rung,
1355
+ intervalMs: governor.intervalMs.current,
1356
+ lastCommitMs: governor.lastCommitMs(),
1357
+ governorEnabled: cfg.renderGovernor,
1358
+ totalEntries: state.entries.length,
1359
+ retain: cfg.transcriptRetain ?? DEFAULT_TRANSCRIPT_RETAIN,
1360
+ droppedEntries: state.droppedEntries,
1361
+ mountedEntries: mountedCount.current,
1362
+ heightsMeasured: heights.stats().measured,
1363
+ heightsEstimated: heights.stats().estimated,
1364
+ cols: contentCols,
1365
+ mode: fullscreen ? 'fullscreen' : 'inline',
1366
+ viewportRows: fullscreen ? viewportBudget : rows,
1367
+ offset: scrolledLines,
1368
+ };
1369
+ 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, mode: mode, nowSec: nowSec, 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, mode: mode, nowSec: nowSec, theme: theme, caps: caps }), overlayNode] }));
1075
1370
  // The completion popup lives inside the bottom chrome and can add up to 9
1076
1371
  // rows; unbounded, it pushes the status bar past the frame height where
1077
1372
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
1078
1373
  const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
1079
- 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' }) }) }));
1080
- 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, ...(state.team
1374
+ 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, 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' }) }) }));
1375
+ return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, 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(BottomStatusRow, { mode: mode, toasts: state.toasts,
1376
+ // `!overlayNode` suppresses the line while an overlay owns the screen:
1377
+ // the full-screen branch replaces the whole viewport with the overlay,
1378
+ // and a working line under a settings screen is noise attached to a
1379
+ // surface the run is not visible on. (Inline overlays are part of the
1380
+ // document flow, so there the suppression is conservative rather than
1381
+ // necessary — P2-5.)
1382
+ activity: running && !overlayNode ? (_jsx(ActivityLine, { startedAt: runStartedAt.current, elapsedMs: elapsedMs, reducedMotion: reducedMotion, runningTool: runningTool, theme: theme, caps: caps })) : null, theme: theme }), 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
1081
1383
  ? {
1082
1384
  teamActive: {
1083
1385
  running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
@@ -1087,5 +1389,16 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1087
1389
  }
1088
1390
  : {}), ...(state.todos && !showRail
1089
1391
  ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
1392
+ : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}), ...(state.retry
1393
+ ? {
1394
+ retryActive: {
1395
+ attempt: state.retry.attempt,
1396
+ max: state.retry.maxRetries,
1397
+ // Recomputed at RENDER from the absolute `resumeAt`, which is
1398
+ // what keeps the chip and the transcript card showing the same
1399
+ // number without either of them owning a second clock.
1400
+ secondsLeft: secondsLeft(state.retry.resumeAt, Date.now()),
1401
+ },
1402
+ }
1090
1403
  : {}) }) }));
1091
1404
  }