@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,87 @@
1
+ /**
2
+ * The per-call approval gate: one wrapper, one shared denial text, no I/O.
3
+ *
4
+ * ASCII ONLY - `src/tools/**` is inside the glyph scanner's scope.
5
+ *
6
+ * WHY A WRAPPER AND NOT A CORE HOOK (headless-host-bridge section 2.3.3). A
7
+ * `beforeExecute` callback on Core's `ToolExecutor` would put a human wait in
8
+ * front of the plan gate's refusal, freeze a new field into the public API that
9
+ * `public-api.test.ts` guards, and teach the engine about a host shape it has no
10
+ * business knowing. The `{...tool, execute}` shape below is the one already used
11
+ * three times in `tools/index.ts` (confirmation, skill ceiling, plan gate), so
12
+ * nothing in the engine has to learn that an approval exists.
13
+ *
14
+ * WHERE IT SITS IN THE STACK IS PART OF THE DESIGN, not an accident of import
15
+ * order: `tools/index.ts` applies it AFTER every append (so `skill_create` is
16
+ * reachable) and BEFORE the `toolPolicy` / plan-gate wrappers are added, so at
17
+ * call time those two refusals - which need no I/O - happen first and a tool that
18
+ * policy would refuse never interrupts a human.
19
+ */
20
+ import { errorResult } from '@aragon-agent/core';
21
+ /**
22
+ * THE ONE PLACE A DENIAL IS WORDED. Shared by the wrapper below and by the tests,
23
+ * so "the model is told what happened and what to do instead" is a single string
24
+ * rather than a paragraph repeated in two modules.
25
+ *
26
+ * The last sentence is not politeness. A bare "denied" makes a model retry the
27
+ * same call; the observed failure mode is a paid loop that burns a budget on one
28
+ * forbidden command. Naming the tool and the reason is what lets it route around
29
+ * the refusal instead.
30
+ */
31
+ export function denialMessage(tool, reason) {
32
+ const why = reason && reason.trim().length > 0 ? ` Reason: ${reason.trim()}` : '';
33
+ return (`The caller denied this tool call (${tool}).${why} ` +
34
+ 'Do not retry the same call. Explain what you were trying to do, or take a different approach.');
35
+ }
36
+ /** The text a timed-out decision produces. Exported for the same reason. */
37
+ export function timeoutMessage(tool, timeoutMs) {
38
+ return (`The caller did not answer within ${timeoutMs} ms. The tool was not run. ` +
39
+ `Do not retry ${tool} without asking; explain what you needed it for instead.`);
40
+ }
41
+ /** The text an aborted turn produces, in the middle of a decision wait. */
42
+ export function interruptedMessage(tool) {
43
+ return `The turn was interrupted before the caller answered, so ${tool} was not run.`;
44
+ }
45
+ /**
46
+ * Wrap a tool behind the gate. Same `{...tool, execute}` shape as every other
47
+ * wrapper in this package, and the same `errorResult` discipline: a refusal is a
48
+ * TOOL RESULT, never a throw, because the engine pushes whatever this returns
49
+ * into history - and a `tool_use` with no `tool_result` is an HTTP 400 on the next
50
+ * request.
51
+ */
52
+ export function withToolApproval(tool, gate, options = {}) {
53
+ const original = tool.execute;
54
+ return {
55
+ ...tool,
56
+ async execute(id, params, ctx) {
57
+ const request = {
58
+ tool: tool.name,
59
+ callId: id,
60
+ input: (params ?? {}),
61
+ summary: summarize(tool.name, (params ?? {})),
62
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
63
+ };
64
+ const ask = () => gate.decide(request);
65
+ const verdict = options.withHumanWait ? await options.withHumanWait(ask) : await ask();
66
+ if (verdict.allow)
67
+ return original(id, params, ctx);
68
+ return errorResult(verdict.message ?? denialMessage(tool.name, verdict.reason));
69
+ },
70
+ };
71
+ }
72
+ /**
73
+ * A local copy of the one-line summary, for the same reason `tools/index.ts` keeps
74
+ * its own: this file must stay importable by a test without dragging in the whole
75
+ * builtin tool set, and the two call sites describe the same three tools.
76
+ */
77
+ function summarize(toolName, params) {
78
+ if (toolName === 'bash')
79
+ return `Run: ${String(params.command ?? '')}`;
80
+ if (toolName === 'write_file')
81
+ return `Write: ${String(params.path ?? '')}`;
82
+ if (toolName === 'edit_file')
83
+ return `Edit: ${String(params.path ?? '')}`;
84
+ if (toolName === 'skill_create')
85
+ return `Create skill: ${String(params.name ?? '')}`;
86
+ return toolName;
87
+ }
package/dist/ui/App.js CHANGED
@@ -87,6 +87,12 @@ import { createClipboardCoordinator } from './clipboard-task.js';
87
87
  import { QueuePanel } from './QueuePanel.js';
88
88
  import { QueueOverlay } from './overlays/QueueOverlay.js';
89
89
  import { AgentsBoardOverlay } from './overlays/AgentsBoardOverlay.js';
