@aragon-agent/cli 0.6.14 → 0.6.16

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 (131) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +295 -238
  3. package/dist/agent/controller.d.ts +37 -1
  4. package/dist/agent/controller.js +195 -45
  5. package/dist/agent/plan-prompt.d.ts +1 -1
  6. package/dist/agent/plan-prompt.js +4 -3
  7. package/dist/agent/project-guidance-prompt.d.ts +4 -0
  8. package/dist/agent/project-guidance-prompt.js +104 -0
  9. package/dist/agent/reducer.d.ts +2 -0
  10. package/dist/agent/reducer.js +4 -0
  11. package/dist/agent/system-prompt.js +4 -0
  12. package/dist/cli.js +49 -2
  13. package/dist/commands/builtins.js +26 -28
  14. package/dist/compaction/child.d.ts +9 -1
  15. package/dist/compaction/child.js +39 -13
  16. package/dist/compaction/command.js +20 -10
  17. package/dist/compaction/compactor.d.ts +33 -204
  18. package/dist/compaction/compactor.js +311 -726
  19. package/dist/compaction/context-command.js +14 -3
  20. package/dist/compaction/digest.d.ts +15 -51
  21. package/dist/compaction/digest.js +113 -148
  22. package/dist/compaction/limits.d.ts +19 -142
  23. package/dist/compaction/limits.js +20 -147
  24. package/dist/compaction/memory-identity.d.ts +16 -0
  25. package/dist/compaction/memory-identity.js +63 -0
  26. package/dist/compaction/memory-input.d.ts +25 -0
  27. package/dist/compaction/memory-input.js +106 -0
  28. package/dist/compaction/memory-render.d.ts +4 -0
  29. package/dist/compaction/memory-render.js +47 -0
  30. package/dist/compaction/memory-validation.d.ts +21 -0
  31. package/dist/compaction/memory-validation.js +268 -0
  32. package/dist/compaction/memory.d.ts +79 -0
  33. package/dist/compaction/memory.js +118 -0
  34. package/dist/compaction/meter.d.ts +14 -10
  35. package/dist/compaction/meter.js +53 -19
  36. package/dist/compaction/operation.d.ts +18 -0
  37. package/dist/compaction/operation.js +68 -0
  38. package/dist/compaction/pressure.d.ts +5 -20
  39. package/dist/compaction/pressure.js +13 -23
  40. package/dist/compaction/summarize-call.d.ts +3 -16
  41. package/dist/compaction/summarize-call.js +20 -20
  42. package/dist/compaction/summary-prompt.d.ts +29 -66
  43. package/dist/compaction/summary-prompt.js +132 -178
  44. package/dist/compaction/types.d.ts +27 -4
  45. package/dist/compaction/wiring.d.ts +10 -25
  46. package/dist/compaction/wiring.js +56 -66
  47. package/dist/config/app-paths.d.ts +10 -0
  48. package/dist/config/app-paths.js +12 -0
  49. package/dist/config/env.d.ts +2 -1
  50. package/dist/config/env.js +13 -1
  51. package/dist/config/load.d.ts +9 -0
  52. package/dist/config/load.js +14 -0
  53. package/dist/config/model-windows.d.ts +91 -0
  54. package/dist/config/model-windows.js +181 -0
  55. package/dist/config/schema.d.ts +18 -1
  56. package/dist/config/schema.js +2 -1
  57. package/dist/exec/events.d.ts +8 -0
  58. package/dist/exec/index.js +13 -6
  59. package/dist/exec/runner.d.ts +8 -0
  60. package/dist/exec/runner.js +26 -2
  61. package/dist/input/csiu-keys.d.ts +66 -0
  62. package/dist/input/csiu-keys.js +170 -0
  63. package/dist/input/keyboard-enhancement.d.ts +42 -0
  64. package/dist/input/keyboard-enhancement.js +65 -0
  65. package/dist/input/stdin-filter.d.ts +9 -0
  66. package/dist/input/stdin-filter.js +45 -9
  67. package/dist/input/win32-input-mode.d.ts +61 -0
  68. package/dist/input/win32-input-mode.js +251 -0
  69. package/dist/session/persist.d.ts +3 -0
  70. package/dist/session/persist.js +9 -0
  71. package/dist/session/store.js +2 -0
  72. package/dist/session/validate-session.js +34 -0
  73. package/dist/team/subagent.js +8 -0
  74. package/dist/ui/App.js +218 -116
  75. package/dist/ui/BottomStatusRow.d.ts +5 -10
  76. package/dist/ui/BottomStatusRow.js +7 -11
  77. package/dist/ui/Composer.d.ts +4 -2
  78. package/dist/ui/Composer.js +2 -2
  79. package/dist/ui/Header.d.ts +6 -2
  80. package/dist/ui/Header.js +7 -6
  81. package/dist/ui/PromptInput.d.ts +3 -1
  82. package/dist/ui/PromptInput.js +32 -41
  83. package/dist/ui/StatusBar.d.ts +6 -0
  84. package/dist/ui/StatusBar.js +8 -7
  85. package/dist/ui/Transcript.js +1 -1
  86. package/dist/ui/entries/CompactionCard.d.ts +3 -1
  87. package/dist/ui/entries/CompactionCard.js +7 -5
  88. package/dist/ui/frame-differ.js +27 -0
  89. package/dist/ui/frame-observer.js +18 -5
  90. package/dist/ui/interaction-copy.d.ts +73 -73
  91. package/dist/ui/interaction-copy.js +30 -38
  92. package/dist/ui/interaction-hints.d.ts +8 -1
  93. package/dist/ui/interaction-hints.js +56 -69
  94. package/dist/ui/layout/AppShell.d.ts +6 -2
  95. package/dist/ui/layout/AppShell.js +2 -2
  96. package/dist/ui/layout/OverlayFrame.d.ts +2 -0
  97. package/dist/ui/layout/OverlayFrame.js +23 -14
  98. package/dist/ui/layout/ScrollViewport.d.ts +3 -1
  99. package/dist/ui/layout/ScrollViewport.js +10 -2
  100. package/dist/ui/layout/budget.d.ts +25 -26
  101. package/dist/ui/layout/budget.js +29 -22
  102. package/dist/ui/layout/document-layout.d.ts +2 -2
  103. package/dist/ui/layout/document-layout.js +2 -3
  104. package/dist/ui/layout/queue-layout.js +4 -4
  105. package/dist/ui/layout/status-detail-layout.d.ts +9 -0
  106. package/dist/ui/layout/status-detail-layout.js +56 -0
  107. package/dist/ui/layout/status-layout.d.ts +19 -2
  108. package/dist/ui/layout/status-layout.js +95 -84
  109. package/dist/ui/layout/wrap-rows.d.ts +2 -0
  110. package/dist/ui/layout/wrap-rows.js +21 -7
  111. package/dist/ui/overlays/ConfirmDialog.d.ts +2 -1
  112. package/dist/ui/overlays/ConfirmDialog.js +22 -3
  113. package/dist/ui/overlays/HelpOverlay.js +9 -2
  114. package/dist/ui/overlays/ModelPicker.d.ts +5 -8
  115. package/dist/ui/overlays/ModelPicker.js +44 -10
  116. package/dist/ui/overlays/ModelProfileEditor.js +2 -0
  117. package/dist/ui/overlays/ModelProfilePicker.d.ts +1 -0
  118. package/dist/ui/overlays/ModelProfilePicker.js +4 -2
  119. package/dist/ui/overlays/PlanReviewOverlay.d.ts +2 -1
  120. package/dist/ui/overlays/PlanReviewOverlay.js +7 -4
  121. package/dist/ui/overlays/QuestionOverlay.d.ts +2 -1
  122. package/dist/ui/overlays/QuestionOverlay.js +45 -32
  123. package/dist/ui/overlays/QueueOverlay.js +2 -2
  124. package/dist/ui/overlays/SettingsScreen.d.ts +1 -0
  125. package/dist/ui/overlays/SettingsScreen.js +10 -3
  126. package/dist/ui/run-metrics.d.ts +23 -0
  127. package/dist/ui/run-metrics.js +27 -0
  128. package/dist/ui/status-feedback.d.ts +33 -0
  129. package/dist/ui/status-feedback.js +36 -0
  130. package/dist/ui/stdout-frame-writer.js +28 -0
  131. package/package.json +2 -2
