@aragon-agent/cli 0.6.3 → 0.6.4

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 (94) hide show
  1. package/CHANGELOG.md +105 -0
  2. package/README.md +95 -2
  3. package/dist/agent/controller.d.ts +119 -0
  4. package/dist/agent/controller.js +275 -1
  5. package/dist/agent/reducer.d.ts +69 -0
  6. package/dist/agent/reducer.js +100 -0
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/cli.js +84 -22
  10. package/dist/commands/builtins.js +92 -1
  11. package/dist/compaction/child.js +5 -0
  12. package/dist/compaction/command.js +14 -1
  13. package/dist/compaction/compactor.d.ts +34 -0
  14. package/dist/compaction/compactor.js +158 -37
  15. package/dist/compaction/types.d.ts +36 -0
  16. package/dist/compaction/wiring.d.ts +9 -0
  17. package/dist/compaction/wiring.js +22 -1
  18. package/dist/config/env.d.ts +1 -1
  19. package/dist/config/env.js +48 -1
  20. package/dist/config/load.d.ts +11 -0
  21. package/dist/config/load.js +39 -1
  22. package/dist/config/schema.d.ts +85 -0
  23. package/dist/config/schema.js +33 -0
  24. package/dist/config/store.js +21 -1
  25. package/dist/input/limits.d.ts +72 -0
  26. package/dist/input/limits.js +63 -0
  27. package/dist/input/paste-parse.d.ts +71 -0
  28. package/dist/input/paste-parse.js +95 -0
  29. package/dist/input/stdin-filter.d.ts +81 -0
  30. package/dist/input/stdin-filter.js +379 -0
  31. package/dist/logging/install.d.ts +2 -0
  32. package/dist/logging/install.js +53 -1
  33. package/dist/proc/classify.d.ts +52 -0
  34. package/dist/proc/classify.js +143 -0
  35. package/dist/proc/kill-tree.d.ts +42 -0
  36. package/dist/proc/kill-tree.js +119 -0
  37. package/dist/proc/limits.d.ts +84 -0
  38. package/dist/proc/limits.js +84 -0
  39. package/dist/proc/log-ring.d.ts +68 -0
  40. package/dist/proc/log-ring.js +102 -0
  41. package/dist/proc/prompt.d.ts +24 -0
  42. package/dist/proc/prompt.js +49 -0
  43. package/dist/proc/readiness.d.ts +102 -0
  44. package/dist/proc/readiness.js +219 -0
  45. package/dist/proc/supervisor.d.ts +103 -0
  46. package/dist/proc/supervisor.js +465 -0
  47. package/dist/proc/types.d.ts +175 -0
  48. package/dist/proc/types.js +15 -0
  49. package/dist/session/persist.js +36 -1
  50. package/dist/tools/bash-tool.d.ts +51 -2
  51. package/dist/tools/bash-tool.js +321 -51
  52. package/dist/tools/fs-tools.d.ts +17 -0
  53. package/dist/tools/index.d.ts +55 -2
  54. package/dist/tools/index.js +39 -1
  55. package/dist/tools/proc-tools.d.ts +25 -0
  56. package/dist/tools/proc-tools.js +102 -0
  57. package/dist/ui/App.d.ts +15 -2
  58. package/dist/ui/App.js +256 -7
  59. package/dist/ui/Composer.d.ts +54 -1
  60. package/dist/ui/Composer.js +50 -6
  61. package/dist/ui/PromptInput.d.ts +49 -3
  62. package/dist/ui/PromptInput.js +184 -52
  63. package/dist/ui/StatusBar.d.ts +19 -0
  64. package/dist/ui/StatusBar.js +5 -2
  65. package/dist/ui/Transcript.js +29 -1
  66. package/dist/ui/composer-limits.d.ts +31 -0
  67. package/dist/ui/composer-limits.js +50 -0
  68. package/dist/ui/composer-rows.d.ts +73 -0
  69. package/dist/ui/composer-rows.js +201 -0
  70. package/dist/ui/editor-reducer.d.ts +37 -0
  71. package/dist/ui/editor-reducer.js +88 -12
  72. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  73. package/dist/ui/entries/ServiceCard.js +116 -0
  74. package/dist/ui/entries/UserEntry.d.ts +17 -3
  75. package/dist/ui/entries/UserEntry.js +34 -5
  76. package/dist/ui/layout/budget.d.ts +34 -6
  77. package/dist/ui/layout/budget.js +32 -7
  78. package/dist/ui/layout/virtual-window.js +51 -1
  79. package/dist/ui/overlays/HelpOverlay.js +3 -0
  80. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  81. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  82. package/dist/ui/overlays/SettingsScreen.js +14 -1
  83. package/dist/ui/paste-frames.d.ts +87 -0
  84. package/dist/ui/paste-frames.js +112 -0
  85. package/dist/ui/paste-tokens.d.ts +90 -0
  86. package/dist/ui/paste-tokens.js +147 -0
  87. package/dist/ui/screen.d.ts +16 -0
  88. package/dist/ui/screen.js +45 -2
  89. package/dist/ui/selection/selection-controller.d.ts +1 -1
  90. package/dist/ui/transcript-text.js +49 -0
  91. package/dist/ui/use-wheel-routing.d.ts +1 -1
  92. package/package.json +2 -2
  93. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  94. package/dist/input/stdin-mouse-filter.js +0 -153
