@aragon-agent/cli 0.5.4 → 0.5.6

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 (60) hide show
  1. package/CHANGELOG.md +670 -577
  2. package/README.md +1105 -995
  3. package/dist/agent/controller.d.ts +71 -1
  4. package/dist/agent/controller.js +135 -1
  5. package/dist/agent/headless.d.ts +29 -0
  6. package/dist/agent/headless.js +86 -1
  7. package/dist/agent/reducer.d.ts +45 -0
  8. package/dist/agent/reducer.js +80 -1
  9. package/dist/agent/system-prompt.d.ts +8 -0
  10. package/dist/agent/system-prompt.js +2 -0
  11. package/dist/cli.js +40 -1
  12. package/dist/commands/builtins.js +166 -0
  13. package/dist/commands/registry.d.ts +27 -1
  14. package/dist/config/env.js +43 -0
  15. package/dist/config/load.d.ts +27 -0
  16. package/dist/config/load.js +25 -1
  17. package/dist/config/schema.d.ts +47 -0
  18. package/dist/config/schema.js +38 -0
  19. package/dist/config/store.js +10 -1
  20. package/dist/session/persist.d.ts +19 -1
  21. package/dist/session/persist.js +21 -2
  22. package/dist/team/subagent.js +11 -0
  23. package/dist/todo/follow-through.d.ts +117 -0
  24. package/dist/todo/follow-through.js +186 -0
  25. package/dist/todo/limits.d.ts +114 -0
  26. package/dist/todo/limits.js +114 -0
  27. package/dist/todo/normalize.d.ts +33 -0
  28. package/dist/todo/normalize.js +117 -0
  29. package/dist/todo/panel-rows.d.ts +53 -0
  30. package/dist/todo/panel-rows.js +103 -0
  31. package/dist/todo/prompt.d.ts +54 -0
  32. package/dist/todo/prompt.js +76 -0
  33. package/dist/todo/store.d.ts +86 -0
  34. package/dist/todo/store.js +183 -0
  35. package/dist/todo/todo-tool.d.ts +38 -0
  36. package/dist/todo/todo-tool.js +134 -0
  37. package/dist/todo/types.d.ts +62 -0
  38. package/dist/todo/types.js +12 -0
  39. package/dist/tools/index.d.ts +16 -2
  40. package/dist/tools/index.js +13 -0
  41. package/dist/ui/App.js +244 -7
  42. package/dist/ui/StatusBar.d.ts +18 -0
  43. package/dist/ui/StatusBar.js +5 -2
  44. package/dist/ui/TodoPanel.d.ts +48 -0
  45. package/dist/ui/TodoPanel.js +74 -0
  46. package/dist/ui/TodoStrip.d.ts +36 -0
  47. package/dist/ui/TodoStrip.js +32 -0
  48. package/dist/ui/Transcript.js +11 -1
  49. package/dist/ui/entries/TodoCard.d.ts +31 -0
  50. package/dist/ui/entries/TodoCard.js +35 -0
  51. package/dist/ui/glyphs.d.ts +12 -0
  52. package/dist/ui/glyphs.js +8 -0
  53. package/dist/ui/layout/AppShell.d.ts +34 -1
  54. package/dist/ui/layout/AppShell.js +3 -3
  55. package/dist/ui/layout/ScrollViewport.d.ts +13 -1
  56. package/dist/ui/layout/ScrollViewport.js +2 -2
  57. package/dist/ui/layout/rail.d.ts +56 -0
  58. package/dist/ui/layout/rail.js +68 -0
  59. package/dist/ui/overlays/HelpOverlay.js +4 -0
  60. package/package.json +3 -2
package/dist/ui/App.js CHANGED
@@ -29,9 +29,14 @@ 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';
36
41
  import { frameHeight, MIN_FULLSCREEN_ROWS } from './layout/frame.js';
37
42
  import { HINT_MIN_ROWS, viewportRows as computeViewportRows } from './layout/budget.js';
@@ -144,6 +149,66 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
144
149
  const pendingHuman = useRef(new Set());
145
150
  /** Written by `AppShell`'s layout effect, read at wheel-event time (§4.5). */
146
151
  const geometryRef = useRef(UNMEASURED_GEOMETRY);
