@aragon-agent/cli 0.6.11 → 0.6.13

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 (115) hide show
  1. package/CHANGELOG.md +10 -1
  2. package/README.md +97 -20
  3. package/dist/agent/controller.d.ts +38 -3
  4. package/dist/agent/controller.js +151 -60
  5. package/dist/agent/model-profile-settings.d.ts +31 -0
  6. package/dist/agent/model-profile-settings.js +199 -0
  7. package/dist/agent/queued-messages.d.ts +20 -0
  8. package/dist/agent/queued-messages.js +46 -0
  9. package/dist/agent/reducer.d.ts +21 -2
  10. package/dist/agent/reducer.js +65 -37
  11. package/dist/cli.js +15 -1
  12. package/dist/commands/builtins.js +83 -42
  13. package/dist/commands/queue-command.d.ts +3 -0
  14. package/dist/commands/queue-command.js +5 -0
  15. package/dist/commands/registry.d.ts +3 -0
  16. package/dist/compaction/child.d.ts +4 -2
  17. package/dist/compaction/child.js +4 -1
  18. package/dist/compaction/compactor.d.ts +7 -2
  19. package/dist/compaction/compactor.js +13 -11
  20. package/dist/compaction/context-command.js +5 -0
  21. package/dist/compaction/wiring.d.ts +4 -3
  22. package/dist/compaction/wiring.js +6 -1
  23. package/dist/config/cli-commands.d.ts +2 -0
  24. package/dist/config/cli-commands.js +16 -4
  25. package/dist/config/load.d.ts +2 -1
  26. package/dist/config/load.js +15 -8
  27. package/dist/config/model-profile-resolution.d.ts +20 -0
  28. package/dist/config/model-profile-resolution.js +81 -0
  29. package/dist/config/model-profile-store.d.ts +67 -0
  30. package/dist/config/model-profile-store.js +153 -0
  31. package/dist/config/model-profiles.d.ts +44 -0
  32. package/dist/config/model-profiles.js +121 -0
  33. package/dist/config/schema.d.ts +9 -0
  34. package/dist/config/store.d.ts +11 -1
  35. package/dist/config/store.js +43 -27
  36. package/dist/fast/resolve.d.ts +2 -1
  37. package/dist/fast/resolve.js +6 -4
  38. package/dist/fast/wiring.d.ts +5 -3
  39. package/dist/fast/wiring.js +8 -4
  40. package/dist/input/enter-sequences.d.ts +11 -10
  41. package/dist/input/enter-sequences.js +29 -27
  42. package/dist/input/stdin-filter.js +93 -28
  43. package/dist/logging/install.d.ts +3 -0
  44. package/dist/logging/install.js +4 -2
  45. package/dist/logging/secret-registry.d.ts +2 -0
  46. package/dist/logging/secret-registry.js +18 -0
  47. package/dist/session/persist.d.ts +6 -7
  48. package/dist/session/persist.js +6 -9
  49. package/dist/session/store.d.ts +4 -1
  50. package/dist/session/validate-session.d.ts +3 -0
  51. package/dist/session/validate-session.js +207 -0
  52. package/dist/team/runtime.d.ts +2 -1
  53. package/dist/team/subagent.d.ts +3 -1
  54. package/dist/team/subagent.js +3 -1
  55. package/dist/ui/ActivityLine.d.ts +6 -70
  56. package/dist/ui/ActivityLine.js +12 -34
  57. package/dist/ui/App.d.ts +1 -0
  58. package/dist/ui/App.js +161 -226
  59. package/dist/ui/BottomStatusRow.d.ts +6 -86
  60. package/dist/ui/BottomStatusRow.js +11 -26
  61. package/dist/ui/Composer.d.ts +18 -84
  62. package/dist/ui/Composer.js +9 -100
  63. package/dist/ui/PromptInput.d.ts +6 -2
  64. package/dist/ui/PromptInput.js +91 -105
  65. package/dist/ui/QueuePanel.d.ts +8 -0
  66. package/dist/ui/QueuePanel.js +9 -0
  67. package/dist/ui/QueueStatusRow.d.ts +24 -0
  68. package/dist/ui/QueueStatusRow.js +51 -0
  69. package/dist/ui/StatusBar.d.ts +15 -149
  70. package/dist/ui/StatusBar.js +27 -112
  71. package/dist/ui/clipboard-task.d.ts +29 -0
  72. package/dist/ui/clipboard-task.js +66 -0
  73. package/dist/ui/clipboard.d.ts +29 -50
  74. package/dist/ui/clipboard.js +141 -75
  75. package/dist/ui/composer-input.d.ts +28 -0
  76. package/dist/ui/composer-input.js +46 -0
  77. package/dist/ui/composer-limits.d.ts +3 -4
  78. package/dist/ui/composer-limits.js +4 -9
  79. package/dist/ui/composer-rows.d.ts +1 -1
  80. package/dist/ui/composer-rows.js +24 -137
  81. package/dist/ui/editor-navigation.d.ts +45 -0
  82. package/dist/ui/editor-navigation.js +85 -0
  83. package/dist/ui/editor-reducer.d.ts +9 -0
  84. package/dist/ui/editor-reducer.js +31 -15
  85. package/dist/ui/enter-frames.d.ts +15 -31
  86. package/dist/ui/enter-frames.js +14 -33
  87. package/dist/ui/interaction-copy.d.ts +77 -0
  88. package/dist/ui/interaction-copy.js +40 -0
  89. package/dist/ui/interaction-hints.d.ts +18 -0
  90. package/dist/ui/interaction-hints.js +92 -0
  91. package/dist/ui/layout/budget.js +1 -1
  92. package/dist/ui/layout/queue-layout.d.ts +19 -0
  93. package/dist/ui/layout/queue-layout.js +26 -0
  94. package/dist/ui/layout/status-layout.d.ts +55 -0
  95. package/dist/ui/layout/status-layout.js +109 -0
  96. package/dist/ui/layout/todo-layout.d.ts +1 -0
  97. package/dist/ui/layout/todo-layout.js +1 -1
  98. package/dist/ui/overlays/HelpOverlay.js +11 -3
  99. package/dist/ui/overlays/ModelProfileEditor.d.ts +12 -0
  100. package/dist/ui/overlays/ModelProfileEditor.js +103 -0
  101. package/dist/ui/overlays/ModelProfilePicker.d.ts +35 -0
  102. package/dist/ui/overlays/ModelProfilePicker.js +167 -0
  103. package/dist/ui/overlays/QueueOverlay.d.ts +22 -0
  104. package/dist/ui/overlays/QueueOverlay.js +79 -0
  105. package/dist/ui/overlays/SettingsScreen.d.ts +9 -2
  106. package/dist/ui/overlays/SettingsScreen.js +347 -3
  107. package/dist/ui/overlays/model-profile-state.d.ts +74 -0
  108. package/dist/ui/overlays/model-profile-state.js +119 -0
  109. package/dist/ui/run-status-row.d.ts +7 -19
  110. package/dist/ui/run-status-row.js +2 -18
  111. package/dist/ui/selection/selection-controller.d.ts +2 -2
  112. package/dist/ui/selection/selection-controller.js +58 -20
  113. package/dist/ui/transcript-text.js +20 -12
  114. package/dist/ui/use-wheel-routing.js +2 -1
  115. package/package.json +2 -2