package/dist/ui/App.js CHANGED
@@ -7,9 +7,9 @@ import { selectTranscriptEntries, mergePendingEntries } from '../agent/queued-me
7
7
  * keybindings, routes slash commands, and renders the header / transcript /
8
8
  * input / toast stack / status bar / overlays.
9
9
  */
10
- import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
10
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react';
11
11
  import { Text, useApp, useInput, useStdout } from 'ink';
12
- import { advanceInterruptGesture, cancelInterruptConfirmation, } from '../input/interrupt-gesture.js';
12
+ import { advanceInterruptGesture, INTERRUPT_CONFIRM_MS, cancelInterruptConfirmation, } from '../input/interrupt-gesture.js';
13
13
  import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
14
14
  import { mergeDeltas } from '../agent/coalesce.js';
15
15
  import { computeCost } from '../agent/usage.js';
@@ -35,15 +35,8 @@ import { secondsLeft } from '../agent/retry-view.js';
35
35
  import { TeamPanel } from './TeamPanel.js';
36
36
  import { TodoPanel } from './TodoPanel.js';
37
37
  import { BottomStatusRow } from './BottomStatusRow.js';
38
- import { UpdateLine } from './UpdateLine.js';
39
- // The updater's two PURE modules. `shouldRenderUpdateLine` decides the row's
40
- // presence AT THE CALL SITE (C-15), and `UPDATE_LIMITS` owns the width
41
- // threshold - both are values, and both are resolved here rather than inside
42
- // `UpdateLine` so that component's only edge into `update/` stays erasable by
43
- // tsc (cli-auto-update section 3.1 rule 1 / IF-1). Neither module opens a
44
- // socket, spawns a process or creates a timer.
38
+ // Only the updater's pure visibility predicate enters the status detail plan.
45
39
  import { shouldRenderUpdateLine } from '../update/types.js';
46
- import { UPDATE_LIMITS } from '../update/limits.js';
47
40
  import { AppShell } from './layout/AppShell.js';
48
41
  import { ScrollViewport } from './layout/ScrollViewport.js';
49
42
  import { buildTodoRailLayout } from './layout/todo-layout.js';
@@ -53,8 +46,7 @@ import { useTerminalSize } from './layout/useTerminalSize.js';
53
46
  import { useHeightStore } from './use-height-store.js';
54
47
  import { useRenderGovernor } from './use-render-governor.js';
55
48
  import { readScrollbarPerf, setPerfResetHook, setPerfSnapshotProvider, } from '../commands/perf.js';
56
- import { MIN_FULLSCREEN_ROWS, MIN_FULLSCREEN_COLS } from './layout/frame.js';
57
- import { HINT_MIN_ROWS, clampDraftRows, viewportRows as computeViewportRows } from './layout/budget.js';
49
+ import { buildFrameBudget } from './layout/budget.js';
58
50
  import { OVERLAY_PAGE, useWheelRouting } from './use-wheel-routing.js';
59
51
  import { installConsoleBridge } from './console-bridge.js';
60
52
  import { publishExitSnapshot } from './exit-snapshot.js';
@@ -74,6 +66,9 @@ import { QueuePanel } from './QueuePanel.js';
74
66
  import { QueueOverlay } from './overlays/QueueOverlay.js';
75
67
  import { buildQueueLayout } from './layout/queue-layout.js';
76
68
  import { interactionCopy } from './interaction-copy.js';
