@aragon-agent/cli 0.5.3 → 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 (66) hide show
  1. package/CHANGELOG.md +670 -540
  2. package/README.md +1105 -932
  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 +72 -6
  12. package/dist/commands/builtins.d.ts +2 -2
  13. package/dist/commands/builtins.js +217 -3
  14. package/dist/commands/registry.d.ts +27 -1
  15. package/dist/config/cli-commands.js +20 -2
  16. package/dist/config/env.d.ts +2 -1
  17. package/dist/config/env.js +60 -5
  18. package/dist/config/load.d.ts +27 -0
  19. package/dist/config/load.js +67 -2
  20. package/dist/config/schema.d.ts +111 -2
  21. package/dist/config/schema.js +111 -2
  22. package/dist/config/store.js +37 -1
  23. package/dist/session/persist.d.ts +19 -1
  24. package/dist/session/persist.js +21 -2
  25. package/dist/team/subagent.js +11 -0
  26. package/dist/todo/follow-through.d.ts +117 -0
  27. package/dist/todo/follow-through.js +186 -0
  28. package/dist/todo/limits.d.ts +114 -0
  29. package/dist/todo/limits.js +114 -0
  30. package/dist/todo/normalize.d.ts +33 -0
  31. package/dist/todo/normalize.js +117 -0
  32. package/dist/todo/panel-rows.d.ts +53 -0
  33. package/dist/todo/panel-rows.js +103 -0
  34. package/dist/todo/prompt.d.ts +54 -0
  35. package/dist/todo/prompt.js +76 -0
  36. package/dist/todo/store.d.ts +86 -0
  37. package/dist/todo/store.js +183 -0
  38. package/dist/todo/todo-tool.d.ts +38 -0
  39. package/dist/todo/todo-tool.js +134 -0
  40. package/dist/todo/types.d.ts +62 -0
  41. package/dist/todo/types.js +12 -0
  42. package/dist/tools/index.d.ts +16 -2
  43. package/dist/tools/index.js +13 -0
  44. package/dist/ui/App.d.ts +22 -1
  45. package/dist/ui/App.js +292 -13
  46. package/dist/ui/StatusBar.d.ts +18 -0
  47. package/dist/ui/StatusBar.js +5 -2
  48. package/dist/ui/TodoPanel.d.ts +48 -0
  49. package/dist/ui/TodoPanel.js +74 -0
  50. package/dist/ui/TodoStrip.d.ts +36 -0
  51. package/dist/ui/TodoStrip.js +32 -0
  52. package/dist/ui/Transcript.js +11 -1
  53. package/dist/ui/entries/TodoCard.d.ts +31 -0
  54. package/dist/ui/entries/TodoCard.js +35 -0
  55. package/dist/ui/glyphs.d.ts +12 -0
  56. package/dist/ui/glyphs.js +8 -0
  57. package/dist/ui/layout/AppShell.d.ts +34 -1
  58. package/dist/ui/layout/AppShell.js +3 -3
  59. package/dist/ui/layout/ScrollViewport.d.ts +13 -1
  60. package/dist/ui/layout/ScrollViewport.js +2 -2
  61. package/dist/ui/layout/rail.d.ts +56 -0
  62. package/dist/ui/layout/rail.js +68 -0
  63. package/dist/ui/overlays/HelpOverlay.js +5 -0
  64. package/dist/ui/overlays/SettingsScreen.d.ts +12 -1
  65. package/dist/ui/overlays/SettingsScreen.js +38 -1
  66. package/package.json +3 -2
package/dist/ui/App.js CHANGED
@@ -11,7 +11,7 @@ import { Box, Text, useApp, useInput, useStdout } from 'ink';
11
11
  import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
12
12
  import { mergeDeltas } from '../agent/coalesce.js';
13
13
  import { computeCost } from '../agent/usage.js';