90
+ // THE KEYBOARD DRAWER AND THE HEADER LABEL (tui-shortcut-drawer-and-task-summary).
91
+ // Both are thin here on purpose: the drawer's geometry lives in
92
+ // `layout/shortcut-panel-layout.ts` and the trigger rule in
93
+ // `layout/task-summary-cell.ts`, so this file only routes state into them.
94
+ import { ShortcutPanel } from './overlays/ShortcutPanel.js';
95
+ import { shouldSummarize, taskSectionFrom } from './layout/task-summary-cell.js';
90
96
  import { buildQueueLayout } from './layout/queue-layout.js';
91
97
  import { interactionCopy } from './interaction-copy.js';
92
98
  import { createRunMetrics, updateRunMetrics, freezeRunMetrics, projectRunMetrics } from './run-metrics.js';
@@ -170,6 +176,36 @@ function applyPromptMutation(action) {
170
176
  return movePrompt(action.id, action.delta);
171
177
  return setPromptFavorite(action.id, action.favorite);
172
178
  }
179
+ /**
180
+ * The first USER message in a transcript, or `''`.
181
+ *
182
+ * Used by the `/resume` rebuild, which must describe the conversation it is
183
+ * ADOPTING rather than the one on screen (D-13). A restored session can open with a
184
+ * tool card or a settled system notice, so "entries[0]" is not the answer.
185
+ */
186
+ function firstUserText(entries) {
187
+ const entry = entries.find((candidate) => candidate.kind === 'user');
188
+ return entry && entry.kind === 'user' ? entry.text : '';
189
+ }
190
+ /**
191
+ * The overlays that hold an unresolved promise to the agent (review fix RF-1).
192
+ *
193
+ * NOT PANELS: `confirm` is a tool waiting for approval, `question` and `plan` are
194
+ * human-input requests, and each one is settled ONLY by the Escape branch of the
195
+ * global key handler. An overlay that can be dismissed without settling it strands
196
+ * the run, so `Ctrl+G` refuses to take the keyboard from these three - the same
197
+ * shape of refusal as `tooSmall` - and the header keeps naming `Esc close`.
198
+ */
199
+ const DECISION_OVERLAYS = new Set(['confirm', 'question', 'plan']);
200
+ /** The LAST user message in a transcript, or `''` — the anchor `/task refresh` uses. */
201
+ function lastUserText(entries) {
202
+ for (let index = entries.length - 1; index >= 0; index -= 1) {
203
+ const entry = entries[index];
204
+ if (entry && entry.kind === 'user')
205
+ return entry.text;
206
+ }
207
+ return '';
208
+ }
173
209
  export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, scrollMode = 'app', scrollback, initialEntries, session, sessionBridge, sessionNotice, }) {
174
210
  const native = scrollMode === 'native' && scrollback !== undefined;
175
211
  /** Set when the native writer swallowed repeated tall frames; cleared by a resize. */
@@ -201,6 +237,14 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
201
237
  const [draftClearedByCtrlC, setDraftClearedByCtrlC] = useState(false);
202
238
  const [popupRows, setPopupRows] = useState(0);
203
239
  const [statusExpanded, setStatusExpanded] = useState(false);
240
+ /**
241
+ * The `/keys <query>` pre-filter for the drawer.
242
+ *
243
+ * STATE RATHER THAN A PROP OF THE COMMAND, because the command and the panel are
244
+ * two moments: `/keys git` sets the filter, `Ctrl+G` opens with whatever was set
245
+ * last, and the panel itself never types (F-2: a text field would fight `Esc`).
246
+ */
247
+ const [shortcutQuery, setShortcutQuery] = useState('');
204
248
  const dimensions = buildFrameBudget({ ...size, draftRows, popupRows, statusExpanded });
205
249
  const { rows, cols } = dimensions;
206
250
  /**
@@ -304,6 +348,17 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
304
348
  const reducedMotion = cfg.reducedMotion ?? false;
305
349
  const stateRef = useRef(state);
306
350
  stateRef.current = state;
351
+ /**
352
+ * The task label's two guards (tui-shortcut-drawer-and-task-summary D-10).
353
+ *
354
+ * `taskSummaryEpoch` IS THE CORRECTNESS GUARD and the `AbortController` is the
355
+ * economy one: the epoch decides whether a late result may still be adopted, the
356
+ * abort releases the connection the moment its answer stopped mattering. Neither
357
+ * substitutes for the other, so both are bumped/cancelled on every new-task
358
+ * boundary - `/clear`, `/resume`, and a new first submit.
359
+ */
360
+ const taskSummaryEpoch = useRef(0);
361
+ const taskSummaryAbort = useRef(null);
307
362
  const startedAt = useRef(Date.now());
308
363
  const confirmRef = useRef(null);
309
364
  confirmRef.current = confirmState;
@@ -1628,6 +1683,74 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1628
1683
  }
1629
1684
  return applied;
1630
1685
  };
