@aragon-agent/cli 0.6.35 → 0.6.36

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 (146) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +3173 -3071
  3. package/dist/agent/agent-mode.d.ts +20 -4
  4. package/dist/agent/agent-mode.js +20 -4
  5. package/dist/agent/controller.d.ts +38 -3
  6. package/dist/agent/controller.js +64 -4
  7. package/dist/agent/headless.js +31 -0
  8. package/dist/agent/reducer.d.ts +13 -2
  9. package/dist/agent/reducer.js +1 -0
  10. package/dist/cli.js +12 -0
  11. package/dist/commands/agents-command.d.ts +15 -0
  12. package/dist/commands/agents-command.js +27 -0
  13. package/dist/commands/builtins.js +131 -4
  14. package/dist/commands/perf.d.ts +11 -0
  15. package/dist/commands/perf.js +2 -1
  16. package/dist/commands/registry.d.ts +14 -0
  17. package/dist/config/app-paths.d.ts +15 -0
  18. package/dist/config/app-paths.js +33 -2
  19. package/dist/config/env.d.ts +4 -1
  20. package/dist/config/env.js +28 -2
  21. package/dist/config/load.d.ts +10 -0
  22. package/dist/config/load.js +8 -1
  23. package/dist/config/schema.d.ts +68 -0
  24. package/dist/config/schema.js +39 -0
  25. package/dist/config/store.js +12 -1
  26. package/dist/config/ui-state.d.ts +18 -0
  27. package/dist/config/ui-state.js +9 -0
  28. package/dist/exec/events.d.ts +25 -0
  29. package/dist/exec/events.js +4 -0
  30. package/dist/exec/index.js +16 -1
  31. package/dist/exec/runner.js +10 -0
  32. package/dist/proc/limits.d.ts +16 -0
  33. package/dist/proc/limits.js +16 -0
  34. package/dist/proc/supervisor.d.ts +20 -1
  35. package/dist/proc/supervisor.js +42 -14
  36. package/dist/prompts/command.d.ts +34 -0
  37. package/dist/prompts/command.js +237 -0
  38. package/dist/prompts/copy.d.ts +89 -0
  39. package/dist/prompts/copy.js +131 -0
  40. package/dist/prompts/fill.d.ts +51 -0
  41. package/dist/prompts/fill.js +46 -0
  42. package/dist/prompts/limits.d.ts +53 -0
  43. package/dist/prompts/limits.js +53 -0
  44. package/dist/prompts/normalize.d.ts +53 -0
  45. package/dist/prompts/normalize.js +190 -0
  46. package/dist/prompts/resolve.d.ts +31 -0
  47. package/dist/prompts/resolve.js +60 -0
  48. package/dist/prompts/search.d.ts +58 -0
  49. package/dist/prompts/search.js +146 -0
  50. package/dist/prompts/store.d.ts +86 -0
  51. package/dist/prompts/store.js +453 -0
  52. package/dist/prompts/types.d.ts +204 -0
  53. package/dist/prompts/types.js +13 -0
  54. package/dist/session/checkpoint.d.ts +8 -1
  55. package/dist/session/checkpoint.js +6 -0
  56. package/dist/session/persist.d.ts +19 -1
  57. package/dist/session/persist.js +13 -1
  58. package/dist/session/startup-session.d.ts +7 -2
  59. package/dist/session/startup-session.js +5 -2
  60. package/dist/session/store.js +3 -0
  61. package/dist/session/validate-session.js +64 -1
  62. package/dist/team/limits.d.ts +25 -0
  63. package/dist/team/limits.js +26 -0
  64. package/dist/team/runtime.js +27 -2
  65. package/dist/team/settled.d.ts +57 -0
  66. package/dist/team/settled.js +87 -0
  67. package/dist/team/subagent.js +48 -2
  68. package/dist/team/timeline.d.ts +74 -0
  69. package/dist/team/timeline.js +101 -0
  70. package/dist/team/types.d.ts +24 -1
  71. package/dist/todo/acceptance.d.ts +120 -0
  72. package/dist/todo/acceptance.js +399 -0
  73. package/dist/todo/follow-through.d.ts +48 -23
  74. package/dist/todo/follow-through.js +110 -44
  75. package/dist/todo/limits.d.ts +62 -1
  76. package/dist/todo/limits.js +62 -1
  77. package/dist/todo/panel-rows.d.ts +29 -39
  78. package/dist/todo/panel-rows.js +37 -83
  79. package/dist/todo/plan-sections.d.ts +60 -0
  80. package/dist/todo/plan-sections.js +108 -0
  81. package/dist/todo/prompt.d.ts +17 -12
  82. package/dist/todo/prompt.js +59 -40
  83. package/dist/todo/row-window.d.ts +51 -0
  84. package/dist/todo/row-window.js +107 -0
  85. package/dist/todo/store.d.ts +139 -31
  86. package/dist/todo/store.js +271 -73
  87. package/dist/todo/todo-tool.d.ts +33 -8
  88. package/dist/todo/todo-tool.js +186 -30
  89. package/dist/todo/types.d.ts +58 -0
  90. package/dist/ui/App.js +310 -46
  91. package/dist/ui/Composer.d.ts +6 -2
  92. package/dist/ui/Composer.js +2 -2
  93. package/dist/ui/Header.d.ts +9 -1
  94. package/dist/ui/Header.js +2 -2
  95. package/dist/ui/PromptInput.d.ts +21 -1
  96. package/dist/ui/PromptInput.js +21 -1
  97. package/dist/ui/ScrollbackShell.d.ts +19 -19
  98. package/dist/ui/ScrollbackShell.js +8 -1
  99. package/dist/ui/StatusBar.d.ts +5 -0
  100. package/dist/ui/StatusBar.js +1 -0
  101. package/dist/ui/TeamPanel.d.ts +26 -44
  102. package/dist/ui/TeamPanel.js +46 -135
  103. package/dist/ui/TodoPanel.d.ts +17 -14
  104. package/dist/ui/TodoPanel.js +146 -38
  105. package/dist/ui/Transcript.js +1 -1
  106. package/dist/ui/composer-input.d.ts +10 -0
  107. package/dist/ui/composer-input.js +57 -24
  108. package/dist/ui/entries/TodoCard.d.ts +26 -11
  109. package/dist/ui/entries/TodoCard.js +56 -15
  110. package/dist/ui/glyphs.d.ts +19 -0
  111. package/dist/ui/glyphs.js +4 -0
  112. package/dist/ui/layout/agents-board.d.ts +102 -0
  113. package/dist/ui/layout/agents-board.js +389 -0
  114. package/dist/ui/layout/cells.d.ts +61 -0
  115. package/dist/ui/layout/cells.js +156 -0
  116. package/dist/ui/layout/header-hints.d.ts +13 -3
  117. package/dist/ui/layout/header-hints.js +48 -14
  118. package/dist/ui/layout/status-detail-layout.js +9 -0
  119. package/dist/ui/layout/status-layout.d.ts +13 -0
  120. package/dist/ui/layout/team-row.d.ts +139 -0
  121. package/dist/ui/layout/team-row.js +246 -0
  122. package/dist/ui/layout/todo-layout.d.ts +38 -0
  123. package/dist/ui/layout/todo-layout.js +75 -0
  124. package/dist/ui/layout/virtual-window.js +23 -3
  125. package/dist/ui/overlays/AgentsBoardOverlay.d.ts +57 -0
  126. package/dist/ui/overlays/AgentsBoardOverlay.js +183 -0
  127. package/dist/ui/overlays/HelpOverlay.js +17 -2
  128. package/dist/ui/overlays/PromptEditorPage.d.ts +48 -0
  129. package/dist/ui/overlays/PromptEditorPage.js +227 -0
  130. package/dist/ui/overlays/PromptLibraryOverlay.d.ts +37 -0
  131. package/dist/ui/overlays/PromptLibraryOverlay.js +191 -0
  132. package/dist/ui/overlays/PromptPicker.d.ts +36 -0
  133. package/dist/ui/overlays/PromptPicker.js +129 -0
  134. package/dist/ui/overlays/PromptTextArea.d.ts +86 -0
  135. package/dist/ui/overlays/PromptTextArea.js +141 -0
  136. package/dist/ui/overlays/prompt-library-state.d.ts +162 -0
  137. package/dist/ui/overlays/prompt-library-state.js +335 -0
  138. package/dist/ui/overlays/prompt-picker-state.d.ts +71 -0
  139. package/dist/ui/overlays/prompt-picker-state.js +98 -0
  140. package/dist/ui/scrollback/band.js +14 -1
  141. package/dist/ui/team-status.d.ts +25 -0
  142. package/dist/ui/team-status.js +31 -3
  143. package/dist/ui/use-startup-notices.d.ts +35 -1
  144. package/dist/ui/use-startup-notices.js +29 -2
  145. package/dist/ui/use-wheel-routing.js +9 -1
  146. package/package.json +2 -2