package/dist/ui/App.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { modelSettingsResultMessage } from '../agent/model-profile-settings.js';
3
+ import { selectTranscriptEntries, mergePendingEntries } from '../agent/queued-messages.js';
2
4
  /**
3
5
  * App — the root Ink component. Owns the view reducer, subscribes to the
4
6
  * controller's event stream (with a streaming coalescer), wires global
@@ -18,7 +20,8 @@ import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
18
20
  import { bumpSubmitCount, setMouseNoticeVersion, setVtInputNoticeVersion, } from '../config/ui-state.js';
19
21
  import { getLogger } from '../logging/logger.js';
20
22
  import { isTerminalStatus } from '../proc/types.js';
21
- import { registerSecret } from '../logging/secret-registry.js';
23
+ import { getSecrets } from '../logging/secret-registry.js';
24
+ import { redactText } from '../logging/redact.js';
22
25
  import { detectCapabilities } from './capabilities.js';
23
26
  import { useStartupNotices, MOUSE_NOTICE_VERSION, VT_INPUT_NOTICE_VERSION, } from './use-startup-notices.js';
24
27
  import { getTheme } from './theme.js';
@@ -32,7 +35,6 @@ import { StatusBar } from './StatusBar.js';
32
35
  import { secondsLeft } from '../agent/retry-view.js';
33
36
  import { TeamPanel } from './TeamPanel.js';
34
37
  import { TodoPanel } from './TodoPanel.js';
35
- import { ActivityLine, liveSpinner } from './ActivityLine.js';
36
38
  import { BottomStatusRow } from './BottomStatusRow.js';
37
39
  import { UpdateLine } from './UpdateLine.js';
38
40
  // The updater's two PURE modules. `shouldRenderUpdateLine` decides the row's
@@ -60,7 +62,7 @@ import { publishExitSnapshot } from './exit-snapshot.js';
60
62
  import { OverlayFrame } from './layout/OverlayFrame.js';
61
63
  import { helpRows } from './overlays/HelpOverlay.js';
62
64
  import { ModelPicker } from './overlays/ModelPicker.js';
63
- import { SettingsScreen, compactionSettingsFrom, fastSettingsFrom, readCompactionSettings, readFastSettings, } from './overlays/SettingsScreen.js';
65
+ import { SettingsScreen, compactionSettingsFrom, fastSettingsFrom, } from './overlays/SettingsScreen.js';
64
66
  import { ConfirmDialog } from './overlays/ConfirmDialog.js';
65
67
  import { QuestionOverlay } from './overlays/QuestionOverlay.js';
66
68
  import { PlanReviewOverlay } from './overlays/PlanReviewOverlay.js';
@@ -68,7 +70,11 @@ import { CommandRegistry, runSlashInput } from '../commands/registry.js';
68
70
  import { registerBuiltinCommands } from '../commands/builtins.js';
69
71
  import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
70
72
  import { MODE_LABEL, MODE_TOGGLE_KEYS, nextMode } from '../agent/agent-mode.js';
71
- import { copyText } from './clipboard.js';
73
+ import { createClipboardCoordinator } from './clipboard-task.js';
74
+ import { QueuePanel } from './QueuePanel.js';
75
+ import { QueueOverlay } from './overlays/QueueOverlay.js';
76
+ import { buildQueueLayout } from './layout/queue-layout.js';
77
+ import { interactionCopy } from './interaction-copy.js';
72
78
  import { emptyTailState } from './layout/follow-state.js';
73
79
  /**
74
80
  * Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
@@ -194,6 +200,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
194
200
  // closing an overlay cannot leave a stale offset behind, and reset whenever
195
201
  // the active overlay changes.
196
202
  const [overlayScroll, setOverlayScroll] = useState(0);
203
+ const [completion, setCompletion] = useState('none');
204
+ const [copyState, setCopyState] = useState({ busy: false, cleanupPending: false });
197
205
  // Bumped whenever the installed skill set changes. The command registry and
198
206
  // the completion list are memoized against it, so a skill installed mid-session
199
207
  // gets its `/<name>` command and its autocomplete entry immediately.
@@ -433,6 +441,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
433
441
  // makes stale events inert WITHOUT making the next run's events inert too.
434
442
  let generation = controller.runGeneration;
435
443
  const unsubscribe = controller.subscribe((event) => {
444
+ // Receipts describe committed history even when force-stop invalidated the run.
445
+ if (event.type === 'steering_accepted') {
446
+ dispatch({ type: 'steeringAccepted', ids: event.ids });
447
+ return;
448
+ }
436
449
  if (controller.runGeneration !== generation) {
437
450
  // The engine is unwinding a force-stopped run. Adopt the new generation
438
451
  // only once it has finished doing so, so a late `agent_end` cannot be
@@ -937,7 +950,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
937
950
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
938
951
  useEffect(() => {
939
952
  publishExitSnapshot({
940
- entries: state.entries,
953
+ entries: mergePendingEntries(state.entries, state.pendingSteering),
941
954
  usageTotal: state.usageTotal,
942
955
  provider: cfg.provider,
943
956
  model: cfg.model,
@@ -947,7 +960,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
947
960
  // count (K-6).
948
961
  droppedEntries: state.droppedEntries,
949
962
  });
950
- }, [state.entries, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model]);
963
+ }, [state.entries, state.pendingSteering, state.usageTotal, state.droppedEntries, cfg.provider, cfg.model]);
951
964
  // --- `/perf` snapshot channel (tui-render-performance §5.4). ------------
952
965
  //
953
966
  // The deps are the two `useCallback`d members, NOT the handle objects: both
@@ -1161,22 +1174,36 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1161
1174
  // redraw when the frame differ has no cache to repaint from. Both are assigned
1162
1175
  // here and cleared on unmount, the same shape `updateBridge` uses.
1163
1176
  const selectionBridge = terminal?.selection;
1177
+ const copyCoordinator = useMemo(() => createClipboardCoordinator({
1178
+ clipboard: { write: terminal?.writeForeign },
1179
+ onRequest: () => {
1180
+ ctrlCArmed.current = false;
1181
+ if (ctrlCTimer.current)
1182
+ clearTimeout(ctrlCTimer.current);
1183
+ ctrlCTimer.current = null;
1184
+ },
1185
+ onStateChange: setCopyState,
1186
+ onBusy: ({ cleanupPending }) => toast('info', cleanupPending
1187
+ ? '\u590d\u5236\u5931\u8d25\uff0c\u6b63\u5728\u6e05\u7406' : '\u6b63\u5728\u590d\u5236'),
1188
+ onResult: (result, request) => {
1189
+ selectionBridge?.onCopied?.(result, request.lines, request.text.length);
1190
+ if (result.status === 'confirmed') {
1191
+ toast('success', `\u5df2\u590d\u5236 ${request.lines} \u884c`);
1192
+ }
1193
+ else if (result.status === 'sent') {
1194
+ toast('info', '\u5df2\u8bf7\u6c42\u7ec8\u7aef\u590d\u5236\uff0c\u7ed3\u679c\u672a\u786e\u8ba4');
1195
+ }
1196
+ else {
1197
+ const text = `${interactionCopy.copyFailed}\uff1a${interactionCopy.copyReasons[result.reason]}`;
1198
+ toast('warn', text);
1199
+ notify('error', text);
1200
+ }
1201
+ },
1202
+ }), []);
1203
+ useEffect(() => () => copyCoordinator.dispose(), [copyCoordinator]);
1164
1204
  useEffect(() => {
1165
1205
  if (!selectionBridge)
1166
1206
  return undefined;
1167
- selectionBridge.onCopied = (via, lines, chars) => {
1168
- // IT NAMES THE MECHANISM RATHER THAN CLAIMING SUCCESS (R-7). Neither OSC 52
1169
- // nor a spawned `xclip` is detectable: tmux without `set -g set-clipboard
1170
- // on` swallows the first silently, and the second is best-effort by
1171
- // construction. A toast that said "copied successfully" would be guessing.
1172
- const noun = lines === 1 ? 'line' : 'lines';
1173
- if (via === 'none') {
1174
- toast('warn', 'Nothing could be copied - no clipboard mechanism is available.');
1175
- return;
1176
- }
1177
- const how = via === 'osc52' ? 'terminal clipboard' : 'system clipboard';
1178
- toast('success', `Sent ${lines} ${noun} (${chars} chars) to the ${how}.`);
1179
- };
1180
1207
  // I-5 of `frame-differ.ts`: writing escapes here would be a foreign write.
1181
1208
  // The nonce is the carrier Ctrl+L already uses (§4.13).
1182
1209
  selectionBridge.requestRedraw = () => setRedrawNonce((n) => n + 1);
@@ -1283,6 +1310,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1283
1310
  };
1284
1311
  const makeCtx = (args) => ({
1285
1312
  args,
1313
+ requestCopy: copyCoordinator.requestCopy,
1286
1314
  controller,
1287
1315
  state: stateRef.current,
1288
1316
  dispatch: (action) => {
@@ -1306,7 +1334,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1306
1334
  toast,
1307
1335
  persistConfig,
1308
1336
  exit: doExit,
1309
- submit: (text, opts) => submitMessage(text, opts),
1337
+ submit: (text, opts) => {
1338
+ const result = submitMessage(text, opts);
1339
+ if (!result.accepted)
1340
+ throw new Error(result.reason ?? 'Message was not accepted.');
1341
+ },
1310
1342
  refreshSkills: () => setSkillsNonce((n) => n + 1),
1311
1343
  applyAgentMode: applyMode,
1312
1344
  // Read at command time, so `/todo status` reports the live count rather than
@@ -1361,28 +1393,33 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1361
1393
  * start with `/`, recurse into command dispatch.
1362
1394
  */
