@aragon-agent/cli 0.6.38 → 0.6.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (156) hide show
  1. package/CHANGELOG.md +2523 -2326
  2. package/README.md +3614 -3173
  3. package/dist/agent/controller.d.ts +142 -1
  4. package/dist/agent/controller.js +171 -3
  5. package/dist/agent/reducer.d.ts +41 -1
  6. package/dist/agent/reducer.js +25 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +5 -0
  9. package/dist/cli.js +85 -27
  10. package/dist/commands/builtins.js +13 -1
  11. package/dist/commands/keys-command.d.ts +23 -0
  12. package/dist/commands/keys-command.js +38 -0
  13. package/dist/commands/registry.d.ts +39 -0
  14. package/dist/commands/task-command.d.ts +39 -0
  15. package/dist/commands/task-command.js +102 -0
  16. package/dist/config/cli-commands.d.ts +23 -0
  17. package/dist/config/cli-commands.js +36 -0
  18. package/dist/config/env.js +17 -4
  19. package/dist/config/load.js +6 -1
  20. package/dist/config/schema.d.ts +43 -4
  21. package/dist/config/schema.js +31 -5
  22. package/dist/config/store.js +17 -2
  23. package/dist/diagnostics/doctor.d.ts +9 -0
  24. package/dist/diagnostics/doctor.js +34 -0
  25. package/dist/diagnostics/info.d.ts +71 -0
  26. package/dist/diagnostics/info.js +91 -5
  27. package/dist/exec/bridge/caller-tools.d.ts +96 -0
  28. package/dist/exec/bridge/caller-tools.js +213 -0
  29. package/dist/exec/bridge/decisions.d.ts +78 -0
  30. package/dist/exec/bridge/decisions.js +116 -0
  31. package/dist/exec/bridge/human-answers.d.ts +38 -0
  32. package/dist/exec/bridge/human-answers.js +164 -0
  33. package/dist/exec/bridge/limits.d.ts +68 -0
  34. package/dist/exec/bridge/limits.js +68 -0
  35. package/dist/exec/bridge/ndjson.d.ts +17 -0
  36. package/dist/exec/bridge/ndjson.js +83 -0
  37. package/dist/exec/bridge/pending.d.ts +70 -0
  38. package/dist/exec/bridge/pending.js +132 -0
  39. package/dist/exec/bridge/protocol.d.ts +346 -0
  40. package/dist/exec/bridge/protocol.js +256 -0
  41. package/dist/exec/bridge/server.d.ts +142 -0
  42. package/dist/exec/bridge/server.js +502 -0
  43. package/dist/exec/bridge/source.d.ts +58 -0
  44. package/dist/exec/bridge/source.js +145 -0
  45. package/dist/exec/bridge/writer.d.ts +26 -0
  46. package/dist/exec/bridge/writer.js +156 -0
  47. package/dist/exec/cli-commands.js +30 -2
  48. package/dist/exec/emitter.d.ts +16 -2
  49. package/dist/exec/emitter.js +26 -6
  50. package/dist/exec/events.d.ts +465 -10
  51. package/dist/exec/events.js +47 -1
  52. package/dist/exec/hooks/dispatch.d.ts +76 -0
  53. package/dist/exec/hooks/dispatch.js +169 -0
  54. package/dist/exec/hooks/index.d.ts +83 -0
  55. package/dist/exec/hooks/index.js +205 -0
  56. package/dist/exec/hooks/limits.d.ts +56 -0
  57. package/dist/exec/hooks/limits.js +49 -0
  58. package/dist/exec/hooks/registry.d.ts +49 -0
  59. package/dist/exec/hooks/registry.js +91 -0
  60. package/dist/exec/hooks/tool-wrapper.d.ts +94 -0
  61. package/dist/exec/hooks/tool-wrapper.js +154 -0
  62. package/dist/exec/hooks/turn.d.ts +61 -0
  63. package/dist/exec/hooks/turn.js +59 -0
  64. package/dist/exec/hooks/validate.d.ts +85 -0
  65. package/dist/exec/hooks/validate.js +310 -0
  66. package/dist/exec/index.d.ts +130 -4
  67. package/dist/exec/index.js +501 -10
  68. package/dist/exec/options.d.ts +124 -2
  69. package/dist/exec/options.js +413 -6
  70. package/dist/exec/permission.d.ts +9 -1
  71. package/dist/exec/permission.js +7 -1
  72. package/dist/exec/request/limits.d.ts +51 -0
  73. package/dist/exec/request/limits.js +49 -0
  74. package/dist/exec/request/scope.d.ts +106 -0
  75. package/dist/exec/request/scope.js +149 -0
  76. package/dist/exec/result/limits.d.ts +74 -0
  77. package/dist/exec/result/limits.js +72 -0
  78. package/dist/exec/result/prompt.d.ts +28 -0
  79. package/dist/exec/result/prompt.js +74 -0
  80. package/dist/exec/result/schema.d.ts +99 -0
  81. package/dist/exec/result/schema.js +464 -0
  82. package/dist/exec/result/sink.d.ts +56 -0
  83. package/dist/exec/result/sink.js +50 -0
  84. package/dist/exec/result/tool.d.ts +37 -0
  85. package/dist/exec/result/tool.js +118 -0
  86. package/dist/exec/runner.d.ts +395 -1
  87. package/dist/exec/runner.js +860 -8
  88. package/dist/exec/threads/aggregate.d.ts +34 -0
  89. package/dist/exec/threads/aggregate.js +30 -0
  90. package/dist/exec/threads/capacity.d.ts +23 -0
  91. package/dist/exec/threads/capacity.js +38 -0
  92. package/dist/exec/threads/dispatch-source.d.ts +60 -0
  93. package/dist/exec/threads/dispatch-source.js +106 -0
  94. package/dist/exec/threads/execute-threads.d.ts +53 -0
  95. package/dist/exec/threads/execute-threads.js +234 -0
  96. package/dist/exec/threads/gate.d.ts +42 -0
  97. package/dist/exec/threads/gate.js +81 -0
  98. package/dist/exec/threads/identity.d.ts +36 -0
  99. package/dist/exec/threads/identity.js +67 -0
  100. package/dist/exec/threads/interrupt.d.ts +29 -0
  101. package/dist/exec/threads/interrupt.js +79 -0
  102. package/dist/exec/threads/limits.d.ts +58 -0
  103. package/dist/exec/threads/limits.js +35 -0
  104. package/dist/exec/threads/outcome.d.ts +47 -0
  105. package/dist/exec/threads/outcome.js +26 -0
  106. package/dist/exec/threads/registry.d.ts +81 -0
  107. package/dist/exec/threads/registry.js +427 -0
  108. package/dist/exec/threads/runtime.d.ts +107 -0
  109. package/dist/exec/threads/runtime.js +523 -0
  110. package/dist/exec/threads/thread-source.d.ts +20 -0
  111. package/dist/exec/threads/thread-source.js +134 -0
  112. package/dist/fast/limits.d.ts +54 -0
  113. package/dist/fast/limits.js +54 -0
  114. package/dist/fast/summary-call.d.ts +72 -0
  115. package/dist/fast/summary-call.js +133 -0
  116. package/dist/fast/summary-prompt.d.ts +106 -0
  117. package/dist/fast/summary-prompt.js +320 -0
  118. package/dist/fast/task-summary-types.d.ts +66 -0
  119. package/dist/fast/task-summary-types.js +17 -0
  120. package/dist/logging/logger.d.ts +1 -1
  121. package/dist/proc/limits.d.ts +7 -0
  122. package/dist/proc/limits.js +7 -0
  123. package/dist/proc/output-streams.d.ts +50 -0
  124. package/dist/proc/output-streams.js +54 -0
  125. package/dist/team/runtime.d.ts +9 -0
  126. package/dist/team/runtime.js +8 -0
  127. package/dist/team/subagent.d.ts +15 -0
  128. package/dist/team/subagent.js +45 -1
  129. package/dist/tools/bash-tool.d.ts +10 -0
  130. package/dist/tools/bash-tool.js +71 -6
  131. package/dist/tools/index.d.ts +93 -1
  132. package/dist/tools/index.js +112 -3
  133. package/dist/tools/tool-gate.d.ts +100 -0
  134. package/dist/tools/tool-gate.js +87 -0
  135. package/dist/ui/App.js +224 -11
  136. package/dist/ui/Header.d.ts +28 -16
  137. package/dist/ui/Header.js +21 -19
  138. package/dist/ui/interaction-hints.js +6 -0
  139. package/dist/ui/layout/OverlayFrame.d.ts +18 -0
  140. package/dist/ui/layout/OverlayFrame.js +19 -4
  141. package/dist/ui/layout/header-hints.d.ts +55 -62
  142. package/dist/ui/layout/header-hints.js +59 -166
  143. package/dist/ui/layout/header-left.d.ts +115 -0
  144. package/dist/ui/layout/header-left.js +206 -0
  145. package/dist/ui/layout/shortcut-panel-layout.d.ts +191 -0
  146. package/dist/ui/layout/shortcut-panel-layout.js +321 -0
  147. package/dist/ui/layout/status-detail-layout.js +5 -1
  148. package/dist/ui/layout/task-summary-cell.d.ts +61 -0
  149. package/dist/ui/layout/task-summary-cell.js +65 -0
  150. package/dist/ui/overlays/HelpOverlay.d.ts +12 -0
  151. package/dist/ui/overlays/HelpOverlay.js +29 -2
  152. package/dist/ui/overlays/ShortcutPanel.d.ts +39 -0
  153. package/dist/ui/overlays/ShortcutPanel.js +106 -0
  154. package/dist/ui/shortcuts.d.ts +81 -0
  155. package/dist/ui/shortcuts.js +221 -0
  156. package/package.json +2 -2