152
+ // --- Follow-through (todo-plan-followthrough §3.4). ---------------------
153
+ //
154
+ // THREE REFS AND NO VIEW STATE, DELIBERATELY (§5.2). Every field added to
155
+ // `ViewState` is a field `restoreEntries`, `/clear`, `/reset` and the session
156
+ // round-trip each have to have an opinion about, and none of them has an
157
+ // opinion about a 3-second timer.
158
+ const budgetRef = useRef(emptyBudget());
159
+ const followTimer = useRef(null);
160
+ /** True while the run in flight was started by the grace timer, not the user. */
161
+ const autoContinuationRef = useRef(false);
162
+ /**
163
+ * WHY THE END REASON TRAVELS IN A REF THIS FEATURE OWNS (C-11 / D-17).
164
+ *
165
+ * `ViewState.aborted` / `.errorNoticed` are the DEFINITION of the end reason —
166
+ * `runEnd` reads exactly that pair for its own silent-failure guard — but the
167
+ * reducer is not a safe CHANNEL for it, because the answer is needed inside a
168
+ * controller callback rather than during a render.
169
+ *
170
+ * Reading `stateRef.current` there happens to work under Ink 5 only because
171
+ * Ink mounts a LEGACY root (`ink.js`: `createContainer(rootNode, 0 …)`), so a
172
+ * dispatch from a promise context flushes synchronously. Under a concurrent
173
+ * root — an Ink major bump, or its `experimental` renderer — React 18's
174
+ * automatic batching returns, and a provider stream error emitted as
175
+ * `message_update` immediately before `agent_end` (core throws in the loop and
176
+ * emits `agent_end` from `runLoopWithLifecycle`'s `finally`, microtasks apart)
177
+ * would still read `errorNoticed: false`. That auto-continues into a run that
178
+ * just failed authentication: silent, and billable.
179
+ *
180
+ * So this ref is mutated in the SAME SYNCHRONOUS CALLBACK that dispatches the
181
+ * corresponding action, and the reducer fields stay as the cross-check.
182
+ */
183
+ const endReasonRef = useRef({
184
+ aborted: false,
185
+ errored: false,
186
+ });
187
+ /**
188
+ * Disarm an armed continuation. Idempotent; NEVER touches the budget — a
189
+ * cancelled continuation was never issued, so it must not be charged (AC-18).
190
+ *
191
+ * Three callers and there must be no fourth: `Esc` in the idle branch, the
192
+ * first line of `submitMessage`, and the subscription effect's cleanup.
193
+ */
194
+ const cancelFollowThrough = useCallback(() => {
195
+ if (followTimer.current) {
196
+ clearTimeout(followTimer.current);
197
+ followTimer.current = null;
198
+ }
199
+ }, []);
200
+ /**
201
+ * `submitMessage` as of the LATEST render, for the grace timer.
202
+ *
203
+ * The subscription effect below never re-runs (its deps are stable by
204
+ * construction), so a direct call would close over the mount render's binding
205
+ * forever. That binding happens to be functionally correct today because
206
+ * `submitMessage` reads everything mutable through refs — which is exactly the
207
+ * kind of "happens to be correct" this feature has already had to unwind once
208
+ * (P1-1, P1-6). Assigned during render, the same discipline `stateRef` uses
209
+ * twenty lines up.
210
+ */
211
+ const submitRef = useRef(() => { });
147
212
  /**
148
213
  * THE SINGLE SETTLE PATH for a human request. Idempotent per entry, and it
149
214
  * clears the overlay as its last act.
@@ -200,6 +265,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
200
265
  const unsubscribe = controller.subscribe((event) => {
201
266
  const cost = controller.getModelInfo().cost;
202
267
  for (const action of reduceEvent(event, cost)) {
268
+ // MUTATED HERE, ONE INSTANT BEFORE THE ACTION THAT SETS
269
+ // `ViewState.errorNoticed`, rather than read back from the reducer one
270
+ // commit later (C-11). This is the whole of the batching-independence
271
+ // guarantee: `agent_end` can follow this action in the same tick with no
272
+ // render in between, and AC-40 asserts exactly that sequence.
273
+ if (action.type === 'notice' && action.level === 'error') {
274
+ endReasonRef.current.errored = true;
275
+ }
203
276
  if (action.type === 'textDelta' || action.type === 'thinkingDelta') {
204
277
  pending.current.push(action);
205
278
  if (!flushTimer.current) {
@@ -220,6 +293,69 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
220
293
  // Without it, an aborted run leaves a plan card on screen whose `a` key
221
294
  // silently flips the session to build, with no visible cause.
222
295
  cancelPendingHuman();
296
+ // --- Follow-through (todo-plan-followthrough §3.4). ------------------
297
+ //
298
+ // Round 1 printed one notice here and stopped, under a good argument
299
+ // ("auto-continuation is an unbounded cost loop wearing a helpful hat").
300
+ // That judgement is not overturned: the loop is BOUNDED first, by two
301
+ // structural counters, and only then permitted — opt-in, and off by
302
+ // default, so `followThrough: 'notify'` reproduces the old notice byte for
303
+ // byte (AC-1).
304
+ const todos = controller.getTodoSnapshot();
305
+ budgetRef.current = advanceBudget(budgetRef.current, todos, autoContinuationRef.current);
306
+ autoContinuationRef.current = false;
307
+ const decision = decideFollowThrough({
308
+ // FROM THE CONTROLLER, NOT FROM `cfg` (P1-1). This effect's dependency
309
+ // list never changes, so it closes over the `cfg` object from the render
310
+ // in which it mounted — and `setTodoConfig` builds a NEW object, which
311
+ // that closure will never see. `cfg.todo.followThrough` would therefore
312
+ // be pinned to the launch value for the whole session and
313
+ // `/todo follow auto` would report success and do nothing. Note the
314
+ // asymmetry with `showStrip` further down, which is right to use `cfg`
315
+ // because it runs in render scope where `cfg` is re-read every frame.
316
+ mode: controller.getTodoConfig().followThrough,
317
+ snapshot: todos,
318
+ runEnd: { ...endReasonRef.current },
319
+ budget: budgetRef.current,
320
+ interactive: true,
321
+ });
322
+ endReasonRef.current = { aborted: false, errored: false };
323
+ if (decision.kind === 'notify') {
324
+ dispatch({ type: 'notice', level: decision.level, text: decision.text });
325
+ }
326
+ else if (decision.kind === 'continue') {
327
+ dispatch({ type: 'notice', level: 'info', text: decision.notice });
328
+ // NO LIVE COUNTDOWN (D-10). The notice is written once and does not
329
+ // tick: a 10 Hz re-render of the whole frame to animate a number is the
330
+ // repaint cost this package refuses elsewhere, and Ink's two dedupe
331
+ // gates mean the alternative is not free either.
332
+ cancelFollowThrough(); // I-3: at most one armed continuation, ever
333
+ followTimer.current = setTimeout(() => {
334
+ followTimer.current = null;
335
+ // TWO RE-CHECKS AT FIRE TIME, not only at decision time. The grace
336
+ // window is 3 s and the user can type in it (C-6): without the first,
337
+ // a fired continuation would either be swallowed into
338
+ // `controller.steer()` (the quiet failure) or reach
339
+ // `controller.prompt()`, which REJECTS while running.
340
+ if (controller.isRunning())
341
+ return;
342
+ const live = controller.getTodoSnapshot();
343
+ if (!live || live.doneCount >= live.total)
344
+ return; // the list moved
345
+ budgetRef.current = { ...budgetRef.current, used: budgetRef.current.used + 1 };
346
+ autoContinuationRef.current = true;
347
+ // COUNTS ONLY, NEVER ITEM TEXT (D-19), and only at the fire point —
348
+ // the quiet paths must stay quiet.
349
+ getLogger().debug('agent', 'todo_follow', {
350
+ used: budgetRef.current.used,
351
+ noProgressStreak: budgetRef.current.noProgressStreak,
352
+ total: live.total,
353
+ done: live.doneCount,
354
+ mode: 'auto',
355
+ });
356
+ submitRef.current(decision.message, { userInitiated: false });
357
+ }, decision.graceMs);
358
+ }
223
359
  // A deferred `plan -> build` becomes real here and nowhere else (§3.2).
224
360
  const applied = controller.applyPendingMode();
225
361
  if (applied) {
@@ -233,9 +369,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
233
369
  });
234
370
  return () => {
235
371
  flushPending();
372
+ // An armed continuation must not outlive the tree that armed it (AC-21):
373
+ // the timer would fire into an unmounted App and `submitRef.current` would
374
+ // prompt a controller nobody is rendering.
375
+ cancelFollowThrough();
236
376
  unsubscribe();
237
377
  };
238
- }, [controller, flushPending, cancelPendingHuman]);
378
+ }, [controller, flushPending, cancelPendingHuman, cancelFollowThrough]);
239
379
  // --- Team-mode event stream (team-subagents §5.2 / §3.9). --------------
240
380
  //
241
381
  // A SEPARATE SUBSCRIPTION, not a branch in the one above: `TeamEvent` is
@@ -292,6 +432,34 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
292
432
  controller.dispose();
293
433
  };
294
434
  }, [controller]);
435
+ // --- Todo event stream (todo-plan-execution §3.8 / §5.2). --------------
436
+ //
437
+ // A THIRD SUBSCRIPTION, for the reason the team one records: `TodoEvent` is
438
+ // deliberately not a member of core's `AgentEvent` union (D-11), so it cannot
439
+ // arrive through `controller.subscribe`.
440
+ //
441
+ // NOT ROUTED THROUGH THE STREAMING COALESCER either. That buffer exists to
442
+ // merge text/thinking deltas; a `todo_write` is one deliberate act per step,
443
+ // and delaying the one surface the user is watching a long task through would
444
+ // be exactly backwards.
445
+ useEffect(() => {
446
+ return controller.subscribeTodos((event) => {
447
+ switch (event.type) {
448
+ case 'updated':
449
+ dispatch({ type: 'todoUpdate', snapshot: event.snapshot });
450
+ break;
451
+ case 'cleared':
452
+ dispatch({ type: 'todoCleared' });
453
+ break;
454
+ case 'rejected':
455
+ // THE COMPENSATING PATH FOR SUPPRESSING THE TOOL CARD (§3.8). Without
456
+ // it, the one case where `todo_write` produces nothing visible would
457
+ // be the case that most needs to be visible.
458
+ dispatch({ type: 'notice', level: 'warn', text: event.reason });
459
+ break;
460
+ }
461
+ });
462
+ }, [controller]);
295
463
  // --- Elapsed timer + tokens/sec baseline while running. ----------------
296
464
  useEffect(() => {
297
465
  if (state.status !== 'running') {
@@ -495,9 +663,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
495
663
  toast,
496
664
  persistConfig,
497
665
  exit: doExit,
498
- submit: (text) => submitMessage(text),
666
+ submit: (text, opts) => submitMessage(text, opts),
499
667
  refreshSkills: () => setSkillsNonce((n) => n + 1),
500
668
  applyAgentMode: applyMode,
669
+ // Read at command time, so `/todo status` reports the live count rather than
670
+ // whatever it was when this context was built.
671
+ followBudget: budgetRef.current,
501
672
  });
502
673
  /**
503
674
  * Submitting a prompt no longer touches `config.json` at all — it appends one
@@ -527,13 +698,26 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
527
698
  * `handleSubmit` would re-parse it as input and, for a body that happens to
528
699
  * start with `/`, recurse into command dispatch.
529
700
  */