1686
+ /**
1687
+ * Cancel any in-flight label call and make its result unadoptable.
1688
+ *
1689
+ * ONE FUNCTION FOR BOTH BOUNDARIES: the epoch is incremented BEFORE the abort so a
1690
+ * resolution that is already sitting in the microtask queue cannot slip through
1691
+ * between the two statements.
1692
+ */
1693
+ const cancelTaskSummary = () => {
1694
+ taskSummaryEpoch.current += 1;
1695
+ taskSummaryAbort.current?.abort();
1696
+ taskSummaryAbort.current = null;
1697
+ };
1698
+ /**
1699
+ * Start one label call, if the trigger rule allows it (D-7).
1700
+ *
1701
+ * FIRE AND FORGET, AND NEVER AWAITED: the caller (`submitMessage`) starts
1702
+ * `controller.prompt()` immediately after this returns, so the summary can never
1703
+ * delay the first token (AC-13). `summarizeTask` never rejects, so there is no
1704
+ * `.catch` here and no unhandled rejection can reach a TUI with nowhere to show it.
1705
+ */
1706
+ const ensureTaskSummary = (anchor, opts = {}) => {
1707
+ const decision = shouldSummarize({
1708
+ anchor,
1709
+ // `force` IS THE `/resume` REBUILD. The reducer is about to replace the label
1710
+ // with `null`, so reading the CURRENT state here would see the old
1711
+ // conversation's label still in place and skip the rebuild - a race the
1712
+ // wrapper cannot win by ordering alone, because React applies the action on
1713
+ // the next render. An explicit instruction outranks the automatic rule; the
1714
+ // `enabled` and anchor checks still apply.
1715
+ hasSummary: opts.force === true ? false : stateRef.current.taskSummary !== null,
1716
+ pending: opts.force === true ? false : stateRef.current.taskSummaryPending,
1717
+ enabled: controller.getConfig().taskSummary?.enabled !== false,
1718
+ userInitiated: opts.userInitiated !== false,
1719
+ });
1720
+ if (!decision.start)
1721
+ return;
1722
+ taskSummaryEpoch.current += 1;
1723
+ const epoch = taskSummaryEpoch.current;
1724
+ taskSummaryAbort.current?.abort();
1725
+ const abort = new AbortController();
1726
+ taskSummaryAbort.current = abort;
1727
+ dispatch({ type: 'taskSummaryPending' });
1728
+ // `?.` FOR THE SAME REASON `isCompactionBusy?.()` AND `isFastRegistered?.()` USE IT:
1729
+ // a host that supplies a partial controller (every App test double, and any
1730
+ // embedding that never wanted this feature) must not crash on a header
1731
+ // decoration. A missing method means "no label machinery here", which is exactly
1732
+ // `view: null`.
1733
+ const call = controller.summarizeTask?.(anchor, {
1734
+ signal: abort.signal,
1735
+ ellipsis: glyphs.ellipsis,
1736
+ });
1737
+ if (!call) {
1738
+ // Nothing was started, so the latch must come back down or every later task
1739
+ // would be skipped as `pending`.
1740
+ dispatch({ type: 'taskSummaryReady', view: null });
1741
+ return;
1742
+ }
1743
+ void call.then((outcome) => {
1744
+ if (!mounted.current)
1745
+ return;
1746
+ // THE EPOCH CHECK IS THE ADOPTION RULE, not a nicety: `/clear` while a call was
1747
+ // in flight would otherwise paint the previous task's label onto an empty
1748
+ // session, which is the one thing a label must never do.
1749
+ if (epoch !== taskSummaryEpoch.current)
1750
+ return;
1751
+ dispatch({ type: 'taskSummaryReady', view: outcome.view });
1752
+ });
1753
+ };
1631
1754
  const makeCtx = (args) => ({
1632
1755
  args,
1633
1756
  requestCopy: copyCoordinator.requestCopy,
@@ -1678,6 +1801,24 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1678
1801
  dispatch({ type: 'abortMark' });
1679
1802
  dispatch({ type: 'runEnd' });
1680
1803
  }
1804
+ // A NEW-TASK BOUNDARY INVALIDATES THE LABEL IN FLIGHT (D-10 / D-13). The
1805
+ // reducer clears the stored view in both branches; this cancels the call
1806
+ // whose answer belongs to the conversation that just ended, and it runs
1807
+ // BEFORE `dispatch(action)` so no late result can be adopted in between.
1808
+ cancelTaskSummary();
1809
+ if (action.type === 'restoreEntries') {
1810
+ // `/resume` REBUILDS ONE LABEL FROM THE ENTRIES IT JUST ADOPTED.
1811
+ //
1812
+ // FROM `action.entries`, NOT `state.entries`: this wrapper runs before the
1813
+ // reducer applies the action, so the state snapshot is still the
1814
+ // conversation being replaced. Reading the reducer's input is also the
1815
+ // only way this works for the `/resume` path at all - and the App's
1816
+ // initial seed (`seedViewState`) calls `viewReducer` DIRECTLY, so
1817
+ // launching with `--resume` costs nothing (AC-26).
1818
+ const anchor = firstUserText(action.entries);
1819
+ if (anchor !== '')
1820
+ ensureTaskSummary(anchor, { userInitiated: true, force: true });
1821
+ }
1681
1822
  }
1682
1823
  dispatch(action);