package/dist/ui/App.js CHANGED
@@ -17,6 +17,7 @@ import { updatePersistedConfig } from '../config/store.js';
17
17
  import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
18
18
  import { bumpSubmitCount, setMouseNoticeVersion, setVtInputNoticeVersion, } from '../config/ui-state.js';
19
19
  import { getLogger } from '../logging/logger.js';
20
+ import { isTerminalStatus } from '../proc/types.js';
20
21
  import { registerSecret } from '../logging/secret-registry.js';
21
22
  import { detectCapabilities } from './capabilities.js';
22
23
  import { useStartupNotices, MOUSE_NOTICE_VERSION, VT_INPUT_NOTICE_VERSION, } from './use-startup-notices.js';
@@ -105,6 +106,24 @@ export function resolveSettingsMaxTokens(raw, previous) {
105
106
  toast: ['warn', 'Max tokens must be a number or "auto".'],
106
107
  };
107
108
  }
109
+ /**
110
+ * How long rung two of the Esc ladder stays armed after rung one (§5.5).
111
+ *
112
+ * Four seconds: long enough that a user who watched the abort fail can react,
113
+ * short enough that a stray Esc minutes later cannot force-stop a healthy run.
114
+ * `runEnd` / `agent_end` disarm it too, so the window is an upper bound rather
115
+ * than the only protection (R-9).
116
+ */
117
+ const ESC_ARM_MS = 4000;
118
+ /** Services in a non-terminal state, from the live snapshot map. */
119
+ function liveServiceCount(services) {
120
+ let n = 0;
121
+ for (const service of Object.values(services)) {
122
+ if (!isTerminalStatus(service.status))
123
+ n += 1;
124
+ }
125
+ return n;
126
+ }
108
127
  /**
109
128
  * The lazy `useReducer` initializer. Module scope, so the reference is stable
110
129
  * across renders and React never re-runs it.
@@ -112,7 +131,7 @@ export function resolveSettingsMaxTokens(raw, previous) {
112
131
  function seedViewState(thinkingVisible) {
113
132
  return initialViewState({ thinkingVisible });
114
133
  }
115
- export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, terminal, }) {
134
+ export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }) {
116
135
  const { exit } = useApp();
117
136
  const { stdout } = useStdout();
118
137
  const fullscreen = mode === 'fullscreen';
@@ -198,6 +217,37 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
198
217
  confirmRef.current = confirmState;
199
218
  const ctrlCArmed = useRef(false);
200
219
  const ctrlCTimer = useRef(null);
220
+ /**
221
+ * Rung two of Esc is armed (background-service-supervision §3.6).
222
+ *
223
+ * CLEARED BY `runEnd` / `agent_end` AS WELL AS BY ITS OWN TIMER, and both
224
+ * halves matter: a healthy run that ended between the two presses must not be
225
+ * force-stoppable retroactively, and in every healthy run the second press
226
+ * lands on the idle branch and does nothing at all (R-9). Rung two only ever
227
+ * fires while the run is STILL running after rung one - i.e. only when the
228
+ * first press provably failed.
229
+ */
230
+ const escArmed = useRef(false);
231
+ const escArmTimer = useRef(null);
232
+ /**
233
+ * Live service snapshots, keyed by id, for the status chip and the Ctrl+C rung.
234
+ *
235
+ * A `useState` MAP rather than a ref, because the chip has to re-render when it
236
+ * changes; and separate from `ViewState`, because a service is not a
237
+ * transcript entry - the transcript records EVENTS about a service (D-11) and
238
+ * this is the live state those events are about.
239
+ */
240
+ const [services, setServices] = useState({});
241
+ /**
242
+ * Read by the Ctrl+C handler, which `useInput` registers ONCE.
243
+ *
244
+ * The same reason `stateRef` exists two lines up: a value captured in that
245
+ * closure would be pinned to the mount render, so the key handler would
246
+ * believe there are never any services and Ctrl+C would arm exit while a dev
247
+ * server was up.
248
+ */
249
+ const servicesRef = useRef(services);
250
+ servicesRef.current = services;
201
251
  const runBaselineOut = useRef(0);
202
252
  /**
203
253
  * Epoch ms the current run began — the activity phrase's seed.
@@ -282,6 +332,20 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
282
332
  aborted: false,
283
333
  errored: false,
284
334
  });
335
+ /**
336
+ * Disarm rung two of the Esc ladder.
337
+ *
338
+ * A `useCallback` so the event subscription's dependency list can name it, and
339
+ * so it is one function rather than three copies of "clear the flag and the
340
+ * timer" that can drift.
341
+ */
342
+ const disarmEsc = useCallback(() => {
343
+ escArmed.current = false;
344
+ if (escArmTimer.current) {
345
+ clearTimeout(escArmTimer.current);
346
+ escArmTimer.current = null;
347
+ }
348
+ }, []);
285
349
  /**
286
350
  * Disarm an armed continuation. Idempotent; NEVER touches the budget — a
287
351
  * cancelled continuation was never issued, so it must not be charged (AC-18).
@@ -368,7 +432,28 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
368
432
  */