530
- const submitMessage = (message) => {
701
+ const submitMessage = (message, opts = {}) => {
702
+ // A USER MESSAGE IS A NEW INTENT AND OUTRANKS A QUEUED ONE (AC-19). First
703
+ // line, before the steering branch: a message typed during the grace window
704
+ // must be the one that runs, whichever branch it takes.
705
+ cancelFollowThrough();
531
706
  if (stateRef.current.status === 'running') {
532
707
  controller.steer(message);
533
708
  toast('info', 'Steering queued.');
534
709
  return;
535
710
  }
536
- recordPrompt(message);
711
+ // `userInitiated: false` skips TWO things and both are wanted: the
712
+ // prompt-history append, and the `submitCount` bump that drives the
713
+ // composer's hint fade. An auto-continuation demonstrates nothing about what
714
+ // the user has learned, so suppressing the fade is correct rather than
715
+ // incidental — which is why the parameter is named for the CAUSE (P2-8).
716
+ if (opts.userInitiated !== false)
717
+ recordPrompt(message);
718
+ // Mirrors where the reducer clears its own copies, in the same action
719
+ // (C-11): a new turn's end reason must not inherit the previous one's.
720
+ endReasonRef.current = { aborted: false, errored: false };
537
721
  dispatch({ type: 'submit', text: message });
538
722
  const pre = controller.preflight();
539
723
  if (!pre.ok) {
@@ -544,6 +728,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
544
728
  // Fire-and-forget: events drive the UI. prompt() never rejects.
545
729
  void controller.prompt(message);
546
730
  };
731
+ submitRef.current = submitMessage;
547
732
  const handleSubmit = async (raw) => {
548
733
  // Submitting is an unconditional "take me to the newest output" (§4.5).
549
734
  setPinToBottomNonce((n) => n + 1);
@@ -782,8 +967,24 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
782
967
  }
783
968
  if (stateRef.current.status === 'running') {
784
969
  controller.abort();
970
+ // Next to `abortMark`, in the same callback, for the reason
971
+ // `endReasonRef`'s own comment gives (C-11). D-5 turns this into
972
+ // silence at `agent_end`: the user's Esc is the statement, and
973
+ // restating what they interrupted is the CLI arguing.
974
+ endReasonRef.current.aborted = true;
785
975
  dispatch({ type: 'abortMark' });
786
976
  toast('info', 'Run aborted.');
977
+ return;
978
+ }
979
+ // IDLE. Today this branch does nothing at all, which is what makes it free
980
+ // to take (R-4). OVERLAY PRECEDENCE IS UNCHANGED AND DELIBERATE (P2-9):
981
+ // the block above returns while an overlay is open, so with one open the
982
+ // first Esc closes the overlay and the second cancels the continuation.
983
+ // Reaching past it would make "close this dialog" silently also mean
984
+ // "abandon the plan".
985
+ if (followTimer.current) {
986
+ cancelFollowThrough();
987
+ toast('info', 'Auto-continue cancelled.');
787
988
  }
788
989
  return;
789
990
  }
@@ -802,6 +1003,40 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
802
1003
  // tells `OverlayFrame` to render everything and show no position indicator.
803
1004
  const viewportBudget = computeViewportRows(rows);
804
1005
  const overlayMaxRows = fullscreen ? viewportBudget : Number.POSITIVE_INFINITY;
1006
+ // --- The todo rail (todo-plan-execution §3.9). --------------------------
1007
+ //
1008
+ // ONE NUMBER FOR THE GATE AND FOR THE LAYOUT (I-10). `railRows` is both what
1009
+ // decides whether the panel mounts and what the panel lays out against; a
1010
+ // panel that mounts on one number and renders against another shows nothing
1011
+ // and reports nothing. `todoRailRows` is NOT `viewportBudget`: the live team
1012
+ // roster costs up to 8 rows inside `AppShell`'s measured bottom box that the
1013
+ // budget knows nothing about (P1-3).
1014
+ const railRows = todoRailRows(viewportBudget, state.team !== null);
1015
+ const railWidth = todoRailWidth(cols);
1016
+ const showRail = fullscreen && // non-goal 4: inline mode has no fixed frame
1017
+ cfg.todo.panel && // `/todo panel off`, persisted
1018
+ overlay === null && // a modal owns the screen (D-7)
1019
+ state.todos !== null && // no list -> no furniture, zero layout cost
1020
+ railWidth > 0 && // too narrow (under 80 columns)
1021
+ railRows >= TODO_LIMITS.panelMinRows; // too short to say anything useful
1022
+ // The inline plan strip (todo-plan-followthrough §3.7 / W2).
1023
+ //
1024
+ // DELIBERATELY NOT `!showRail`, which is true for FOUR different reasons and
1025
+ // three of them mean "do not draw this". `cfg.todo.panel` is included because
1026
+ // `--no-todo-panel` means "keep the planning, drop the display" and a strip is
1027
+ // a display. Overlays are NOT consulted: inline mode's overlays are part of
1028
+ // the document flow, not a modal that owns the screen.
1029
+ //
1030
+ // `cfg` is the right read here and `controller.getTodoConfig()` would be
1031
+ // needless — this runs in RENDER scope, where `cfg` is re-read every frame.
1032
+ // The asymmetry with the `agent_end` handler above is the whole of P1-1.
1033
+ const showStrip = !fullscreen && cfg.todo.panel && state.todos !== null;
1034
+ // Every consumer INSIDE the viewport slot switches to this; the header,
1035
+ // composer, status bar, toasts and every overlay keep the full frame width
1036
+ // (C-7). Overlays keep it because `showRail` already includes `overlay ===
1037
+ // null`: splitting the screen with a status panel behind a modal is both worse
1038
+ // design and a second source of width-contract bugs in six components.
1039
+ const contentCols = cols - (showRail ? railWidth : 0);
805
1040
  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
1041
  provider: cfg.provider,
807
1042
  model: cfg.model,
@@ -834,15 +1069,15 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
834
1069
  // The wordmark + getting-started card now live INSIDE the viewport, as its
835
1070
  // first block of content, so they scroll away instead of being yanked out of
836
1071
  // 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;
1072
+ 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
1073
  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] }));
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] }));
840
1075
  // The completion popup lives inside the bottom chrome and can add up to 9