@@ -0,0 +1,321 @@
1
+ /**
2
+ * The shortcut drawer's geometry and row layout
3
+ * (tui-shortcut-drawer-and-task-summary §4.5).
4
+ *
5
+ * ASCII ONLY: `src/ui/**` is inside the glyph scanner's scope, so every symbol
6
+ * this module emits arrives as a `glyphs` field and is never spelled here.
7
+ *
8
+ * PURE AND TOTAL. No Ink, no clock, no terminal: the drawer component renders
9
+ * what this returns, `OverlayFrame` slices the window this function DERIVED, and
10
+ * `shortcut-panel-layout.test.ts` pins the arithmetic directly. That split is the
11
+ * whole defence against P0-1, which was not a rendering bug: v1 hand-fixed the key
12
+ * column at 21 cells, which left six cells for the keyword at 80 columns, so every
13
+ * keyword was truncated to `promp...` while a "does not overflow" assertion stayed
14
+ * green. The column is therefore DERIVED from the table, and a keyword that does
15
+ * not fit makes its row STACK rather than be cut.
16
+ *
17
+ * THERE IS EXACTLY ONE WINDOW AUTHORITY: `cursor`. v1 took both `cursor` and
18
+ * `scrollOffset`, which meant paging could leave the selected row - and the detail
19
+ * line that belongs to it - off screen. Paging now moves the CURSOR by a page and
20
+ * the window is the smallest one containing it.
21
+ */
22
+ import stringWidth from 'string-width';
23
+ import { fitCells, padCells, wrapCells } from './cells.js';
24
+ import { sliceWindow } from './overlay-window.js';
25
+ import { SHORTCUT_GROUPS, widestKeysCells } from '../shortcuts.js';
26
+ export const SHORTCUT_PANEL_LIMITS = {
27
+ /** Below this the drawer is full width: a 30-cell bar on a 40-cell screen is noise. */
28
+ minSplitCols: 64,
29
+ /** Fraction of the terminal the drawer takes when it IS a drawer. */
30
+ widthFraction: 0.34,
31
+ minWidth: 30,
32
+ maxWidth: 46,
33
+ /** Rail (1 cell) + `paddingLeft` (1 cell) that `OverlayFrame` spends below 48 columns. */
34
+ railCells: 2,
35
+ /** Border (2 cells) + `paddingX` (2 cells) that `OverlayFrame` spends at or above 48. */
36
+ boxCells: 4,
37
+ /**
38
+ * The key column is DERIVED: `min(longestKeys, ceil(listCells * 0.55))`.
39
+ *
40
+ * A hard 21 is what turned `prompt library` into `promp...` at 80 columns. The
41
+ * share is a ceiling, not a target: a table of short keys gets a narrow key
42
+ * column and the leftover width goes to the keyword.
43
+ */
44
+ keyColumnShare: 0.55,
45
+ /** The keyword column must be at least this wide, or the row stacks. */
46
+ keywordMinCells: 8,
47
+ /** Hard ceiling for a keyword string; a longer one is a spec bug, not a width problem. */
48
+ keywordMaxCells: 10,
49
+ /** Gap between the key column and the keyword column. */
50
+ keyGapCells: 1,
51
+ /** Indent for the selected row's detail lines, and for a stacked keyword. */
52
+ detailIndent: 2,
53
+ /** Title + footer rows are chrome and are spent out of the row budget. */
54
+ chromeRows: 2,
55
+ /** The task section is capped so the key list always gets the majority of the panel. */
56
+ taskMaxRows: 2,
57
+ /**
58
+ * ABOVE THIS BODY HEIGHT THE GROUP HEADERS GET THEIR OWN ROWS.
59
+ *
60
+ * Below it they are dropped entirely - not compressed into `---- GROUP ----`
61
+ * separators, which was v2's rule and left FOUR entry rows visible on an
62
+ * 80x24 terminal (title + task + rule + five separators = 9 of 13 body rows).
63
+ * See `spec.md`'s `## 实施过程发现的方案缺陷` note 1: the ordering still runs
64
+ * COMPOSE -> RUN -> PANELS -> SESSION -> VIEW, and the requirement ("list every
65
+ * shortcut from top to bottom") is met by the entries themselves.
66
+ */
67
+ groupHeaderMinRows: 18,
68
+ /** The longest detail line the selected row may grow to. */
69
+ detailMaxRows: 2,
70
+ };
71
+ /**
72
+ * The drawer's width in columns.
73
+ *
74
+ * `cols < 64` is FULL WIDTH on purpose: at 40 columns a right-aligned 30-cell bar
75
+ * spends 10 of the user's columns on empty space. The clamp mirrors
76
+ * `rail.ts::todoRailWidth` (same shape, same reason).
77
+ */
78
+ export function shortcutPanelWidth(cols) {
79
+ const width = Number.isFinite(cols) ? Math.floor(cols) : 80;
80
+ const safe = Math.max(0, width);
81
+ if (safe < SHORTCUT_PANEL_LIMITS.minSplitCols)
82
+ return safe;
83
+ const fraction = Math.round(safe * SHORTCUT_PANEL_LIMITS.widthFraction);
84
+ return Math.min(SHORTCUT_PANEL_LIMITS.maxWidth, Math.max(SHORTCUT_PANEL_LIMITS.minWidth, fraction));
85
+ }
86
+ /**
87
+ * Cells the drawer may draw on INSIDE its frame.
88
+ *
89
+ * THE FORM IS DECIDED BY THE TERMINAL, exactly as `OverlayFrame` decides it
90
+ * (`cols >= BORDERLESS_COLS`), so this function takes the same `cols` the frame
91
+ * receives. Passing the panel's own width instead would make a 46-cell drawer on
92
+ * a 200-column terminal fall back to the rail form for ever, because 46 < 48.
93
+ */
94
+ export function shortcutPanelContentCells(cols) {
95
+ const width = shortcutPanelWidth(cols);
96
+ const chrome = Math.max(0, Math.floor(cols)) >= 48
97
+ ? SHORTCUT_PANEL_LIMITS.boxCells
98
+ : SHORTCUT_PANEL_LIMITS.railCells;
99
+ return Math.max(0, width - chrome);
100
+ }
101
+ /**
102
+ * Derive the two columns (P0-1).
103
+ *
104
+ * `stacked` is a GLOBAL flag only when the keyword column is too narrow for any
105
+ * keyword at all; otherwise stacking is decided per row, so one long binding
106
+ * (`Shift+Up / Shift+Down`, 20 cells) does not cost the other thirty rows a line.
107
+ */
108
+ export function shortcutColumns(cols, entries, glyphs) {
109
+ const width = shortcutPanelWidth(cols);
110
+ const available = shortcutPanelContentCells(cols);
111
+ // The cursor glyph owns one cell on the selected row, so it comes out of the
112
+ // LIST's budget rather than out of the keyword's.
113
+ const cursorCells = stringWidth(glyphs.cursor) > 0 ? 1 : 0;
114
+ const listCells = Math.max(0, available - cursorCells);
115
+ const longest = widestKeysCells(entries);
116
+ const share = Math.ceil(listCells * SHORTCUT_PANEL_LIMITS.keyColumnShare);
117
+ const keyCells = Math.min(longest, Math.max(0, share));
118
+ const keywordCells = Math.max(0, listCells - keyCells - SHORTCUT_PANEL_LIMITS.keyGapCells);
119
+ return {
120
+ width,
121
+ available,
122
+ listCells,
123
+ keyCells,
124
+ keywordCells,
125
+ stacked: keywordCells < SHORTCUT_PANEL_LIMITS.keywordMinCells,
126
+ };
127
+ }
128
+ /** `12s ago` / `3m ago` / `1h ago`, without introducing a second clock. */
129
+ function ageLabel(ageMs) {
130
+ if (ageMs === null || !Number.isFinite(ageMs) || ageMs < 0)
131
+ return '';
132
+ const seconds = Math.floor(ageMs / 1000);
133
+ if (seconds < 60)
134
+ return `${seconds}s ago`;
135
+ const minutes = Math.floor(seconds / 60);
136
+ if (minutes < 60)
137
+ return `${minutes}m ago`;
138
+ return `${Math.floor(minutes / 60)}h ago`;
139
+ }
140
+ /** The task section's status line, or the reason there is none. */
141
+ function taskStatusLine(task, glyphs) {
142
+ if (!task.enabled)
143
+ return 'task summary is off (config taskSummary.enabled)';
144
+ if (task.pending)
145
+ return 'summarizing' + ` ${glyphs.midDot} ` + 'r refresh';
146
+ if (task.text === null) {
147
+ const reason = task.reason === null ? '' : ` ${glyphs.midDot} ${task.reason}`;
148
+ return `no summary yet${reason} ${glyphs.midDot} r refresh`;
149
+ }
150
+ const age = ageLabel(task.ageMs);
151
+ const who = task.tone === 'fallback' ? 'fallback' : task.by;
152
+ const parts = [who, age].filter((part) => part.length > 0);
153
+ return `${parts.join(` ${glyphs.midDot} `)} ${glyphs.midDot} r refresh`;
154
+ }
155
+ /**
156
+ * The footer, built by DROPPING clauses from the right (never by re-wording).
157
+ *
158
+ * `Esc close` is never dropped: it is the one thing a user must be able to find on
159
+ * a terminal too narrow to explain anything else. The rest go in a fixed order so
160
+ * the footer is stable across resizes instead of shuffling.
161
+ */
162
+ export function shortcutFooter(cols, glyphs, opts = { canRefresh: true }) {
163
+ const available = shortcutPanelContentCells(cols);
164
+ // SHORT SPELLINGS ON PURPOSE. The drawer is 30 cells wide at 80 columns and 46 at
165
+ // most, so a footer that spelled `Up/Down select · PgUp/PgDn page · d details ·
166
+ // r refresh · Esc close` (67 cells) would lose three clauses at EVERY width - the
167
+ // ladder would never be exercised, only its last rung. These forms fit four of the
168
+ // five at the maximum width and still keep the one that matters at the minimum.
169
+ const clauses = [
170
+ 'Up/Dn',
171
+ 'PgUp/PgDn',
172
+ 'd details',
173
+ ...(opts.canRefresh ? ['r refresh'] : []),
174
+ 'Esc close',
175
+ ];
176
+ // Drop order: the least essential clause first. `Esc close` is never dropped - on a
177
+ // terminal too narrow to explain anything else, that is the one key a user must be
178
+ // able to find.
179
+ const droppable = ['r refresh', 'Up/Dn', 'PgUp/PgDn', 'd details'];
180
+ const sep = ` ${glyphs.midDot} `;
181
+ let kept = [...clauses];
182
+ while (kept.length > 1 && stringWidth(kept.join(sep)) > available) {
183
+ const victim = droppable.find((clause) => kept.includes(clause));
184
+ if (victim === undefined)
185
+ break;
186
+ kept = kept.filter((clause) => clause !== victim);
187
+ }
188
+ return kept.join(sep);
189
+ }
190
+ /**
191
+ * Build every row the drawer can show, and derive the window from the cursor.
192
+ *
193
+ * @param input.cols Terminal columns (the frame receives the same number).
194
+ * @param input.rows Body rows inside the frame - `overlayBodyRows(maxRows, cols)`.
195
+ * @param input.cursor Index into `input.entries`; clamped, never trusted.
196
+ */
197
+ export function buildShortcutRows(input) {
198
+ const { glyphs } = input;
199
+ const columns = shortcutColumns(input.cols, input.entries, glyphs);
200
+ const rows = [];
201
+ const push = (row) => { rows.push(row); };
202
+ const wrapped = (text, cells, maxRows) => wrapCells(text, cells, maxRows, glyphs.ellipsis).lines;
203
+ // --- title ---------------------------------------------------------------
204
+ // NO TITLE ROW (deviation from appendix A's sketch, recorded in `spec.md`'s
205
+ // implementation-issues note): `OverlayFrame` already draws `title` as chrome,
206
+ // and the `details on|off` marker rides there instead - one string on a row
207
+ // that is already paid for, rather than a whole body row spent on it.
208
+ // --- task section (at most `taskMaxRows`) --------------------------------
209
+ const statusText = () => fitCells(taskStatusLine(input.task, glyphs), columns.available, glyphs.ellipsis);
210
+ if (!input.task.enabled) {
211
+ push({ kind: 'task', text: statusText(), entryId: null, tone: 'dim' });
212
+ }
213
+ else if (input.task.text !== null) {
214
+ for (const line of wrapped(input.task.text, columns.available, 1)) {
215
+ push({
216
+ kind: 'task',
217
+ text: line,
218
+ entryId: null,
219
+ tone: input.task.tone === 'fallback' ? 'dim' : 'primary',
220
+ });
221
+ }
222
+ if (input.rows >= SHORTCUT_PANEL_LIMITS.groupHeaderMinRows) {
223
+ push({ kind: 'task', text: statusText(), entryId: null, tone: 'muted' });
224
+ }
225
+ }
226
+ else {
227
+ push({ kind: 'task', text: statusText(), entryId: null, tone: 'muted' });
228
+ }
229
+ // --- rule ----------------------------------------------------------------
230
+ if (input.rows >= SHORTCUT_PANEL_LIMITS.groupHeaderMinRows) {
231
+ push({
232
+ kind: 'rule',
233
+ text: glyphs.hrule.repeat(columns.available),
234
+ entryId: null,
235
+ tone: 'muted',
236
+ });
237
+ }
238
+ // --- entries -------------------------------------------------------------
239
+ const entries = input.entries;
240
+ const total = entries.length;
241
+ const cursor = total === 0 ? 0 : Math.min(total - 1, Math.max(0, Math.floor(input.cursor)));
242
+ /** Indent shared by the detail lines and a stacked keyword. */
243
+ const indent = ' '.repeat(SHORTCUT_PANEL_LIMITS.detailIndent);
244
+ let cursorRow = 0;
245
+ const groupHeaders = input.rows >= SHORTCUT_PANEL_LIMITS.groupHeaderMinRows;
246
+ let currentGroup = '';
247
+ entries.forEach((entry, index) => {
248
+ const selected = index === cursor;
249
+ if (groupHeaders && entry.group !== currentGroup) {
250
+ currentGroup = entry.group;
251
+ const title = SHORTCUT_GROUPS.find((group) => group.id === entry.group)?.title ?? '';
252
+ push({
253
+ kind: 'group',
254
+ text: fitCells(title, columns.available, glyphs.ellipsis),
255
+ entryId: null,
256
+ tone: 'accent',
257
+ });
258
+ }
259
+ const usable = input.entryAvailable ? input.entryAvailable(entry) : true;
260
+ const tone = usable ? (selected ? 'primary' : 'muted') : 'dim';
261
+ const lead = selected ? glyphs.cursor : ' ';
262
+ if (selected)
263
+ cursorRow = rows.length;
264
+ const keyRoom = columns.keyCells;
265
+ if (columns.stacked || stringWidth(entry.keys) > keyRoom) {
266
+ push({
267
+ kind: 'stacked',
268
+ text: fitCells(`${lead}${entry.keys}`, columns.listCells, glyphs.ellipsis),
269
+ entryId: entry.id,
270
+ tone,
271
+ });
272
+ push({
273
+ kind: 'keyword',
274
+ text: indent
275
+ + fitCells(entry.keyword, Math.max(0, columns.available - SHORTCUT_PANEL_LIMITS.detailIndent), glyphs.ellipsis),
276
+ entryId: entry.id,
277
+ tone: 'muted',
278
+ });
279
+ }
280
+ else {
281
+ const keyCell = padCells(`${lead}${entry.keys}`, 1 + keyRoom);
282
+ const keyword = fitCells(entry.keyword, columns.keywordCells, glyphs.ellipsis);
283
+ push({
284
+ kind: 'entry',
285
+ text: `${keyCell}${' '.repeat(SHORTCUT_PANEL_LIMITS.keyGapCells)}${keyword}`,
286
+ entryId: entry.id,
287
+ tone,
288
+ });
289
+ }
290
+ if (!selected)
291
+ return;
292
+ // The detail line belongs to the selected row and is the reason the window is
293
+ // derived from the cursor rather than from an independent offset (P1-7).
294
+ const detailRoom = Math.max(0, columns.available - SHORTCUT_PANEL_LIMITS.detailIndent);
295
+ // THE UNAVAILABILITY SENTENCE COMES FIRST, not last: the detail line is capped at
296
+ // two rows, and a reason appended after a long explanation is the first thing the
297
+ // cap removes - leaving a dim row with no explanation, which is the one outcome
298
+ // this line exists to prevent.
299
+ const detail = usable
300
+ ? entry.detail
301
+ : `Unavailable now - ${input.unavailableReason ?? 'not in this mode'}. ${entry.detail}`;
302
+ for (const line of wrapped(detail, detailRoom, SHORTCUT_PANEL_LIMITS.detailMaxRows)) {
303
+ push({ kind: 'detail', text: `${indent}${line}`, entryId: entry.id, tone: 'dim' });
304
+ }
305
+ });
306
+ // --- the window ----------------------------------------------------------
307
+ const height = Math.max(1, Math.floor(Number.isFinite(input.rows) ? input.rows : 1));
308
+ // THE SMALLEST WINDOW CONTAINING THE CURSOR: the cursor sits on the last visible
309
+ // row while scrolling down and the window never starts past it.
310
+ const desired = Math.max(0, cursorRow - height + 1);
311
+ const window = sliceWindow(rows.length, desired, height);
312
+ return {
313
+ rows,
314
+ window,
315
+ cursorRow,
316
+ total: rows.length,
317
+ hint: shortcutFooter(input.cols, glyphs, {
318
+ canRefresh: input.task.enabled && !input.task.pending,
319
+ }),
320
+ };
321
+ }
@@ -17,7 +17,11 @@ export function planStatusDetail(input) {
17
17
  const budget = Math.max(0, Math.floor(status.columns) - 1);
18
18
  const escape = escapeClause({ ...hints, escapeAction: status.escapeAction });
19
19
  const reserved = [...(escape ? [statusField('escape', escape, 'warning')] : []),
20
- statusField('toggle', hints.overlay ? 'Details on' : '^G less')];
20
+ // THE TOGGLE HINT NAMES A TWO-KEY PATH NOW (tui-shortcut-drawer-and-task-summary
21
+ // D-6). This row said `^G less` because `Ctrl+G` collapsed it directly; the key
22
+ // opens the drawer instead, and `d` inside the drawer is what collapses this row.
23
+ // Saying `^G less` would be a hint that does nothing.
24
+ statusField('toggle', hints.overlay ? 'Details on' : '^G then d')];
21
25
  const fields = [];