369
433
  const patchSource = useMemo(() => ({ take: (id) => controller.takeFilePatch(id) }), [controller]);
370
434
  useEffect(() => {
435
+ // CAPTURED AT SUBSCRIBE TIME AND COMPARED ON EVERY EVENT (I-7 / P1-7).
436
+ //
437
+ // `forceStop()` bumps the controller's generation, and everything the engine
438
+ // emits while it unwinds - `turn_start`, `tool_call_start`, `turn_end`,
439
+ // `agent_end` - belongs to the run the user just abandoned. `runStart` /
440
+ // `turnStart` would set `status: 'running'` again, bouncing the view straight
441
+ // back out of the `idle` rung two just gave them, and stray entries would
442
+ // append to a transcript they believe is finished.
443
+ //
444
+ // A LOCAL `let`, RE-READ RATHER THAN CAPTURED ONCE: the generation this
445
+ // listener considers current advances with every submit, so a force-stop
446
+ // makes stale events inert WITHOUT making the next run's events inert too.
447
+ let generation = controller.runGeneration;
371
448
  const unsubscribe = controller.subscribe((event) => {
449
+ if (controller.runGeneration !== generation) {
450
+ // The engine is unwinding a force-stopped run. Adopt the new generation
451
+ // only once it has finished doing so, so a late `agent_end` cannot be
452
+ // mistaken for the next run's.
453
+ if (event.type === 'agent_end')
454
+ generation = controller.runGeneration;
455
+ return;
456
+ }
372
457
  const cost = controller.getModelInfo().cost;
373
458
  for (const action of reduceEvent(event, cost, patchSource)) {
374
459
  // MUTATED HERE, ONE INSTANT BEFORE THE ACTION THAT SETS
@@ -395,6 +480,11 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
395
480
  }
396
481
  if (event.type !== 'agent_end')
397
482
  return;
483
+ // RUNG TWO DISARMS WITH THE RUN (§3.6 / R-9). A healthy run that ended
484
+ // between the two Esc presses must not be force-stoppable retroactively:
485
+ // the second press then lands on the idle branch, which is what makes the
486
+ // ladder free to take rather than a footgun.
487
+ disarmEsc();
398
488
  // NO OVERLAY OUTLIVES ITS RUN (P1-4). `ctx.signal` already covers the
399
489
  // abort path, so this is DELIBERATE REDUNDANCY: it makes "the card cannot
400
490
  // survive the run that opened it" a property of the App's lifecycle
@@ -484,7 +574,37 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
484
574
  cancelFollowThrough();
485
575
  unsubscribe();
486
576
  };
487
- }, [controller, patchSource, flushPending, cancelPendingHuman, cancelFollowThrough]);
577
+ }, [controller, patchSource, flushPending, cancelPendingHuman, cancelFollowThrough, disarmEsc]);
578
+ // --- Background services (background-service-supervision §3.8). ---------
579
+ //
580
+ // A SEPARATE SUBSCRIPTION, not a branch in the one above: `ProcEvent` is
581
+ // deliberately not a member of core's `AgentEvent` union (I-5 / D-1), so it
582
+ // cannot arrive through `controller.subscribe`.
583
+ //
584
+ // TWO SINKS PER EVENT, and they are not redundant. `setServices` holds the
585
+ // LIVE state the status chip, the composer hint and the Ctrl+C rung read;
586
+ // `dispatch` writes the TRANSCRIPT, which records events about a service
587
+ // rather than mirroring it (D-11). Feeding one from the other would make the
588
+ // chip lie about a card that has already been printed into `<Static>`.
589
+ //
590
+ // NOT ROUTED THROUGH THE STREAMING COALESCER. `output` is already coalesced at
591
+ // the SOURCE, inside the supervisor, because `/bg` and `bash_output` consume
592
+ // the same event (§6); a second buffer here would only add latency.
593
+ useEffect(() => {
594
+ return controller.subscribeProc((event) => {
595
+ const service = event.service;
596
+ setServices((prev) => ({ ...prev, [service.id]: service }));
597
+ if (event.type === 'started') {
598
+ dispatch({ type: 'serviceStart', service });
599
+ return;
600
+ }
601
+ if (event.type === 'exited' || event.type === 'stopped') {
602
+ dispatch({ type: 'serviceEnd', service });
603
+ return;
604
+ }
605
+ dispatch({ type: 'serviceUpdate', service });
606
+ });
607
+ }, [controller]);
488
608
  // --- Team-mode event stream (team-subagents §5.2 / §3.9). --------------
489
609
  //
490
610
  // A SEPARATE SUBSCRIPTION, not a branch in the one above: `TeamEvent` is
@@ -983,6 +1103,56 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
983
1103
  // --- Helpers -----------------------------------------------------------
984
1104
  const notify = (level, text) => dispatch({ type: 'notice', level, text });
985
1105
  const toast = (level, text) => dispatch({ type: 'pushToast', level, text });