1683
1824
  },
@@ -1692,6 +1833,27 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1692
1833
  resolve: (ref) => resolvePromptRef(loadPromptLibrary(), ref),
1693
1834
  },
1694
1835
  setOverlay: (overlay) => dispatch({ type: 'setOverlay', overlay }),
1836
+ // THE TASK LABEL PORT (`/keys`, `/task`; §6.4). Reads go through `stateRef` so a
1837
+ // command always sees the LATEST label rather than the one from the render that
1838
+ // built this context - the same rule `transcriptEntries` above records.
1839
+ taskSummary: {
1840
+ openPanel: (query) => {
1841
+ setShortcutQuery(query ?? '');
1842
+ dispatch({ type: 'setOverlay', overlay: 'shortcuts' });
1843
+ },
1844
+ read: () => ({
1845
+ view: stateRef.current.taskSummary,
1846
+ pending: stateRef.current.taskSummaryPending,
1847
+ enabled: controller.getConfig().taskSummary?.enabled !== false,
1848
+ }),
1849
+ refresh: () => {
1850
+ const anchor = lastUserText(stateRef.current.entries);
1851
+ if (anchor === '')
1852
+ return '';
1853
+ ensureTaskSummary(anchor, { force: true, userInitiated: true });
1854
+ return anchor;
1855
+ },
1856
+ },
1695
1857
  notify,
1696
1858
  toast,
1697
1859
  persistConfig,
@@ -1805,6 +1967,15 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1805
1967
  return { accepted: true };
1806
1968
  }
1807
1969
  // Fire-and-forget: events drive the UI. prompt() never rejects.
1970
+ //
1971
+ // THE LABEL HOOK SITS HERE AND NOWHERE ELSE (P1-1): after `preflight()` said
1972
+ // yes, before `prompt()` is started. Above this line the two `return`s are a
1973
+ // restart notice and a run already starting; between `dispatch({type:'submit'})`
1974
+ // and `preflight()` is the path where a rejected submission would pay for a
1975
+ // model call that describes a turn nobody ran; and below, in the queueing
1976
+ // branch, the message is steering rather than a new task.
1977
+ if (opts.userInitiated !== false)
1978
+ ensureTaskSummary(message);
1808
1979
  void controller.prompt(message, { todoPolicy: opts.todoPolicy ?? 'new-task' }).then((outcome) => {
1809
1980
  if (!mounted.current || sequence !== submissionSequence.current)
1810
1981
  return;
@@ -2170,10 +2341,36 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2170
2341
  }, 1500);
2171
2342
  return;
2172
2343
  }
2344
+ // Ctrl+G: THE KEYBOARD DRAWER (tui-shortcut-drawer-and-task-summary D-1).
2345
+ //
2346
+ // THREE STATES, NOT ONE GUARD (P2-3). It used to require "no overlay open" and
2347
+ // then flipped the status detail row; both halves are gone:
2348
+ // - `shortcuts` open -> close it (the `Ctrl+B` precedent);
2349
+ // - nothing open -> open it;
2350
+ // - some OTHER overlay -> TAKE OVER. Swallowing the key would leave the user
2351
+ // pressing a key whose only on-screen hint is `Esc close` and getting
2352
+ // nothing, and what they asked for was this panel.
2353
+ //
2354
+ // THE DECISION OVERLAYS ARE THE ONE EXCEPTION, AND IT IS NOT A NICETY (review fix
2355
+ // RF-1). `confirm`, `question` and `plan` are not panels: each holds an
2356
+ // unresolved promise to the agent - a tool waiting for approval, a question
2357
+ // waiting for an answer - and the Escape branch further down is the ONLY path
2358
+ // that settles one (`cancelConfirmation` / `cancelPendingHuman`). Taking the
2359
+ // keyboard away from such a dialog would hide it and leave that promise pending
2360
+ // for ever: a run that never resumes, and a screen that cannot say why. A
2361
+ // read-only reference panel is not worth stranding a run, so the key stays inert
2362
+ // here and the header keeps naming the key that does work.
2363
+ //
2364
+ // THE DETAIL-ROW TOGGLE MOVED INSIDE THE DRAWER (`d`, D-6): `HelpOverlay` and
2365
+ // `CHANGELOG` both name the new home, and the panel's title line carries the
2366
+ // `details on|off` state.
2173
2367
  if (key.ctrl && input.toLowerCase() === 'g') {
2174
- if (tooSmall || stateRef.current.overlay)
2368
+ if (tooSmall)
2369
+ return;
2370
+ const open = stateRef.current.overlay;
2371
+ if (DECISION_OVERLAYS.has(open))
2175
2372
  return;
2176
- setStatusExpanded(value => !value);
2373
+ dispatch({ type: 'setOverlay', overlay: open === 'shortcuts' ? null : 'shortcuts' });
2177
2374
  return;
2178
2375
  }
2179
2376
  // Ctrl+B: the Agents board (tui-subagent-panel D-6 / §6.5).
@@ -2652,14 +2849,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2652
2849
  // ALWAYS CALLED, INERT WHEN `enabled` IS FALSE: hooks cannot be conditional, and