1363
1395
  const submitMessage = (message, opts = {}) => {
1396
+ if (controller.getConfig().modelSettingsRestartRequired) {
1397
+ return { accepted: false, reason: 'Settings require a restart before sending requests.' };
1398
+ }
1399
+ if (interactionPhase.current === 'starting') {
1400
+ return { accepted: false, reason: 'A run is still starting. Press Esc twice to cancel.' };
1401
+ }
1364
1402
  // A USER MESSAGE IS A NEW INTENT AND OUTRANKS A QUEUED ONE (AC-19). First
1365
1403
  // line, before the steering branch: a message typed during the grace window
1366
1404
  // must be the one that runs, whichever branch it takes.
1367
1405
  cancelFollowThrough();
1368
1406
  cancelConfirmation();
1369
1407
  if (interactionPhase.current === 'running') {
1370
- controller.steer(message);
1371
- // THE TRANSCRIPT ENTRY IS THE FEEDBACK (tui-shift-enter-copy-queue
1372
- // 5.3). A 2.5 s toast cannot satisfy "until the message is picked
1373
- // up": the `queued` entry stays visible and is rewritten in place
1374
- // as the user message it became on the next `turn_start`. Keeping
1375
- // the toast too would confirm one event twice.
1376
- dispatch({ type: 'steerQueued', text: message });
1377
- return;
1378
- }
1379
- if (interactionPhase.current === 'starting') {
1380
- toast('info', 'A run is still starting. Press Esc twice to cancel.');
1381
- return;
1408
+ try {
1409
+ const queueId = controller.queueUserMessage(message);
1410
+ dispatch({ type: 'steerQueued', queueId, text: message });
1411
+ return { accepted: true };
1412
+ }
1413
+ catch (error) {
1414
+ const reason = error instanceof Error ? error.message : String(error);
1415
+ notify('error', reason);
1416
+ return { accepted: false, reason };
1417
+ }
1382
1418
  }
1383
1419
  resetInterrupt();
1384
1420
  const sequence = ++submissionSequence.current;
1385
1421
  interactionPhase.current = 'starting';
1422
+ dispatch({ type: 'runStarting' });
1386
1423
  // `userInitiated: false` skips TWO things and both are wanted: the
1387
1424
  // prompt-history append, and the `submitCount` bump that drives the
1388
1425
  // composer's hint fade. An auto-continuation demonstrates nothing about what
@@ -1396,12 +1433,13 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1396
1433
  dispatch({ type: 'submit', text: message });
1397
1434
  const pre = controller.preflight();
1398
1435
  if (!pre.ok) {
1436
+ dispatch({ type: 'runError' });
1399
1437
  notify('error', pre.message ?? 'Configuration error.');
1400
1438
  interactionPhase.current = 'idle';
1401
1439
  resetInterrupt();
1402
1440
  autoContinuationRef.current = false;
1403
1441
  dispatch({ type: 'runEnd' }); // reset status; nothing was started
1404
- return;
1442
+ return { accepted: true };
1405
1443
  }
1406
1444
  // Fire-and-forget: events drive the UI. prompt() never rejects.
1407
1445
  void controller.prompt(message, { todoPolicy: opts.todoPolicy ?? 'new-task' }).then((outcome) => {
@@ -1409,110 +1447,63 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1409
1447
  return;
1410
1448
  if (outcome?.status !== 'not-started')
1411
1449
  return;
1450
+ if (outcome.reason !== 'cancelled')
1451
+ dispatch({ type: 'runError' });
1412
1452
  interactionPhase.current = 'idle';
1413
1453
  resetInterrupt();
1414
1454
  autoContinuationRef.current = false;
1415
1455
  dispatch({ type: 'runEnd' });
1416
1456
  });
1457
+ return { accepted: true };
1417
1458
  };