1106
+ // --- Paste bridge (tui-paste-handling section 5.1.2 / I-15). -------------
1107
+ //
1108
+ // The filter exists before this component does, so it holds a slot rather than
1109
+ // a callback. Cleared on unmount: a rejection that somehow arrived after the
1110
+ // tree came down would otherwise dispatch into a dead reducer.
1111
+ useEffect(() => {
1112
+ if (!pasteBridge)
1113
+ return undefined;
1114
+ const bridge = pasteBridge;
1115
+ bridge.notify = (level, text) => dispatch({ type: 'notice', level, text });
1116
+ return () => {
1117
+ bridge.notify = null;
1118
+ };
1119
+ }, [pasteBridge]);
1120
+ // --- The composer's height (tui-paste-handling section 5.5 / D-12). ------
1121
+ //
1122
+ // GROWTH IS IMMEDIATE, SHRINK IS DEFERRED BY ONE TICK (R-12). Growing late
1123
+ // overdraws the frame for a frame; shrinking early makes a `Backspace` that
1124
+ // crosses a wrap boundary bounce the whole transcript, and a run of deletions
1125
+ // bounce it repeatedly. Deferring coalesces the run into one move.
1126
+ //
1127
+ // The ref mirrors the state so the decision is made OUTSIDE the updater: React
1128
+ // may call an updater more than once, and scheduling a timer from inside one
1129
+ // would arm it twice.
1130
+ const [draftRows, setDraftRows] = useState(1);
1131
+ const draftRowsRef = useRef(1);
1132
+ const shrinkTimer = useRef(null);
1133
+ const onDraftRows = useCallback((next) => {
1134
+ const wanted = Math.max(1, next);
1135
+ if (shrinkTimer.current) {
1136
+ clearTimeout(shrinkTimer.current);
1137
+ shrinkTimer.current = null;
1138
+ }
1139
+ if (wanted === draftRowsRef.current)
1140
+ return;
1141
+ if (wanted > draftRowsRef.current) {
1142
+ draftRowsRef.current = wanted;
1143
+ setDraftRows(wanted);
1144
+ return;
1145
+ }
1146
+ shrinkTimer.current = setTimeout(() => {
1147
+ shrinkTimer.current = null;
1148
+ draftRowsRef.current = wanted;
1149
+ setDraftRows(wanted);
1150
+ }, 0);
1151
+ }, []);
1152
+ useEffect(() => () => {
1153
+ if (shrinkTimer.current)
1154
+ clearTimeout(shrinkTimer.current);
1155
+ }, []);
986
1156
  // --- Auto-update bridge (cli-auto-update section 3.8 / 6.3). ------------
987
1157
  //
988
1158
  // TWO ARRIVAL ORDERS, AND BOTH HAPPEN. The service is built by a dynamic
@@ -1119,6 +1289,16 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1119
1289
  };
1120
1290
  const doExit = () => {
1121
1291
  controller.abort();
1292
+ // G5. `force: true` means `reapSync()` SEMANTICS - immediate SIGKILL, no
1293
+ // grace ladder - because THIS FUNCTION AWAITS NOTHING AND MUST NOT START
1294
+ // (§3.6 step 3). An async `stopAll` with a 3 s escalation would still be
1295
+ // running when `exit()` tore the tree down, and every child would survive.
1296
+ //
1297
+ // IT IS DELIBERATELY REDUNDANT with the signal hook the controller
1298
+ // registered: a normal quit reaches both, and `reapSync` is idempotent
1299
+ // precisely so that is safe. The hook covers SIGINT/SIGTERM and a crash;
1300
+ // this covers `/exit` and `Ctrl+C x2`, which are not signals at all.
1301
+ void controller.stopAllServices({ force: true });
1122
1302
  exit();
1123
1303
  };