2653
2850
  // an inert hook is what keeps `scrollMode: 'app'` byte-identical (I-S9).
2654
2851
  const viewCols = native ? Math.max(1, cols - 1) : cols;
2655
- const header = (_jsx(Header, { columns: viewCols, statusExpanded: statusExpanded, scrolledLines: scrolledLines, overlayOpen: overlay !== null, cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: pickHeaderVariant(cols), theme: theme, caps: caps,
2656
- // ALWAYS TRUE (agent-model-kits §4.7 / RV-P1-2): the requirement is that
2657
- // `ctrl+G` reveals `ctrl+K`, and the user with an empty library needs that
2658
- // hint most - the picker itself carries the "create one in /settings" row.
2659
- kitHint: true,
2660
- // `prompts.hint=false` removes the `^P prompts` clause and nothing else; the key
2661
- // still works (AC-3).
2662
- promptsHint: cfg.prompts?.hint !== false }));
2852
+ const header = (_jsx(Header, { columns: viewCols,
2853
+ // DERIVED IN EXACTLY ONE PLACE (0-3 (d)): both overlay slots render
2854
+ // `overlayNode`, and both read this same three-state value from here.
2855
+ panel: overlay === null ? 'none' : overlay === 'shortcuts' ? 'shortcuts' : 'other', scrolledLines: scrolledLines, taskSummary: state.taskSummary === null
2856
+ ? null
2857
+ : { text: state.taskSummary.text, tone: state.taskSummary.tone }, summaryEnabled: cfg.taskSummary?.enabled !== false, cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: pickHeaderVariant(cols), theme: theme, caps: caps }));
2663
2858
  // The header and the opener are SLOTS OF THE SHELL, not printed bytes (D3/D4): at
2664
2859
  // `H = rows - 1` a printed row is pushed off the screen by the very next frame, so
2665
2860
  // `printBanner()` was a way of making the banner invisible. Neither is passed here.
@@ -2710,7 +2905,25 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2710
2905
  const openerCols = nativeBudget ? nativeBudget.previewCols : contentCols;
2711
2906
  const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(openerRows, openerCols), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: openerRows, theme: theme, caps: caps })) : null;
2712
2907
  const overlayMaxRows = nativeBudget ? nativeBudget.overlayRows : viewportBudget;