22
26
  const add = (candidate) => {
23
27
  if (statusLinePlan([...fields, candidate, ...reserved]).cells <= budget)
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The task-summary cell's decisions (tui-shortcut-drawer-and-task-summary §6.3).
3
+ *
4
+ * ASCII ONLY: `src/ui/**` is inside the glyph scanner's scope.
5
+ *
6
+ * WHY THIS IS A SEPARATE MODULE FROM `header-left.ts`: the planner answers "how
7
+ * wide is the cell", this one answers "should there be a cell at all, and what is
8
+ * in it". The second question is the trigger rule (D-7) and it has to be testable
9
+ * without a renderer - `task-summary-app.test.tsx` pins "one call per session" by
10
+ * calling THIS function, not by counting renders.
11
+ *
12
+ * THE TRIGGER IS NARROW ON PURPOSE. A header label is an identity strip, not a
13
+ * live subtitle: refreshing it every turn would flicker, cost money, and teach the
14
+ * user to distrust the one thing on the row that is not a status field.
15
+ */
16
+ /** Why no summary call was started. Every branch is a branch the tests can name. */
17
+ export type TaskSummarySkipReason = 'already-set' | 'no-anchor' | 'not-user-initiated' | 'disabled' | 'pending';
18
+ /**
19
+ * D-7's trigger rule.
20
+ *
21
+ * FIVE INPUTS, ONE ANSWER. `userInitiated` is the second guard on the same fact
22
+ * `submitMessage` already knows (`opts.userInitiated !== false`): an automatic
23
+ * continuation is not a new task, and paying for a label for it would spend the
24
+ * user's money to describe work they did not start.
25
+ */
26
+ export declare function shouldSummarize(input: {
27
+ anchor: string;
28
+ hasSummary: boolean;
29
+ pending: boolean;
30
+ enabled: boolean;
31
+ userInitiated: boolean;
32
+ }): {
33
+ start: true;
34
+ } | {
35
+ start: false;
36
+ reason: TaskSummarySkipReason;
37
+ };
38
+ /**
39
+ * The drawer's `Task` section, flattened from the view state so the pure layout
40
+ * module never learns what a `ViewState` is.
41
+ */
42
+ export declare function taskSectionFrom(input: {
43
+ view: {
44
+ text: string;
45
+ tone: 'model' | 'fallback';
46
+ reason: string | null;
47
+ by: string;
48
+ at: number;
49
+ } | null;
50
+ pending: boolean;
51
+ enabled: boolean;
52
+ now: number;
53
+ }): {
54
+ text: string | null;
55
+ tone: 'model' | 'fallback' | null;
56
+ reason: string | null;
57
+ by: string;
58
+ ageMs: number | null;
59
+ enabled: boolean;
60
+ pending: boolean;
61
+ };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The task-summary cell's decisions (tui-shortcut-drawer-and-task-summary §6.3).
3
+ *
4
+ * ASCII ONLY: `src/ui/**` is inside the glyph scanner's scope.
5
+ *
6
+ * WHY THIS IS A SEPARATE MODULE FROM `header-left.ts`: the planner answers "how
7
+ * wide is the cell", this one answers "should there be a cell at all, and what is
8
+ * in it". The second question is the trigger rule (D-7) and it has to be testable
9
+ * without a renderer - `task-summary-app.test.tsx` pins "one call per session" by
10
+ * calling THIS function, not by counting renders.
11
+ *
12
+ * THE TRIGGER IS NARROW ON PURPOSE. A header label is an identity strip, not a
13
+ * live subtitle: refreshing it every turn would flicker, cost money, and teach the
14
+ * user to distrust the one thing on the row that is not a status field.
15
+ */
16
+ /**
17
+ * D-7's trigger rule.
18
+ *
19
+ * FIVE INPUTS, ONE ANSWER. `userInitiated` is the second guard on the same fact
20
+ * `submitMessage` already knows (`opts.userInitiated !== false`): an automatic
21
+ * continuation is not a new task, and paying for a label for it would spend the
22
+ * user's money to describe work they did not start.
23
+ */
24
+ export function shouldSummarize(input) {
25
+ // ORDER IS THE DOCUMENTED PRECEDENCE: a prompt that is not a task cannot start
26
+ // one even when everything else says yes, and an OFF feature must report the
27
+ // switch rather than a downstream reason the user cannot act on.
28
+ if (!input.enabled)
29
+ return { start: false, reason: 'disabled' };
30
+ if (input.anchor.trim().length === 0)
31
+ return { start: false, reason: 'no-anchor' };
32
+ if (input.hasSummary)
33
+ return { start: false, reason: 'already-set' };
34
+ if (input.pending)
35
+ return { start: false, reason: 'pending' };
36
+ // ITS OWN REASON, not `no-anchor`: an automatic continuation HAS an anchor, it is
37
+ // simply not a new task, and a skip reason a maintainer cannot trust is worse than
38
+ // no reason at all (review fix RF-3).
39
+ if (!input.userInitiated)
40
+ return { start: false, reason: 'not-user-initiated' };
41
+ return { start: true };
42
+ }
43
+ /**
44
+ * The drawer's `Task` section, flattened from the view state so the pure layout
45
+ * module never learns what a `ViewState` is.
46
+ */
47
+ export function taskSectionFrom(input) {
48
+ if (input.view === null) {
49
+ return {
50
+ text: null, tone: null, reason: null, by: '', ageMs: null,
51
+ enabled: input.enabled, pending: input.pending,
52
+ };
53
+ }
54
+ return {
55
+ text: input.view.text,
56
+ tone: input.view.tone,
57
+ reason: input.view.reason,
58
+ by: input.view.by,
59
+ // The age is computed from the view's own timestamp and the caller's clock,
60
+ // so the drawer never introduces a second one.
61
+ ageMs: input.now >= input.view.at ? input.now - input.view.at : null,
62
+ enabled: input.enabled,
63
+ pending: input.pending,
64
+ };
65
+ }
@@ -13,5 +13,17 @@
13
13
  import React from 'react';
14
14
  import type { Theme } from '../theme.js';
15
15
  import type { TermCapabilities } from '../capabilities.js';
16
+ /**
17
+ * The key column of every binding row, EXPORTED so the drawer's drift guard can read
18
+ * the real table instead of scraping this file with a regex
19
+ * (tui-shortcut-drawer-and-task-summary D-8 / AC-5).
20
+ *
21
+ * A SOURCE SCRAPE WOULD PASS ON A COMMENT: the regex has no way to tell a key row
22
+ * from a legend row from a line inside a block comment, and the guard's whole job is
23
+ * to notice a row someone forgot.
24
+ */
25
+ export declare function allKeyRows(times: string): [string, string][];
26
+ /** The slash-command table's key column, for the same guard (commands are not keys). */
27
+ export declare function allCommandRows(): [string, string][];
16
28
  /** Build the overlay's rows. Exported so `app.test.tsx` can count them. */
17
29
  export declare function helpRows(theme: Theme, caps: TermCapabilities, native?: boolean): React.ReactElement[];
@@ -20,7 +20,16 @@ function keyRows(times, native = false) {
20
20
  .map(([key, description]) => key === 'PgUp / PgDn' ? [key, 'Page through an open overlay'] : [key, description])
21
21
  .concat([['Scroll', 'Use your terminal (wheel, Shift+PgUp, scrollbar, search)']]);
22
22
  }
23
- function allKeyRows(times) {
23
+ /**
24
+ * The key column of every binding row, EXPORTED so the drawer's drift guard can read
25
+ * the real table instead of scraping this file with a regex
26
+ * (tui-shortcut-drawer-and-task-summary D-8 / AC-5).
27
+ *
28
+ * A SOURCE SCRAPE WOULD PASS ON A COMMENT: the regex has no way to tell a key row
29
+ * from a legend row from a line inside a block comment, and the guard's whole job is
30
+ * to notice a row someone forgot.
31
+ */
32
+ export function allKeyRows(times) {
24
33
  return [
25
34
  ['Enter', 'Submit (idle) / queue steering (running); re-pins to the newest output'],
26
35
  ['Alt+Enter / Shift+Enter', 'Newline when supported; the host may intercept either binding'],
@@ -57,7 +66,15 @@ function allKeyRows(times) {
57
66
  // went - and because `^K` is only advertised in the expanded header row.
58
67
  ['Ctrl+K', 'Agent kits: pick a preset for this session (see /kit)'],
59
68
  ['Alt+K', 'Delete from the cursor to the end of the line (was Ctrl+K)'],
60
- ['Ctrl+G', 'Expand/collapse status details; disabled while an overlay is open'],
69
+ // Ctrl+G OPENED THE STATUS DETAIL ROW UNTIL THE DRAWER TOOK THE KEY
70
+ // (tui-shortcut-drawer-and-task-summary D-6). The detail row did not disappear,
71
+ // it moved to `d` inside this panel, and saying so here is the only place a
72
+ // user's muscle memory can find out.
73
+ ['Ctrl+G', 'Keyboard drawer: every shortcut, with keywords (d = status details)'],
74
+ // Ctrl+O WAS MISSING FROM THIS TABLE ENTIRELY while it worked in the app, which
75
+ // is exactly the gap the drawer's reverse drift guard exists to force closed: a
76
+ // key that only exists in the code is a key only its author knows.
77
+ ['Ctrl+O', 'Expand the newest tool output (native mode reprints it as a block)'],
61
78
  // The board's key is listed HERE and in the team table below: the roster shows
62
79
  // `ctrl+b` only on a terminal wide enough for the hint cell, and a key a user can
63
80
  // only learn from a wide terminal is a key the rest never discover (AC-18).
@@ -92,6 +109,12 @@ function allKeyRows(times) {
92
109
  }
93
110
  const COMMANDS = [
94
111
  ['/help', 'Show this help'],
112
+ // THE DRAWER'S COMMAND-SIDE ENTRY (tui-shortcut-drawer-and-task-summary §4.7).
113
+ // Listed here as well as behind Ctrl+G, because a key is unreachable in a script
114
+ // and `/keys` is how a paste or a screen reader gets to the same list.
115
+ ['/keys [query]', 'List every shortcut in the drawer; a query pre-filters it'],
116
+ ['/task', 'Show the header task summary: what it says, where it came from'],
117
+ ['/task refresh', 'Regenerate the header task summary from the last prompt'],
95
118
  ['/model', 'Open the model picker'],
96
119
  ['/settings', 'Open the settings screen'],
97
120
  ['/plan [on|off|status]', 'Toggle plan mode (same as Shift+Tab)'],
@@ -172,6 +195,10 @@ const FAST_ROWS = [
172
195
  ['--fast / --no-fast', 'Start a session with the tier on or off (also ARAGON_FAST=1)'],
173
196
  ['<fast_review>', 'An automated second opinion from the fast model - advice, not the user'],
174
197
  ];
198
+ /** The slash-command table's key column, for the same guard (commands are not keys). */
199
+ export function allCommandRows() {
200
+ return COMMANDS;
201
+ }
175
202
  /** Build the overlay's rows. Exported so `app.test.tsx` can count them. */
176
203
  export function helpRows(theme, caps, native = false) {
177
204
  const glyphs = pickGlyphs(caps);
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The keyboard drawer (tui-shortcut-drawer-and-task-summary §4.5).
3
+ *
4
+ * ASCII ONLY: `src/ui/**` is inside `glyphs.test.ts::inScope`.
5
+ *
6
+ * IT OWNS NO CHROME AND NO ARITHMETIC. `OverlayFrame` draws the frame, the
7
+ * window and the position indicator; `shortcut-panel-layout.ts` decides the
8
+ * columns, the rows and which window contains the cursor. This component adds two
9
+ * things and nothing else: its own key handling and the text-run colours. v1
10
+ * planned 260 lines that re-implemented the frame's windowing and clamping - a
11
+ * second source of truth for the one thing overlays have already been rewritten
12
+ * once to unify.
13
+ *
14
+ * IT DOES NOT REGISTER `Esc` AND DOES NOT TOUCH ANY `ctrl`/`meta` COMBINATION:
15
+ * `Esc` belongs to `App` (it closes every overlay and cancels the pending
16
+ * question/plan), and `Ctrl+G` is handled there too. Two listeners both acting on
17
+ * one byte is how a panel closes twice.
18
+ */
19
+ import React from 'react';
20
+ import type { Theme } from '../theme.js';
21
+ import type { TermCapabilities } from '../capabilities.js';
22
+ import { type ShortcutTaskSection } from '../layout/shortcut-panel-layout.js';
23
+ export interface ShortcutPanelProps {
24
+ /** The terminal width, which is what `OverlayFrame` needs to pick its form. */
25
+ cols: number;
26
+ maxRows: number;
27
+ theme: Theme;
28
+ caps: TermCapabilities;
29
+ isActive: boolean;
30
+ native: boolean;
31
+ running: boolean;
32
+ /** `/keys <query>` pre-filter. */
33
+ query?: string;
34
+ task: ShortcutTaskSection;
35
+ detailsExpanded: boolean;
36
+ onToggleDetails(): void;
37
+ onRefresh(): void;
38
+ }
39
+ export declare function ShortcutPanel(props: ShortcutPanelProps): React.ReactElement;