1124
1304
  /**
@@ -1407,6 +1587,26 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1407
1587
  // press; it clears the selection here like every other key and nothing more.
1408
1588
  selectionController?.clear();
1409
1589
  if (key.ctrl && (input === 'c' || input === 'C')) {
1590
+ // RUNG ONE: Ctrl+C acquires the meaning a terminal user already expects
1591
+ // it to have (§3.6 / G4 / D-5). While services are live it stops THEM, and
1592
+ // only once nothing is running does it fall through to the existing
1593
+ // arm/exit ladder.
1594
+ //
1595
+ // IT DOES NOT ARM EXIT. Stopping a server and quitting the app are
1596
+ // different intentions, and a user who pressed Ctrl+C to kill a dev server
1597
+ // must not find themselves one keystroke from losing the session. The
1598
+ // composer hint still names an exit clause, so quitting stays discoverable
1599
+ // (P1-4).
1600
+ //
1601
+ // THE GRACEFUL LADDER, not `force`: this path has a live event loop to run
1602
+ // the SIGTERM -> SIGKILL escalation on, and a dev server given a chance to
1603
+ // shut down cleanly releases its port.
1604
+ const live = liveServiceCount(servicesRef.current);
1605
+ if (live > 0) {
1606
+ void controller.stopAllServices();
1607
+ toast('warn', `Stopping ${live} service${live === 1 ? '' : 's'}.`);
1608
+ return;
1609
+ }
1410
1610
  if (ctrlCArmed.current) {
1411
1611
  doExit();
1412
1612
  return;
@@ -1526,9 +1726,17 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1526
1726
  // and without this the compaction card renders its own `ctrl+o to expand`
1527
1727
  // hint, the keystroke silently expands an older tool card instead, and
1528
1728
  // nothing errors. AC-21 is the assertion that the hint does not lie.
1729
+ // `service` joins the closed list (P1-3). Omitting it is not a missing
1730
+ // feature but an actively wrong one: `ServiceCard` draws its own
1731
+ // `ctrl+o log` hint, the keystroke would silently expand AN OLDER TOOL
1732
+ // CARD, and nothing would error. The terminal one-row record is skipped -
1733
+ // it has nothing to reveal, and expanding it would consume the press.
1529
1734
  const last = [...stateRef.current.entries]
1530
1735
  .reverse()
1531
- .find((e) => e.kind === 'tool' || e.kind === 'team' || e.kind === 'compaction');
1736
+ .find((e) => e.kind === 'tool' ||
1737
+ e.kind === 'team' ||
1738
+ e.kind === 'compaction' ||
1739
+ (e.kind === 'service' && !e.terminal));
1532
1740
  if (last)
1533
1741
  dispatch({ type: 'toggleExpand', id: last.id });
1534
1742
  // WIDENED FROM `No tool output to expand.`: with three expandable kinds the
@@ -1554,6 +1762,34 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1554
1762
  return;
1555
1763
  }
1556
1764
  if (stateRef.current.status === 'running') {
1765
+ // RUNG TWO (§3.6 / G3). The first press asked the engine to stop; if the
1766
+ // run is STILL running when the second arrives, that request provably
1767
+ // failed - so this one does not ask, it takes.
1768
+ //
1769
+ // THREE THINGS HAPPEN, AND THE THIRD IS THE BELT-AND-BRACES.
1770
+ // `controller.forceStop()` aborts, hard-kills every tracked foreground
1771
+ // `bash` child (which is what actually unblocks a wedged
1772
+ // `await tool.execute`) and bumps the run generation so the unwinding
1773
+ // engine can no longer move the view. The local `runEnd` is dispatched
1774
+ // UNCONDITIONALLY on top: even if the engine is blocked somewhere
1775
+ // nothing can reach, the view returns to `idle`, the composer is usable,
1776
+ // and the reducer releases every live tail. Making the view's usability
1777
+ // conditional on the engine recovering is what produced the reported
1778
+ // screenshot (D-6).
1779
+ //
1780
+ // AND THE NEXT MESSAGE MUST STILL WORK. `AgentController.prompt()` waits
1781
+ // a still-running engine out and never rejects (I-8) - without that,
1782
+ // "Esc, Esc, then type" would reach `Agent.prompt()`'s synchronous throw,
1783
+ // become an unhandled rejection, and EXIT THE PROCESS (P0-1).
1784
+ if (escArmed.current) {
1785
+ disarmEsc();
1786
+ controller.forceStop();
1787
+ endReasonRef.current.aborted = true;
1788
+ dispatch({ type: 'abortMark' });
1789
+ dispatch({ type: 'runEnd' });
1790
+ toast('warn', 'Force-stopped.');
1791
+ return;
1792
+ }
1557
1793
  controller.abort();
1558
1794
  // Next to `abortMark`, in the same callback, for the reason
1559
1795
  // `endReasonRef`'s own comment gives (C-11). D-5 turns this into
@@ -1561,7 +1797,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1561
1797
  // restating what they interrupted is the CLI arguing.
1562
1798
  endReasonRef.current.aborted = true;
1563
1799
  dispatch({ type: 'abortMark' });
1564
- toast('info', 'Run aborted.');
1800
+ escArmed.current = true;
1801
+ if (escArmTimer.current)
1802
+ clearTimeout(escArmTimer.current);
1803
+ escArmTimer.current = setTimeout(() => {
1804
+ escArmed.current = false;
1805
+ escArmTimer.current = null;
1806
+ }, ESC_ARM_MS);
1807
+ toast('info', 'Run aborted. Esc again to force-stop.');
1565
1808
  return;
1566
1809
  }
1567
1810
  // IDLE. Today this branch does nothing at all, which is what makes it free
@@ -1583,6 +1826,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1583
1826
  const overlay = state.overlay;
1584
1827
  const hasKey = controller.hasApiKey();
1585
1828
  const empty = state.entries.length === 0;
1829
+ // RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
1830
+ // handler will do on the very next keystroke.
1831
+ const liveServices = liveServiceCount(services);
1586
1832
  const tokPerSec = state.status === 'running' && elapsedMs > 500
1587
1833
  ? Math.max(0, Math.round((state.usageTotal.outputTokens - runBaselineOut.current) / (elapsedMs / 1000)))
1588
1834
  : 0;
@@ -1627,7 +1873,10 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1627
1873
  // Row budget for the viewport and everything sized against it (§4.2a). The
1628
1874
  // inline path has no fixed frame, hence no height to fit into: `Infinity`
1629
1875
  // tells `OverlayFrame` to render everything and show no position indicator.
1630
- const viewportBudget = computeViewportRows(rows);
1876
+ // `draftRows` is the number `PromptInput` just reported for the rows it is
1877
+ // ACTUALLY RENDERING (I-8). Defaulting it to 1 is what makes the inline path
1878
+ // and every test that never types byte-identical to before.
1879
+ const viewportBudget = computeViewportRows(rows, draftRows);
1631
1880
  const overlayMaxRows = fullscreen ? viewportBudget : Number.POSITIVE_INFINITY;
1632
1881
  // --- The todo rail (todo-plan-execution §3.9). --------------------------
1633
1882
  //
@@ -1782,7 +2031,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1782
2031
  // Both keys always WORK (they share one branch in the input handler); this
1783
2032
  // only decides which one the user is taught.
1784
2033
  const modeToggleKey = vtInputWarning ? MODE_TOGGLE_KEYS.fallback : MODE_TOGGLE_KEYS.primary;
1785
- const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, scrolledLines: scrolledLines, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
2034
+ const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, services: liveServices, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, scrolledLines: scrolledLines, onDraftRows: onDraftRows, onNotice: notify, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
1786
2035
  return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, rows: rows, cols: cols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null, rail: showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null, strip: showStrip && state.todos ? (_jsx(TodoStrip, { snapshot: state.todos, cols: cols, theme: theme, caps: caps })) : null, toast: _jsx(BottomStatusRow, { mode: mode, toasts: state.toasts,
1787
2036
  // `!overlayNode` suppresses the line while an overlay owns the screen:
1788
2037
  // the full-screen branch replaces the whole viewport with the overlay,
@@ -1831,7 +2080,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1831
2080
  }
1832
2081
  : {}), ...(state.todos && !showRail
1833
2082
  ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
1834
- : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}), ...(state.compaction?.live
2083
+ : {}), ...(liveServices > 0 ? { servicesActive: { live: liveServices } } : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}), ...(state.compaction?.live
1835
2084
  ? {
1836
2085
  compactionActive: { inFlight: state.compaction.inFlight },
1837
2086
  gaugeMarks: {
@@ -17,6 +17,7 @@
17
17
  import React from 'react';
18
18
  import type { Theme } from './theme.js';
19
19
  import type { TermCapabilities } from './capabilities.js';
20
+ import { pickGlyphs } from './glyphs.js';
20
21
  import { type CommandOption } from './PromptInput.js';
21
22
  import { type AgentMode } from '../agent/agent-mode.js';
22
23
  export interface ComposerProps {
@@ -60,9 +61,61 @@ export interface ComposerProps {
60
61
  * un-faded in `hintText`.
61
62
  */
