@aragon-agent/cli 0.6.8 → 0.6.10

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 (97) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +89 -44
  3. package/dist/agent/controller.d.ts +1 -1
  4. package/dist/agent/reducer.d.ts +31 -113
  5. package/dist/agent/reducer.js +41 -63
  6. package/dist/cli.js +53 -155
  7. package/dist/commands/builtins.d.ts +2 -1
  8. package/dist/commands/builtins.js +47 -2
  9. package/dist/commands/perf.d.ts +32 -2
  10. package/dist/commands/perf.js +32 -2
  11. package/dist/commands/registry.d.ts +2 -2
  12. package/dist/config/env.js +0 -130
  13. package/dist/config/load.d.ts +0 -2
  14. package/dist/config/load.js +2 -31
  15. package/dist/config/schema.d.ts +1 -14
  16. package/dist/config/schema.js +0 -1
  17. package/dist/config/store.js +2 -0
  18. package/dist/input/enter-sequences.d.ts +63 -0
  19. package/dist/input/enter-sequences.js +151 -0
  20. package/dist/input/limits.d.ts +11 -0
  21. package/dist/input/limits.js +11 -0
  22. package/dist/input/stdin-filter.js +19 -1
  23. package/dist/logging/install.d.ts +2 -2
  24. package/dist/logging/install.js +2 -2
  25. package/dist/session/persist.js +20 -0
  26. package/dist/todo/follow-through.d.ts +0 -4
  27. package/dist/todo/follow-through.js +0 -4
  28. package/dist/todo/limits.d.ts +0 -10
  29. package/dist/todo/limits.js +0 -10
  30. package/dist/todo/prompt.d.ts +0 -15
  31. package/dist/ui/ActivityLine.d.ts +26 -34
  32. package/dist/ui/ActivityLine.js +30 -19
  33. package/dist/ui/App.d.ts +1 -41
  34. package/dist/ui/App.js +94 -129
  35. package/dist/ui/BottomStatusRow.d.ts +1 -3
  36. package/dist/ui/BottomStatusRow.js +4 -8
  37. package/dist/ui/Composer.d.ts +28 -1
  38. package/dist/ui/Composer.js +46 -17
  39. package/dist/ui/Header.d.ts +2 -16
  40. package/dist/ui/Header.js +3 -18
  41. package/dist/ui/Logo.d.ts +1 -17
  42. package/dist/ui/Logo.js +0 -16
  43. package/dist/ui/ModeChip.d.ts +7 -18
  44. package/dist/ui/ModeChip.js +9 -0
  45. package/dist/ui/PromptInput.d.ts +2 -28
  46. package/dist/ui/PromptInput.js +38 -9
  47. package/dist/ui/StatusBar.d.ts +0 -11
  48. package/dist/ui/ToastStack.d.ts +2 -18
  49. package/dist/ui/ToastStack.js +4 -11
  50. package/dist/ui/Transcript.d.ts +5 -71
  51. package/dist/ui/Transcript.js +15 -199
  52. package/dist/ui/enter-frames.d.ts +63 -0
  53. package/dist/ui/enter-frames.js +136 -0
  54. package/dist/ui/entries/AssistantEntry.d.ts +0 -23
  55. package/dist/ui/entries/AssistantEntry.js +3 -4
  56. package/dist/ui/entries/DiffView.d.ts +1 -7
  57. package/dist/ui/entries/DiffView.js +2 -2
  58. package/dist/ui/entries/QueuedEntry.d.ts +27 -0
  59. package/dist/ui/entries/QueuedEntry.js +30 -0
  60. package/dist/ui/entries/ToolCard.d.ts +0 -6
  61. package/dist/ui/entries/ToolCard.js +4 -13
  62. package/dist/ui/frame-differ.d.ts +13 -0
  63. package/dist/ui/frame-differ.js +36 -6
  64. package/dist/ui/glyphs.d.ts +9 -0
  65. package/dist/ui/glyphs.js +2 -0
  66. package/dist/ui/ink-runtime.d.ts +1 -0
  67. package/dist/ui/ink-runtime.js +21 -0
  68. package/dist/ui/layout/AppShell.d.ts +1 -10
  69. package/dist/ui/layout/AppShell.js +2 -5
  70. package/dist/ui/layout/OverlayFrame.d.ts +1 -25
  71. package/dist/ui/layout/OverlayFrame.js +0 -24
  72. package/dist/ui/layout/ScrollViewport.d.ts +3 -0
  73. package/dist/ui/layout/ScrollViewport.js +17 -5
  74. package/dist/ui/layout/frame.d.ts +0 -22
  75. package/dist/ui/layout/frame.js +0 -36
  76. package/dist/ui/layout/todo-layout.d.ts +1 -3
  77. package/dist/ui/layout/todo-layout.js +7 -8
  78. package/dist/ui/layout/viewport-geometry.d.ts +1 -1
  79. package/dist/ui/layout/viewport-geometry.js +1 -1
  80. package/dist/ui/live-clamp.d.ts +1 -29
  81. package/dist/ui/live-clamp.js +1 -29
  82. package/dist/ui/overlays/HelpOverlay.js +3 -1
  83. package/dist/ui/overlays/PlanReviewOverlay.js +5 -1
  84. package/dist/ui/overlays/QuestionOverlay.js +7 -2
  85. package/dist/ui/overlays/SettingsScreen.js +8 -1
  86. package/dist/ui/run-status-row.d.ts +61 -0
  87. package/dist/ui/run-status-row.js +70 -0
  88. package/dist/ui/selection/selection-controller.d.ts +23 -7
  89. package/dist/ui/selection/selection-controller.js +68 -16
  90. package/dist/ui/transcript-text.d.ts +0 -8
  91. package/dist/ui/transcript-text.js +0 -8
  92. package/dist/ui/use-startup-notices.d.ts +6 -58
  93. package/dist/ui/use-startup-notices.js +10 -57
  94. package/dist/ui/use-wheel-routing.d.ts +1 -1
  95. package/package.json +2 -2
  96. package/dist/ui/TodoStrip.d.ts +0 -36
  97. package/dist/ui/TodoStrip.js +0 -32
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Enter-family sequence recognition (tui-shift-enter-copy-queue §3.3) -- pure,
3
+ * stream-free, no node/React imports, callable from any test environment.
4
+ *
5
+ * WHY THIS LIVES BELOW INK. Ink 5's `parse-keypress.js` calls `parseKeypress`
6
+ * ONCE per stdin chunk and hard-codes `shift: false` for the exact string
7
+ * `'\r'`, so a component can never see `key.return && key.shift` -- the
8
+ * `PromptInput` branch for it is dead code. Worse, terminals that DO encode a
9
+ * distinct Shift+Enter send CSI-u (`\x1b[13;2u`), which Ink's `fnKeyRe` does
10
+ * not match: the ESC is stripped by `use-input.js` and `'[13;2u'` falls through
11
+ * to the PRINTABLE branch, typing garbage into the draft. The only layer that
12
+ * sees the bytes before Ink mangles them is the stdin filter, which is where
13
+ * the mouse-report and paste-marker dialects are already translated.
14
+ *
15
+ * THE CONTRACT: rewrite every recognized sequence to ONE normalized token --
16
+ * `ENTER_NEWLINE_FRAME` for "this was a modified Enter, the user wants a
17
+ * newline" and `'\r'` for the unmodified CSI-u Enter some other program left
18
+ * behind. No state, no partial-output: `rewriteEnterSequences` is idempotent
19
+ * because its output contains none of its inputs.
20
+ */
21
+ import { ENTER_NEWLINE_FRAME } from './limits.js';
22
+ /**
23
+ * The recognition table (§3.3). CSI-u modifiers 2-8 cover Shift(2), Alt(3),
24
+ * Shift+Alt(4), Ctrl(5), Ctrl+Shift(6), Ctrl+Alt(7), Ctrl+Alt+Shift(8) as
25
+ * emitted by kitty/WezTerm keyboard-enhancement mode and by
26
+ * `modifyOtherKeys=2`; every one of them means "Enter with a modifier", and a
27
+ * modified Enter must NEVER submit the draft. `\x1b\r` / `\x1b\n` are the
28
+ * classic Alt+Enter encodings. `\x1b[13u` (no modifier field) is what a
29
+ * leftover kitty mode sends for a PLAIN Enter: it must submit, so it maps to
30
+ * `'\r'` rather than to a newline.
31
+ */
32
+ export const ENTER_SEQUENCES = [
33
+ {
34
+ seq: '\x1b[13;2u',
35
+ meaning: 'shift+enter',
36
+ to: ENTER_NEWLINE_FRAME,
37
+ },
38
+ {
39
+ seq: '\x1b[13;3u',
40
+ meaning: 'alt+enter',
41
+ to: ENTER_NEWLINE_FRAME,
42
+ },
43
+ {
44
+ seq: '\x1b[13;4u',
45
+ meaning: 'shift+alt+enter',
46
+ to: ENTER_NEWLINE_FRAME,
47
+ },
48
+ {
49
+ seq: '\x1b[13;5u',
50
+ meaning: 'ctrl+enter',
51
+ to: ENTER_NEWLINE_FRAME,
52
+ },
53
+ {
54
+ seq: '\x1b[13;6u',
55
+ meaning: 'ctrl+shift+enter',
56
+ to: ENTER_NEWLINE_FRAME,
57
+ },
58
+ {
59
+ seq: '\x1b[13;7u',
60
+ meaning: 'ctrl+alt+enter',
61
+ to: ENTER_NEWLINE_FRAME,
62
+ },
63
+ {
64
+ seq: '\x1b[13;8u',
65
+ meaning: 'ctrl+alt+shift+enter',
66
+ to: ENTER_NEWLINE_FRAME,
67
+ },
68
+ {
69
+ seq: '\x1b\r',
70
+ meaning: 'alt+enter',
71
+ to: ENTER_NEWLINE_FRAME,
72
+ },
73
+ {
74
+ seq: '\x1b\n',
75
+ meaning: 'alt+enter',
76
+ to: ENTER_NEWLINE_FRAME,
77
+ },
78
+ {
79
+ seq: '\x1b[13u',
80
+ meaning: 'enter',
81
+ to: '\r',
82
+ },
83
+ ];
84
+ /**
85
+ * Longest strict prefix any recognized sequence can have. Derived from the
86
+ * table rather than hard-coded so adding a sequence can never leave the hold
87
+ * ceiling behind: the longest sequence is 7 bytes, so a torn sequence can hold
88
+ * up to 6 of them.
89
+ */
90
+ const MAX_ENTER_PREFIX = Math.max(...ENTER_SEQUENCES.map((s) => s.seq.length)) - 1;
91
+ /** `\x1b[13;2u` .. `\x1b[13;8u`, or `\x1b[13u` when the modifier is absent. */
92
+ const CSI_U_ENTER = /\x1b\[13(?:;[2-8])?u/g;
93
+ /**
94
+ * Rewrite every recognized Enter-family sequence to its normalized token.
95
+ *
96
+ * ONE ANCHORED REGEX, not a per-sequence scan and not a state machine: the
97
+ * CSI-u family shares one shape and the two literal Alt+Enter encodings cannot
98
+ * nest inside it, so a single left-to-right pass is unambiguous. `\x1b[13;9u`
99
+ * and other unknown modifiers are deliberately left untouched -- rewriting an
100
+ * unrecognized sequence would be guessing at a terminal dialect we have not
101
+ * seen.
102
+ */
103
+ export function rewriteEnterSequences(text) {
104
+ if (!text.includes('\x1b'))
105
+ return text;
106
+ // ONE PASS, TWO TARGETS: a MODIFIED Enter is a newline intent, while the
107
+ // modifier-less CSI-u Enter a leftover kitty mode emits is a PLAIN Enter
108
+ // and must still submit (`\r`). The `;` is the discriminator the table
109
+ // above records in each entry's `to` field.
110
+ let out = text.replace(CSI_U_ENTER, (seq) => (seq.includes(';') ? ENTER_NEWLINE_FRAME : '\r'));
111
+ // Only the two literal Alt+Enter encodings remain. `\x1b\n` is checked
112
+ // before `\x1b\r` is replaced; they share no bytes, so order is free, but
113
+ // splitting the escapes keeps each replacement self-evident.
114
+ if (out.includes('\x1b\n'))
115
+ out = out.split('\x1b\n').join(ENTER_NEWLINE_FRAME);
116
+ if (out.includes('\x1b\r'))
117
+ out = out.split('\x1b\r').join(ENTER_NEWLINE_FRAME);
118
+ return out;
119
+ }
120
+ /** True when `s` is a PROPER prefix of some recognized sequence (never whole). */
121
+ function isEnterPrefix(s) {
122
+ if (s.length === 0 || s.length > MAX_ENTER_PREFIX)
123
+ return false;
124
+ for (const spec of ENTER_SEQUENCES) {
125
+ // `length >`, not `startsWith` alone: `startsWith` accepts the WHOLE
126
+ // sequence too, and holding a complete sequence back would add 12 ms
127
+ // of latency to a key that is ready to be rewritten right now.
128
+ if (spec.seq.length > s.length && spec.seq.startsWith(s))
129
+ return true;
130
+ }
131
+ return false;
132
+ }
133
+ /**
134
+ * Length of the trailing run of `text` that is a proper prefix of an
135
+ * Enter-family sequence, or 0.
136
+ *
137
+ * LONGEST MATCH, not shortest: unlike the mouse and paste families, a torn
138
+ * CSI-u sequence diverges from its siblings only at the LAST byte
139
+ * (`\x1b[13;` + `2u`), so every byte before the divergence point must be held
140
+ * back for the next chunk or the sequence is destroyed a byte early. Feeding
141
+ * the result into the filter's `keep = max(...)` alongside the other two
142
+ * families keeps every family's hold intact.
143
+ */
144
+ export function trailingEnterPrefixLength(text) {
145
+ const max = Math.min(text.length, MAX_ENTER_PREFIX);
146
+ for (let k = max; k >= 1; k -= 1) {
147
+ if (isEnterPrefix(text.slice(text.length - k)))
148
+ return k;
149
+ }
150
+ return 0;
151
+ }
@@ -53,6 +53,17 @@ export declare const PASTE_ASSEMBLY_MAX_MS = 2000;
53
53
  */
54
54
  export declare const PASTE_OPEN = "\0[";
55
55
  export declare const PASTE_CLOSE = "\0]";
56
+ /**
57
+ * The inline frame that carries ONE newline intent from the stdin filter to the
58
+ * composer (tui-shift-enter-copy-queue 3.2).
59
+ *
60
+ * SAME FAMILY AS `PASTE_OPEN`, with the same guarantee and for the same reason:
61
+ * NUL is the delimiter because no terminal delivers it for any key, and because
62
+ * `sanitisePaste` strips every NUL out of every payload -- so pasted text can
63
+ * never forge this frame either. A newline intent is single, atomic and carries
64
+ * no payload, so one constant replaces the OPEN/CLOSE pair.
65
+ */
66
+ export declare const ENTER_NEWLINE_FRAME = "\0n";
56
67
  /**
57
68
  * The one channel from the stdin filter to `App`'s notice dispatch (P1-3 / I-15).
58
69
  *
@@ -53,6 +53,17 @@ export const PASTE_ASSEMBLY_MAX_MS = 2_000;
53
53
  */
54
54
  export const PASTE_OPEN = '\u0000[';
55
55
  export const PASTE_CLOSE = '\u0000]';
56
+ /**
57
+ * The inline frame that carries ONE newline intent from the stdin filter to the
58
+ * composer (tui-shift-enter-copy-queue 3.2).
59
+ *
60
+ * SAME FAMILY AS `PASTE_OPEN`, with the same guarantee and for the same reason:
61
+ * NUL is the delimiter because no terminal delivers it for any key, and because
62
+ * `sanitisePaste` strips every NUL out of every payload -- so pasted text can
63
+ * never forge this frame either. A newline intent is single, atomic and carries
64
+ * no payload, so one constant replaces the OPEN/CLOSE pair.
65
+ */
66
+ export const ENTER_NEWLINE_FRAME = '\u0000n';
56
67
  /** Human-readable byte size for the refusal toast (`4.1 MB`, `812.0 KB`, `7 B`). */
57
68
  export function formatPasteSize(bytes) {
58
69
  if (bytes >= 1024 * 1024)
@@ -35,6 +35,7 @@ import { PassThrough } from 'node:stream';
35
35
  import { MAX_PENDING_MOUSE_CHARS, splitMouseEvents, isMousePrefix, } from './mouse-events.js';
36
36
  import { PASTE_ASSEMBLY_MAX_MS, PASTE_BURST_MS, PASTE_CLOSE, PASTE_MAX_BYTES, PASTE_OPEN, formatPasteSize, } from './limits.js';
37
37
  import { PASTE_BEGIN_MARK, PASTE_END_MARK, classifyChunk, sanitisePaste, trailingPastePrefixLength, } from './paste-parse.js';
38
+ import { rewriteEnterSequences, trailingEnterPrefixLength } from './enter-sequences.js';
38
39
  /**
39
40
  * How long a possible-prefix tail is held before being flushed as text.
40
41
  *
@@ -192,6 +193,17 @@ export function createStdinFilter(real, features, bridge, onWarn) {
192
193
  const handleOutsidePaste = (text, holdTail) => {
193
194
  if (text.length === 0)
194
195
  return;
196
+ // ENTER-FAMILY REWRITE, AND IT IS FIRST FOR A REASON
197
+ // (tui-shift-enter-copy-queue 3.4). The Alt+Enter encoding contains a CR,
198
+ // and `classifyChunk`'s primary rule -- "longer than one character AND
199
+ // contains a line break" -- would buffer that two-byte key as a Tier 2
200
+ // paste body whose sanitiser then strips the ESC, silently swallowing the
201
+ // keystroke. After the rewrite the frame is NUL-led and classifies as
202
+ // `keys` under both rules. The cost is that an UNBRACKETED paste whose
203
+ // body happens to contain the literal Alt+Enter bytes loses that one
204
+ // pair's newline; a bracketed body never reaches this function at all
205
+ // (I-3), and losing two bytes of a heuristic tier beats eating the key.
206
+ text = rewriteEnterSequences(text);
195
207
  if (paste.kind === 'burst') {
196
208
  paste = { kind: 'burst', body: paste.body + text, bytes: paste.bytes + byteLength(text) };
197
209
  burstTimer = clearTimer(burstTimer);
@@ -266,7 +278,13 @@ export function createStdinFilter(real, features, bridge, onWarn) {
266
278
  const strayEnd = rest.indexOf(PASTE_END_MARK);
267
279
  const first = begin === -1 ? strayEnd : strayEnd === -1 ? begin : Math.min(begin, strayEnd);
268
280
  if (first === -1) {
269
- const keep = Math.max(trailingPastePrefixLength(rest), features.mouse ? trailingMousePrefixLength(rest) : 0);
281
+ // THIRD PREFIX FAMILY: a torn CSI-u Enter sequence
282
+ // (tui-shift-enter-copy-queue 3.4). `keep = max(...)` holds the longest
283
+ // tail any family might still complete; the three families share only
284
+ // the ESC / ESC-[ head bytes, which the max keeps for whoever needs
285
+ // them. The existing 12 ms flush and the 32-char ceiling bound the
286
+ // hold, so no new timer is needed.
287
+ const keep = Math.max(trailingPastePrefixLength(rest), features.mouse ? trailingMousePrefixLength(rest) : 0, trailingEnterPrefixLength(rest));
270
288
  const tail = rest.slice(rest.length - keep);
271
289
  handleOutsidePaste(rest.slice(0, rest.length - keep), true);
272
290
  pending += tail;
@@ -25,7 +25,7 @@
25
25
  * Adding a listener suppresses it, turning a crash into a silent hang.
26
26
  *
27
27
  * `SIGINT` / `SIGTERM` / `SIGHUP` — the handlers in `runInteractive()` exist
28
- * ONLY in its full-screen branch, so inline mode, `-p`, and every
28
+ * ONLY in its full-screen branch, so `-p` and every
29
29
  * subcommand had no handler at all and took Node's default termination,
30
30
  * which does not run `'exit'` listeners: the whole queue was lost. But a
31
31
  * signal listener registered here runs BEFORE the one `runInteractive`
@@ -45,7 +45,7 @@ import { Logger, type LogScope } from './logger.js';
45
45
  export declare function setScreenRestore(fn: () => void): void;
46
46
  /**
47
47
  * Replace what a signal does after the flush. The default (a bare
48
- * `exit(128 + signo)`) is correct for headless, inline and every subcommand;
48
+ * `exit(128 + signo)`) is correct for headless and every subcommand;
49
49
  * full-screen substitutes one that restores the terminal first.
50
50
  *
51
51
  * A TERMINATOR MAY NOW RETURN WITHOUT EXITING, AND THAT IS A CONTRACT CHANGE
@@ -25,7 +25,7 @@
25
25
  * Adding a listener suppresses it, turning a crash into a silent hang.
26
26
  *
27
27
  * `SIGINT` / `SIGTERM` / `SIGHUP` — the handlers in `runInteractive()` exist
28
- * ONLY in its full-screen branch, so inline mode, `-p`, and every
28
+ * ONLY in its full-screen branch, so `-p` and every
29
29
  * subcommand had no handler at all and took Node's default termination,
30
30
  * which does not run `'exit'` listeners: the whole queue was lost. But a
31
31
  * signal listener registered here runs BEFORE the one `runInteractive`
@@ -63,7 +63,7 @@ export function setScreenRestore(fn) {
63
63
  }
64
64
  /**
65
65
  * Replace what a signal does after the flush. The default (a bare
66
- * `exit(128 + signo)`) is correct for headless, inline and every subcommand;
66
+ * `exit(128 + signo)`) is correct for headless and every subcommand;
67
67
  * full-screen substitutes one that restores the terminal first.
68
68
  *
69
69
  * A TERMINATOR MAY NOW RETURN WITHOUT EXITING, AND THAT IS A CONTRACT CHANGE
@@ -193,6 +193,26 @@ export function normalizeLoadedEntries(entries) {
193
193
  (entry.status === 'starting' || entry.status === 'ready' || entry.status === 'running')) {
194
194
  return { ...entry, status: 'stopped', rows: [] };
195
195
  }
196
+ // THE SEVENTH CLAUSE (tui-shift-enter-copy-queue 5.2.5). Core's steering
197
+ // queue is memory-only, so a `queued` entry on disk is a promise this
198
+ // process can no longer keep: the message was never accepted into any
199
+ // conversation. Downgrade to a WARN NOTICE carrying the first line
200
+ // rather than to a `user` entry -- pretending it was sent is the lie
201
+ // that loses the user's words with a straight face.
202
+ //
203
+ // THE ID IS KEPT: `restoreEntries` splices these positions into the
204
+ // live transcript, and a fresh id here would collide with the next
205
+ // `e<n>` the reducer allocates.
206
+ if (entry.kind === 'queued') {
207
+ const firstLine = entry.text.split('\n', 1)[0] ?? '';
208
+ const downgraded = {
209
+ id: entry.id,
210
+ kind: 'notice',
211
+ level: 'warn',
212
+ text: `Queued but never sent: ${firstLine}`,
213
+ };
214
+ return downgraded;
215
+ }
196
216
  return entry;
197
217
  });
198
218
  }
@@ -84,10 +84,6 @@ export type FollowThroughDecision = {
84
84
  };
85
85
  /** The zero budget. A fresh App, a fresh headless run, and every cleared list. */
86
86
  export declare function emptyBudget(): FollowThroughBudget;
87
- /**
88
- * The decision. EVALUATED IN THIS ORDER, AND THE ORDER IS SEMANTICS, NOT STYLE —
89
- * the same sentence `decideRenderMode` carries.
90
- */
91
87
  export declare function decideFollowThrough(input: FollowThroughInput): FollowThroughDecision;
92
88
  /**
93
89
  * Fold a finished run into the budget. Called BEFORE `decideFollowThrough`, on
@@ -36,10 +36,6 @@ function legacyUnfinishedNotice(left) {
36
36
  function advise(interactive, text) {
37
37
  return interactive ? ` ${text}` : '';
38
38
  }
39
- /**
40
- * The decision. EVALUATED IN THIS ORDER, AND THE ORDER IS SEMANTICS, NOT STYLE —
41
- * the same sentence `decideRenderMode` carries.
42
- */
43
39
  export function decideFollowThrough(input) {
44
40
  const { mode, snapshot, runEnd, budget, interactive } = input;
45
41
  // 1. Nothing is unfinished. `beginUserTurn` already drops a fully-completed
@@ -38,16 +38,6 @@ export declare const TODO_LIMITS: {
38
38
  readonly panelFullRows: 6;
39
39
  /** Below this many terminal columns the status-bar counter degrades to `[3/7]`. */
40
40
  readonly statusCompactCols: 100;
41
- /**
42
- * Below this many columns the inline strip drops its `+N done` suffix
43
- * (todo-plan-followthrough §3.7 / D-19).
44
- *
45
- * NAMED RATHER THAN INLINED, and the reason is that it sits between two other
46
- * real bounds — `statusCompactCols` (100) above and `MIN_FULLSCREEN_COLS` (40)
47
- * in `frame.ts` — with nothing to tell a reader whether a bare `80` in the
48
- * component was a third policy or a typo for one of them.
49
- */
50
- readonly stripDoneCols: 80;
51
41
  /**
52
42
  * Consecutive user turns an UNFINISHED list survives without a `todo_write`
53
43
  * before `beginUserTurn()` drops it (§3.2 / P1-7). Three keeps the common
@@ -38,16 +38,6 @@ export const TODO_LIMITS = {
38
38
  panelFullRows: 6,
39
39
  /** Below this many terminal columns the status-bar counter degrades to `[3/7]`. */
40
40
  statusCompactCols: 100,
41
- /**
42
- * Below this many columns the inline strip drops its `+N done` suffix
43
- * (todo-plan-followthrough §3.7 / D-19).
44
- *
45
- * NAMED RATHER THAN INLINED, and the reason is that it sits between two other
46
- * real bounds — `statusCompactCols` (100) above and `MIN_FULLSCREEN_COLS` (40)
47
- * in `frame.ts` — with nothing to tell a reader whether a bare `80` in the
48
- * component was a third policy or a typo for one of them.
49
- */
50
- stripDoneCols: 80,
51
41
  /**
52
42
  * Consecutive user turns an UNFINISHED list survives without a `todo_write`
53
43
  * before `beginUserTurn()` drops it (§3.2 / P1-7). Three keeps the common
@@ -22,21 +22,6 @@
22
22
  import { TODO_BLOCK_VERSION } from './limits.js';
23
23
  export { TODO_BLOCK_VERSION };
24
24
  export interface TodoBlockParams {
25
- /**
26
- * Whether this session can actually render the rail.
27
- *
28
- * FOUR SUPPORTED PATHS HAVE THE TOOL AND NO PANEL — `-p` (§3.12), inline mode
29
- * (non-goal 4), `--no-todo-panel` (§4.3), and a terminal under 80 columns or 6
30
- * viewport rows (§6.4) — and in the first three the answer is fixed for the
31
- * whole session, before the prompt is composed. Telling the model about a
32
- * panel the user does not have is the same error `composeSystemPrompt` already
33
- * guards against for team mode with its TWO flags (P1-6 / D-24).
34
- *
35
- * Dropping the block instead would be wrong: the planning discipline is worth
36
- * having without the column, which is precisely why `enabled` and `panel` are
37
- * separate config keys, and a screen-reader user is the case that argument was
38
- * written for.
39
- */
40
25
  panelVisible: boolean;
41
26
  }
42
27
  /**
@@ -41,6 +41,8 @@
41
41
  import React from 'react';
42
42
  import type { Theme } from './theme.js';
43
43
  import type { TermCapabilities } from './capabilities.js';
44
+ import { pickGlyphs } from './glyphs.js';
45
+ import type { RunActivity } from './run-status-row.js';
44
46
  /**
45
47
  * The animated spinner this row draws while it is live, or `null` when it would
46
48
  * be STATIC — an ASCII terminal, or a user who asked for stillness.
@@ -57,42 +59,32 @@ import type { TermCapabilities } from './capabilities.js';
57
59
  * reader has to reason about; one more caller of this function is not.
58
60
  */
59
61
  export declare function liveSpinner(reducedMotion: boolean, caps: TermCapabilities): React.ReactElement | null;
60
- export interface ActivityLineProps {
61
- /** Epoch ms the current run began — the phrase sequence's seed. */
62
- startedAt: number;
63
- /** `Date.now() - startedAt`, already computed by App's 200 ms ticker. */
64
- elapsedMs: number;
65
- reducedMotion: boolean;
66
- /**
67
- * The name of the tool currently in flight, or `undefined`
68
- * (agent-activity-presentation-live L4 / D-31).
69
- *
70
- * `status === 'running'` covers the WHOLE turn, including the minutes the
71
- * model is idle and a child process is doing the work. Round 1 chose the
72
- * thinking vocabulary deliberately for the gap before the first token, and it
73
- * is right for that gap; a UI that says "Pondering" while `npm test` runs is
74
- * not calm, it is wrong.
75
- */
76
- runningTool?: string;
62
+ /**
63
+ * The label ladder, MOST SPECIFIC FIRST: `Compacting context` outranks a tool name
64
+ * because compaction can be the only thing happening, and the tool name outranks
65
+ * the rotating phrase for the reason `runningTool` exists at all. One
66
+ * implementation, because the fixed bottom row and the run status row above the
67
+ * input must never disagree about what the agent is doing.
68
+ *
69
+ * `Running ${name}` rather than a per-tool verb table: a table would be a SECOND
70
+ * registry of tool names, and `toolGlyph` is already the one place that knows
71
+ * them. ASCII only, and the ellipsis comes from `pickGlyphs` like every other one.
72
+ */
73
+ export declare function resolveActivityLabel(activity: RunActivity, glyphs: ReturnType<typeof pickGlyphs>): string;
74
+ export interface ActivityLabelProps extends RunActivity {
77
75
  /**
78
- * A context compaction is in flight (context-auto-compaction §6.1).
79
- *
80
- * HIGHEST PRECEDENCE IN THE LABEL LADDER, above `runningTool` and above the
81
- * rotating phrase. Compaction happens BEFORE `turn_start`, i.e. at a moment
82
- * when `status` is `'running'` but no tool is in flight — precisely the window
83
- * in which the phrase rotation would say something false, and it can last
84
- * tens of seconds. `runningTool` already established the principle one step
85
- * back: a UI that says "Pondering" while `npm test` runs is not calm, it is
86
- * wrong, and one that says it while a 30 k-token summarization runs is the same
87
- * error.
88
- *
89
- * STILL NO SECOND SPINNER. `single-spinner-while-running`'s D-1 stands: this
90
- * row owns the only animation while it is mounted, and the compaction card
91
- * follows `FastCard` in taking a `reducedMotion` that `App` has already
92
- * widened.
76
+ * False draws the STATIC glyph even when motion is allowed. The run status row
77
+ * uses it when it has scrolled out of the viewport (the fixed bottom row owns
78
+ * the animation then) or an overlay covers the chat. The phrase still rotates
79
+ * from the RAW `reducedMotion`, so a still icon never freezes the wording.
93
80
  */
94
- compacting?: boolean;
81
+ spinnerLive?: boolean;
82
+ /** Prefix one space (the fixed bottom row's indent); the run row indents itself. */
83
+ leadingSpace?: boolean;
95
84
  theme: Theme;
96
85
  caps: TermCapabilities;
97
86
  }
98
- export declare function ActivityLine({ startedAt, elapsedMs, reducedMotion, runningTool, compacting, theme, caps, }: ActivityLineProps): React.ReactElement;
87
+ /** `<spinner> <label>` as ONE truncating `<Text>`; the only place that composes them. */
88
+ export declare function ActivityLabel({ spinnerLive, leadingSpace, theme, caps, ...activity }: ActivityLabelProps): React.ReactElement;
89
+ export type ActivityLineProps = Omit<ActivityLabelProps, 'spinnerLive' | 'leadingSpace'>;
90
+ export declare function ActivityLine(props: ActivityLineProps): React.ReactElement;
@@ -21,32 +21,43 @@ import { pickActivityPhrase } from './activity-phrases.js';
21
21
  export function liveSpinner(reducedMotion, caps) {
22
22
  return caps.unicode && !reducedMotion ? _jsx(Spinner, { type: "dots" }) : null;
23
23
  }
24
- export function ActivityLine({ startedAt, elapsedMs, reducedMotion, runningTool, compacting, theme, caps, }) {
25
- const glyphs = pickGlyphs(caps);
24
+ /**
25
+ * The label ladder, MOST SPECIFIC FIRST: `Compacting context` outranks a tool name
26
+ * because compaction can be the only thing happening, and the tool name outranks
27
+ * the rotating phrase for the reason `runningTool` exists at all. One
28
+ * implementation, because the fixed bottom row and the run status row above the
29
+ * input must never disagree about what the agent is doing.
30
+ *
31
+ * `Running ${name}` rather than a per-tool verb table: a table would be a SECOND
32
+ * registry of tool names, and `toolGlyph` is already the one place that knows
33
+ * them. ASCII only, and the ellipsis comes from `pickGlyphs` like every other one.
34
+ */
35
+ export function resolveActivityLabel(activity, glyphs) {
36
+ if (activity.compacting)
37
+ return `Compacting context${glyphs.ellipsis}`;
38
+ if (activity.runningTool)
39
+ return `Running ${activity.runningTool}`;
26
40
  // PURE AND DERIVED FROM `(startedAt, now)`, so the sequence does not need to
27
41
  // keep rotating underneath the tool label: when the tool settles the phrase
28
42
  // resumes at the position the clock implies, with no state anywhere.
29
- const phrase = pickActivityPhrase(startedAt, startedAt + elapsedMs, !reducedMotion);
30
- // `Running ${name}` rather than a per-tool verb table: a table would be a
31
- // SECOND registry of tool names, and `toolGlyph` is already the one place that
32
- // knows them. Everything round 1 legislated for this row survives — one row,
33
- // no digits, no `esc abort`, no width ladder, ASCII only.
34
- // THREE RUNGS, MOST SPECIFIC FIRST. `Compacting context` outranks a tool name
35
- // because compaction can be the only thing happening, and it outranks the
36
- // phrase for the reason `runningTool` does. ASCII only, and the ellipsis comes
37
- // from `pickGlyphs` like every other one on this row.
38
- const label = compacting
39
- ? `Compacting context${glyphs.ellipsis}`
40
- : runningTool
41
- ? `Running ${runningTool}`
42
- : `${phrase}${glyphs.ellipsis}`;
43
+ const phrase = pickActivityPhrase(activity.startedAt, activity.startedAt + activity.elapsedMs, !activity.reducedMotion);
44
+ return `${phrase}${glyphs.ellipsis}`;
45
+ }
46
+ /** `<spinner> <label>` as ONE truncating `<Text>`; the only place that composes them. */
47
+ export function ActivityLabel({ spinnerLive = true, leadingSpace = false, theme, caps, ...activity }) {
48
+ const glyphs = pickGlyphs(caps);
49
+ const label = resolveActivityLabel(activity, glyphs);
43
50
  // The IDENTICAL branch `Transcript.tsx` uses for the assistant role marker, and
44
51
  // it must stay identical: braille dots are both an animation AND a Unicode-only
45
52
  // glyph, so reduced motion and an ASCII terminal fall back to the same marker.
46
53
  //
47
54
  // Through `liveSpinner` rather than inline, so this row and the glyph
48
55
  // `BottomStatusRow` composes beside a toast can never disagree about WHEN the
49
- // spinner is live — the same reason `App` names its mount condition once.
50
- const spinner = liveSpinner(reducedMotion, caps) ?? glyphs.spinnerStill;
51
- return (_jsx(Box, { flexShrink: 0, children: _jsxs(Text, { wrap: "truncate", color: theme.thinking, children: [' ', spinner, " ", label] }) }));
56
+ // spinner is live - the same reason `App` names its mount condition once.
57
+ const spinner = (spinnerLive ? liveSpinner(activity.reducedMotion, caps) : null) ??
58
+ glyphs.spinnerStill;
59
+ return (_jsxs(Text, { wrap: "truncate", color: theme.thinking, children: [leadingSpace ? ' ' : '', spinner, " ", label] }));
60
+ }
61
+ export function ActivityLine(props) {
62
+ return (_jsx(Box, { flexShrink: 0, children: _jsx(ActivityLabel, { ...props, leadingSpace: true }) }));
52
63
  }
package/dist/ui/App.d.ts CHANGED
@@ -10,7 +10,6 @@ import type { AgentController } from '../agent/controller.js';
10
10
  import type { ConfirmRequest } from '../tools/index.js';
11
11
  import { type Overlay, type ToastLevel } from '../agent/reducer.js';
12
12
  import type { UpdateBridge } from '../update/types.js';
13
- import { type RenderMode } from './layout/frame.js';
14
13
  import type { MouseSource } from '../input/stdin-filter.js';
15
14
  import type { PasteBridge } from '../input/limits.js';
16
15
  import type { SelectionBridge } from './selection/selection-controller.js';
@@ -43,15 +42,11 @@ export interface AppProps {
43
42
  controller: AgentController;
44
43
  version: string;
45
44
  /** Decided once in `cli.tsx::runInteractive()`; never switched at run time. */
46
- mode: RenderMode;
47
45
  initialOverlay?: Overlay;
48
46
  initialPrompt?: string;
49
47
  confirmBridge?: ConfirmBridge;
50
48
  /** The plan-mode human channel; absent in tests that do not exercise it. */
51
49
  humanInputBridge?: HumanInputBridge;
52
- /** Wheel events, already stripped out of stdin. Absent whenever mouse support
53
- * is off: inline mode, `--no-mouse`, a non-TTY, or a filter that failed to
54
- * build (mouse-wheel-region-routing §4.2). */
55
50
  mouseSource?: MouseSource;
56
51
  /**
57
52
  * Present ONLY on a console that can deliver neither `CSI Z` nor a mouse
@@ -87,28 +82,8 @@ export interface AppProps {
87
82
  * on a non-TTY, and in every test that does not exercise it.
88
83
  */
89
84
  pasteBridge?: PasteBridge;
90
- /**
91
- * The terminal-facing services `cli.tsx` owns and this component cannot build
92
- * (tui-selection-and-scroll-follow §4.4).
93
- *
94
- * ONE PROP RATHER THAN FOUR, and every FIELD of it is independently optional,
95
- * so `--no-mouse`, `--no-diff-render` and inline mode each drop exactly the
96
- * services they do not have. Absent altogether on a non-TTY and in every test
97
- * that does not exercise selection, which is what keeps those trees
98
- * byte-identical.
99
- */
100
85
  terminal?: TerminalBridge;
101
86
  }
102
- /**
103
- * Terminal-level services, almost all of which exist only in a full-screen TTY
104
- * session — `writeForeign` is the exception, because `/copy` is available in
105
- * inline mode too and OSC 52 is what makes it work over SSH (§4.4.5).
106
- *
107
- * `selection` is a LATE-BOUND BRIDGE for the reason `updateBridge` above is one:
108
- * the controller is constructed before `render()` and must not hold React state,
109
- * while the toast sink and the redraw nonce only exist once this component has
110
- * mounted. The effect below assigns both and clears them on unmount.
111
- */
112
87
  export interface TerminalBridge {
113
88
  scrollbar?: ScrollbarBridge;
114
89
  /** Whether drag-select is on for this session (`mouseSelect` + a filter). */
@@ -117,22 +92,7 @@ export interface TerminalBridge {
117
92
  setMouseCapture?: (on: boolean) => void;
118
93
  /** Whether the mouse is captured right now — read at command time. */
119
94
  isMouseCaptured?: () => boolean;
120
- /**
121
- * Write bytes that are ours but are not a frame (OSC 52).
122
- *
123
- * IT MUST NOT BE `stdout.write` WITH THE DIFFER IN FRONT OF STDOUT (P1-6):
124
- * that goes through the frame-writer proxy, which does not recognise an OSC 52
125
- * chunk, counts it as a foreign write and prints `FRAME_FALLBACK_NOTICE` at
126
- * the user the first time they copy anything. Without a differ — inline mode,
127
- * `--no-diff-render` — stdout IS the real stream and `cli.tsx` supplies a
128
- * direct write instead.
129
- *
130
- * Absent only when there is no terminal to accept it at all. It is never a
131
- * function that writes nowhere: `copyText` reports `'osc52'` whenever its door
132
- * took the text, so a no-op door would make the toast name a mechanism that
133
- * was never attempted.
134
- */
135
95
  writeForeign?: (text: string) => void;
136
96
  selection?: SelectionBridge;
137
97
  }
138
- export declare function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }: AppProps): React.ReactElement;
98
+ export declare function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }: AppProps): React.ReactElement;