2713
- const overlayNode = overlay === 'queue' ? (_jsx(QueueOverlay, { pending: state.pendingSteering, cols: viewCols, maxRows: overlayMaxRows, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: viewCols, rows: helpRows(theme, caps, native), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { isActive: !tooSmall, registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: viewCols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'kit' ? (_jsx(KitPicker, { isActive: !tooSmall, library: cfg.modelProfiles, config: cfg, ...(kitDiskConfig ? { diskConfig: kitDiskConfig } : {}), active: activeKit,
2908
+ const overlayNode =
2909
+ // THE DRAWER (tui-shortcut-drawer-and-task-summary D-4): it rides the SAME
2910
+ // overlay slot as `/help` and `/agents`, so it needs no new frame shape, no
2911
+ // second scroll authority and no geometry change in `ScrollViewport`.
2912
+ overlay === 'shortcuts' ? (_jsx(ShortcutPanel, { isActive: !tooSmall, cols: viewCols, maxRows: overlayMaxRows, theme: theme, caps: caps, native: native, running: running, query: shortcutQuery, task: taskSectionFrom({
2913
+ view: state.taskSummary,
2914
+ pending: state.taskSummaryPending,
2915
+ enabled: cfg.taskSummary?.enabled !== false,
2916
+ now: Date.now(),
2917
+ }), detailsExpanded: statusExpanded, onToggleDetails: () => setStatusExpanded((value) => !value), onRefresh: () => {
2918
+ // THE MANUAL OVERRIDE (D-7): the last thing the user asked for. An empty
2919
+ // conversation has nothing to describe, so it says so instead of calling.
2920
+ const anchor = lastUserText(state.entries);
2921
+ if (anchor === '') {
2922
+ toast('info', 'No prompt to summarise yet.');
2923
+ return;
2924
+ }
2925
+ ensureTaskSummary(anchor, { force: true, userInitiated: true });
2926
+ } })) : overlay === 'queue' ? (_jsx(QueueOverlay, { pending: state.pendingSteering, cols: viewCols, maxRows: overlayMaxRows, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: viewCols, rows: helpRows(theme, caps, native), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { isActive: !tooSmall, registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: viewCols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'kit' ? (_jsx(KitPicker, { isActive: !tooSmall, library: cfg.modelProfiles, config: cfg, ...(kitDiskConfig ? { diskConfig: kitDiskConfig } : {}), active: activeKit,
2714
2927
  // ONLY THE APPLY IS BLOCKED while work is in flight (§4.7): a list a
2715
2928
  // user cannot even OPEN reads as a broken key.
2716
2929
  busy: controller.isModelSettingsBusy(), maxRows: overlayMaxRows, cols: viewCols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onChoose: handleKitChoose, onClose: () => dispatch({ type: 'setOverlay', overlay: null }) })) : overlay === 'prompts' ? (_jsx(PromptLibraryOverlay, { isActive: !tooSmall, library: promptLibrary, ...(promptIntent ? { intent: promptIntent } : {}), cols: viewCols, maxRows: overlayMaxRows, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onMutate: handlePromptMutate, onNotice: notify, onClose: () => dispatch({ type: 'setOverlay', overlay: null }) })) : overlay === 'prompt-pick' ? (_jsx(PromptPicker, { isActive: !tooSmall, library: promptLibrary, cols: viewCols, maxRows: overlayMaxRows, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onFill: handlePromptFill, onManage: (entry) => openPromptLibrary(entry), onClose: closePromptPicker })) : overlay === 'settings' ? (_jsx(SettingsScreen, { isActive: !tooSmall, initial: {
@@ -1,8 +1,19 @@
1
- /** One-row brand bar with a fixed-width history and detail affordance. */
1
+ /**
2
+ * One-row brand bar: word mark, identity, working directory, task summary and a
3
+ * fixed-width hint column (tui-shortcut-drawer-and-task-summary §4.2 / §4.3).
4
+ *
5
+ * THIS COMPONENT COMPUTES NO WIDTHS ANY MORE. Both halves come from pure
6
+ * planners - `planHeaderHints` for the right column and `planHeaderLeft` for the
7
+ * clause ladder - so the only arithmetic left here is "join what I was given".
8
+ * The old shape carried a `statusExpanded` flag, a kit switch and a prompts
9
+ * switch, all of which existed to choose between header clauses; the clauses are
10
+ * gone (the drawer lists the keys instead), so the props are gone with them.
11
+ */
2
12
  import React from 'react';
3
13
  import type { Theme } from './theme.js';
4
14
  import type { TermCapabilities } from './capabilities.js';
5
15
  import { type HeaderVariant } from './Logo.js';
16
+ import { type HeaderPanelState } from './layout/header-hints.js';
6
17
  interface HeaderProps {
7
18
  cwd: string;
8
19
  provider: string;
@@ -12,25 +23,26 @@ interface HeaderProps {
12
23
  theme: Theme;
13
24
  caps: TermCapabilities;
14
25
  columns?: number;
15
- statusExpanded?: boolean;
16
- scrolledLines?: number;
17
- overlayOpen?: boolean;
18
26
  /**
19
- * Whether the expanded status row teaches `ctrl+K` (agent-model-kits §4.7).
20
- *
21
- * DEFAULTS TO TRUE because that is the requirement - `ctrl+G` must reveal the
22
- * kit key - and because a default of `false` would leave the one caller that
23
- * forgets the prop silently teaching nothing.
27
+ * What the right column says. Derived in ONE place - the line that renders
28
+ * `<Header>` - so the two overlay slots cannot disagree about it.
24
29
  */
25
- kitHint?: boolean;
30
+ panel?: HeaderPanelState;
31
+ scrolledLines?: number;
26
32
  /**
27
- * Whether the collapsed row teaches `ctrl+P` (quick-prompt-library D-15).
33
+ * The session's task label, or `null` while there is none.
28
34
  *
29
- * DEFAULTS TO TRUE for the same reason `kitHint` does: the requirement is that the
30
- * key be visible, and a default of `false` would leave the one caller that forgets
31
- * the prop silently teaching nothing. `cfg.prompts.hint` is what turns it off.
35
+ * `null` WHILE PENDING TOO: a placeholder here would spend eight columns of the
36
+ * row to draw an ellipsis and then move everything back when the label lands,
37
+ * so the first submit visibly jogged the header. The label now appears once,
38
+ * complete (P2-10).
32
39
  */
33
- promptsHint?: boolean;
40
+ taskSummary?: {
41
+ text: string;
42
+ tone: 'model' | 'fallback';
43
+ } | null;
44
+ /** `config.taskSummary.enabled`; `false` removes the cell without removing the words. */
45
+ summaryEnabled?: boolean;
34
46
  }
35
- export declare function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns, statusExpanded, scrolledLines, overlayOpen, kitHint, promptsHint }: HeaderProps): React.ReactElement;
47
+ export declare function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns, panel, scrolledLines, taskSummary, summaryEnabled, }: HeaderProps): React.ReactElement;
36
48
  export {};
package/dist/ui/Header.js CHANGED
@@ -2,24 +2,26 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
2
2
  import { basename } from 'node:path';
3
3
  import { Box, Text } from 'ink';
4
4
  import { Logo } from './Logo.js';
5
- import { planHeaderHints, HEADER_HINT_LIMITS } from './layout/header-hints.js';
6
- export function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns = 80, statusExpanded = false, scrolledLines = 0, overlayOpen = false, kitHint = true, promptsHint = true }) {
7
- // The right-hand hints are planned, not assembled (tui-branch-terminal-shortcut
8
- // 3.9 / D-11). `plan.text` is exactly `plan.cells` wide and already carries the
9
- // scroll chip's own trailing spacing, so this component can no longer clip a
10
- // hint by guessing one cell short - and the ladder that decides which hints
11
- // survive a narrow terminal lives in a pure function with its own tests.
12
- //
13
- // `kitHint` is the `^K switch kit` clause (agent-model-kits §4.7): `App`
14
- // passes `true` always, because the requirement is that `ctrl+G` reveals the
15
- // key - and the user with no kits needs that hint most.
16
- const plan = planHeaderHints({
17
- columns, statusExpanded, overlayOpen, scrolledLines, kitHint, promptsHint,
5
+ import { pickGlyphs } from './glyphs.js';
6
+ import { planHeaderHints, HEADER_HINT_LIMITS, } from './layout/header-hints.js';
7
+ import { planHeaderLeft } from './layout/header-left.js';
8
+ export function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns = 80, panel = 'none', scrolledLines = 0, taskSummary = null, summaryEnabled = true, }) {
9
+ const glyphs = pickGlyphs(caps);
10
+ // THE RIGHT COLUMN FIRST: it is the only clause that may never be dropped, so
11
+ // the left half is planned against what is left after it (defect 12).
12
+ const plan = planHeaderHints({ columns, panel, scrolledLines });
13
+ const leftCells = Math.max(0, columns - plan.cells - HEADER_HINT_LIMITS.separatorCells);
14
+ const left = planHeaderLeft({
15
+ leftCells,
16
+ provider,
17
+ model,
18
+ cwd: basename(cwd) || cwd,
19
+ hasKey,
20
+ summary: taskSummary,
21
+ summaryEnabled,
22
+ ellipsis: glyphs.ellipsis,
18
23
  });
19
- // THE TWO HALVES ARE SEPARATED BY CELLS, NOT BY LUCK (defect 12). The left half
20
- // is `wrap="truncate"`, which draws its ellipsis in the LAST cell it owns; with
21
- // the right half starting in the very next cell a narrow terminal read
22
- // `aragon...Details off` as one string. The blank column comes out of the left
23
- // half's width, so the row still spans exactly `columns`.
24
- return _jsxs(Box, { flexDirection: "row", height: 1, width: columns, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { width: Math.max(0, columns - plan.cells - HEADER_HINT_LIMITS.separatorCells), flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Logo, { variant: variant === 'mini' ? 'mini' : 'bar', theme: theme, caps: caps }), variant !== 'mini' && _jsxs(_Fragment, { children: [_jsxs(Text, { color: theme.accent, children: [' ', provider, ":", model] }), _jsxs(Text, { color: theme.muted, children: [' ', basename(cwd) || cwd] }), _jsxs(Text, { color: hasKey ? theme.toolDone : theme.toolRunning, children: [' ', hasKey ? theme.symbols.keyOn : theme.symbols.keyOff] })] })] }) }), _jsx(Box, { width: HEADER_HINT_LIMITS.separatorCells, flexShrink: 0 }), _jsx(Box, { width: plan.cells, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: plan.text }) })] });
24
+ return _jsxs(Box, { flexDirection: "row", height: 1, width: columns, flexShrink: 0, overflow: "hidden", children: [_jsx(Box, { width: leftCells, flexShrink: 0, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", children: [_jsx(Logo, { variant: variant === 'mini' ? 'mini' : 'bar', theme: theme, caps: caps }), variant !== 'mini' && _jsxs(_Fragment, { children: [left.text.model && _jsxs(Text, { color: theme.accent, children: [' ', left.text.model] }), left.text.cwd && _jsxs(Text, { color: theme.muted, children: [' ', left.text.cwd] }), left.text.summary && _jsxs(Text, { color: theme.muted, children: [' ', glyphs.midDot, ' '] }), left.text.summary && (taskSummary?.tone === 'fallback'
25
+ ? _jsx(Text, { color: theme.muted, dimColor: true, children: left.text.summary })
26
+ : _jsx(Text, { color: theme.primary, children: left.text.summary })), _jsxs(Text, { color: hasKey ? theme.toolDone : theme.toolRunning, children: [' ', hasKey ? theme.symbols.keyOn : theme.symbols.keyOff] })] })] }) }), _jsx(Box, { width: HEADER_HINT_LIMITS.separatorCells, flexShrink: 0 }), _jsx(Box, { width: plan.cells, flexShrink: 0, justifyContent: "flex-end", children: _jsx(Text, { color: theme.muted, wrap: "truncate", children: plan.text }) })] });
25
27
  }