@@ -0,0 +1,246 @@
1
+ /**
2
+ * Panel row geometry — the ONE authority for how a subagent row is measured and
3
+ * what it says (tui-subagent-panel §4.2 / §6.1).
4
+ *
5
+ * WHY THIS FILE EXISTS. The roster used to place its right-hand columns by
6
+ * `flexGrow`, and a fixed-height parent box makes Yoga stretch the column to its
7
+ * widest CHILD instead of to the available width: on a 100-column terminal inside
8
+ * the band slot the row measured 48 (spec §0.1, cases 3/4), so `grep 22.1s` sat
9
+ * hard against the description and the right half of the row was blank. Flex
10
+ * cannot fix that, because the number it needs - "how wide is this terminal" - is
11
+ * not part of the layout it is given. So the widths are COMPUTED here, by a total
12
+ * pure function, and the component only places fixed-width boxes at the offsets
13
+ * this module names.
14
+ *
15
+ * THE FIVE CELLS, in drawing order:
16
+ *
17
+ * marker(1+status+1) label(8) description(flex) gap(2) activity(n) clock(8)
18
+ *
19
+ * `description` is the ONLY flexible cell, and it is the one that absorbs every
20
+ * width pressure - the same degradation discipline `StatusBar` documents for its
21
+ * left cluster. The two right-hand cells are fixed and `flexShrink={0}`, so their
22
+ * absolute offsets follow from `rowWidth = parentWidth` and the sum of the fixed
23
+ * cells in front of them, and never from how a particular frame allocates slack.
24
+ *
25
+ * ASCII ONLY: `src/ui/**` is inside `glyphs.test.ts`'s static scan.
26
+ *
27
+ * THE ACTIVITY HELPERS LIVE HERE TOO (v2 implementation note, recorded in the
28
+ * spec's "implementation findings"). `teamRowCells` has to compose the activity
29
+ * text with the supervisor badge, which is what `activityWithBadge` already does;
30
+ * leaving those in `TeamPanel.tsx` would make this module and the component
31
+ * import each other. `TeamPanel` therefore RE-EXPORTS every symbol below, so the
32
+ * existing import sites (three test files) keep their import path unchanged.
33
+ */
34
+ import { describeToolActivity, sanitizeActivity } from '../../team/activity.js';
35
+ import { TEAM_LIMITS } from '../../team/limits.js';
36
+ import { formatDuration } from '../../agent/usage.js';
37
+ import { fitCells } from './cells.js';
38
+ import { childElapsedMs, childStatus, statusMarkerCell, statusMarkerWidth } from '../team-status.js';
39
+ /** The clock cell: wide enough for `125m07s` (7) with one column of headroom. */
40
+ export const TEAM_CLOCK_CELLS = 8;
41
+ /** `run.label.padEnd(8).slice(0,8)` — unchanged from the row this replaces. */
42
+ export const TEAM_LABEL_CELLS = 8;
43
+ /** The two spaces in front of the activity cell. */
44
+ export const TEAM_GAP_CELLS = 2;
45
+ /**
46
+ * The description column's floor: below this the row is unreadable, so the
47
+ * activity cell gives way instead (`activityDropCols`).
48
+ */
49
+ export const TEAM_MIN_DESCRIPTION_CELLS = 12;
50
+ /**
51
+ * The concession to code units vs terminal columns, inherited verbatim from the
52
+ * `WIDE_CHAR_ALLOWANCE` comment on the row this replaces: this budget is
53
+ * denominated in code units while the terminal is denominated in columns, and CJK
54
+ * and emoji occupy two columns per unit, so the budget is halved against the
55
+ * reserve rather than spent to the last cell. Erring small costs a few characters
56
+ * of prose; erring large costs the clock column.
57
+ */
58
+ const WIDE_CHAR_ALLOWANCE = 2;
59
+ /** The marker cell of the Unicode tier, used as the default by callers that pass no tier. */
60
+ const DEFAULT_MARKER_CELLS = 3;
61
+ function nonNegativeInt(value) {
62
+ return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
63
+ }
64
+ /**
65
+ * How many characters the activity cell may spend at this width.
66
+ *
67
+ * `TEAM_LIMITS.activityChars` is a STORAGE ceiling; a terminal has a COLUMN
68
+ * budget, and the two are not the same number. Below
69
+ * `TEAM_LIMITS.activityDropCols` the cell gives way ENTIRELY (returns 0) rather
70
+ * than shrinking to its 12-character floor: at 40 columns those twelve characters
71
+ * would push the description below its own floor, and "who is doing what" outranks
72
+ * "what they are writing right now" exactly when there is room for one of them.
73
+ * That is a semantic change to a shipped assertion (`activityBudget(40)` used to
74
+ * return 12) and is recorded in the spec's §10 collateral table.
75
+ *
76
+ * `markerCells` defaults to the Unicode tier so the three existing single-argument
77
+ * call sites keep compiling; the panel passes the real tier's width.
78
+ */
79
+ export function activityBudget(cols, markerCells = DEFAULT_MARKER_CELLS) {
80
+ if (!Number.isFinite(cols) || cols < TEAM_LIMITS.activityDropCols)
81
+ return 0;
82
+ const fixed = markerCells + TEAM_LABEL_CELLS + TEAM_GAP_CELLS + TEAM_CLOCK_CELLS
83
+ + TEAM_MIN_DESCRIPTION_CELLS;
84
+ const spare = Math.floor((cols - fixed) / WIDE_CHAR_ALLOWANCE);
85
+ return Math.max(TEAM_LIMITS.activityMinChars, Math.min(TEAM_LIMITS.activityChars, spare));
86
+ }
87
+ /**
88
+ * The row's cell widths. A TOTAL function: every cell is a non-negative integer
89
+ * and the five of them never sum past `cols`, for any `cols >= 0` and either
90
+ * glyph tier (I-3, `team-row-geometry.test.ts`'s property test).
91
+ *
92
+ * THE LADDER IS THE SPECIFICATION (§4.2.2), in the order the cells give way:
93
+ * 1. a wide enough terminal gets the business budget;
94
+ * 2. below `activityDropCols` the activity cell is 0;
95
+ * 3. description still negative -> the gap goes;
96
+ * 4. still negative -> the label shrinks to `max(3, cols - marker - clock)`;
97
+ * 5. still negative -> the clock shrinks (`cols < marker + 3 + clock`);
98
+ * 6. the residue (a terminal narrower than the marker itself) is clamped so the
99
+ * sum stays inside `cols` — unreachable on a real device, where native mode
100
+ * degrades to the placeholder below 40x12, but the function is total because
101
+ * the property test runs `cols = 1..400`.
102
+ */
103
+ export function teamRowGeometry(cols, glyphs) {
104
+ const width = nonNegativeInt(cols);
105
+ const markerCells = 1 + statusMarkerWidth(glyphs) + 1;
106
+ const cells = {
107
+ marker: Math.min(markerCells, width),
108
+ label: TEAM_LABEL_CELLS,
109
+ gap: TEAM_GAP_CELLS,
110
+ activity: activityBudget(width, markerCells),
111
+ clock: TEAM_CLOCK_CELLS,
112
+ minDescription: TEAM_MIN_DESCRIPTION_CELLS,
113
+ };
114
+ const sum = () => cells.marker + cells.label + cells.gap + cells.activity + cells.clock;
115
+ if (sum() > width)
116
+ cells.gap = 0;
117
+ if (sum() > width)
118
+ cells.label = Math.max(3, width - cells.marker - cells.clock);
119
+ if (sum() > width)
120
+ cells.clock = Math.max(0, Math.min(TEAM_CLOCK_CELLS, width - cells.marker - cells.label));
121
+ if (sum() > width)
122
+ cells.label = Math.max(0, Math.min(cells.label, width - cells.marker));
123
+ cells.minDescription = sum() >= width ? 0 : TEAM_MIN_DESCRIPTION_CELLS;
124
+ return cells;
125
+ }
126
+ /**
127
+ * The clock cell's text: this child's elapsed time, or empty when it never
128
+ * started (a `queued` row has no clock to show, and `0ms` would be a lie).
129
+ */
130
+ export function teamClockText(run, at) {
131
+ return run.startedAt === undefined ? '' : formatDuration(childElapsedMs(run, at));
132
+ }
133
+ /** The past-tense word the supervisor badge and status line render. */
134
+ export function interventionWord(action) {
135
+ switch (action) {
136
+ case 'nudge':
137
+ return 'nudged';
138
+ case 'replace':
139
+ return 'replaced';
140
+ case 'abandon':
141
+ return 'abandoned';
142
+ default:
143
+ return 'waited';
144
+ }
145
+ }
146
+ /**
147
+ * The row's supervisor badge (subagent-overseer-v2 AC-5): what the supervisor
148
+ * last did to THIS child and how long ago, e.g. `nudged 2m03s`.
149
+ *
150
+ * `lastIntervention.at` is ABSOLUTE epoch ms (R-P2-3), the same units as
151
+ * `startedAt`, so this is one subtraction - no second clock convention.
152
+ */
153
+ export function overseerBadge(run, at) {
154
+ const last = run.lastIntervention;
155
+ if (last === undefined)
156
+ return '';
157
+ return `${interventionWord(last.action)} ${formatDuration(Math.max(0, at - last.at))}`;
158
+ }
159
+ /**
160
+ * The middle cell: what this child is doing right now, in a few words.
161
+ *
162
+ * Two width policies, answering different questions. `wide` decides WHICH FACT to
163
+ * show - a whole path or its basename, a whole command line or its program name -
164
+ * mirroring `StatusBar`'s `statusCompactCols` degradation rather than inventing a
165
+ * second threshold. `activityBudget(cols)` decides HOW MUCH OF IT FITS, and it is
166
+ * a continuous function of width rather than a threshold, because the failure it
167
+ * prevents - a row wider than the terminal - is continuous.
168
+ */
169
+ export function activityLine(run, cols, markerCells) {
170
+ const wide = cols >= TEAM_LIMITS.activityWideCols;
171
+ const budget = activityBudget(cols, markerCells);
172
+ switch (run.phase) {
173
+ case 'queued':
174
+ return 'queued';
175
+ case 'starting':
176
+ // The one row that legitimately restarts its clock, saying why (P2-4):
177
+ // F-2 ranks the running group by `startedAt` and a retry re-stamps it.
178
+ return run.retries ? 'starting (retry)' : 'starting';
179
+ case 'thinking':
180
+ // Clamped AT RENDER, against this terminal's budget. `run.activity` was
181
+ // clamped at STORE against `activityChars`; that is a ceiling, not a fit.
182
+ return run.activity ? `writing: ${sanitizeActivity(run.activity, budget)}` : 'thinking';
183
+ case 'tool':
184
+ case 'waiting':
185
+ return run.lastTool
186
+ ? sanitizeActivity(describeToolActivity(run.lastTool, run.activityArgs, wide), budget)
187
+ : run.phase === 'waiting'
188
+ ? 'waiting for mail'
189
+ : 'tool';
190
+ case 'done':
191
+ // `compacted N` ON THE SETTLED ROW ONLY (hardening §3.4.4 / W3). A running
192
+ // row is already competing for `activityBudget(cols)` with the child's own
193
+ // prose, and a marker that displaces what the child is DOING right now
194
+ // buys nothing - the dispatch report carries the same fact for good.
195
+ return run.compactions
196
+ ? `done ${run.turns} turns compacted ${run.compactions}`
197
+ : `done ${run.turns} turns`;
198
+ case 'failed':
199
+ return 'failed';
200
+ case 'aborted':
201
+ return 'aborted';
202
+ }
203
+ }
204
+ /**
205
+ * The activity cell WITH the badge composed in (AC-5 / risk table): the badge
206
+ * rides the SAME allocation the prose uses, so a row can never grow past its
207
+ * terminal; on a narrow terminal the badge degrades to the action word alone -
208
+ * the who-acted fact outranks the what-they-are-doing tail exactly when there is
209
+ * room for only one.
210
+ */
211
+ export function activityWithBadge(run, cols, at, markerCells) {
212
+ const last = run.lastIntervention;
213
+ if (last === undefined)
214
+ return activityLine(run, cols, markerCells);
215
+ const badge = overseerBadge(run, at);
216
+ if (cols < TEAM_LIMITS.activityWideCols)
217
+ return interventionWord(last.action);
218
+ return sanitizeActivity(`${badge}; ${activityLine(run, cols, markerCells)}`, activityBudget(cols, markerCells));
219
+ }
220
+ /**
221
+ * The five text fragments of one row.
222
+ *
223
+ * The description is normalized here rather than in the component because
224
+ * truncation preserves explicit newlines and the band reserves exactly ONE row
225
+ * for a child: a description carrying a newline would make the row two rows tall
226
+ * and every row below it would land one row lower than `band.teamRows` allows.
227
+ */
228
+ export function teamRowCells(run, cols, at, glyphs) {
229
+ const geometry = teamRowGeometry(cols, glyphs);
230
+ // THE TEXT IS CLAMPED TO THE SAME WIDTH THE GEOMETRY ALLOCATED, so a cell's content
231
+ // measures no more than its box. `activityWithBadge` already clamps to the business
232
+ // budget; this is the belt-and-braces that also covers the two cases the budget alone
233
+ // cannot express - a ZERO-width cell (below `activityDropCols`, where the answer is
234
+ // "no activity column at all", not "a nine-character word in a zero-column box") and
235
+ // a wide character straddling the edge.
236
+ const activity = geometry.activity === 0
237
+ ? ''
238
+ : fitCells(activityWithBadge(run, cols, at, geometry.marker), geometry.activity, glyphs.ellipsis);
239
+ return {
240
+ marker: statusMarkerCell(childStatus(run), glyphs),
241
+ label: run.label.padEnd(TEAM_LABEL_CELLS).slice(0, TEAM_LABEL_CELLS),
242
+ description: run.description.replace(/\s+/g, ' '),
243
+ activity,
244
+ clock: teamClockText(run, at),
245
+ };
246
+ }
@@ -1,3 +1,4 @@
1
+ import type { TodoSnapshot } from '../../todo/types.js';
1
2
  export interface TodoRailLayoutInput {
2
3
  cols: number;
3
4
  panelEnabled: boolean;
@@ -22,3 +23,40 @@ export interface TodoRailLayout {
22
23
  * never depends on menu occupancy or rail visibility, preventing feedback loops.
23
24
  */
24
25
  export declare function buildTodoRailLayout(input: TodoRailLayoutInput): TodoRailLayout;
26
+ export interface AcceptanceChipInput {
27
+ snapshot: TodoSnapshot | null;
28
+ /** Rows the rail (in-band or full-screen) actually drew; 0 means it drew none. */
29
+ railRows: number;
30
+ width: number;
31
+ }
32
+ /**
33
+ * Does the acceptance count still need the status-bar chip
34
+ * (todo-acceptance-criteria §6.11)?
35
+ *
36
+ * HERE, NEXT TO `buildTodoRailLayout`, because it answers the same question from
37
+ * the same inputs: "what did the rail actually draw". Putting it in `App` - a
38
+ * 3,215-line registered oversize file - would give one fact a second owner, which
39
+ * is precisely what `scrollback/band.ts`'s "one number, one owner" rule exists to
40
+ * prevent (P1-8 / C-9).
41
+ *
42
+ * THE RULE IS ABOUT THE RAIL, NOT ABOUT THE SECTION: while the section is `visible`
43
+ * or `summary` the rail is already reporting the count, so a chip would repeat it in
44
+ * the same glance (RV-P2-4). Only two situations leave the count unreported - no rail
45
+ * at all, and the single `rows === 3 && footer` corner where the panel is up but the
46
+ * section could not be fitted.
47
+ */
48
+ export declare function acceptanceChip(input: AcceptanceChipInput): {
49
+ checked: number;
50
+ total: number;
51
+ failed: number;
52
+ } | null;
53
+ /**
54
+ * Which rung the acceptance section reached in the rail, for `/perf`'s band line
55
+ * (§6.9.6).
56
+ *
57
+ * `n/a` means "this plan has no criteria", which is a different fact from `absent`
58
+ * ("the plan has criteria and the rail could not show them"). Both come from the
59
+ * same `buildPlanSections` call the rail rendered with, so the readout cannot
60
+ * disagree with the screen.
61
+ */
62
+ export declare function acceptanceRung(input: AcceptanceChipInput): 'visible' | 'summary' | 'absent' | 'n/a';
@@ -1,4 +1,6 @@
1
1
  import { TODO_LIMITS } from '../../todo/limits.js';
2
+ import { derivePlanPhase } from '../../todo/acceptance.js';
3
+ import { buildPlanSections } from '../../todo/plan-sections.js';
2
4
  import { todoRailWidth } from './rail.js';
3
5
  /** Allocate columns and rows from the same team/menu projections as the renderers.
4
6
  * Invalid geometry becomes zero, excess team/menu rows are clamped. Menu capacity
@@ -25,3 +27,76 @@ export function buildTodoRailLayout(input) {
25
27
  function nonNegativeInt(value) {
26
28
  return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0;
27
29
  }
30
+ /**
31
+ * Does the acceptance count still need the status-bar chip
32
+ * (todo-acceptance-criteria §6.11)?
33
+ *
34
+ * HERE, NEXT TO `buildTodoRailLayout`, because it answers the same question from
35
+ * the same inputs: "what did the rail actually draw". Putting it in `App` - a
36
+ * 3,215-line registered oversize file - would give one fact a second owner, which
37
+ * is precisely what `scrollback/band.ts`'s "one number, one owner" rule exists to
38
+ * prevent (P1-8 / C-9).
39
+ *
40
+ * THE RULE IS ABOUT THE RAIL, NOT ABOUT THE SECTION: while the section is `visible`
41
+ * or `summary` the rail is already reporting the count, so a chip would repeat it in
42
+ * the same glance (RV-P2-4). Only two situations leave the count unreported - no rail
43
+ * at all, and the single `rows === 3 && footer` corner where the panel is up but the
44
+ * section could not be fitted.
45
+ */
46
+ export function acceptanceChip(input) {
47
+ const acceptance = input.snapshot?.acceptance;
48
+ if (!acceptance || acceptance.total === 0)
49
+ return null;
50
+ const chip = {
51
+ checked: acceptance.checkedCount,
52
+ total: acceptance.total,
53
+ failed: acceptance.failedCount,
54
+ };
55
+ if (input.railRows <= 0)
56
+ return chip;
57
+ return acceptanceSectionOf(input) === 'absent' ? chip : null;
58
+ }
59
+ /**
60
+ * Which rung the acceptance section reached in the rail, for `/perf`'s band line
61
+ * (§6.9.6).
62
+ *
63
+ * `n/a` means "this plan has no criteria", which is a different fact from `absent`
64
+ * ("the plan has criteria and the rail could not show them"). Both come from the
65
+ * same `buildPlanSections` call the rail rendered with, so the readout cannot
66
+ * disagree with the screen.
67
+ */
68
+ export function acceptanceRung(input) {
69
+ const acceptance = input.snapshot?.acceptance;
70
+ if (!acceptance || acceptance.total === 0)
71
+ return 'n/a';
72
+ // No rail drawn at all: the section is not on screen, and saying `absent` is the
73
+ // one honest answer for that.
74
+ if (input.railRows <= 0)
75
+ return 'absent';
76
+ return acceptanceSectionOf(input) ?? 'absent';
77
+ }
78
+ /**
79
+ * The section's reason for a given rail, or `null` when the plan has no criteria.
80
+ *
81
+ * `showGauge` is assumed TRUE. That is the conservative direction for the ROW BUDGET -
82
+ * it can only make the rail look shorter - and it cannot change the REASON at all: the
83
+ * compact rung (rows < 6, where the real gauge is always off) ignores `available`
84
+ * entirely, and the full rung spends the difference on item rows. So this readout can
85
+ * never disagree with the rail about `visible` / `summary` / `absent`, and the chip
86
+ * therefore never duplicates a count the rail is already showing (RV-P2-4).
87
+ */
88
+ function acceptanceSectionOf(input) {
89
+ const snapshot = input.snapshot;
90
+ const acceptance = snapshot?.acceptance;
91
+ if (!snapshot || !acceptance || acceptance.total === 0)
92
+ return null;
93
+ const sections = buildPlanSections({
94
+ rows: input.railRows,
95
+ width: input.width,
96
+ phase: derivePlanPhase(snapshot.items, acceptance.items),
97
+ todoCount: snapshot.items.length,
98
+ acceptanceCount: acceptance.total,
99
+ showGauge: true,
100
+ });
101
+ return sections.acceptance?.reason ?? null;
102
+ }
@@ -96,7 +96,17 @@ export function entryRevision(entry) {
96
96
  .map((r) => r.phase[0])
97
97
  .join('')}.${entry.runs.reduce((acc, r) => acc + r.toolCalls, 0)}`;
98
98
  case 'todo':
99
- return `d${entry.live ? 1 : 0}.${entry.doneCount}.${entry.total}.${entry.items.length}`;
99
+ // REVISION MUST COVER EVERY FIELD THAT CAN CHANGE THE HEIGHT (§6.10.1). A
100
+ // criterion going `pending` -> `failed` changes NONE of `items.length`,
101
+ // `doneCount` or `total`, yet it adds an evidence row; without
102
+ // `checkedCount` and `failedCount` here the height cache would keep the old
103
+ // measurement and the cache would be silently wrong - textbook form of "add a
104
+ // field, forget the revision".
105
+ return `d${entry.live ? 1 : 0}.${entry.doneCount}.${entry.total}.${entry.items.length}`
106
+ + `.a${entry.acceptance
107
+ ? `${entry.acceptance.items.length}.${entry.acceptance.checkedCount}` +
108
+ `.${entry.acceptance.failedCount}`
109
+ : '-'}`;
100
110
  /**
101
111
  * The retry card is ONE ROW IN EVERY PHASE, so its revision carries the phase
102
112
  * and the counter but deliberately NOT the countdown: `resumeAt` changes the
@@ -325,8 +335,18 @@ export function estimateEntryRows(entry, cols, density, expanded, thinkingVisibl
325
335
  if (isLiveTeamCard(entry))
326
336
  return 0;
327
337
  return separation + 1 + entry.runs.length + (expanded ? entry.runs.length * 3 + 1 : 1);
328
- case 'todo':
329
- return separation + 1 + entry.items.length;
338
+ case 'todo': {
339
+ // THE ACCEPTANCE BLOCK'S ROWS, EXACTLY (§6.10.1): one title row plus one row
340
+ // per criterion plus one row per FAILED criterion's evidence. Exact because
341
+ // every one of those lines is `wrap="truncate"`; the moment one of them wraps,
342
+ // this stops being an equality and becomes an upper bound - and the failure
343
+ // mode is scroll drift, which nothing reports.
344
+ const acceptance = entry.acceptance;
345
+ const acceptanceRows = acceptance && acceptance.total > 0
346
+ ? 1 + acceptance.items.length + acceptance.failedCount
347
+ : 0;
348
+ return separation + 1 + entry.items.length + acceptanceRows;
349
+ }
330
350
  // ONE ROW, ALWAYS. The card is `wrap="truncate"` in every phase, so this is an
331
351
  // exact height rather than an upper bound.
332
352
  case 'retry':
@@ -0,0 +1,57 @@
1
+ /**
2
+ * AgentsBoardOverlay — the read-only detail board behind `Ctrl+B` / `/agents`
3
+ * (tui-subagent-panel §4.4 / §6.3).
4
+ *
5
+ * READ-ONLY IS THE SAFETY BOUNDARY (§2.2). Nothing here nudges, kills or re-dispatches
6
+ * a child, and no new `TeamEvent` member was introduced to feed it: a board that can
7
+ * only read can never kill a healthy child, which is the whole reason this is a
8
+ * second surface rather than a control panel.
9
+ *
10
+ * ONE SCREEN, CONTROLLED MODE. `OverlayFrame`'s controlled form already owns paging,
11
+ * the position indicator and the narrow-terminal degradation, and `helpRows` /
12
+ * `projectQueueRows` are the precedent. A second pane would mean a second horizontal
13
+ * budget inside Ink for a benefit the board's 2-up field rows already deliver.
14
+ *
15
+ * IT DOES NOT GO BLANK WHEN THE DISPATCH ENDS (D-9). `state.team` becomes `null` the
16
+ * frame the dispatch settles, and a reader would lose the child they were reading
17
+ * about at the exact moment they wanted the post-mortem. The last live snapshot is
18
+ * therefore FROZEN (a copy, held in a ref) and keeps rendering with a
19
+ * `dispatch ended` row; a board opened AFTER the dispatch has ended builds from the
20
+ * transcript's settled `team` entry instead (`resolveBoardSource`), which is where the
21
+ * `result` section earns its place - the error is the one fact that is never stripped.
22
+ *
23
+ * NO `ink-spinner` HERE (D-13): `spinner-census.test.ts` pins the exact list of files
24
+ * that mount an animated spinner, and this is a reading surface, not an animation.
25
+ * The panel is where motion belongs.
26
+ *
27
+ * AND NO MOTION PROP (v2 implementation note, appended to the spec's
28
+ * "implementation findings"). Because nothing here animates, a motion flag would be a
29
+ * prop with no consumer - and worse, the same census counts how many times `App.tsx`
30
+ * hands the RAW motion flag to a child and asserts the count is EXACTLY ONE, so passing
31
+ * it from the board's branch would break a shipped guard rail for no gain.
32
+ *
33
+ * ASCII ONLY: every symbol comes through `glyphs`.
34
+ */
35
+ import React from 'react';
36
+ import type { Entry } from '../../agent/reducer.js';
37
+ import type { Theme } from '../theme.js';
38
+ import type { TermCapabilities } from '../capabilities.js';
39
+ import type { TeamSnapshot } from '../../team/types.js';
40
+ export interface AgentsBoardOverlayProps {
41
+ /** The live snapshot; `null` once the dispatch has settled. */
42
+ team: TeamSnapshot | null;
43
+ /** The transcript, for the settled fallback (D-9 path 4). */
44
+ entries: readonly Entry[];
45
+ /** Overlay content width (`App`'s `viewCols`). */
46
+ cols: number;
47
+ maxRows: number;
48
+ scrollOffset: number;
49
+ onScrollClamp: (offset: number) => void;
50
+ isActive: boolean;
51
+ native: boolean;
52
+ theme: Theme;
53
+ caps: TermCapabilities;
54
+ /** Injectable clock, so the board's tests do not depend on wall time. */
55
+ now?: number;
56
+ }
57
+ export declare function AgentsBoardOverlay({ team, entries, cols, maxRows, scrollOffset, onScrollClamp, isActive, native, theme, caps, now, }: AgentsBoardOverlayProps): React.ReactElement;
@@ -0,0 +1,183 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * AgentsBoardOverlay — the read-only detail board behind `Ctrl+B` / `/agents`
4
+ * (tui-subagent-panel §4.4 / §6.3).
5
+ *
6
+ * READ-ONLY IS THE SAFETY BOUNDARY (§2.2). Nothing here nudges, kills or re-dispatches
7
+ * a child, and no new `TeamEvent` member was introduced to feed it: a board that can
8
+ * only read can never kill a healthy child, which is the whole reason this is a
9
+ * second surface rather than a control panel.
10
+ *
11
+ * ONE SCREEN, CONTROLLED MODE. `OverlayFrame`'s controlled form already owns paging,
12
+ * the position indicator and the narrow-terminal degradation, and `helpRows` /
13
+ * `projectQueueRows` are the precedent. A second pane would mean a second horizontal
14
+ * budget inside Ink for a benefit the board's 2-up field rows already deliver.
15
+ *
16
+ * IT DOES NOT GO BLANK WHEN THE DISPATCH ENDS (D-9). `state.team` becomes `null` the
17
+ * frame the dispatch settles, and a reader would lose the child they were reading
18
+ * about at the exact moment they wanted the post-mortem. The last live snapshot is
19
+ * therefore FROZEN (a copy, held in a ref) and keeps rendering with a
20
+ * `dispatch ended` row; a board opened AFTER the dispatch has ended builds from the
21
+ * transcript's settled `team` entry instead (`resolveBoardSource`), which is where the
22
+ * `result` section earns its place - the error is the one fact that is never stripped.
23
+ *
24
+ * NO `ink-spinner` HERE (D-13): `spinner-census.test.ts` pins the exact list of files
25
+ * that mount an animated spinner, and this is a reading surface, not an animation.
26
+ * The panel is where motion belongs.
27
+ *
28
+ * AND NO MOTION PROP (v2 implementation note, appended to the spec's
29
+ * "implementation findings"). Because nothing here animates, a motion flag would be a
30
+ * prop with no consumer - and worse, the same census counts how many times `App.tsx`
31
+ * hands the RAW motion flag to a child and asserts the count is EXACTLY ONE, so passing
32
+ * it from the board's branch would break a shipped guard rail for no gain.
33
+ *
34
+ * ASCII ONLY: every symbol comes through `glyphs`.
35
+ */
36
+ import { useCallback, useEffect, useRef, useState } from 'react';
37
+ import { Text, useInput } from 'ink';
38
+ import { pickGlyphs } from '../glyphs.js';
39
+ import { OverlayFrame } from '../layout/OverlayFrame.js';
40
+ import { buildAgentBoardRows, resolveBoardSource, resolveSelection, } from '../layout/agents-board.js';
41
+ /** `OverlayFrame`'s own breakpoint, mirrored so the chrome estimate cannot drift. */
42
+ const FRAME_BORDERLESS_COLS = 48;
43
+ /**
44
+ * Close a frozen live snapshot: the dispatch it described has ended.
45
+ *
46
+ * `durationMs` is derived from the LAST frame the snapshot was live, so the board's
47
+ * summary shows the dispatch's real total instead of a clock that keeps running against
48
+ * a dispatch nobody can see any more. When the snapshot never had a `startedAt` there is
49
+ * nothing to derive and the field stays absent - the LIVE-ONLY rule turns that into an
50
+ * empty clock cell rather than an invented number (§4.4.2).
51
+ */
52
+ function settleFrozen(frozen) {
53
+ const { source, at } = frozen;
54
+ const total = source.startedAt === undefined ? undefined : Math.max(0, at - source.startedAt);
55
+ return {
56
+ ...source,
57
+ active: false,
58
+ ...(total !== undefined ? { durationMs: total } : {}),
59
+ };
60
+ }
61
+ /** Palette slot -> theme colour. One place, so a tone can never be spelled twice. */
62
+ function toneColor(tone, theme) {
63
+ switch (tone) {
64
+ case 'accent':
65
+ return theme.accent;
66
+ case 'primary':
67
+ return theme.primary;
68
+ case 'muted':
69
+ return theme.muted;
70
+ case 'warn':
71
+ return theme.noticeWarn;
72
+ case 'error':
73
+ return theme.noticeError;
74
+ case 'done':
75
+ return theme.toolDone;
76
+ case 'hint':
77
+ return theme.hintFg;
78
+ }
79
+ }
80
+ export function AgentsBoardOverlay({ team, entries, cols, maxRows, scrollOffset, onScrollClamp, isActive, native, theme, caps, now, }) {
81
+ const glyphs = pickGlyphs(caps);
82
+ const at = now ?? Date.now();
83
+ const [selectedLabel, setSelectedLabel] = useState(null);
84
+ /**
85
+ * THE FROZEN SNAPSHOT (D-9), plus the clock reading it was frozen at.
86
+ *
87
+ * A ref rather than state on purpose: it is written from an effect that must not
88
+ * trigger a render of its own, and reading it during the render that FIRST sees
89
+ * `team === null` is what keeps the board from blinking empty for one frame. The
90
+ * timestamp is kept WITH the snapshot because a frozen live source still says
91
+ * `active: true` - it was live when it was copied - and a board that kept believing
92
+ * that would never show its `dispatch ended` row nor stop its clock.
93
+ */
94
+ const frozen = useRef(null);
95
+ const live = team === null ? null : resolveBoardSource({ team, entries });
96
+ useEffect(() => {
97
+ if (live !== null)
98
+ frozen.current = { source: live, at };
99
+ }, [live, at]);
100
+ // PRIORITY: live > frozen > the most recent settled entry (§4.4.5). The frozen copy
101
+ // only ever takes over on the frame the live snapshot disappears, and a NEW dispatch
102
+ // overwrites it on its own first frame, so the two paths cannot both be active.
103
+ //
104
+ // A FROZEN SOURCE IS SETTLED, and it is closed here rather than at the moment of
105
+ // copying: `active: false` is what draws `dispatch ended` (task AC-17), and taking
106
+ // `durationMs` from the freeze instant stops the clock at the dispatch's real end
107
+ // instead of letting it count up forever from a snapshot that no longer exists.
108
+ const frozenSource = frozen.current === null ? null : settleFrozen(frozen.current);
109
+ const source = live ?? frozenSource ?? resolveBoardSource({ team: null, entries });
110
+ const selected = source === null ? null : resolveSelection(source.runs, selectedLabel);
111
+ /**
112
+ * UP/DOWN MOVES THE CHILD CURSOR, and a change of child RESETS the scroll.
113
+ *
114
+ * `agents` deliberately does NOT join `App`'s row-scroll set (`help | plan | queue`):
115
+ * the same trade `settings` makes with its field cursor, where Up/Down is worth more
116
+ * as navigation than as a six-row scroll. `PgUp` / `PgDn` / the wheel still page the
117
+ * board through `App`'s `controlled` set, so no reading gesture is lost.
118
+ */
119
+ const move = useCallback((delta) => {
120
+ const runs = source?.runs ?? [];
121
+ if (runs.length === 0)
122
+ return;
123
+ const current = resolveSelection(runs, selectedLabel);
124
+ const index = current === null ? -1 : runs.findIndex((run) => run.label === current.label);
125
+ const next = runs[Math.min(runs.length - 1, Math.max(0, index + delta))];
126
+ if (next === undefined || next.label === current?.label)
127
+ return;
128
+ setSelectedLabel(next.label);
129
+ // The documented semantic of this port is "the offset should now be 0" (§6.3).
130
+ onScrollClamp(0);
131
+ }, [onScrollClamp, selectedLabel, source]);
132
+ useInput((_input, key) => {
133
+ if (!isActive)
134
+ return;
135
+ if (key.upArrow)
136
+ move(-1);
137
+ else if (key.downArrow)
138
+ move(1);
139
+ }, { isActive });
140
+ // THE FRAME'S CHROME IS NOT FREE (v2 implementation note). `cols` arrives as the
141
+ // overlay's FRAME width (`App`'s `viewCols`), and `OverlayFrame` spends 4 of those
142
+ // columns on its border plus `paddingX={1}` (2 in the narrow rail form, which drops
143
+ // the right border and the right padding). A board laid out against the frame width
144
+ // therefore has its LAST four columns eaten by Ink's truncation - which lands exactly
145
+ // on the right-anchored figures this board exists to align. `helpRows` gets away with
146
+ // it because its rows are prose; these are a table.
147
+ const contentCols = Math.max(1, cols - (cols >= FRAME_BORDERLESS_COLS ? 4 : 2));
148
+ const rows = source === null
149
+ ? [{
150
+ key: 'empty',
151
+ kind: 'hint',
152
+ text: 'No subagent dispatch yet.',
153
+ tone: 'muted',
154
+ }]
155
+ : buildAgentBoardRows({
156
+ source,
157
+ cols: contentCols,
158
+ now: at,
159
+ selectedLabel: selected?.label ?? null,
160
+ glyphs,
161
+ native,
162
+ });
163
+ // The `dispatch ended` banner is a ROW rather than a title swap: the title is the
164
+ // frame's own ("Agents"), and replacing it would hide which screen the reader is on.
165
+ const endedRow = source !== null && !source.active
166
+ ? [{
167
+ key: 'ended',
168
+ kind: 'hint',
169
+ text: `dispatch ended ${glyphs.midDot} Esc to close`,
170
+ tone: 'warn',
171
+ }]
172
+ : [];
173
+ const elements = [...endedRow, ...rows].map((row) => (_jsx(Text, { wrap: "truncate", color: toneColor(row.tone, theme), ...(row.kind === 'section' ? { bold: true } : {}), children: row.text }, row.key)));
174
+ // THE HINT SHRINKS WITH THE FRAME. `OverlayFrame` prints the hint as one unwrapped
175
+ // row, so a 41-column hint inside a 30-column frame makes the FRAME 41 wide - the
176
+ // board would overflow the terminal and take the overlay's border with it.
177
+ const hint = contentCols >= 60
178
+ ? `Esc close ${glyphs.midDot} Up/Down child ${glyphs.midDot} PgUp/PgDn scroll`
179
+ : contentCols >= 30
180
+ ? `Esc close ${glyphs.midDot} Up/Down child`
181
+ : 'Esc close';
182
+ return (_jsx(OverlayFrame, { title: "Agents", hint: hint, maxRows: maxRows, cols: cols, rows: elements, scrollOffset: scrollOffset, onScrollClamp: onScrollClamp, theme: theme, caps: caps }));
183
+ }