14
- import { coerceMaxTokens, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
14
+ import { parseMaxTokensInput, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
15
15
  import { updatePersistedConfig } from '../config/store.js';
16
16
  import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
17
17
  import { bumpSubmitCount, setMouseNoticeSeen } from '../config/ui-state.js';
@@ -29,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';
@@ -50,6 +55,41 @@ import { CommandRegistry, runSlashInput } from '../commands/registry.js';
50
55
  import { registerBuiltinCommands } from '../commands/builtins.js';
51
56
  import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
52
57
  import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
58
+ /**
59
+ * Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
60
+ * discard the rest of the form.
61
+ *
62
+ * The three outcomes are genuinely different and must stay distinguishable:
63
+ * `auto` is a setting, a clamped number is a setting the user should be told
64
+ * about, and nonsense is not a setting at all — it keeps the previous value and
65
+ * says so, rather than silently resolving to a default the user never chose.
66
+ *
67
+ * Exported for the tests; there is no other caller.
68
+ */
69
+ export function resolveSettingsMaxTokens(raw, previous) {
70
+ const parsed = parseMaxTokensInput(raw);
71
+ if (parsed.kind === 'auto') {
72
+ return {
73
+ applied: undefined,
74
+ persist: null,
75
+ toast: ['info', 'Max tokens: auto (per-model ceiling).'],
76
+ };
77
+ }
78
+ if (parsed.kind === 'value') {
79
+ return {
80
+ applied: parsed.value,
81
+ persist: parsed.value,
82
+ ...(parsed.clamped
83
+ ? { toast: ['warn', `Max tokens clamped to ${parsed.value}.`] }
84
+ : {}),
85
+ };
86
+ }
87
+ return {
88
+ applied: previous,
89
+ persist: 'skip',
90
+ toast: ['warn', 'Max tokens must be a number or "auto".'],
91
+ };
92
+ }
53
93
  /** Merge pending delta actions ~33 ms and flush the coalescer buffer. */
54
94
  const COALESCE_MS = 33;
55
95
  export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, geometryOverride, }) {
@@ -109,6 +149,66 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
109
149
  const pendingHuman = useRef(new Set());
110
150
  /** Written by `AppShell`'s layout effect, read at wheel-event time (§4.5). */
111
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(() => { });
112
212
  /**
113
213
  * THE SINGLE SETTLE PATH for a human request. Idempotent per entry, and it
114
214
  * clears the overlay as its last act.
@@ -165,6 +265,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
165
265
  const unsubscribe = controller.subscribe((event) => {
166
266
  const cost = controller.getModelInfo().cost;
167
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
+ }
168
276
  if (action.type === 'textDelta' || action.type === 'thinkingDelta') {
169
277
  pending.current.push(action);
170
278
  if (!flushTimer.current) {
@@ -185,6 +293,69 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
185
293
  // Without it, an aborted run leaves a plan card on screen whose `a` key
186
294
  // silently flips the session to build, with no visible cause.
187
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
+ }
188
359
  // A deferred `plan -> build` becomes real here and nowhere else (§3.2).
189
360
  const applied = controller.applyPendingMode();
190
361
  if (applied) {
@@ -198,9 +369,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
198
369
  });
199
370
  return () => {
200
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();
201
376
  unsubscribe();
202
377
  };
203
- }, [controller, flushPending, cancelPendingHuman]);
378
+ }, [controller, flushPending, cancelPendingHuman, cancelFollowThrough]);
204
379
  // --- Team-mode event stream (team-subagents §5.2 / §3.9). --------------
205
380
  //
206
381
  // A SEPARATE SUBSCRIPTION, not a branch in the one above: `TeamEvent` is
@@ -257,6 +432,34 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
257
432
  controller.dispose();
258
433
  };
259
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]);
260
463
  // --- Elapsed timer + tokens/sec baseline while running. ----------------
261
464
  useEffect(() => {
262
465
  if (state.status !== 'running') {
@@ -460,9 +663,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
460
663
  toast,
461
664
  persistConfig,
462
665
  exit: doExit,
463
- submit: (text) => submitMessage(text),
666
+ submit: (text, opts) => submitMessage(text, opts),
464
667
  refreshSkills: () => setSkillsNonce((n) => n + 1),
465
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,
466
672
  });
467
673
  /**
468
674
  * Submitting a prompt no longer touches `config.json` at all — it appends one
@@ -492,13 +698,26 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
492
698
  * `handleSubmit` would re-parse it as input and, for a body that happens to
493
699
  * start with `/`, recurse into command dispatch.
494
700
  */
495
- 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();
496
706
  if (stateRef.current.status === 'running') {
497
707
  controller.steer(message);
498
708
  toast('info', 'Steering queued.');
499
709
  return;
500
710
  }
501
- 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 };
502
721
  dispatch({ type: 'submit', text: message });
503
722
  const pre = controller.preflight();
504
723
  if (!pre.ok) {
@@ -509,6 +728,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
509
728
  // Fire-and-forget: events drive the UI. prompt() never rejects.
510
729
  void controller.prompt(message);
511
730
  };