69
+ import { createRunMetrics, updateRunMetrics, freezeRunMetrics, projectRunMetrics } from './run-metrics.js';
70
+ import { projectStatusFeedback } from './status-feedback.js';
71
+ import { planStatusDetail } from './layout/status-detail-layout.js';
77
72
  import { emptyTailState } from './layout/follow-state.js';
78
73
  /**
79
74
  * Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
@@ -129,15 +124,13 @@ function seedViewState(thinkingVisible) {
129
124
  export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }) {
130
125
  const { exit } = useApp();
131
126
  const { stdout } = useStdout();
132
- const { rows, cols } = useTerminalSize();
133
- const tooSmall = (rows < MIN_FULLSCREEN_ROWS || cols < MIN_FULLSCREEN_COLS);
134
- const composerRef = useRef(null);
135
- const activityRef = useRef(null);
136
- const [cursorVisible, setCursorVisible] = useState(true);
137
- // Whether the run status row above the input is inside the viewport. It is
138
- // `true` while the row is not mounted (`elementVisible(null)`), so it is NEVER
139
- // a decision on its own - always AND it with `runRowEnabled` (`runRowShown`).
140
- const [activityRowVisible, setActivityRowVisible] = useState(true);
127
+ const size = useTerminalSize();
128
+ const [draftRows, setDraftRows] = useState(1);
129
+ const [popupRows, setPopupRows] = useState(0);
130
+ const [statusExpanded, setStatusExpanded] = useState(false);
131
+ const dimensions = buildFrameBudget({ ...size, draftRows, popupRows, statusExpanded });
132
+ const { rows, cols } = dimensions;
133
+ const tooSmall = dimensions.inactive;
141
134
  // --- Render governor (tui-render-performance L4). ------------------------
142
135
  //
143
136
  // FIRST, BEFORE ANY OTHER WORK IN THIS BODY. The hook stamps
@@ -155,7 +148,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
155
148
  // frame 1. `seedViewState` is module-scope so the lazy initializer is a stable
156
149
  // function reference.
157
150
  const [state, dispatch] = useReducer(viewReducer, cfg.showThinking, seedViewState);
158
- const [elapsedMs, setElapsedMs] = useState(0);
151
+ const metrics = useRef(createRunMetrics());
152
+ const manualMetrics = useRef(createRunMetrics());
153
+ const [, setMetricsClock] = useState(0);
154
+ const copyFeedback = useRef(null);
159
155
  /**
160
156
  * Forces the render that re-reads `Date.now()` for the live compaction card
161
157
  * while the run is IDLE (hardening W5). The value itself is never read - the
@@ -220,6 +216,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
220
216
  const interactionPhase = useRef(controller.isRunning() ? 'running' : 'idle');
221
217
  const submissionSequence = useRef(0);
222
218
  const mounted = useRef(true);
219
+ const switchingConversation = useRef(false);
220
+ const drainCompactionQueue = useRef(() => { });
223
221
  /**
224
222
  * Live service snapshots, keyed by id, for the status chip and the Ctrl+C rung.
225
223
  *
@@ -239,7 +237,6 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
239
237
  */
240
238
  const servicesRef = useRef(services);
241
239
  servicesRef.current = services;
242
- const runBaselineOut = useRef(0);
243
240
  const runStartedAt = useRef(0);
244
241
  const toastTimers = useRef(new Map());