62
63
  scrolledLines?: number;
64
+ /**
65
+ * Live background services (background-service-supervision §3.8), 0 when none.
66
+ *
67
+ * IT IS STATE, NOT A FADEABLE HINT, exactly like `scrolledLines` above: the
68
+ * clause it adds is the only visible way to stop something, and `hintText`
69
+ * below keeps it out of the faded form for the same reason `esc abort` is kept
70
+ * there.
71
+ */
72
+ services?: number;
73
+ /**
74
+ * The composer's DRAFT row count, forwarded upward (tui-paste-handling
75
+ * section 5.5).
76
+ *
77
+ * `hasDraft` stays here, because the border reacts to it and routing that
78
+ * through `App` would re-render the transcript on the first keystroke of every
79
+ * message. The row count is the opposite case: `layout/budget.ts` needs it, so
80
+ * it has to reach `App` — and it changes on a line break rather than on a
81
+ * character, which is what keeps the re-render rate the same (R-6).
82
+ */
83
+ onDraftRows?: (rows: number) => void;
84
+ /** Paste refusals from `PromptInput`, routed to `App`'s notice dispatch. */
85
+ onNotice?: (level: 'warn' | 'error', text: string) => void;
63
86
  theme: Theme;
64
87
  caps: TermCapabilities;
65
88
  onSubmit: (text: string) => void;
66
89
  onHelp?: () => void;
67
90
  }
68
- export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, modeToggleKey, popupMaxRows, scrolledLines, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
91
+ /**
92
+ * Build the hint row.
93
+ *
94
+ * The `running` branch is NEVER abbreviated, no matter how experienced the user
95
+ * is. Once the status bar's hint cluster was removed, the composer became the
96
+ * only place that names the abort key — and fading that out after eight
97
+ * submissions would leave a long-running task with no visible way to stop it.
98
+ * Progressive disclosure is for teaching discoverable things, not for retiring
99
+ * an emergency exit (§4.6 / A-14 / M-10b).
100
+ */
101
+ declare function hintText(opts: {
102
+ running: boolean;
103
+ submitCount: number;
104
+ glyphs: ReturnType<typeof pickGlyphs>;
105
+ mode: AgentMode;
106
+ toggleKey: string;
107
+ /** Live background services; 0 leaves both rows byte-identical to today's. */
108
+ services: number;
109
+ }): string;
110
+ /**
111
+ * `hintText`, for the interrupt-ladder tests (AC-43).
112
+ *
113
+ * EXPORTED UNDER A TEST-FACING NAME rather than exporting `hintText` itself, so
114
+ * the private helper stays private in the module graph and nothing in `src/`
115
+ * grows a second caller by accident. The row is the ONLY visible way to stop a
116
+ * process the agent left running, so it is worth asserting on directly rather
117
+ * than fishing the string out of a rendered frame.
118
+ */
119
+ export declare const hintTextForTest: typeof hintText;
120
+ export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, services, modeToggleKey, popupMaxRows, scrolledLines, onDraftRows, onNotice, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
121
+ export {};
@@ -32,9 +32,28 @@ import { MODE_LABEL, MODE_TOGGLE_KEYS, nextMode } from '../agent/agent-mode.js';
32
32
  * Progressive disclosure is for teaching discoverable things, not for retiring