841
1076
  // rows; unbounded, it pushes the status bar past the frame height where
842
1077
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
843
1078
  const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
844
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' }) }) }));
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
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
846
1081
  ? {
847
1082
  teamActive: {
848
1083
  running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
@@ -850,5 +1085,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
850
1085
  total: state.team.runs.length,
851
1086
  },
852
1087
  }
1088
+ : {}), ...(state.todos && !showRail
1089
+ ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
853
1090
  : {}) }) }));
854
1091
  }
@@ -70,6 +70,24 @@ 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
+ };
73
91
  }
74
92
  export declare function StatusBar(props: StatusBarProps): React.ReactElement;
75
93
  export {};
@@ -5,6 +5,7 @@ import { formatCost, formatTokens, formatDuration } from '../agent/usage.js';
5
5
  import { buildGauge } from './gauge.js';
6
6
  import { MODE_LABEL } from '../agent/agent-mode.js';
7
7
  import { TEAM_LIMITS } from '../team/limits.js';
8
+ import { TODO_LIMITS } from '../todo/limits.js';
8
9
  /**
9
10
  * I-5 — DO NOT "CLEAN THIS UP". Ink short-circuits identical output at TWO
10
11
  * gates (`ink.js:132` and `log-update.js:13`), so a fixed frame whose React
@@ -24,7 +25,7 @@ function redrawChar(nonce) {
24
25
  return nonce % 2 === 0 ? ' ' : NBSP;
25
26
  }
26
27
  export function StatusBar(props) {
27
- const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, } = props;
28
+ const { model, provider, usageTotal, contextTokens, contextWindow, contextWindowKnown, status, elapsedMs, thinkingLevel, tokPerSec, theme, caps, scrolledLines = 0, redrawNonce = 0, agentMode = 'build', pendingAgentMode = null, teamActive, todoActive, } = props;
28
29
  const { stdout } = useStdout();
29
30
  const cols = stdout?.columns ?? 80;
30
31
  const running = status === 'running';
@@ -50,5 +51,7 @@ export function StatusBar(props) {
50
51
  // land in one predictable place.
51
52
  _jsxs(Box, { flexDirection: "row", justifyContent: "space-between", flexShrink: 0, children: [_jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsxs(Text, { color: running ? theme.toolRunning : theme.toolDone, children: [redrawChar(redrawNonce), statusGlyph, " ", running ? 'running' : 'idle'] }), modeWord && (_jsxs(Text, { color: theme.accent, bold: true, children: [' ', modeWord] })), teamActive && (_jsxs(Text, { color: theme.toolRunning, bold: true, children: [' ', cols >= TEAM_LIMITS.statusCompactCols
52
53
  ? `agents ${teamActive.running}/${teamActive.total}`
53
- : `[${teamActive.running}]`] })), cols >= 60 && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= 72 && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] }));
54
+ : `[${teamActive.running}]`] })), todoActive && (_jsxs(Text, { color: theme.accent, children: [' ', cols >= TODO_LIMITS.statusCompactCols
55
+ ? `todo ${todoActive.done}/${todoActive.total}`
56
+ : `[${todoActive.done}/${todoActive.total}]`] })), cols >= 60 && (_jsxs(Text, { color: theme.muted, children: [' ', provider, ":", model] })), thinkingLevel !== 'off' && cols >= 72 && (_jsxs(Text, { color: theme.accent, children: [" think:", thinkingLevel] }))] }), _jsxs(Box, { flexDirection: "row", children: [scrolledLines > 0 && (_jsxs(Text, { color: theme.noticeWarn, children: [glyphs.arrowUp, scrolledLines, ' '] })), cols >= 60 && (_jsxs(Text, { children: [_jsx(Text, { color: theme.muted, children: "[" }), _jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty }), _jsx(Text, { color: theme.muted, children: "] " })] })), _jsx(Text, { color: theme.muted, children: pctLabel }), cols >= 72 && _jsxs(Text, { color: theme.muted, children: [" ", tokens] }), _jsxs(Text, { color: theme.muted, children: [" ", formatCost(usageTotal.costUsd)] }), running && (_jsxs(Text, { color: theme.muted, children: [tokPerSec > 0 && cols >= 72 ? ` ${tokPerSec} tok/s` : '', " ", formatDuration(elapsedMs)] }))] })] }));
54
57
  }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * TodoPanel — the right rail (todo-plan-execution §6.1).
3
+ *
4
+ * ASCII ONLY: every glyph comes from `pickGlyphs`; a literal here bypasses the
5
+ * capability probe and shows mojibake on a legacy console (`glyphs.ts` header).
6
+ *
7
+ * MOUNTED ONLY WHEN A LIST EXISTS. `App` passes `null` the rest of the time and
8
+ * this component is not rendered at all, so a session that never plans has zero
9
+ * columns and zero layout shift (R-g's "no furniture when idle" / AC-24).
10
+ *
11
+ * IT MUST BE MOUNTED IN `AppShell`'s MIDDLE BAND, NEVER INSIDE `bottomRef`
12
+ * (C-6 / I-4). That box is MEASURED and the wheel router reads the composer band
13
+ * boundary from it; a column added there would inflate the measurement and start
14
+ * routing transcript scrolls into prompt history.
15
+ *
16
+ * WIDTH IS MEASURED IN COLUMNS, NEVER IN CODE UNITS (P2-3). This rail is 18-36
17
+ * columns wide and the system prompt's last line is "Respond in the language the
18
+ * user writes in", so CJK item text in an 18-column rail is the NORMAL case
19
+ * rather than the edge case. Every text cell is `wrap="truncate"` and Ink's
20
+ * truncation is display-width aware, so nothing can overflow the box — and NO
21
+ * ARITHMETIC HERE MAY BUDGET USER TEXT BY `.length`. The `padEnd(3)` /
22
+ * `padStart(2)` cells below are ASCII markers and indices, which is why they are
23
+ * safe; a "characters that fit" calculation over `content` would not be. That is
24
+ * the whole of the lesson `TeamPanel`'s `WIDE_CHAR_ALLOWANCE` records at length,
25
+ * and the mitigation here is to have no such budget at all.
26
+ */
27
+ import React from 'react';
28
+ import type { Theme } from './theme.js';
29
+ import type { TermCapabilities } from './capabilities.js';
30
+ import type { TodoSnapshot } from '../todo/types.js';
31
+ export interface TodoPanelProps {
32
+ snapshot: TodoSnapshot;
33
+ /** From `todoRailWidth(cols)`. The rail's whole column budget, separator included. */
34
+ width: number;
35
+ /**
36
+ * From `todoRailRows(viewportBudget, teamActive)` — NOT `viewportBudget`
37
+ * (I-10 / P1-3). The gate that mounts this component and the number it lays
38
+ * out against are ONE value computed once in `App`: a panel that mounts on one
39
+ * number and lays out against another renders nothing and reports nothing.
40
+ */
41
+ rows: number;
42
+ /** Whether the agent is running; gates the spinner (never animate when idle). */
43
+ running: boolean;
44
+ reducedMotion: boolean;
45
+ theme: Theme;
46
+ caps: TermCapabilities;
47
+ }
48
+ export declare function TodoPanel({ snapshot, width, rows, running, reducedMotion, theme, caps, }: TodoPanelProps): React.ReactElement;
@@ -0,0 +1,74 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import Spinner from 'ink-spinner';
4
+ import { pickGlyphs } from './glyphs.js';
5
+ import { buildGauge } from './gauge.js';
6
+ import { railBorderProps } from './layout/Gutter.js';
7
+ import { TODO_RAIL_INDEX_MIN_COLS } from './layout/rail.js';
8
+ import { TODO_LIMITS } from '../todo/limits.js';
9
+ import { selectTodoRows } from '../todo/panel-rows.js';
10
+ /** Header row + the blank row under the gauge. */
11
+ const FIXED_ROWS = 2;
12
+ /** The gauge is the one purely decorative element, so it is the first to go. */
13
+ const GAUGE_MIN_ROWS = 8;
14
+ const GAUGE_MIN_COLS = 20;
15
+ /** Separator column + one space of padding, supplied by `railBorderProps`. */
16
+ const CHROME_COLS = 2;
17
+ /** Marker cell. Three, because the ASCII tier's `[x]` / `[ ]` are three wide. */
18
+ const MARKER_COLS = 3;
19
+ /** Index cell: two digits plus the space that separates it from the text. */
20
+ const INDEX_COLS = 3;
21
+ function marker(item, glyphs) {
22
+ if (item.status === 'completed')
23
+ return glyphs.todoDone;
24
+ if (item.status === 'in_progress')
25
+ return glyphs.todoActive;
26
+ return glyphs.todoPending;
27
+ }
28
+ function rowColor(item, theme) {
29
+ if (item.status === 'completed')
30
+ return theme.muted;
31
+ if (item.status === 'in_progress')
32
+ return theme.primary;
33
+ return theme.hintFg ?? theme.muted;
34
+ }
35
+ export function TodoPanel({ snapshot, width, rows, running, reducedMotion, theme, caps, }) {
36
+ const glyphs = pickGlyphs(caps);
37
+ const showGauge = width >= GAUGE_MIN_COLS && rows >= GAUGE_MIN_ROWS;
38
+ const showIndex = width >= TODO_RAIL_INDEX_MIN_COLS;
39
+ const itemRows = Math.max(1, rows - FIXED_ROWS - (showGauge ? 1 : 0));
40
+ const { visible, hiddenAbove, hiddenBelow } = selectTodoRows(snapshot.items, itemRows);
41
+ const pct = snapshot.total > 0 ? Math.round((snapshot.doneCount / snapshot.total) * 100) : 0;
42
+ const gauge = buildGauge(pct, Math.max(4, width - CHROME_COLS - 2), theme, caps);
43
+ const complete = snapshot.doneCount === snapshot.total;
44
+ return (
45
+ // `flexShrink={0}` IS LOAD-BEARING (P1-4). Ink's `Box.defaultProps` is
46
+ // `{ flexWrap:'nowrap', flexDirection:'row', flexGrow:0, flexShrink:1 }`, so
47
+ // a `width={n}` box inside a shrinking row is SQUEEZED under pressure — and
48
+ // every piece of arithmetic in `rail.ts` silently becomes a suggestion at
49
+ // exactly the narrow widths where the guarantee matters. `ScrollIndicator`
50
+ // spells the same pair one level down. Cross-axis height needs no prop:
51
+ // `alignItems` is unset in this tree and yoga's default is `stretch`.
52
+ _jsxs(Box, { flexDirection: "column", flexShrink: 0, width: width, overflow: "hidden", paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsx(Text, { wrap: "truncate", color: theme.accent, bold: true, children: "TODO" }), _jsxs(Text, { wrap: "truncate", color: complete ? theme.toolDone : theme.muted, children: [snapshot.doneCount, "/", snapshot.total, complete ? ' done' : ''] })] }), showGauge && (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: gauge.fillColor, children: gauge.filled }), _jsx(Text, { color: gauge.trackColor, children: gauge.empty })] })), _jsx(Text, { children: " " }), hiddenAbove > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenAbove, " above"] })), visible.map(({ item, index }) => {
53
+ const active = item.status === 'in_progress';
54
+ // The spinner is gated on `running`: a spinner while the agent is idle
55
+ // is a lie about the state of the world, and this panel outlives the run
56
+ // that filled it. Reduced motion and a non-Unicode terminal fall back to
57
+ // the same static marker, exactly as `AssistantEntry` and `TeamPanel` do.
58
+ const animate = active && running && !reducedMotion && caps.unicode;
59
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsx(Box, { flexShrink: 0, width: MARKER_COLS, children: _jsx(Text, { color: rowColor(item, theme), bold: active, children: animate ? _jsx(Spinner, { type: "dots" }) : marker(item, glyphs) }) }), showIndex && (_jsx(Box, { flexShrink: 0, width: INDEX_COLS, children: _jsx(Text, { color: theme.muted, children: String(index + 1).padStart(2) }) })), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: active ? 'wrap' : 'truncate', color: rowColor(item, theme), bold: active, children: active ? clampWrapped(item.activeForm, width) : item.content }) })] }, `${index}-${item.content}`));
60
+ }), hiddenBelow > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenBelow, " more"] }))] }));
61
+ }
62
+ /**
63
+ * Keep the wrapping row inside `TODO_LIMITS.activeWrapRows`.
64
+ *
65
+ * A CHARACTER BUDGET IS NOT A COLUMN BUDGET (P2-3), so this is deliberately
66
+ * generous rather than exact: it exists to stop a maximum-length `activeForm`
67
+ * from claiming five rows of an 18-column rail, and Ink's display-width-aware
68
+ * truncation is what actually keeps the row inside the box. Erring long costs
69
+ * nothing — the surrounding `overflow="hidden"` clips it.
70
+ */
71
+ function clampWrapped(text, width) {
72
+ const budget = Math.max(1, width - CHROME_COLS) * TODO_LIMITS.activeWrapRows;
73
+ return text.length > budget ? text.slice(0, budget) : text;
74
+ }
@@ -0,0 +1,36 @@
1
+ /**
2
+ * TodoStrip — the inline-mode plan surface (todo-plan-followthrough §3.7 / W2).
3
+ *
4
+ * ASCII ONLY: every glyph comes from `pickGlyphs`; a literal here bypasses the
5
+ * capability probe and shows mojibake on a legacy console. This file is inside
6
+ * the glyph scanner's scope because it is under `ui/`, NOT because of the
7
+ * `todo/` clause (C-4) — moving it would not move the guard rail with it.
8
+ *
9
+ * WHY A STRIP AND NOT THE RAIL. `decideRenderMode` picks inline for every pipe,
10
+ * every `CI=1`, every `TERM=dumb` and every terminal under 12 rows, and inline
11
+ * has no fixed frame to hang a column off (round 1's non-goal 4). Before this,
12
+ * such a session had the tool and a `todo 3/7` counter in the status bar, and no
13
+ * way at all to see WHICH step was running (OQ-1).
14
+ *
15
+ * WHY NOT IN THE STATUS BAR. The bar's left cluster is `flexShrink={0}` and its
16
+ * own comment records what happens when the row over-subscribes: Ink drops a
17
+ * character from EACH cluster and it renders `idl` / `? hel`. A variable-length
18
+ * step title cannot go there (C-7), which is what forces this into its own row.
19
+ *
20
+ * EXACTLY ONE RENDERED ROW, ALWAYS (AC-29). A strip that wrapped would push the
21
+ * composer down by an unpredictable number of rows on every keystroke, which is
22
+ * the worst possible behaviour for the mode with no frame to absorb it — hence
23
+ * `wrap="truncate"` inside a `flexGrow`/`flexShrink` box and no exceptions.
24
+ */
25
+ import React from 'react';
26
+ import type { Theme } from './theme.js';
27
+ import type { TermCapabilities } from './capabilities.js';
28
+ import type { TodoSnapshot } from '../todo/types.js';
29
+ export interface TodoStripProps {
30
+ snapshot: TodoSnapshot;
31
+ /** Full terminal width; the strip spans the row in inline mode. */
32
+ cols: number;
33
+ theme: Theme;
34
+ caps: TermCapabilities;
35
+ }
36
+ export declare function TodoStrip({ snapshot, cols, theme, caps }: TodoStripProps): React.ReactElement;
@@ -0,0 +1,32 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Box, Text } from 'ink';
3
+ import { pickGlyphs } from './glyphs.js';
4
+ import { TODO_LIMITS } from '../todo/limits.js';
5
+ import { todoAnchorIndex } from '../todo/panel-rows.js';
6
+ export function TodoStrip({ snapshot, cols, theme, caps }) {
7
+ const glyphs = pickGlyphs(caps);
8
+ // THE SAME FUNCTION THE RAIL USES (R-9 / AC-30). Two surfaces that each decide
9
+ // for themselves which step is "current" is two surfaces that disagree, and
10
+ // this one is on screen in exactly the mode where the rail is not, so the
11
+ // disagreement would never be seen side by side and never be reported.
12
+ const anchor = todoAnchorIndex(snapshot.items);
13
+ const item = snapshot.items[anchor];
14
+ const active = item?.status === 'in_progress';
15
+ // `activeForm` while in progress, `content` otherwise — `TodoPanel`'s rule.
16
+ const label = item ? (active ? item.activeForm : item.content) : '';
17
+ // The same degradation the status bar already applies, so the two counters can
18
+ // never disagree about how much room they need.
19
+ const counter = cols >= TODO_LIMITS.statusCompactCols
20
+ ? `todo ${snapshot.doneCount}/${snapshot.total}`
21
+ : `[${snapshot.doneCount}/${snapshot.total}]`;
22
+ const showDone = snapshot.doneCount > 0 && cols >= TODO_LIMITS.stripDoneCols;
23
+ return (
24
+ // `width` IS LOAD-BEARING, exactly as it is on `TodoPanel` and for a related
25
+ // reason. Inline mode has no fixed frame, so nothing upstream constrains
26
+ // this row: without an explicit width the flexible middle cell is sized by
27
+ // whatever the parent offers, and a long item overflows the terminal and
28
+ // WRAPS — the one thing this component exists not to do. Pinning it here
29
+ // makes the truncation deterministic and self-contained rather than a
30
+ // property of the tree above.
31
+ _jsxs(Box, { flexDirection: "row", width: cols, overflow: "hidden", children: [_jsx(Box, { flexShrink: 0, children: _jsx(Text, { wrap: "truncate", color: theme.accent, children: counter }) }), _jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', glyphs.arrowRight, ' '] }) }), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: "truncate", color: active ? theme.primary : theme.muted, bold: active, children: label }) }), showDone && (_jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", snapshot.doneCount, " done"] }) }))] }));
32
+ }