245
242
  /** Every outstanding `gate.request()`. Held in a ref, never in state: it is
@@ -313,11 +310,21 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
313
310
  aborted: false,
314
311
  errored: false,
315
312
  });
313
+ const interruptTimer = useRef(null);
316
314
  const resetInterrupt = useCallback(() => {
315
+ if (interruptTimer.current)
316
+ clearTimeout(interruptTimer.current);
317
+ interruptTimer.current = null;
317
318
  interruptGesture.current = { phase: 'ready' };
318
319
  }, []);
319
320
  const cancelConfirmation = useCallback(() => {
321
+ if (interruptGesture.current.phase !== 'armed')
322
+ return;
320
323
  interruptGesture.current = cancelInterruptConfirmation(interruptGesture.current);
324
+ if (interruptTimer.current)
325
+ clearTimeout(interruptTimer.current);
326
+ interruptTimer.current = null;
327
+ setMetricsClock(Date.now());
321
328
  }, []);
322
329
  useEffect(() => {
323
330
  if (state.overlay)
@@ -328,12 +335,41 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
328
335
  return () => {
329
336
  mounted.current = false;
330
337
  submissionSequence.current += 1;
331
- if (interactionPhase.current === 'starting')
338
+ if (interactionPhase.current === 'starting' || controller.isCompactionBusy?.())
332
339
  controller.abort();
333
340
  interactionPhase.current = 'idle';
334
341
  resetInterrupt();
335
342
  };
336
343
  }, [controller, resetInterrupt]);
344
+ useEffect(() => {
345
+ drainCompactionQueue.current = () => {
346
+ // A conversation switch owns the queue until its command has committed.
347
+ void Promise.resolve().then(async () => {
348
+ if (!mounted.current || switchingConversation.current
349
+ || controller.isCompactionBusy() || controller.isRunning()
350
+ || interactionPhase.current !== 'idle' || !controller.hasPendingUserMessages()
351
+ || controller.areModelSettingsBlocked())
352
+ return;
353
+ const preflight = controller.preflight();
354
+ if (!preflight.ok) {
355
+ dispatch({ type: 'notice', level: 'error', text: preflight.message ?? 'Configuration error.' });
356
+ return;
357
+ }
358
+ interactionPhase.current = 'starting';
359
+ try {
360
+ await controller.continue();
361
+ }
362
+ finally {
363
+ if (interactionPhase.current === 'starting')
364
+ interactionPhase.current = 'idle';
365
+ }
366
+ });
367
+ };
368
+ return controller.subscribeCompactionBusy?.((busy) => {
369
+ if (!busy)
370
+ drainCompactionQueue.current();
371
+ });
372
+ }, [controller]);
337
373
  /**
338
374
  * Disarm an armed continuation. Idempotent; NEVER touches the budget — a
339
375
  * cancelled continuation was never issued, so it must not be charged (AC-18).
@@ -447,8 +483,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
447
483
  generation = controller.runGeneration;
448
484
  return;
449
485
  }
450
- if (event.type === 'agent_start')
486
+ metrics.current = updateRunMetrics(metrics.current, event, Date.now());
487
+ if (event.type === 'agent_start') {
451
488
  interactionPhase.current = 'running';
489
+ manualMetrics.current = createRunMetrics();
490
+ }
452
491
  if (event.type === 'agent_end') {
453
492
  interactionPhase.current = 'idle';
454
493
  resetInterrupt();
@@ -522,7 +561,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
522
561
  // a fired continuation would either be swallowed into
523
562
  // `controller.steer()` (the quiet failure) or reach
524
563
  // `controller.prompt()`, which REJECTS while running.
525
- if (controller.isRunning())
564
+ if (controller.isRunning() || controller.isCompactionBusy?.())
526
565
  return;
527
566
  const live = controller.getTodoSnapshot();
528
567
  if (!live || live.doneCount >= live.total)
@@ -769,6 +808,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
769
808
  return controller.subscribeCompaction((event) => {
770
809
  switch (event.type) {
771
810
  case 'compaction_start':
811
+ if (interactionPhase.current === 'idle') {
812
+ manualMetrics.current = updateRunMetrics(createRunMetrics(), { type: 'agent_start' }, Date.now());
813
+ }
772
814
  dispatch({
773
815
  type: 'compactionStart',
774
816
  index: event.index,
@@ -777,6 +819,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
777
819
  });
778
820
  break;
779
821
  case 'compaction_end':
822
+ manualMetrics.current = freezeRunMetrics(manualMetrics.current, Date.now());
780
823
  // THE CARD, AND NOTHING ELSE (context-usage-gauge-accuracy §3.3).
781
824
  //
782
825
  // A `contextTokensEstimated` dispatch used to live here, hand-building
@@ -798,10 +841,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
798
841
  // same class of lie as the `$0.00` `pricingUnknown` exists to prevent,
799
842
  // just in the other direction.
800
843
  const snapshot = controller.getCompactionSnapshot();
801
- const ref = controller.getCompactionSummarizerRef();
802
- const costDelta = snapshot.pricingUnknown || !ref
844
+ const ref = event.modelRef ?? controller.getCompactionSummarizerRef();
845
+ const costDelta = (event.pricingUnknown ?? snapshot.pricingUnknown)
803
846
  ? 0
804
- : computeCost(event.usage, controller.getModelInfoFor(ref).cost);
847
+ : event.costUsd ?? (ref
848
+ ? computeCost(event.usage, controller.getModelInfoFor(ref).cost) : 0);
805
849
  dispatch({ type: 'compactionUsage', usage: event.usage, costDelta });
806
850
  break;
807
851
  }
@@ -847,18 +891,21 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
847
891
  }
848
892
  });
849
893
  }, [controller, flushPending, governorInterval]);
850
- // --- Elapsed timer + tokens/sec baseline while running. ----------------
894
+ // Event boundaries own start/end; ticks only refresh the live projection.
851
895
  useEffect(() => {
852
- if (state.status !== 'running') {
853
- setElapsedMs(0);
896
+ if (state.status !== 'running')
854
897
  return;
855
- }
856
- runBaselineOut.current = stateRef.current.usageTotal.outputTokens;
857
- const startedAt = Date.now();
858
- setElapsedMs(0);
859
- const id = setInterval(() => setElapsedMs(Date.now() - startedAt), 200);
898
+ const id = setInterval(() => setMetricsClock(Date.now()), 200);
860
899
  return () => clearInterval(id);
861
900
  }, [state.status]);
901
+ useLayoutEffect(() => {
902
+ if (manualMetrics.current.startedAt === null || !state.compactionEntryId)
903
+ return;
904
+ const entry = state.entries.find(item => item.id === state.compactionEntryId);
905
+ if (entry?.kind === 'compaction' && entry.startedAt !== undefined) {
906
+ manualMetrics.current = { ...manualMetrics.current, startedAt: entry.startedAt };
907
+ }
908
+ }, [state.compactionEntryId, state.entries]);
862
909
  // --- Live compaction elapsed tick (hardening §3.6 / W5). ---------------
863
910
  //
864
911
  // ONLY WHILE A CARD IS LIVE AND THE RUN IS NOT, so an ordinary session has no
@@ -905,6 +952,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
905
952
  for (const timer of toastTimers.current.values())
906
953
  clearTimeout(timer);
907
954
  toastTimers.current.clear();
955
+ if (ctrlCTimer.current)
956
+ clearTimeout(ctrlCTimer.current);
957
+ if (interruptTimer.current)
958
+ clearTimeout(interruptTimer.current);
908
959
  }, []);
909
960
  // --- Console bridge (fullscreen only, I-4). ----------------------------
910
961
  // Ink's own patchConsole writes straight to stdout, which shifts the fixed
@@ -1077,45 +1128,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1077
1128
  bridge.notify = null;
1078
1129
  };
1079
1130
  }, [pasteBridge]);
1080
- // --- The composer's height (tui-paste-handling section 5.5 / D-12). ------
1081
- //
1082
- // GROWTH IS IMMEDIATE, SHRINK IS DEFERRED BY ONE TICK (R-12). Growing late
1083
- // overdraws the frame for a frame; shrinking early makes a `Backspace` that
1084
- // crosses a wrap boundary bounce the whole transcript, and a run of deletions
1085
- // bounce it repeatedly. Deferring coalesces the run into one move.
1086
- //
1087
- // The ref mirrors the state so the decision is made OUTSIDE the updater: React
1088
- // may call an updater more than once, and scheduling a timer from inside one
1089
- // would arm it twice.
1090
- const [draftRows, setDraftRows] = useState(1);
1091
- const [popupRows, setPopupRows] = useState(0);
1131
+ // Synchronous layout reports keep the fixed slot and its contents in one frame.
1092
1132
  const onPopupRowsChange = useCallback((next) => {
1093
1133
  setPopupRows(previous => previous === next ? previous : next);
1094
1134
  }, []);
1095
- const draftRowsRef = useRef(1);
1096
- const shrinkTimer = useRef(null);
1097
1135
  const onDraftRows = useCallback((next) => {
1098
- const wanted = Math.max(1, next);
1099
- if (shrinkTimer.current) {
1100
- clearTimeout(shrinkTimer.current);
1101
- shrinkTimer.current = null;
1102
- }
1103
- if (wanted === draftRowsRef.current)
1104
- return;
1105
- if (wanted > draftRowsRef.current) {
1106
- draftRowsRef.current = wanted;
1107
- setDraftRows(wanted);
1108
- return;
1109
- }
1110
- shrinkTimer.current = setTimeout(() => {
1111
- shrinkTimer.current = null;
1112
- draftRowsRef.current = wanted;
1113
- setDraftRows(wanted);
1114
- }, 0);
1115
- }, []);
1116
- useEffect(() => () => {
1117
- if (shrinkTimer.current)
1118
- clearTimeout(shrinkTimer.current);
1136
+ setDraftRows(previous => previous === next ? previous : next);
1119
1137
  }, []);
1120
1138
  // --- Auto-update bridge (cli-auto-update section 3.8 / 6.3). ------------
1121
1139
  //
@@ -1171,26 +1189,26 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1171
1189
  clipboard: { write: terminal?.writeForeign },
1172
1190
  onRequest: () => {
1173
1191
  ctrlCArmed.current = false;
1192
+ setMetricsClock(Date.now());
1174
1193
  if (ctrlCTimer.current)
1175
1194
  clearTimeout(ctrlCTimer.current);
1176
1195
  ctrlCTimer.current = null;
1177
1196
  },
1178
1197
  onStateChange: setCopyState,
1179
1198
  onBusy: ({ cleanupPending }) => toast('info', cleanupPending
1180
- ? '\u590d\u5236\u5931\u8d25\uff0c\u6b63\u5728\u6e05\u7406' : '\u6b63\u5728\u590d\u5236'),
1199
+ ? 'Copy failed; cleaning up' : 'Copying'),
1181
1200
  onResult: (result, request) => {
1182
1201
  selectionBridge?.onCopied?.(result, request.lines, request.text.length);
1183
- if (result.status === 'confirmed') {
1184
- toast('success', `\u5df2\u590d\u5236 ${request.lines} \u884c`);
1185
- }
1186
- else if (result.status === 'sent') {
1187
- toast('info', '\u5df2\u8bf7\u6c42\u7ec8\u7aef\u590d\u5236\uff0c\u7ed3\u679c\u672a\u786e\u8ba4');
1188
- }
1189
- else {
1190
- const text = `${interactionCopy.copyFailed}\uff1a${interactionCopy.copyReasons[result.reason]}`;
1191
- toast('warn', text);
1202
+ const text = result.status === 'confirmed' ? `Copied ${request.lines} lines`
1203
+ : result.status === 'sent' ? 'Copy sent to terminal; not confirmed'
1204
+ : `${interactionCopy.copyFailed}: ${interactionCopy.copyReasons[result.reason]}`;
1205
+ copyFeedback.current = { result: {
1206
+ status: result.status === 'confirmed' || result.status === 'sent' ? result.status : 'error',
1207
+ text
1208
+ }, previousIds: new Set(stateRef.current.toasts.map(item => item.id)) };
1209
+ toast(result.status === 'confirmed' ? 'success' : result.status === 'sent' ? 'info' : 'warn', text);
1210
+ if (result.status !== 'confirmed' && result.status !== 'sent')
1192
1211
  notify('error', text);
1193
- }
1194
1212
  },
1195
1213
  }), []);
1196
1214
  useEffect(() => () => copyCoordinator.dispose(), [copyCoordinator]);
@@ -1210,6 +1228,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1210
1228
  // subscription on every render.
1211
1229
  // eslint-disable-next-line react-hooks/exhaustive-deps
1212
1230
  }, [selectionBridge]);
1231
+ useLayoutEffect(() => {
1232
+ const feedback = copyFeedback.current;
1233
+ if (!feedback)
1234
+ return;
1235
+ if (!feedback.toastId) {
1236
+ feedback.toastId = state.toasts.find(item => !feedback.previousIds.has(item.id)
1237
+ && item.text === feedback.result.text)?.id;
1238
+ }
1239
+ if (feedback.toastId && !state.toasts.some(item => item.id === feedback.toastId)) {
1240
+ copyFeedback.current = null;
1241
+ }
1242
+ }, [state.toasts]);
1213
1243
  const selectionController = selectionBridge?.controller ?? null;
1214
1244
  /**
1215
1245
  * I-9, the DERIVED half — and the one that matters (D-14 / P1-4).
@@ -1231,6 +1261,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1231
1261
  const onViewportShiftChange = useCallback(() => {
1232
1262
  selectionController?.clear();
1233
1263
  }, [selectionController]);
1264
+ const onGeometryChange = useCallback(() => {
1265
+ selectionController?.clear();
1266
+ terminal?.scrollbar?.controller?.cancel();
1267
+ terminal?.scrollbar?.invalidate();
1268
+ setSelectionHold(false);
1269
+ }, [selectionController, terminal]);
1234
1270
  // I-9, the ENUMERATED half. `shiftUp` covers every movement of the rows and is
1235
1271
  // wired straight into `ScrollViewport` below; these three are the cases that
1236
1272
  // change the screen while leaving `shiftUp` untouched — a key that scrolls
@@ -1308,6 +1344,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1308
1344
  state: stateRef.current,
1309
1345
  dispatch: (action) => {
1310
1346
  if (action.type === 'resetConversation' || action.type === 'restoreEntries') {
1347
+ metrics.current = createRunMetrics();
1348
+ manualMetrics.current = createRunMetrics();
1311
1349
  setScrollResetKey((n) => n + 1);
1312
1350
  const wasStarting = interactionPhase.current === 'starting';
1313
1351
  submissionSequence.current += 1;
@@ -1397,7 +1435,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1397
1435
  // must be the one that runs, whichever branch it takes.
1398
1436
  cancelFollowThrough();
1399
1437
  cancelConfirmation();
1400
- if (interactionPhase.current === 'running') {
1438
+ if (interactionPhase.current === 'running' || controller.isCompactionBusy?.()) {
1401
1439
  try {
1402
1440
  const queueId = controller.queueUserMessage(message);
1403
1441
  dispatch({ type: 'steerQueued', queueId, text: message });
@@ -1451,6 +1489,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1451
1489
  };
1452
1490
  submitRef.current = submitMessage;
1453
1491
  const executeSlashInput = async (raw) => {
1492
+ const switchWhileBusy = controller.isCompactionBusy?.()
1493
+ && /^\s*\/(clear|reset|resume)(?:\s|$)/.test(raw);
1494
+ if (switchWhileBusy) {
1495
+ switchingConversation.current = true;
1496
+ await controller.cancelCompaction();
1497
+ }
1454
1498
  // Retain a recoverable command before any asynchronous handler can fail.
1455
1499
  const safe = redactText(raw, getSecrets());
1456
1500
  recordPrompt(safe);
@@ -1462,6 +1506,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1462
1506
  const reason = error instanceof Error ? error.message : String(error);
1463
1507
  notify('error', `Command failed: ${redactText(reason, getSecrets())}\nInput: ${safe}`);
1464
1508
  }
1509
+ finally {
1510
+ if (switchWhileBusy) {
1511
+ switchingConversation.current = false;
1512
+ drainCompactionQueue.current();
1513
+ }
1514
+ }
1465
1515
  };
1466
1516
  const handleSubmit = (raw) => {
1467
1517
  const trimmed = raw.trimStart();
@@ -1612,6 +1662,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1612
1662
  (selectionController?.hasPendingSelection() ?? false)) {
1613
1663
  const payload = selectionController.takeSelection();
1614
1664
  ctrlCArmed.current = false;
1665
+ setMetricsClock(Date.now());
1615
1666
  if (ctrlCTimer.current)
1616
1667
  clearTimeout(ctrlCTimer.current);
1617
1668
  ctrlCTimer.current = null;
@@ -1647,14 +1698,25 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1647
1698
  return;
1648
1699
  }
1649
1700
  ctrlCArmed.current = true;
1650
- toast('warn', 'Press Ctrl+C again to exit.');
1701
+ // Gesture feedback follows its live state, without a second toast lifetime.
1702
+ setMetricsClock(value => value + 1);
1651
1703
  if (ctrlCTimer.current)
1652
1704
  clearTimeout(ctrlCTimer.current);
1653
1705
  ctrlCTimer.current = setTimeout(() => {
1706
+ ctrlCTimer.current = null;
1654
1707
  ctrlCArmed.current = false;
1708
+ setMetricsClock(value => value + 1);
1655
1709
  }, 1500);
1656
1710
  return;
1657
1711
  }
1712
+ if (key.ctrl && input.toLowerCase() === 'g') {
1713
+ if (tooSmall || stateRef.current.overlay)
1714
+ return;
1715
+ setStatusExpanded(value => !value);
1716
+ return;
1717
+ }
1718
+ if (tooSmall)
1719
+ return;
1658
1720
  // R-P1-7: the overlay branch MUST come before the transcript scroll branch
1659
1721
  // and MUST return. `PgUp` used to fall through to `scrollBy()` while an
1660
1722
  // overlay was open. The stable viewport ignores inactive intents, but routing
@@ -1796,6 +1858,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1796
1858
  const handleComposerEscape = useCallback(() => {
1797
1859
  if (stateRef.current.overlay)
1798
1860
  return;
1861
+ if (controller.isCompactionBusy?.()) {
1862
+ controller.abort();
1863
+ return;
1864
+ }
1799
1865
  if (interactionPhase.current === 'idle') {
1800
1866
  if (followTimer.current) {
1801
1867
  cancelFollowThrough();
@@ -1808,8 +1874,16 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1808
1874
  const next = advanceInterruptGesture(interruptGesture.current, performance.now());
1809
1875
  interruptGesture.current = next.state;
1810
1876
  if (next.action === 'hint') {
1811
- dispatch({ type: 'pushToast', level: 'info',
1812
- text: '\u4e2d\u65ad\u5df2\u51c6\u5907' });
1877
+ if (interruptTimer.current)
1878
+ clearTimeout(interruptTimer.current);
1879
+ interruptTimer.current = setTimeout(() => {
1880
+ interruptTimer.current = null;
1881
+ if (interruptGesture.current.phase !== 'armed')
1882
+ return;
1883
+ interruptGesture.current = { phase: 'ready' };
1884
+ setMetricsClock(Date.now());
1885
+ }, INTERRUPT_CONFIRM_MS + 1);
1886
+ setMetricsClock(value => value + 1);
1813
1887
  return;
1814
1888
  }
1815
1889
  endReasonRef.current.aborted = true;
@@ -1819,6 +1893,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1819
1893
  interactionPhase.current = 'idle';
1820
1894
  resetInterrupt();
1821
1895
  autoContinuationRef.current = false;
1896
+ metrics.current = freezeRunMetrics(metrics.current, Date.now());
1822
1897
  if (next.action === 'force-stop')
1823
1898
  controller.forceStop();
1824
1899
  else
@@ -1829,8 +1904,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1829
1904
  }
1830
1905
  controller.abort();
1831
1906
  if (sequence === submissionSequence.current && interactionPhase.current === 'running') {
1832
- dispatch({ type: 'pushToast', level: 'info',
1833
- text: '\u6b63\u5728\u505c\u6b62' });
1907
+ setMetricsClock(value => value + 1);
1834
1908
  }
1835
1909
  }, [controller, cancelFollowThrough, resetInterrupt]);
1836
1910
  // --- Render ------------------------------------------------------------
@@ -1848,9 +1922,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1848
1922
  // RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
1849
1923
  // handler will do on the very next keystroke.
1850
1924
  const liveServices = liveServiceCount(services);
1851
- const tokPerSec = state.status === 'running' && elapsedMs > 500
1852
- ? Math.max(0, Math.round((state.usageTotal.outputTokens - runBaselineOut.current) / (elapsedMs / 1000)))
1853
- : 0;
1925
+ const projectedMetrics = projectRunMetrics(manualMetrics.current.startedAt !== null
1926
+ && state.status !== 'running' ? manualMetrics.current : metrics.current, Date.now());
1927
+ const { elapsedMs, tokPerSec, speedKnown } = projectedMetrics;
1854
1928
  // --- The activity line's seed (§3.2.3 / P1-7). --------------------------
1855
1929
  //
1856
1930
  // RENDER SCOPE. Without this the ref holds `0` on the first run (an arbitrary
@@ -1889,9 +1963,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1889
1963
  // milliseconds because `ToolCard` is `React.memo`'d with the default
1890
1964
  // comparator; `Transcript` passes it on only to running tool entries.
1891
1965
  const nowSec = running || compactionLive ? Math.floor(Date.now() / 1000) : undefined;
1892
- const viewportBudget = computeViewportRows(rows, draftRows);
1966
+ const frameBudget = buildFrameBudget({ rows, cols, draftRows,
1967
+ popupRows: overlay ? 0 : popupRows, statusExpanded });
1968
+ const viewportBudget = frameBudget.viewportRows;
1893
1969
  const overlayMaxRows = viewportBudget;
1894
- const composerBaseRows = 2 + clampDraftRows(rows, draftRows);
1970
+ const composerBaseRows = 0;
1895
1971
  const baseRail = buildTodoRailLayout({
1896
1972
  cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
1897
1973
  itemCount: state.todos?.items.length ?? 0, viewportBudget,
@@ -1908,11 +1984,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1908
1984
  const railLayout = buildTodoRailLayout({
1909
1985
  cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
1910
1986
  itemCount: state.todos?.items.length ?? 0, viewportBudget,
1911
- teamRows: teamLayout.rowCount, queueRows: queueLayout.rows, popupRows, composerBaseRows,
1987
+ teamRows: teamLayout.rowCount, queueRows: queueLayout.rows, popupRows: 0, composerBaseRows,
1912
1988
  });
1913
1989
  const { visible: showRail, width: railWidth, rows: railRows, contentCols } = railLayout;
1914
1990
  const transcriptEntries = useMemo(() => selectTranscriptEntries(state.entries, state.pendingSteering), [state.entries, state.pendingSteering]);
1915
- 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: {
1991
+ 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, { isActive: !tooSmall, registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { isActive: !tooSmall, initial: {
1916
1992
  provider: cfg.provider,
1917
1993
  model: cfg.model,
1918
1994
  baseUrl: cfg.baseUrl ?? '',
@@ -1944,8 +2020,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1944
2020
  }, 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 }),
1945
2021
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
1946
2022
  // while the screen is open shows the new value on the next paint.
1947
- 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;
1948
- const header = (_jsx(Header, { cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: pickHeaderVariant(cols), theme: theme, caps: caps }));
2023
+ retrySummary: cfg.retry.enabled && cfg.retry.maxRetries > 0 ? String(cfg.retry.maxRetries) : 'off' })) : overlay === 'confirm' && confirmState ? (_jsx(ConfirmDialog, { isActive: !tooSmall, state: confirmState, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onClose: closeConfirm })) : overlay === 'question' && humanRequest?.kind === 'questions' ? (_jsx(QuestionOverlay, { isActive: !tooSmall, 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, { isActive: !tooSmall, plan: humanRequest.plan, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onVerdict: handlePlanVerdict })) : null;
2024
+ const header = (_jsx(Header, { columns: cols, statusExpanded: statusExpanded, scrolledLines: scrolledLines, overlayOpen: overlay !== null, cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: pickHeaderVariant(cols), theme: theme, caps: caps }));
1949
2025
  // The wordmark + getting-started card now live INSIDE the viewport, as its
1950
2026
  // first block of content, so they scroll away instead of being yanked out of
1951
2027
  // the header the moment the first message lands.
@@ -1978,8 +2054,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1978
2054
  });
1979
2055
  },
1980
2056
  };
1981
- // Completion belongs to the document footer; cap it to keep the editor usable.
1982
- const popupMaxRows = Math.max(1, viewportBudget - 4);
2057
+ // Completion shares the fixed editor slot and its frame budget.
2058
+ const popupMaxRows = 6;
1983
2059
  // WHICH KEY THE HINT ROW NAMES (shift-tab-mode-toggle-still-dead-on-windows,
1984
2060
  // C3-1). Derived from `vtInputWarning` rather than from a probe of its own,
1985
2061
  // because that prop is already the single answer to "can this console deliver
@@ -1990,21 +2066,47 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1990
2066
  // Both keys always WORK (they share one branch in the input handler); this
1991
2067
  // only decides which one the user is taught.
1992
2068
  const modeToggleKey = vtInputWarning ? MODE_TOGGLE_KEYS.fallback : MODE_TOGGLE_KEYS.primary;
1993
- 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' }) }));
2069
+ const pendingCopy = copyFeedback.current;
2070
+ const copyResult = pendingCopy && state.toasts.some(item => pendingCopy.toastId
2071
+ ? item.id === pendingCopy.toastId
2072
+ : !pendingCopy.previousIds.has(item.id) && item.text === pendingCopy.result.text)
2073
+ ? pendingCopy.result : undefined;
2074
+ const latestToast = state.toasts.at(-1);
2075
+ const feedbackProjection = projectStatusFeedback({
2076
+ interactionPhase: interactionPhase.current, interruptPhase: interruptGesture.current.phase,
2077
+ completion, overlay, copyState, copyResult, ctrlCArmed: ctrlCArmed.current,
2078
+ selectionPending: selectionController?.hasPendingSelection() ?? false, liveServices,
2079
+ toast: latestToast ? { text: latestToast.text,
2080
+ level: latestToast.level === 'success' ? 'info' : latestToast.level } : undefined,
2081
+ });
2082
+ const statusInput = {
2083
+ columns: cols, phase: state.runPhase, pendingCount: state.pendingSteering.length,
2084
+ context: state.context, usageTotal: state.usageTotal, thinkingLevel: cfg.thinkingLevel,
2085
+ elapsedMs, tokPerSec, speedKnown, activeTool: state.activeTool, runOutcome: state.runOutcome,
2086
+ waitingForConfirmation: !!humanRequest || !!confirmState,
2087
+ stopping: interruptGesture.current.phase === 'stopping',
2088
+ interruptPhase: interruptGesture.current.phase,
2089
+ compacting: compactionLive || !!state.compaction?.inFlight,
2090
+ mode: state.agentMode, pendingMode: state.pendingAgentMode,
2091
+ servicesActive: { live: liveServices }, ecoRung: governor.rung,
2092
+ teamActive: state.team ? { running: state.team.runs.filter(item => !['queued', 'done', 'failed', 'aborted'].includes(item.phase)).length,
2093
+ total: state.team.runs.length } : undefined,
2094
+ todoActive: state.todos ? { done: state.todos.doneCount, total: state.todos.total } : undefined,
2095
+ fastActive: state.fast?.live ? { inFlight: state.fast.inFlight } : undefined,
2096
+ ...feedbackProjection,
2097
+ };
2098
+ const details = planStatusDetail({ status: statusInput, model: cfg.model, provider: cfg.provider,
2099
+ hints: { interactionPhase: interactionPhase.current, cols, completion, overlay,
2100
+ selectionPending: selectionController?.hasPendingSelection() ?? false,
2101
+ copyInFlight: copyState.busy, copyCleanupPending: copyState.cleanupPending,
2102
+ services: liveServices, hintsEnabled: cfg.hints, modeToggleKey,
2103
+ interruptPhase: interruptGesture.current.phase,
2104
+ updateAvailable: !!updateSnapshot && shouldRenderUpdateLine(updateSnapshot) } });
2105
+ const composer = (_jsx(Composer, { deleteDisambiguated: terminal?.deleteDisambiguated ?? false, onCompletionContextChange: setCompletion, cols: frameBudget.composerCols, terminalRows: rows, statusExpanded: statusExpanded, 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: false, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, services: liveServices, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, popupMaxHeight: frameBudget.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, onDraftRows: onDraftRows, onNotice: notify, theme: theme, caps: caps, onSubmit: handleSubmit, onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }));
1994
2106
  const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
1995
2107
  const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
1996
- 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 })] }));
1997
- 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'
1998
- : running ? 'running' : 'idle', cols, completion,
1999
- selectionPending: selectionController?.hasPendingSelection() ?? false,
2000
- copyInFlight: copyState.busy, copyCleanupPending: copyState.cleanupPending,
2001
- overlay, services: liveServices, hintsEnabled: cfg.hints, modeToggleKey,
2002
- interruptHint: interruptGesture.current.phase === 'stopping'
2003
- ? 'Esc force-stop' : interruptGesture.current.phase === 'armed'
2004
- ? 'Esc confirm' : 'Esc x2 interrupt',
2005
- updateAvailable: !!updateSnapshot && !overlayNode && !running
2006
- && shouldRenderUpdateLine(updateSnapshot),
2007
- }, 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'
2108
+ const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: overlayNode, footer: _jsxs(_Fragment, { children: [team, _jsx(QueuePanel, { layout: queueLayout, theme: theme })] }), rail: rail, scrollbar: terminal?.scrollbar, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, onGeometryChange: onGeometryChange, geometryKey: `${rows}:${cols}:${viewportBudget}:${frameBudget.composerSlotRows}:${frameBudget.statusRows}:${contentCols}:${overlay}:${tooSmall}`, 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 })] }));
2109
+ 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, viewportRows: frameBudget.viewportRows, composer: composer, composerSlotRows: frameBudget.composerSlotRows, statusRows: frameBudget.statusRows, details: _jsx(BottomStatusRow, { plan: details, columns: cols, theme: theme }), status: _jsx(StatusBar, { columns: cols, speedKnown: speedKnown, ...feedbackProjection, interruptPhase: interruptGesture.current.phase, 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'
2008
2110
  ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2009
2111
  // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2010
2112
  // projection of "many sources of truth" into the props layer, and a
@@ -2022,9 +2124,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2022
2124
  }
2023
2125
  : {}), ...(state.todos && !showRail
2024
2126
  ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
2025
- : {}), ...(liveServices > 0 ? { servicesActive: { live: liveServices } } : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}), ...(state.compaction?.live
2026
- ? { compactionActive: { inFlight: state.compaction.inFlight } }
2027
- : {}), ...(controller.isCompactionEnabled()
2127
+ : {}), ...(liveServices > 0 ? { servicesActive: { live: liveServices } } : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}),
2128
+ // Manual compaction starts before its next snapshot is published.
2129
+ compactionActive: { inFlight: statusInput.compacting ?? false }, ...(controller.isCompactionEnabled()
2028
2130
  ? {
2029
2131
  gaugeMarks: {
2030
2132
  warn: cfg.compaction.warnThreshold * 100,