33
33
  * an emergency exit (§4.6 / A-14 / M-10b).
34
34
  */
35
- function hintText(running, submitCount, glyphs, mode, toggleKey) {
35
+ function hintText(opts) {
36
+ const { running, submitCount, glyphs, mode, toggleKey, services } = opts;
36
37
  const dot = ` ${glyphs.midDot} `;
38
+ // THE EXIT CLAUSE SURVIVES IN BOTH ROWS (P1-4). The obvious edit here is to
39
+ // replace `ctrl+c*2 exit` with `ctrl+c stop N` — and that would delete the
40
+ // only visible way to quit at exactly the moment Ctrl+C stops meaning "quit",
41
+ // on the screen a user reaches when something has already gone wrong. Both
42
+ // clauses are shown; the row is `wrap="truncate"` and they are ordered
43
+ // most-urgent-first, so a terminal too narrow for all of them drops the exit
44
+ // clause last rather than the abort key.
37
45
  if (running) {
46
+ if (services > 0) {
47
+ return [
48
+ `${glyphs.enterKey} steer`,
49
+ // Rung two is named because the whole point of the ladder is that the
50
+ // first press can fail; a user who does not know there is a second press
51
+ // is left exactly where the reported screenshot left them.
52
+ `esc abort${glyphs.times}2 force`,
53
+ `ctrl+c stop ${services}`,
54
+ `ctrl+c${glyphs.times}2 exit`,
55
+ ].join(dot);
56
+ }
38
57
  return [`${glyphs.enterKey} steer`, 'esc abort', `ctrl+c${glyphs.times}2 exit`].join(dot);
39
58
  }
40
59
  // The hint names the DESTINATION, not the current state — the correct label
@@ -45,9 +64,14 @@ function hintText(running, submitCount, glyphs, mode, toggleKey) {
45
64
  // retire things the user has demonstrably learned, and `submitCount` counts
46
65
  // submissions — a user can send fifty messages without ever discovering
47
66
  // Shift+Tab. Same argument that keeps `esc abort` un-faded above.
67
+ // NEVER FADED, for the reason the props doc gives: it is the only visible way
68
+ // to stop a process the agent left running, and progressive disclosure retires
69
+ // things the user has demonstrably learned — not emergency exits.
70
+ const stop = services > 0 ? [`ctrl+c stop ${services}`] : [];
48
71
  if (submitCount >= HINT_FADE_AFTER)
49
- return [toggle, '? help'].join(dot);
72
+ return [...stop, toggle, '? help'].join(dot);
50
73
  return [
74
+ ...stop,
51
75
  `${glyphs.enterKey} send`,
52
76
  `${glyphs.shiftEnter} newline`,
53
77
  '/ commands',
@@ -56,9 +80,22 @@ function hintText(running, submitCount, glyphs, mode, toggleKey) {
56
80
  '? help',
57
81
  ].join(dot);
58
82
  }
59
- export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, modeToggleKey = MODE_TOGGLE_KEYS.primary, popupMaxRows, scrolledLines = 0, theme, caps, onSubmit, onHelp, }) {
83
+ /**
84
+ * `hintText`, for the interrupt-ladder tests (AC-43).
85
+ *
86
+ * EXPORTED UNDER A TEST-FACING NAME rather than exporting `hintText` itself, so
87
+ * the private helper stays private in the module graph and nothing in `src/`
88
+ * grows a second caller by accident. The row is the ONLY visible way to stop a
89
+ * process the agent left running, so it is worth asserting on directly rather
90
+ * than fishing the string out of a rendered frame.
91
+ */
92
+ export const hintTextForTest = hintText;
93
+ export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, services = 0, modeToggleKey = MODE_TOGGLE_KEYS.primary, popupMaxRows, scrolledLines = 0, onDraftRows, onNotice, theme, caps, onSubmit, onHelp, }) {
60
94
  const [hasDraft, setHasDraft] = useState(false);
61
- const onDraftChange = useCallback((next) => setHasDraft(next), []);
95
+ const onDraftChange = useCallback((next) => {
96
+ setHasDraft(next.hasDraft);
97
+ onDraftRows?.(next.rows);
98
+ }, [onDraftRows]);
62
99
  // Plan mode inserts ONE branch, above `draft` and below `running`. The
63
100
  // ordering is load-bearing: an in-flight run's border must stay `toolRunning`,
64
101
  // because "is it running" is more urgent than "which mode".
@@ -73,8 +110,15 @@ export function Composer({ isActive, running, history, commands, cwd, showHint,
73
110
  : theme.idleBorder ?? theme.border;
74
111
  const glyphs = pickGlyphs(caps);
75
112
  const visible = showHint && hintsEnabled;
76
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, bordered: { color: borderColor }, onDraftChange: onDraftChange, scrolledLines: scrolledLines }), visible && (
113
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, bordered: { color: borderColor }, onDraftChange: onDraftChange, onNotice: onNotice, scrolledLines: scrolledLines }), visible && (
77
114
  // The chip rides the EXISTING hint row rather than adding one, so it
78
115
  // costs zero rows on a 24-row terminal.
79
- _jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Text, { children: ' ' }), _jsx(ModeChip, { mode: agentMode, theme: theme, caps: caps }), _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: hintText(running, submitCount, glyphs, agentMode, modeToggleKey) })] }))] }));
116
+ _jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Text, { children: ' ' }), _jsx(ModeChip, { mode: agentMode, theme: theme, caps: caps }), _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: hintText({
117
+ running,
118
+ submitCount,
119
+ glyphs,
120
+ mode: agentMode,
121
+ toggleKey: modeToggleKey,
122
+ services,
123
+ }) })] }))] }));
80
124
  }