731
+ submitRef.current = submitMessage;
512
732
  const handleSubmit = async (raw) => {
513
733
  // Submitting is an unconditional "take me to the newest output" (§4.5).
514
734
  setPinToBottomNonce((n) => n + 1);
@@ -518,10 +738,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
518
738
  submitMessage(raw.startsWith('//') ? raw.slice(1) : raw);
519
739
  };
520
740
  const handleSettingsSave = (values) => {
521
- const maxTokens = coerceMaxTokens(values.maxTokens);
741
+ // An unusable cap must NOT discard the rest of the form: the same Enter
742
+ // press is often carrying a freshly pasted API key, and losing that to a
743
+ // typo in an unrelated field is the worst outcome available here.
744
+ const cap = resolveSettingsMaxTokens(values.maxTokens, controller.getConfig().maxTokens);
522
745
  controller.setModel(values.provider, values.model, values.baseUrl || undefined);
523
746
  controller.setThinkingLevel(values.thinkingLevel);
524
- controller.setMaxTokens(maxTokens);
747
+ controller.setMaxTokens(cap.applied);
525
748
  const key = values.apiKey.trim();
526
749
  if (key.length > 0) {
527
750
  // Registration site 4 (§4.4.3), and the one that matters most: this is how
@@ -536,7 +759,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
536
759
  model: values.model,
537
760
  baseUrl: values.baseUrl.trim() ? values.baseUrl.trim() : null,
538
761
  thinkingLevel: values.thinkingLevel,
539
- maxTokens: maxTokens ?? null,
762
+ // Omitted entirely when the field was unusable, so the previous value
763
+ // survives instead of being overwritten with a guess.
764
+ ...(cap.persist !== 'skip' ? { maxTokens: cap.persist } : {}),
540
765
  };
541
766
  if (key.length > 0)
542
767
  patch.apiKeys = { [values.provider]: key };
@@ -547,7 +772,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
547
772
  persistConfig(patch);
548
773
  getLogger().reconfigure({ ...controller.getConfig().log, level: values.logLevel });
549
774
  dispatch({ type: 'setOverlay', overlay: null });
550
- toast('success', 'Settings saved.');
775
+ toast(cap.toast?.[0] ?? 'success', cap.toast?.[1] ?? 'Settings saved.');
551
776
  };
552
777
  const handleModelSelect = (provider, model) => {
553
778
  controller.setModel(provider, model);
@@ -742,8 +967,24 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
742
967
  }
743
968
  if (stateRef.current.status === 'running') {
744
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;
745
975
  dispatch({ type: 'abortMark' });
746
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.');
747
988
  }
748
989
  return;
749
990
  }
@@ -762,12 +1003,48 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
762
1003
  // tells `OverlayFrame` to render everything and show no position indicator.
763
1004
  const viewportBudget = computeViewportRows(rows);
764
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);
765
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: {
766
1041
  provider: cfg.provider,
767
1042
  model: cfg.model,
768
1043
  baseUrl: cfg.baseUrl ?? '',
769
1044
  thinkingLevel: cfg.thinkingLevel,
770
- maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : '',
1045
+ // `auto`, not an empty string: an empty field reads as "unset", which
1046
+ // is indistinguishable from AUTO and is not a state this can be in.
1047
+ maxTokens: cfg.maxTokens ? String(cfg.maxTokens) : 'auto',
771
1048
  apiKey: '',
772
1049
  logLevel: cfg.log.level,
773
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;
@@ -792,15 +1069,15 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
792
1069
  // The wordmark + getting-started card now live INSIDE the viewport, as its
793
1070
  // first block of content, so they scroll away instead of being yanked out of
794
1071
  // the header the moment the first message lands.
795
- 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;
796
1073
  const density = cfg.density;
797
- 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] }));
798
1075
  // The completion popup lives inside the bottom chrome and can add up to 9
799
1076
  // rows; unbounded, it pushes the status bar past the frame height where
800
1077
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
801
1078
  const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
802
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' }) }) }));
803
- 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
804
1081
  ? {
805
1082
  teamActive: {
806
1083
  running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
@@ -808,5 +1085,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
808
1085
  total: state.team.runs.length,
809
1086
  },
810
1087
  }
1088
+ : {}), ...(state.todos && !showRail
1089
+ ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
811
1090
  : {}) }) }));
812
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
+ }