@@ -29,6 +29,12 @@ export function actionCandidates(input) {
29
29
  if (input.overlay) {
30
30
  if (input.overlay === 'confirm')
31
31
  return [copy.approveTool, copy.rejectTool];
32
+ // THE DRAWER SELECTS, IT DOES NOT SCROLL (tui-shortcut-drawer-and-task-summary
33
+ // §5). It is a list with a cursor, so the generic `copy.scroll` fallback would
34
+ // describe the wrong interaction - and the drawer's own footer already names
35
+ // every key, so one clause is the whole line.
36
+ if (input.overlay === 'shortcuts')
37
+ return [copy.select];
32
38
  const selects = ['question', 'model', 'session', 'settings'].includes(input.overlay);
33
39
  return [selects ? copy.select : copy.scroll,
34
40
  ...(selects ? [input.overlay === 'settings' ? copy.saveSettings : copy.confirm] : [])];
@@ -10,6 +10,24 @@ interface OverlayFrameBase {
10
10
  cols?: number;
11
11
  theme: Theme;
12
12
  caps: TermCapabilities;
13
+ /**
14
+ * Where the frame sits on the row when `width` is given.
15
+ *
16
+ * DEFAULT `'left'` AND NO WRAPPER BOX: with neither new prop set, the render
17
+ * tree is byte-identical to the pre-`shortcut-drawer` one, which is what keeps
18
+ * the eight existing overlays' layout assertions green (`R-16`).
19
+ */
20
+ align?: 'left' | 'right';
21
+ /**
22
+ * An explicit width for a frame that is NOT the full terminal width - the
23
+ * shortcut drawer's right-hand bar.
24
+ *
25
+ * THE FRAME STILL DECIDES ITS OWN FORM from `cols` (`BORDERLESS_COLS`), so the
26
+ * caller passes the TERMINAL width here and the bar's width separately. Passing
27
+ * the bar's width as `cols` would put a 46-cell drawer on a 200-column terminal
28
+ * into the rail form for ever.
29
+ */
30
+ width?: number;
13
31
  }
14
32
  interface OverlayFrameControlled extends OverlayFrameBase {
15
33
  /** One element per row, each already `wrap="truncate"`. */
@@ -60,14 +60,29 @@ export function OverlayFrame(props) {
60
60
  onScrollClamp?.(overflowOffset);
61
61
  }, [overflowOffset, onScrollClamp]);
62
62
  const inner = (_jsxs(_Fragment, { children: [_jsxs(Box, { flexDirection: "row", height: 1, flexShrink: 0, children: [_jsx(Box, { flexGrow: 1, flexShrink: 1, children: _jsx(Text, { wrap: "truncate", color: theme.primary, bold: true, children: title }) }), position && _jsx(Box, { width: stringWidth(position), flexShrink: 0, children: _jsx(Text, { color: theme.muted, children: position }) })] }), _jsx(Box, { flexDirection: "column", flexShrink: 0, height: unbounded ? undefined : Math.max(1, maxRows - (compact ? 2 : bordered ? 5 : 3)), overflowY: "hidden", children: body }), _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: hint })] }));