@@ -24,6 +24,7 @@ import type { TermCapabilities } from './capabilities.js';
24
24
  import { pickGlyphs } from './glyphs.js';
25
25
  import { type EditIntent } from '../input/keymap.js';
26
26
  import { type Suggestion } from './AutocompletePopup.js';
27
+ import { type EditorState, type InputSegment } from './editor-reducer.js';
27
28
  import type { AgentMode } from '../agent/agent-mode.js';
28
29
  export interface CommandOption {
29
30
  name: string;
@@ -64,8 +65,30 @@ interface PromptInputProps {
64
65
  bordered?: {
65
66
  color?: string;
66
67
  };
67
- /** Fires only when the draft flips empty ⇄ non-empty, for the border color. */
68
- onDraftChange?: (hasDraft: boolean) => void;
68
+ /**
69
+ * Fires when the draft flips empty ⇄ non-empty (for the border color) OR when
70
+ * its ROW COUNT changes (for `layout/budget.ts`) — never per keystroke.
71
+ *
72
+ * Widened from `(hasDraft: boolean)` by tui-paste-handling section 5.5. The row
73
+ * count is what makes I-8 true: the composer's rendered height and the
74
+ * `draftRows` handed to `viewportRows` have to be the same number, and the only
75
+ * component that knows the first one is this one. It is reported ALREADY
76
+ * CLAMPED, so a 218-line draft and a 400-line draft report the same value and
77
+ * the transcript stops moving once the composer is full (R-12).
78
+ */
79
+ onDraftChange?: (state: {
80
+ hasDraft: boolean;
81
+ rows: number;
82
+ }) => void;
83
+ /**
84
+ * Tell the user a paste was refused (D-11 / P1-4).
85
+ *
86
+ * `PASTE_DRAFT_MAX_BYTES` and `PASTE_MAX_BLOCKS` cannot be enforced in the
87
+ * filter (it does not know the draft) and cannot be enforced in the reducer (it
88
+ * is pure, so it can neither raise a toast nor say why it refused). This
89
+ * component is the only place that holds both facts.
90
+ */
91
+ onNotice?: (level: 'warn' | 'error', text: string) => void;
69
92
  /**
70
93
  * Rows of newer output hidden BELOW the viewport — `ScrollViewport`'s offset
71
94
  * (tui-selection-and-scroll-follow §4.2 / G7).
@@ -99,6 +122,29 @@ export interface ScrollChip {
99
122
  * scan fails the build if that rule is broken here.
100
123
  */
101
124
  export declare function scrollChip(scrolledLines: number, cols: number, glyphs: ReturnType<typeof pickGlyphs>): ScrollChip | null;
125
+ export interface OverflowChip {
126
+ text: string;
127
+ /** FIXED width, so a number gaining a digit cannot re-wrap the draft. */
128
+ cells: number;
129
+ }
130
+ /**
131
+ * Build the "there is more draft above / below this window" indicator, or `null`
132
+ * when the whole draft is on screen.
133
+ *
134
+ * Every glyph comes from `pickGlyphs` and none from a literal (I-5): on a legacy
135
+ * console this renders `^3 v7` rather than mojibake, and `glyphs.test.ts`'s
136
+ * static scan fails the build if that rule is broken here.
137
+ */
138
+ export declare function overflowChip(hiddenAbove: number, hiddenBelow: number, glyphs: ReturnType<typeof pickGlyphs>): OverflowChip | null;
139
+ /**
140
+ * Would these segments push the draft past a limit? Returns the refusal text, or
141
+ * `null` when the paste is allowed.
142
+ *
143
+ * Checked BEFORE `dispatch`, so a refusal dispatches nothing at all and the draft
144
+ * is left byte-for-byte unchanged (AC-9). The one-dispatch-per-key rule is
145
+ * unaffected for the same reason.
146
+ */
147
+ export declare function draftLimitRefusal(segments: InputSegment[], editor: Pick<EditorState, 'pastes'>): string | null;
102
148
  /** Apply an edit intent as a pure buffer/cursor transform. */
103
149
  export declare function applyEdit(buffer: string, cursor: number, intent: EditIntent): {
104
150
  buffer: string;
@@ -127,5 +173,5 @@ export declare function fileTokenAt(buffer: string, cursor: number): {
127
173
  end: number;
128
174
  query: string;
129
175
  } | null;
130
- export declare function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, scrolledLines, }: PromptInputProps): React.ReactElement;
176
+ export declare function PromptInput({ isActive, running, history, commands, cwd, theme, caps, popupMaxRows, agentMode, onSubmit, onHelp, bordered, onDraftChange, onNotice, scrolledLines, }: PromptInputProps): React.ReactElement;
131
177
  export {};