1418
1459
  submitRef.current = submitMessage;
1419
- const handleSubmit = async (raw) => {
1420
- if (interactionPhase.current === 'running' && /^\s*\/(reset|resume)(?:\s|$)/.test(raw)) {
1421
- notify('warn', 'Interrupt the current run before switching conversations.');
1422
- return;
1460
+ const executeSlashInput = async (raw) => {
1461
+ // Retain a recoverable command before any asynchronous handler can fail.
1462
+ const safe = redactText(raw, getSecrets());
1463
+ recordPrompt(safe);
1464
+ notify('info', `Command: ${safe}`);
1465
+ try {
1466
+ await runSlashInput(registry, raw, makeCtx);
1467
+ }
1468
+ catch (error) {
1469
+ const reason = error instanceof Error ? error.message : String(error);
1470
+ notify('error', `Command failed: ${redactText(reason, getSecrets())}\nInput: ${safe}`);
1471
+ }
1472
+ };
1473
+ const handleSubmit = (raw) => {
1474
+ const trimmed = raw.trimStart();
1475
+ const slash = trimmed.startsWith('/') && !trimmed.startsWith('//');
1476
+ if (slash && (interactionPhase.current === 'running' || controller.isRunning()) &&
1477
+ /^\/(reset|resume)(?:\s|$)/.test(trimmed)) {
1478
+ return { accepted: false,
1479
+ reason: 'Interrupt the current run before switching conversations.' };
1423
1480
  }
1424
1481
  // Submitting is an unconditional "take me to the newest output" (§4.5).
1425
1482
  setPinToBottomNonce((n) => n + 1);
1426
- const handled = await runSlashInput(registry, raw, makeCtx);
1427
- if (handled)
1428
- return;
1429
- submitMessage(raw.startsWith('//') ? raw.slice(1) : raw);
1483
+ if (slash) {
1484
+ void executeSlashInput(raw);
1485
+ return { accepted: true };
1486
+ }
1487
+ return submitMessage(trimmed.startsWith('//') ? raw.replace('//', '/') : raw);
1430
1488
  };
1431
- const handleSettingsSave = (values) => {
1432
- // An unusable cap must NOT discard the rest of the form: the same Enter
1433
- // press is often carrying a freshly pasted API key, and losing that to a
1434
- // typo in an unrelated field is the worst outcome available here.
1435
- const cap = resolveSettingsMaxTokens(values.maxTokens, controller.getConfig().maxTokens);
1436
- controller.setModel(values.provider, values.model, values.baseUrl || undefined);
1437
- controller.setThinkingLevel(values.thinkingLevel);
1438
- controller.setMaxTokens(cap.applied);
1439
- // THE SETTER AND THE PERSIST ARE BOTH REQUIRED, the pair `/todo panel`
1440
- // documents: persisting alone reports success and changes nothing until the
1441
- // next launch. `thinkingVisible` lives in `ViewState`, not in the config the
1442
- // controller holds, so the live half is a dispatch rather than a setter —
1443
- // and it is a no-op when the row already agrees with the current state.
1444
- if ((values.showThinking === 'on') !== stateRef.current.thinkingVisible) {
1489
+ const handleProfileSave = (draft) => {
1490
+ const result = controller.saveModelSettings(draft);
1491
+ if (!result.ok)
1492
+ return result;
1493
+ if (draft.patch.showThinking !== undefined
1494
+ && controller.getConfig().showThinking !== stateRef.current.thinkingVisible) {
1445
1495
  dispatch({ type: 'toggleThinking' });
1446
1496
  }
1447
- const key = values.apiKey.trim();
1448
- if (key.length > 0) {
1449
- // Registration site 4 (§4.4.3), and the one that matters most: this is how
1450
- // a key most often enters the process, and it happens long after
1451
- // `installLogging()` ran. A startup snapshot would miss it entirely, and
1452
- // the vendor regexes only cover keys that look like a vendor's.
1453
- registerSecret(key);
1454
- controller.setApiKey(values.provider, key);
1455
- }
1456
- const patch = {
1457
- provider: values.provider,
1458
- model: values.model,
1459
- baseUrl: values.baseUrl.trim() ? values.baseUrl.trim() : null,
1460
- thinkingLevel: values.thinkingLevel,
1461
- showThinking: values.showThinking === 'on',
1462
- // PERSIST ONLY, with no paired live setter — the store is allocated in
1463
- // `AgentController`'s constructor, so this row takes effect on the next
1464
- // launch. That is what manual row 8 asks for, and it is why the seed above
1465
- // reads `cfg` rather than `ViewState`.
1466
- liveToolOutput: values.liveToolOutput === 'on',
1467
- // Omitted entirely when the field was unusable, so the previous value
1468
- // survives instead of being overwritten with a guess.
1469
- ...(cap.persist !== 'skip' ? { maxTokens: cap.persist } : {}),
1470
- };
1471
- if (key.length > 0)
1472
- patch.apiKeys = { [values.provider]: key };
1473
- // A PARTIAL `log` section, safe only because `updatePersistedConfig` deep-
1474
- // merges it — a shallow merge here would reset `redactSecrets` every time
1475
- // someone touched the level.
1476
- patch.log = { level: values.logLevel };
1477
- // --- Fast tier (fast-model-tier §4.5) ---------------------------------
1478
- //
1479
- // THE SETTER AND THE PERSIST ARE BOTH REQUIRED, and this is the pair `/todo
1480
- // panel` documents (P1-2): `App` reads `controller.getConfig()` at render
1481
- // time, so persisting alone reports success and changes nothing until the
1482
- // next launch, while the setter alone forgets by morning.
1483
- //
1484
- // `setFastConfig` also re-resolves the tier, which matters HERE more than
1485
- // anywhere: this same Enter press may have changed the main provider, the
1486
- // main model or the API key, and `fast.provider: ''` inherits from all
1487
- // three (§3.2 rule 8 / RV-3).
1488
- const fastPatch = readFastSettings(values);
1489
- controller.setFastConfig(fastPatch);
1490
- patch.fast = fastPatch;
1491
- // --- Context compaction (context-auto-compaction §4.5) ------------------
1492
- //
1493
- // THE SAME SETTER-AND-PERSIST PAIR, and the same reason. `setCompactionConfig`
1494
- // additionally re-emits the snapshot, which is what moves the status chip and
1495
- // the gauge's marks without waiting for the next turn.
1496
- //
1497
- // `enabled` here flips the LIVE switch only. Whether the wiring exists at all
1498
- // was decided at construction and cannot be undone in this session (§3.2), so
1499
- // a user who turns it ON in a `--no-compaction` session gets the persisted
1500
- // value for next launch and nothing this session — which is exactly what
1501
- // `/compact on` reports in words.
1502
- const compactionPatch = readCompactionSettings(values);
1503
- controller.setCompactionConfig(compactionPatch);
1504
- if (compactionPatch.enabled !== undefined) {
1505
- controller.setCompactionEnabled(compactionPatch.enabled);
1506
- }
1507
- patch.compaction = compactionPatch;
1508
- persistConfig(patch);
1509
- getLogger().reconfigure({ ...controller.getConfig().log, level: values.logLevel });
1510
1497
  dispatch({ type: 'setOverlay', overlay: null });
1511
- toast(cap.toast?.[0] ?? 'success', cap.toast?.[1] ?? 'Settings saved.');
1498
+ toast(result.restartRequired ? 'warn' : 'success', modelSettingsResultMessage(result));
1499
+ return result;
1512
1500
  };
1513
1501
  const handleModelSelect = (provider, model) => {
1514
- controller.setModel(provider, model);
1515
- persistConfig({ provider, model });
1502
+ const result = controller.saveModelSettingsPatch({ provider, model });
1503
+ if (!result.ok) {
1504
+ toast('error', modelSettingsResultMessage(result));
1505
+ return;
1506
+ }
1516
1507
  dispatch({ type: 'setOverlay', overlay: null });
1517
1508
  toast('info', `Model set to ${provider}:${model}.`);
1518
1509
  };
@@ -1619,15 +1610,22 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1619
1610
  // (`via === 'none'`) still reports through the shared toast funnel and
1620
1611
  // returns: the selection is already consumed, and the NEXT Ctrl+C
1621
1612
  // walks the original ladder.
1613
+ if (key.ctrl && (input === 'c' || input === 'C') && copyCoordinator.isBusy()) {
1614
+ void copyCoordinator.requestCopy({ text: '', lines: 0 });
1615
+ return;
1616
+ }
1622
1617
  if (key.ctrl &&
1623
1618
  (input === 'c' || input === 'C') &&
1624
1619
  (selectionController?.hasPendingSelection() ?? false)) {
1625
1620
  const payload = selectionController.takeSelection();
1621
+ ctrlCArmed.current = false;
1622
+ if (ctrlCTimer.current)
1623
+ clearTimeout(ctrlCTimer.current);
1624
+ ctrlCTimer.current = null;
1626
1625
  if (payload) {
1627
- const via = copyText(payload.text, terminal?.writeForeign);
1628
- selectionBridge?.onCopied?.(via, payload.lines, payload.text.length);
1629
- return; // copied -- no service stop, no arm, no clear
1626
+ void copyCoordinator.requestCopy({ text: payload.text, lines: payload.lines });
1630
1627
  }
1628
+ return; // A consumed copy intent never becomes exit or service stop.
1631
1629
  }
1632
1630
  selectionController?.clear();
1633
1631
  if (key.ctrl && (input === 'c' || input === 'C')) {
@@ -1672,7 +1670,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1672
1670
  const overlay = stateRef.current.overlay;
1673
1671
  // Modes A only. `model` / `confirm` manage their own keys; registering an
1674
1672
  // arrow handler for them would double every keypress (R-12).
1675
- const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan';
1673
+ const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan' || overlay === 'queue';
1676
1674
  if (key.pageUp) {
1677
1675
  if (controlled)
1678
1676
  setOverlayScroll((n) => Math.max(0, n - OVERLAY_PAGE));
@@ -1687,7 +1685,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1687
1685
  // worth more than scrolling six fields (§4.9). `question` keeps them for
1688
1686
  // its option cursor, for the same reason; `plan` has no cursor of its own
1689
1687
  // and scrolls a line at a time.
1690
- if ((overlay === 'help' || overlay === 'plan') && (key.upArrow || key.downArrow)) {
1688
+ if ((overlay === 'help' || overlay === 'plan' || overlay === 'queue') && (key.upArrow || key.downArrow)) {
1691
1689
  setOverlayScroll((n) => Math.max(0, n + (key.upArrow ? -1 : 1)));
1692
1690
  return;
1693
1691
  }
@@ -1782,6 +1780,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1782
1780
  }
1783
1781
  if (key.escape) {
1784
1782
  const overlay = stateRef.current.overlay;
1783
+ if (overlay === 'settings')
1784
+ return;
1785
1785
  if (overlay) {
1786
1786
  cancelConfirmation();
1787
1787
  if (overlay === 'confirm' && confirmRef.current) {
@@ -1816,7 +1816,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1816
1816
  interruptGesture.current = next.state;
1817
1817
  if (next.action === 'hint') {
1818
1818
  dispatch({ type: 'pushToast', level: 'info',
1819
- text: 'Press Esc again within 1.5s to interrupt.' });
1819
+ text: '\u4e2d\u65ad\u5df2\u51c6\u5907' });
1820
1820
  return;
1821
1821
  }
1822
1822
  endReasonRef.current.aborted = true;
@@ -1837,7 +1837,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1837
1837
  controller.abort();
1838
1838
  if (sequence === submissionSequence.current && interactionPhase.current === 'running') {
1839
1839
  dispatch({ type: 'pushToast', level: 'info',
1840
- text: 'Interrupt requested. Esc again to force-stop.' });
1840
+ text: '\u6b63\u5728\u505c\u6b62' });
1841
1841
  }
1842
1842
  }, [controller, cancelFollowThrough, resetInterrupt]);
1843
1843
  // --- Render ------------------------------------------------------------
@@ -1848,6 +1848,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1848
1848
  // which is what lets the user's `contextWindow` override reach the gauge at
1849
1849
  // all. Nothing else in this render read them.
1850
1850
  const overlay = state.overlay;
1851
+ const settingsDraft = useMemo(() => overlay === 'settings'
1852
+ ? controller.createModelSettingsDraft() : undefined, [controller, overlay]);
1851
1853
  const hasKey = controller.hasApiKey();
1852
1854
  const empty = state.entries.length === 0;
1853
1855
  // RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
@@ -1896,24 +1898,28 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1896
1898
  const nowSec = running || compactionLive ? Math.floor(Date.now() / 1000) : undefined;
1897
1899
  const viewportBudget = computeViewportRows(rows, draftRows);
1898
1900
  const overlayMaxRows = viewportBudget;
1899
- // Renderers consume these same projections. Team capacity does not depend on
1900
- // popup occupancy, so a menu cannot toggle the team's collapsed state.
1901
- // THE ONE CONDITION for "the hint row exists": it sizes the footer below AND
1902
- // decides whether the run status row may replace that hint row while running,
1903
- // so starting a run can never add or remove a footer row.
1904
- const hintRowEnabled = rows >= HINT_MIN_ROWS && cfg.hints;
1905
- const composerBaseRows = 2 + clampDraftRows(rows, draftRows) + Number(hintRowEnabled);
1901
+ const composerBaseRows = 2 + clampDraftRows(rows, draftRows);
1902
+ const baseRail = buildTodoRailLayout({
1903
+ cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
1904
+ itemCount: state.todos?.items.length ?? 0, viewportBudget,
1905
+ teamRows: 0, popupRows: 0, composerBaseRows,
1906
+ });
1907
+ const queueLayout = useMemo(() => buildQueueLayout({
1908
+ pending: state.pendingSteering, columns: baseRail.contentCols, terminalRows: rows,
1909
+ availableRows: Math.max(0, viewportBudget - composerBaseRows - 1), paused: !running,
1910
+ }), [state.pendingSteering, baseRail.contentCols, rows, viewportBudget, composerBaseRows, running]);
1906
1911
  const teamLayout = buildTeamPanelLayout({
1907
- snapshot: state.team, terminalRows: rows, availableRows: Math.max(0, viewportBudget - composerBaseRows),
1912
+ snapshot: state.team, terminalRows: rows,
1913
+ availableRows: Math.max(0, viewportBudget - composerBaseRows - queueLayout.rows - 1),
1908
1914
  });
1909
1915
  const railLayout = buildTodoRailLayout({
1910
1916
  cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
1911
1917
  itemCount: state.todos?.items.length ?? 0, viewportBudget,
1912
- teamRows: teamLayout.rowCount, popupRows, composerBaseRows,
1918
+ teamRows: teamLayout.rowCount, queueRows: queueLayout.rows, popupRows, composerBaseRows,
1913
1919
  });
1914
- // Transcript and composer share contentCols; fixed chrome and overlays use terminal width.
1915
1920
  const { visible: showRail, width: railWidth, rows: railRows, contentCols } = railLayout;
1916
- const overlayNode = overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: cols, rows: helpRows(theme, caps), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { initial: {
1921
+ const transcriptEntries = useMemo(() => selectTranscriptEntries(state.entries, state.pendingSteering), [state.entries, state.pendingSteering]);
1922
+ const overlayNode = overlay === 'queue' ? (_jsx(QueueOverlay, { pending: state.pendingSteering, cols: cols, 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: cols, rows: helpRows(theme, caps), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { initial: {
1917
1923
  provider: cfg.provider,
1918
1924
  model: cfg.model,
1919
1925
  baseUrl: cfg.baseUrl ?? '',
@@ -1942,7 +1948,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1942
1948
  // the save handler cannot disagree about what `(inherit)` means.
1943
1949
  ...fastSettingsFrom(cfg.fast),
1944
1950
  ...compactionSettingsFrom(cfg.compaction),
1945
- }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave,
1951
+ }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, draft: settingsDraft, onProfileSave: handleProfileSave, onReload: () => controller.createModelSettingsDraft(), onClose: () => dispatch({ type: 'setOverlay', overlay: null }),
1946
1952
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
1947
1953
  // while the screen is open shows the new value on the next paint.
1948
1954
  retrySummary: cfg.retry.enabled && cfg.retry.maxRetries > 0 ? String(cfg.retry.maxRetries) : 'off' })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { questions: humanRequest.questions, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSubmit: (answers) => resolveHuman({ kind: 'answers', answers, cancelled: false }) })) : overlay === 'plan' && humanRequest?.kind === 'plan' ? (_jsx(PlanReviewOverlay, { plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
@@ -1953,61 +1959,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1953
1959
  const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(viewportBudget, contentCols, caps), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: viewportBudget, theme: theme, caps: caps })) : null;
1954
1960
  const density = cfg.density;
1955
1961
  const transcriptWindow = cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW;
1956
- // --- One spinner per run (single-spinner-while-running D-1 .. D-5). ------
1957
- //
1958
- // THE MOUNT CONDITION, NAMED ONCE AND USED TWICE (D-4). This const both gates
1959
- // the `<ActivityLine>` element below and feeds the derivation on the next
1960
- // line, so "the line is up" and "every other site is still" can never
1961
- // disagree. Two copies of the same boolean expression is exactly how that
1962
- // invariant becomes false in six months, silently and in only one branch.
1963
- //
1964
- // NOT `running` ALONE (D-3). While an overlay owns the screen the activity row
1965
- // is suppressed, so keying suppression off `running` would leave the frame
1966
- // with ZERO life signals: the bar shows state, this row shows life. Never
1967
- // trade one duplicate for one absence.
1968
- //
1969
- // THE CLAIM ABOVE WAS ONCE FALSE, AND THAT WAS THE WHOLE OF
1970
- // `activity-spinner-vanishes-behind-toast`. `BottomStatusRow` — not this const
1971
- // — decides what the row actually shows, and a toast used to take the row
1972
- // outright, so for its 2.5 s TTL this said "the line is up" while nothing was
1973
- // mounted and all seven other sites sat still. The answer was NOT to enumerate
1974
- // `&& !state.toasts.length` here (the next occupant reopens it, and the seven
1975
- // sites animating out of phase is the defect this whole feature removed):
1976
- // `BottomStatusRow` now carries the bare spinner alongside the toast, so the
1977
- // signal is mounted exactly when this const is true and the claim holds by
1978
- // construction.
1979
- const activityVisible = running && !overlayNode;
1980
- // The FOUR VIEW CONSUMERS (transcript x 2, team panel, todo panel) take this;
1981
- // the activity line keeps the raw config flag (D-5), because it reads that
1982
- // flag a second time to gate phrase rotation and the widened value would
1983
- // freeze the phrase at the first word of every run.
1984
- //
1985
- // So below `App` the prop now means "this frame does not animate here" rather
1986
- // than "the user asked for reduced motion". `cfg.reducedMotion` (`:279`) stays
1987
- // the sole config source, and `spinner-census.test.ts` pins this wiring —
1988
- // threading the raw flag into a fifth consumer is the one silent way back to
1989
- // two spinners (R-2).
1990
- const viewReducedMotion = reducedMotion || activityVisible;
1991
- // The run status row (spinner + phrase + queue/interrupt hint) replaces the idle
1992
- // hint row ABOVE the input while running; it exists only when that hint row
1993
- // would, so the footer height is identical with and without it. `running`, not
1994
- // `activityVisible`: an overlay must not flip the Composer between two layouts.
1995
- const runRowEnabled = running && hintRowEnabled;
1996
- // The raw `reducedMotion` flag lives here and nowhere else (D-5).
1997
- const runActivity = {
1998
- startedAt: runStartedAt.current,
1999
- elapsedMs,
2000
- reducedMotion,
2001
- runningTool,
2002
- // THE ROW MUST NEVER SAY SOMETHING FALSE (§6.1). Compaction runs BEFORE
2003
- // `turn_start`, so `status` is `'running'` with no tool in flight - precisely
2004
- // the window in which the rotating phrase would claim the model is thinking
2005
- // while it is actually waiting on a 30k-token summarization.
2006
- compacting: state.compaction?.inFlight === true,
2007
- };
2008
- // The fixed bottom row takes over the life signal once the run row scrolls out
2009
- // of the viewport; exactly one of the two ever animates.
2010
- const runRowShown = runRowEnabled && activityRowVisible;
1962
+ // The fixed status bar remains visible underneath every overlay and toast.
1963
+ const viewReducedMotion = reducedMotion || running || state.runPhase === 'starting' || compactionLive;
2011
1964
  // Assigned during render so `/perf` reads the CURRENT frame (see `perfRef`).
2012
1965
  // `mountedEntries` is the same number `render-budget.test.tsx` asserts (AC-9).
2013
1966
  perfRef.current = {
@@ -2044,39 +1997,21 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2044
1997
  // Both keys always WORK (they share one branch in the input handler); this
2045
1998
  // only decides which one the user is taught.
2046
1999
  const modeToggleKey = vtInputWarning ? MODE_TOGGLE_KEYS.fallback : MODE_TOGGLE_KEYS.primary;
2047
- const composer = (_jsx(Composer, { measureRef: composerRef, runRow: runRowEnabled
2048
- ? { activity: runActivity, live: runRowShown && !overlayNode, rowRef: activityRef }
2049
- : null, cols: contentCols, cursorVisible: cursorVisible, onInteraction: returnToComposer, reducedMotion: cfg.reducedMotion ?? false, onEscape: handleComposerEscape, onEscapeDismiss: cancelConfirmation, isActive: overlay === null && !tooSmall, 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, popupMaxHeight: railLayout.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, scrolledLines: scrolledLines, onDraftRows: onDraftRows, onNotice: notify, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }));
2000
+ const composer = (_jsx(Composer, { measureRef: composerRef, deleteDisambiguated: terminal?.deleteDisambiguated ?? false, onCompletionContextChange: setCompletion, cols: contentCols, cursorVisible: cursorVisible, onInteraction: returnToComposer, reducedMotion: cfg.reducedMotion ?? false, onEscape: handleComposerEscape, onEscapeDismiss: cancelConfirmation, isActive: overlay === null && !tooSmall, 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, popupMaxHeight: railLayout.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, scrolledLines: scrolledLines, onDraftRows: onDraftRows, onNotice: notify, theme: theme, caps: caps, onSubmit: handleSubmit, onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }));
2050
2001
  const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2051
2002
  const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2052
- const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: tooSmall ? null : overlayNode, footer: _jsxs(_Fragment, { children: [team, composer] }), rail: rail, scrollbar: terminal?.scrollbar, composerRef: composerRef, onComposerVisibilityChange: setCursorVisible, activityRef: activityRef, onActivityVisibilityChange: setActivityRowVisible, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }));
2053
- return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, toast: _jsx(BottomStatusRow, { toasts: state.toasts, activity: activityVisible && !runRowShown ? (_jsx(ActivityLine, { ...runActivity, theme: theme, caps: caps })) : null,
2054
- // THE FORM THE LIFE SIGNAL TAKES WHEN A TOAST HAS THE ROW
2055
- // (`activity-spinner-vanishes-behind-toast`). Passed UNCONDITIONALLY:
2056
- // `BottomStatusRow` reads it only when `activity` above is non-null,
2057
- // so `activityVisible` stays the one mount condition and this cannot
2058
- // become a second copy of it that drifts. `null` here means the
2059
- // spinner would be static anyway (ASCII tier, or reduced motion), and
2060
- // those builds must come out unchanged.
2061
- activityGlyph: liveSpinner(reducedMotion, caps),
2062
- // PRESENCE IS DECIDED HERE, NOT BY `UpdateLine` RETURNING `null`
2063
- // (C-15 / cli-auto-update P0-1 / D-19). `BottomStatusRow` tests this
2064
- // prop for truthiness, and a React element is truthy however it
2065
- // renders - so a component that rendered nothing would give the row
2066
- // ZERO rows and silently unbalance the frame budget. Same rule the
2067
- // activity line above follows with `running && !overlayNode`.
2068
- //
2069
- // `!overlayNode` for the same reason it appears there: the full-screen
2070
- // branch replaces the whole viewport with the overlay, and an update
2071
- // notice under a settings screen is noise attached to a surface it is
2072
- // not visible on.
2073
- //
2074
- // `!running` (cli-auto-update D-2): the update notice stays silent for
2075
- // the whole run. It used to be guaranteed by the activity line
2076
- // out-ranking it inside this row; with the activity line moved above
2077
- // the input, the row can be free during a run, so the silence is stated
2078
- // here instead of inherited.
2079
- update: updateSnapshot && !overlayNode && !running && shouldRenderUpdateLine(updateSnapshot) ? (_jsx(UpdateLine, { snapshot: updateSnapshot, compact: cols < UPDATE_LIMITS.statusCompactCols, theme: theme, caps: caps })) : null, theme: theme }), status: _jsx(StatusBar, { interruptHint: interruptGesture.current.phase === 'stopping'
2003
+ const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: tooSmall ? null : overlayNode, footer: _jsxs(_Fragment, { children: [team, _jsx(QueuePanel, { layout: queueLayout, theme: theme }), composer] }), rail: rail, scrollbar: terminal?.scrollbar, composerRef: composerRef, onComposerVisibilityChange: setCursorVisible, activityRef: activityRef, onActivityVisibilityChange: setActivityRowVisible, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: transcriptEntries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }));
2004
+ return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, toast: _jsx(BottomStatusRow, { toasts: state.toasts, hints: { interactionPhase: state.runPhase === 'starting' ? 'starting'
2005
+ : running ? 'running' : 'idle', cols, completion,
2006
+ selectionPending: selectionController?.hasPendingSelection() ?? false,
2007
+ copyInFlight: copyState.busy, copyCleanupPending: copyState.cleanupPending,
2008
+ overlay, services: liveServices, hintsEnabled: cfg.hints, modeToggleKey,
2009
+ interruptHint: interruptGesture.current.phase === 'stopping'
2010
+ ? 'Esc force-stop' : interruptGesture.current.phase === 'armed'
2011
+ ? 'Esc confirm' : 'Esc x2 interrupt',
2012
+ updateAvailable: !!updateSnapshot && !overlayNode && !running
2013
+ && shouldRenderUpdateLine(updateSnapshot),
2014
+ }, update: updateSnapshot && !overlayNode && !running && shouldRenderUpdateLine(updateSnapshot) ? (_jsx(UpdateLine, { snapshot: updateSnapshot, compact: cols < UPDATE_LIMITS.statusCompactCols, theme: theme, caps: caps })) : null, theme: theme }), status: _jsx(StatusBar, { pendingSteering: state.pendingSteering, runPhase: state.runPhase, activeTool: state.activeTool, runOutcome: state.runOutcome, reducedMotion: reducedMotion, waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
2080
2015
  ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2081
2016
  // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2082
2017
  // projection of "many sources of truth" into the props layer, and a