63
- if (compact)
64
- return _jsx(Box, { width: cols, height: Math.max(0, maxRows), flexDirection: "column", flexShrink: 0, overflowY: "hidden", children: inner });
63
+ // THE NEW PROPS ARE OPTIONAL AND THEIR DEFAULT IS TODAY'S BEHAVIOUR (R-16): the
64
+ // three branches below return exactly what they always returned unless the
65
+ // caller asked for a width or a right-hand placement. When it did, the SAME box
66
+ // is built and then placed inside one row-wide wrapper, which is the only way
67
+ // Ink can push a fixed-width child to the right edge (a `marginLeft: 'auto'`
68
+ // does not exist here).
69
+ const width = props.width;
70
+ const align = props.align ?? 'left';
71
+ const placed = (node) => {
72
+ if (width === undefined && align === 'left')
73
+ return node;
74
+ return (_jsx(Box, { width: cols, flexDirection: "row", flexShrink: 0, justifyContent: align === 'right' ? 'flex-end' : 'flex-start', children: node }));
75
+ };
76
+ const boxWidth = width === undefined ? {} : { width };
77
+ if (compact) {
78
+ return placed(_jsx(Box, { ...boxWidth, width: width ?? cols, height: Math.max(0, maxRows), flexDirection: "column", flexShrink: 0, overflowY: "hidden", children: inner }));
79
+ }
65
80
  if (!bordered) {
66
81
  // Narrow terminals trade the box for a rail: same grouping, 2 fewer rows
67
82
  // and 4 fewer columns (§5.2).
68
- return (_jsx(Box, { flexDirection: "column", flexShrink: 0, marginTop: 1, children: _jsx(Box, { flexDirection: "column", flexShrink: 0, borderStyle: railBorderStyle, borderTop: false, borderBottom: false, borderRight: false, borderColor: theme.primary, paddingLeft: 1, children: inner }) }));
83
+ return placed(_jsx(Box, { flexDirection: "column", flexShrink: 0, marginTop: 1, ...boxWidth, children: _jsx(Box, { flexDirection: "column", flexShrink: 0, borderStyle: railBorderStyle, borderTop: false, borderBottom: false, borderRight: false, borderColor: theme.primary, paddingLeft: 1, children: inner }) }));
69
84
  }
70
- return (_jsx(Box, { flexDirection: "column", flexShrink: 0, borderStyle: glyphs.boxStyle, borderColor: theme.primary, paddingX: 1, marginTop: 1, children: inner }));
85
+ return placed(_jsx(Box, { flexDirection: "column", flexShrink: 0, borderStyle: glyphs.boxStyle, borderColor: theme.primary, paddingX: 1, marginTop: 1, ...boxWidth, children: inner }));
71
86
  }
72
87
  /**
73
88
  * Rows one PgUp/PgDn - and one Shift+wheel notch - turns inside a controlled