@aragon-agent/cli 0.6.13 → 0.6.15

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 +41 -0
  2. package/README.md +250 -222
  3. package/dist/agent/controller.d.ts +36 -2
  4. package/dist/agent/controller.js +244 -57
  5. package/dist/agent/model-profile-settings.js +11 -2
  6. package/dist/agent/plan-prompt.d.ts +1 -1
  7. package/dist/agent/plan-prompt.js +4 -3
  8. package/dist/agent/project-guidance-prompt.d.ts +4 -0
  9. package/dist/agent/project-guidance-prompt.js +104 -0
  10. package/dist/agent/reducer.d.ts +2 -0
  11. package/dist/agent/reducer.js +5 -0
  12. package/dist/agent/system-prompt.js +4 -0
  13. package/dist/boot/guard.d.ts +3 -3
  14. package/dist/boot/guard.js +3 -3
  15. package/dist/cli.js +33 -15
  16. package/dist/commands/builtins.js +2 -2
  17. package/dist/compaction/child.d.ts +9 -1
  18. package/dist/compaction/child.js +43 -13
  19. package/dist/compaction/command.js +20 -10
  20. package/dist/compaction/compactor.d.ts +33 -204
  21. package/dist/compaction/compactor.js +311 -723
  22. package/dist/compaction/context-command.js +5 -3
  23. package/dist/compaction/digest.d.ts +15 -51
  24. package/dist/compaction/digest.js +113 -148
  25. package/dist/compaction/limits.d.ts +19 -142
  26. package/dist/compaction/limits.js +20 -147
  27. package/dist/compaction/memory-identity.d.ts +16 -0
  28. package/dist/compaction/memory-identity.js +63 -0
  29. package/dist/compaction/memory-input.d.ts +25 -0
  30. package/dist/compaction/memory-input.js +106 -0
  31. package/dist/compaction/memory-render.d.ts +4 -0
  32. package/dist/compaction/memory-render.js +47 -0
  33. package/dist/compaction/memory-validation.d.ts +21 -0
  34. package/dist/compaction/memory-validation.js +268 -0
  35. package/dist/compaction/memory.d.ts +79 -0
  36. package/dist/compaction/memory.js +118 -0
  37. package/dist/compaction/meter.d.ts +14 -10
  38. package/dist/compaction/meter.js +55 -19
  39. package/dist/compaction/operation.d.ts +18 -0
  40. package/dist/compaction/operation.js +68 -0
  41. package/dist/compaction/pressure.d.ts +6 -20
  42. package/dist/compaction/pressure.js +14 -23
  43. package/dist/compaction/summarize-call.d.ts +3 -16
  44. package/dist/compaction/summarize-call.js +20 -20
  45. package/dist/compaction/summary-prompt.d.ts +29 -66
  46. package/dist/compaction/summary-prompt.js +132 -178
  47. package/dist/compaction/types.d.ts +23 -3
  48. package/dist/compaction/wiring.d.ts +10 -25
  49. package/dist/compaction/wiring.js +60 -67
  50. package/dist/config/load.d.ts +3 -1
  51. package/dist/config/load.js +8 -4
  52. package/dist/config/schema.d.ts +3 -3
  53. package/dist/config/schema.js +1 -1
  54. package/dist/exec/events.d.ts +8 -0
  55. package/dist/exec/index.js +13 -6
  56. package/dist/exec/runner.d.ts +8 -0
  57. package/dist/exec/runner.js +26 -2
  58. package/dist/session/persist.d.ts +3 -0
  59. package/dist/session/persist.js +9 -0
  60. package/dist/session/store.js +2 -0
  61. package/dist/session/validate-session.js +34 -0
  62. package/dist/team/subagent.js +8 -0
  63. package/dist/ui/App.js +221 -126
  64. package/dist/ui/BottomStatusRow.d.ts +5 -10
  65. package/dist/ui/BottomStatusRow.js +7 -11
  66. package/dist/ui/Composer.d.ts +4 -2
  67. package/dist/ui/Composer.js +2 -2
  68. package/dist/ui/Header.d.ts +6 -2
  69. package/dist/ui/Header.js +7 -6
  70. package/dist/ui/Logo.d.ts +3 -5
  71. package/dist/ui/Logo.js +4 -8
  72. package/dist/ui/PromptInput.d.ts +3 -1
  73. package/dist/ui/PromptInput.js +35 -42
  74. package/dist/ui/StatusBar.d.ts +6 -0
  75. package/dist/ui/StatusBar.js +8 -7
  76. package/dist/ui/Transcript.js +1 -1
  77. package/dist/ui/capabilities.d.ts +2 -0
  78. package/dist/ui/capabilities.js +6 -0
  79. package/dist/ui/color-runtime.d.ts +5 -0
  80. package/dist/ui/color-runtime.js +29 -0
  81. package/dist/ui/entries/CompactionCard.d.ts +3 -1
  82. package/dist/ui/entries/CompactionCard.js +7 -5
  83. package/dist/ui/frame-differ.js +27 -0
  84. package/dist/ui/frame-observer.js +18 -5
  85. package/dist/ui/glyphs.js +10 -8
  86. package/dist/ui/ink-runtime.d.ts +3 -0
  87. package/dist/ui/ink-runtime.js +30 -0
  88. package/dist/ui/interaction-copy.d.ts +73 -73
  89. package/dist/ui/interaction-copy.js +30 -38
  90. package/dist/ui/interaction-hints.d.ts +8 -1
  91. package/dist/ui/interaction-hints.js +56 -69
  92. package/dist/ui/layout/AppShell.d.ts +6 -2
  93. package/dist/ui/layout/AppShell.js +2 -2
  94. package/dist/ui/layout/OverlayFrame.d.ts +2 -0
  95. package/dist/ui/layout/OverlayFrame.js +23 -14
  96. package/dist/ui/layout/ScrollViewport.d.ts +3 -1
  97. package/dist/ui/layout/ScrollViewport.js +10 -2
  98. package/dist/ui/layout/budget.d.ts +25 -26
  99. package/dist/ui/layout/budget.js +29 -22
  100. package/dist/ui/layout/document-layout.d.ts +2 -2
  101. package/dist/ui/layout/document-layout.js +2 -3
  102. package/dist/ui/layout/queue-layout.js +4 -4
  103. package/dist/ui/layout/status-detail-layout.d.ts +9 -0
  104. package/dist/ui/layout/status-detail-layout.js +56 -0
  105. package/dist/ui/layout/status-layout.d.ts +19 -2
  106. package/dist/ui/layout/status-layout.js +95 -83
  107. package/dist/ui/layout/wrap-rows.d.ts +2 -0
  108. package/dist/ui/layout/wrap-rows.js +21 -7
  109. package/dist/ui/overlays/ConfirmDialog.d.ts +2 -1
  110. package/dist/ui/overlays/ConfirmDialog.js +22 -3
  111. package/dist/ui/overlays/HelpOverlay.js +9 -2
  112. package/dist/ui/overlays/ModelPicker.d.ts +5 -8
  113. package/dist/ui/overlays/ModelPicker.js +44 -10
  114. package/dist/ui/overlays/ModelProfileEditor.js +2 -0
  115. package/dist/ui/overlays/ModelProfilePicker.d.ts +1 -0
  116. package/dist/ui/overlays/ModelProfilePicker.js +4 -2
  117. package/dist/ui/overlays/PlanReviewOverlay.d.ts +2 -1
  118. package/dist/ui/overlays/PlanReviewOverlay.js +7 -4
  119. package/dist/ui/overlays/QuestionOverlay.d.ts +2 -1
  120. package/dist/ui/overlays/QuestionOverlay.js +45 -32
  121. package/dist/ui/overlays/QueueOverlay.js +2 -2
  122. package/dist/ui/overlays/SettingsScreen.d.ts +1 -0
  123. package/dist/ui/overlays/SettingsScreen.js +10 -3
  124. package/dist/ui/render-cache.d.ts +2 -2
  125. package/dist/ui/render-cache.js +4 -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,10 +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';
11
- import process from 'node:process';
10
+ import { useCallback, useEffect, useLayoutEffect, useMemo, useReducer, useRef, useState } from 'react';
12
11
  import { Text, useApp, useInput, useStdout } from 'ink';
13
- import { advanceInterruptGesture, cancelInterruptConfirmation, } from '../input/interrupt-gesture.js';
12
+ import { advanceInterruptGesture, INTERRUPT_CONFIRM_MS, cancelInterruptConfirmation, } from '../input/interrupt-gesture.js';
14
13
  import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
15
14
  import { mergeDeltas } from '../agent/coalesce.js';
16
15
  import { computeCost } from '../agent/usage.js';
@@ -22,7 +21,7 @@ import { getLogger } from '../logging/logger.js';
22
21
  import { isTerminalStatus } from '../proc/types.js';
23
22
  import { getSecrets } from '../logging/secret-registry.js';
24
23
  import { redactText } from '../logging/redact.js';
25
- import { detectCapabilities } from './capabilities.js';
24
+ import { resolveTuiCapabilities } from './capabilities.js';
26
25
  import { useStartupNotices, MOUSE_NOTICE_VERSION, VT_INPUT_NOTICE_VERSION, } from './use-startup-notices.js';
27
26
  import { getTheme } from './theme.js';
28
27
  import { pickGlyphs } from './glyphs.js';
@@ -36,15 +35,8 @@ import { secondsLeft } from '../agent/retry-view.js';
36
35
  import { TeamPanel } from './TeamPanel.js';
37
36
  import { TodoPanel } from './TodoPanel.js';
38
37
  import { BottomStatusRow } from './BottomStatusRow.js';
39
- import { UpdateLine } from './UpdateLine.js';
40
- // The updater's two PURE modules. `shouldRenderUpdateLine` decides the row's
41
- // presence AT THE CALL SITE (C-15), and `UPDATE_LIMITS` owns the width
42
- // threshold - both are values, and both are resolved here rather than inside
43
- // `UpdateLine` so that component's only edge into `update/` stays erasable by
44
- // tsc (cli-auto-update section 3.1 rule 1 / IF-1). Neither module opens a
45
- // socket, spawns a process or creates a timer.
38
+ // Only the updater's pure visibility predicate enters the status detail plan.
46
39
  import { shouldRenderUpdateLine } from '../update/types.js';
47
- import { UPDATE_LIMITS } from '../update/limits.js';
48
40
  import { AppShell } from './layout/AppShell.js';
49
41
  import { ScrollViewport } from './layout/ScrollViewport.js';
50
42
  import { buildTodoRailLayout } from './layout/todo-layout.js';
@@ -54,8 +46,7 @@ import { useTerminalSize } from './layout/useTerminalSize.js';
54
46
  import { useHeightStore } from './use-height-store.js';
55
47
  import { useRenderGovernor } from './use-render-governor.js';
56
48
  import { readScrollbarPerf, setPerfResetHook, setPerfSnapshotProvider, } from '../commands/perf.js';
57
- import { MIN_FULLSCREEN_ROWS, MIN_FULLSCREEN_COLS } from './layout/frame.js';
58
- import { HINT_MIN_ROWS, clampDraftRows, viewportRows as computeViewportRows } from './layout/budget.js';
49
+ import { buildFrameBudget } from './layout/budget.js';
59
50
  import { OVERLAY_PAGE, useWheelRouting } from './use-wheel-routing.js';
60
51
  import { installConsoleBridge } from './console-bridge.js';
61
52
  import { publishExitSnapshot } from './exit-snapshot.js';
@@ -75,6 +66,9 @@ import { QueuePanel } from './QueuePanel.js';
75
66
  import { QueueOverlay } from './overlays/QueueOverlay.js';
76
67
  import { buildQueueLayout } from './layout/queue-layout.js';
77
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';
78
72
  import { emptyTailState } from './layout/follow-state.js';
79
73
  /**
80
74
  * Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
@@ -130,15 +124,13 @@ function seedViewState(thinkingVisible) {
130
124
  export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }) {
131
125
  const { exit } = useApp();
132
126
  const { stdout } = useStdout();
133
- const { rows, cols } = useTerminalSize();
134
- const tooSmall = (rows < MIN_FULLSCREEN_ROWS || cols < MIN_FULLSCREEN_COLS);
135
- const composerRef = useRef(null);
136
- const activityRef = useRef(null);
137
- const [cursorVisible, setCursorVisible] = useState(true);
138
- // Whether the run status row above the input is inside the viewport. It is
139
- // `true` while the row is not mounted (`elementVisible(null)`), so it is NEVER
140
- // a decision on its own - always AND it with `runRowEnabled` (`runRowShown`).
141
- 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;
142
134
  // --- Render governor (tui-render-performance L4). ------------------------
143
135
  //
144
136
  // FIRST, BEFORE ANY OTHER WORK IN THIS BODY. The hook stamps
@@ -156,7 +148,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
156
148
  // frame 1. `seedViewState` is module-scope so the lazy initializer is a stable
157
149
  // function reference.
158
150
  const [state, dispatch] = useReducer(viewReducer, cfg.showThinking, seedViewState);
159
- 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);
160
155
  /**
161
156
  * Forces the render that re-reads `Date.now()` for the live compaction card
162
157
  * while the run is IDLE (hardening W5). The value itself is never read - the
@@ -206,13 +201,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
206
201
  // the completion list are memoized against it, so a skill installed mid-session
207
202
  // gets its `/<name>` command and its autocomplete entry immediately.
208
203
  const [skillsNonce, setSkillsNonce] = useState(0);
209
- const caps = useMemo(() => {
210
- const detected = detectCapabilities(process.env, stdout);
211
- return {
212
- colorLevel: cfg.color === false ? 0 : cfg.colorLevel ?? detected.colorLevel,
213
- unicode: cfg.unicode ?? detected.unicode,
214
- };
215
- }, [cfg.color, cfg.colorLevel, cfg.unicode, stdout]);
204
+ const caps = useMemo(() => resolveTuiCapabilities({}, cfg.color), [cfg.color]);
216
205
  const theme = useMemo(() => getTheme(cfg.theme, caps), [cfg.theme, caps]);
217
206
  const glyphs = useMemo(() => pickGlyphs(caps), [caps]);
218
207
  const reducedMotion = cfg.reducedMotion ?? false;
@@ -227,6 +216,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
227
216
  const interactionPhase = useRef(controller.isRunning() ? 'running' : 'idle');
228
217
  const submissionSequence = useRef(0);
229
218
  const mounted = useRef(true);
219
+ const switchingConversation = useRef(false);
220
+ const drainCompactionQueue = useRef(() => { });
230
221
  /**
231
222
  * Live service snapshots, keyed by id, for the status chip and the Ctrl+C rung.
232
223
  *
@@ -246,7 +237,6 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
246
237
  */
247
238
  const servicesRef = useRef(services);
248
239
  servicesRef.current = services;
249
- const runBaselineOut = useRef(0);
250
240
  const runStartedAt = useRef(0);
251
241
  const toastTimers = useRef(new Map());
252
242
  /** Every outstanding `gate.request()`. Held in a ref, never in state: it is
@@ -320,11 +310,21 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
320
310
  aborted: false,
321
311
  errored: false,
322
312
  });
313
+ const interruptTimer = useRef(null);
323
314
  const resetInterrupt = useCallback(() => {
315
+ if (interruptTimer.current)
316
+ clearTimeout(interruptTimer.current);
317
+ interruptTimer.current = null;
324
318
  interruptGesture.current = { phase: 'ready' };
325
319
  }, []);
326
320
  const cancelConfirmation = useCallback(() => {
321
+ if (interruptGesture.current.phase !== 'armed')
322
+ return;
327
323
  interruptGesture.current = cancelInterruptConfirmation(interruptGesture.current);
324
+ if (interruptTimer.current)
325
+ clearTimeout(interruptTimer.current);
326
+ interruptTimer.current = null;
327
+ setMetricsClock(Date.now());
328
328
  }, []);
329
329
  useEffect(() => {
330
330
  if (state.overlay)
@@ -335,12 +335,41 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
335
335
  return () => {
336
336
  mounted.current = false;
337
337
  submissionSequence.current += 1;
338
- if (interactionPhase.current === 'starting')
338
+ if (interactionPhase.current === 'starting' || controller.isCompactionBusy?.())
339
339
  controller.abort();
340
340
  interactionPhase.current = 'idle';
341
341
  resetInterrupt();
342
342
  };
343
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]);
344
373
  /**
345
374
  * Disarm an armed continuation. Idempotent; NEVER touches the budget — a
346
375
  * cancelled continuation was never issued, so it must not be charged (AC-18).
@@ -454,8 +483,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
454
483
  generation = controller.runGeneration;
455
484
  return;
456
485
  }
457
- if (event.type === 'agent_start')
486
+ metrics.current = updateRunMetrics(metrics.current, event, Date.now());
487
+ if (event.type === 'agent_start') {
458
488
  interactionPhase.current = 'running';
489
+ manualMetrics.current = createRunMetrics();
490
+ }
459
491
  if (event.type === 'agent_end') {
460
492
  interactionPhase.current = 'idle';
461
493
  resetInterrupt();
@@ -529,7 +561,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
529
561
  // a fired continuation would either be swallowed into
530
562
  // `controller.steer()` (the quiet failure) or reach
531
563
  // `controller.prompt()`, which REJECTS while running.
532
- if (controller.isRunning())
564
+ if (controller.isRunning() || controller.isCompactionBusy?.())
533
565
  return;
534
566
  const live = controller.getTodoSnapshot();
535
567
  if (!live || live.doneCount >= live.total)
@@ -776,6 +808,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
776
808
  return controller.subscribeCompaction((event) => {
777
809
  switch (event.type) {
778
810
  case 'compaction_start':
811
+ if (interactionPhase.current === 'idle') {
812
+ manualMetrics.current = updateRunMetrics(createRunMetrics(), { type: 'agent_start' }, Date.now());
813
+ }
779
814
  dispatch({
780
815
  type: 'compactionStart',
781
816
  index: event.index,
@@ -784,6 +819,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
784
819
  });
785
820
  break;
786
821
  case 'compaction_end':
822
+ manualMetrics.current = freezeRunMetrics(manualMetrics.current, Date.now());
787
823
  // THE CARD, AND NOTHING ELSE (context-usage-gauge-accuracy §3.3).
788
824
  //
789
825
  // A `contextTokensEstimated` dispatch used to live here, hand-building
@@ -805,10 +841,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
805
841
  // same class of lie as the `$0.00` `pricingUnknown` exists to prevent,
806
842
  // just in the other direction.
807
843
  const snapshot = controller.getCompactionSnapshot();
808
- const ref = controller.getCompactionSummarizerRef();
809
- const costDelta = snapshot.pricingUnknown || !ref
844
+ const ref = event.modelRef ?? controller.getCompactionSummarizerRef();
845
+ const costDelta = (event.pricingUnknown ?? snapshot.pricingUnknown)
810
846
  ? 0
811
- : computeCost(event.usage, controller.getModelInfoFor(ref).cost);
847
+ : event.costUsd ?? (ref
848
+ ? computeCost(event.usage, controller.getModelInfoFor(ref).cost) : 0);
812
849
  dispatch({ type: 'compactionUsage', usage: event.usage, costDelta });
813
850
  break;
814
851
  }
@@ -854,18 +891,21 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
854
891
  }
855
892
  });
856
893
  }, [controller, flushPending, governorInterval]);
857
- // --- Elapsed timer + tokens/sec baseline while running. ----------------
894
+ // Event boundaries own start/end; ticks only refresh the live projection.
858
895
  useEffect(() => {
859
- if (state.status !== 'running') {
860
- setElapsedMs(0);
896
+ if (state.status !== 'running')
861
897
  return;
862
- }
863
- runBaselineOut.current = stateRef.current.usageTotal.outputTokens;
864
- const startedAt = Date.now();
865
- setElapsedMs(0);
866
- const id = setInterval(() => setElapsedMs(Date.now() - startedAt), 200);
898
+ const id = setInterval(() => setMetricsClock(Date.now()), 200);
867
899
  return () => clearInterval(id);
868
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]);
869
909
  // --- Live compaction elapsed tick (hardening §3.6 / W5). ---------------
870
910
  //
871
911
  // ONLY WHILE A CARD IS LIVE AND THE RUN IS NOT, so an ordinary session has no
@@ -912,6 +952,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
912
952
  for (const timer of toastTimers.current.values())
913
953
  clearTimeout(timer);
914
954
  toastTimers.current.clear();
955
+ if (ctrlCTimer.current)
956
+ clearTimeout(ctrlCTimer.current);
957
+ if (interruptTimer.current)
958
+ clearTimeout(interruptTimer.current);
915
959
  }, []);
916
960
  // --- Console bridge (fullscreen only, I-4). ----------------------------
917
961
  // Ink's own patchConsole writes straight to stdout, which shifts the fixed
@@ -1084,45 +1128,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1084
1128
  bridge.notify = null;
1085
1129
  };
1086
1130
  }, [pasteBridge]);
1087
- // --- The composer's height (tui-paste-handling section 5.5 / D-12). ------
1088
- //
1089
- // GROWTH IS IMMEDIATE, SHRINK IS DEFERRED BY ONE TICK (R-12). Growing late
1090
- // overdraws the frame for a frame; shrinking early makes a `Backspace` that
1091
- // crosses a wrap boundary bounce the whole transcript, and a run of deletions
1092
- // bounce it repeatedly. Deferring coalesces the run into one move.
1093
- //
1094
- // The ref mirrors the state so the decision is made OUTSIDE the updater: React
1095
- // may call an updater more than once, and scheduling a timer from inside one
1096
- // would arm it twice.
1097
- const [draftRows, setDraftRows] = useState(1);
1098
- const [popupRows, setPopupRows] = useState(0);
1131
+ // Synchronous layout reports keep the fixed slot and its contents in one frame.
1099
1132
  const onPopupRowsChange = useCallback((next) => {
1100
1133
  setPopupRows(previous => previous === next ? previous : next);
1101
1134
  }, []);
1102
- const draftRowsRef = useRef(1);
1103
- const shrinkTimer = useRef(null);
1104
1135
  const onDraftRows = useCallback((next) => {
1105
- const wanted = Math.max(1, next);
1106
- if (shrinkTimer.current) {
1107
- clearTimeout(shrinkTimer.current);
1108
- shrinkTimer.current = null;
1109
- }
1110
- if (wanted === draftRowsRef.current)
1111
- return;
1112
- if (wanted > draftRowsRef.current) {
1113
- draftRowsRef.current = wanted;
1114
- setDraftRows(wanted);
1115
- return;
1116
- }
1117
- shrinkTimer.current = setTimeout(() => {
1118
- shrinkTimer.current = null;
1119
- draftRowsRef.current = wanted;
1120
- setDraftRows(wanted);
1121
- }, 0);
1122
- }, []);
1123
- useEffect(() => () => {
1124
- if (shrinkTimer.current)
1125
- clearTimeout(shrinkTimer.current);
1136
+ setDraftRows(previous => previous === next ? previous : next);
1126
1137
  }, []);
1127
1138
  // --- Auto-update bridge (cli-auto-update section 3.8 / 6.3). ------------
1128
1139
  //
@@ -1178,26 +1189,26 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1178
1189
  clipboard: { write: terminal?.writeForeign },
1179
1190
  onRequest: () => {
1180
1191
  ctrlCArmed.current = false;
1192
+ setMetricsClock(Date.now());
1181
1193
  if (ctrlCTimer.current)
1182
1194
  clearTimeout(ctrlCTimer.current);
1183
1195
  ctrlCTimer.current = null;
1184
1196
  },
1185
1197
  onStateChange: setCopyState,
1186
1198
  onBusy: ({ cleanupPending }) => toast('info', cleanupPending
1187
- ? '\u590d\u5236\u5931\u8d25\uff0c\u6b63\u5728\u6e05\u7406' : '\u6b63\u5728\u590d\u5236'),
1199
+ ? 'Copy failed; cleaning up' : 'Copying'),
1188
1200
  onResult: (result, request) => {
1189
1201
  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);
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')
1199
1211
  notify('error', text);
1200
- }
1201
1212
  },
1202
1213
  }), []);
1203
1214
  useEffect(() => () => copyCoordinator.dispose(), [copyCoordinator]);
@@ -1217,6 +1228,18 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1217
1228
  // subscription on every render.
1218
1229
  // eslint-disable-next-line react-hooks/exhaustive-deps
1219
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]);
1220
1243
  const selectionController = selectionBridge?.controller ?? null;
1221
1244
  /**
1222
1245
  * I-9, the DERIVED half — and the one that matters (D-14 / P1-4).
@@ -1238,6 +1261,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1238
1261
  const onViewportShiftChange = useCallback(() => {
1239
1262
  selectionController?.clear();
1240
1263
  }, [selectionController]);
1264
+ const onGeometryChange = useCallback(() => {
1265
+ selectionController?.clear();
1266
+ terminal?.scrollbar?.controller?.cancel();
1267
+ terminal?.scrollbar?.invalidate();
1268
+ setSelectionHold(false);
1269
+ }, [selectionController, terminal]);
1241
1270
  // I-9, the ENUMERATED half. `shiftUp` covers every movement of the rows and is
1242
1271
  // wired straight into `ScrollViewport` below; these three are the cases that
1243
1272
  // change the screen while leaving `shiftUp` untouched — a key that scrolls
@@ -1315,6 +1344,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1315
1344
  state: stateRef.current,
1316
1345
  dispatch: (action) => {
1317
1346
  if (action.type === 'resetConversation' || action.type === 'restoreEntries') {
1347
+ metrics.current = createRunMetrics();
1348
+ manualMetrics.current = createRunMetrics();
1318
1349
  setScrollResetKey((n) => n + 1);
1319
1350
  const wasStarting = interactionPhase.current === 'starting';
1320
1351
  submissionSequence.current += 1;
@@ -1404,7 +1435,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1404
1435
  // must be the one that runs, whichever branch it takes.
1405
1436
  cancelFollowThrough();
1406
1437
  cancelConfirmation();
1407
- if (interactionPhase.current === 'running') {
1438
+ if (interactionPhase.current === 'running' || controller.isCompactionBusy?.()) {
1408
1439
  try {
1409
1440
  const queueId = controller.queueUserMessage(message);
1410
1441
  dispatch({ type: 'steerQueued', queueId, text: message });
@@ -1458,6 +1489,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1458
1489
  };
1459
1490
  submitRef.current = submitMessage;
1460
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
+ }
1461
1498
  // Retain a recoverable command before any asynchronous handler can fail.
1462
1499
  const safe = redactText(raw, getSecrets());
1463
1500
  recordPrompt(safe);
@@ -1469,6 +1506,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1469
1506
  const reason = error instanceof Error ? error.message : String(error);
1470
1507
  notify('error', `Command failed: ${redactText(reason, getSecrets())}\nInput: ${safe}`);
1471
1508
  }
1509
+ finally {
1510
+ if (switchWhileBusy) {
1511
+ switchingConversation.current = false;
1512
+ drainCompactionQueue.current();
1513
+ }
1514
+ }
1472
1515
  };
1473
1516
  const handleSubmit = (raw) => {
1474
1517
  const trimmed = raw.trimStart();
@@ -1619,6 +1662,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1619
1662
  (selectionController?.hasPendingSelection() ?? false)) {
1620
1663
  const payload = selectionController.takeSelection();
1621
1664
  ctrlCArmed.current = false;
1665
+ setMetricsClock(Date.now());
1622
1666
  if (ctrlCTimer.current)
1623
1667
  clearTimeout(ctrlCTimer.current);
1624
1668
  ctrlCTimer.current = null;
@@ -1654,14 +1698,25 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1654
1698
  return;
1655
1699
  }
1656
1700
  ctrlCArmed.current = true;
1657
- 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);
1658
1703
  if (ctrlCTimer.current)
1659
1704
  clearTimeout(ctrlCTimer.current);
1660
1705
  ctrlCTimer.current = setTimeout(() => {
1706
+ ctrlCTimer.current = null;
1661
1707
  ctrlCArmed.current = false;
1708
+ setMetricsClock(value => value + 1);
1662
1709
  }, 1500);
1663
1710
  return;
1664
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;
1665
1720
  // R-P1-7: the overlay branch MUST come before the transcript scroll branch
1666
1721
  // and MUST return. `PgUp` used to fall through to `scrollBy()` while an
1667
1722
  // overlay was open. The stable viewport ignores inactive intents, but routing
@@ -1803,6 +1858,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1803
1858
  const handleComposerEscape = useCallback(() => {
1804
1859
  if (stateRef.current.overlay)
1805
1860
  return;
1861
+ if (controller.isCompactionBusy?.()) {
1862
+ controller.abort();
1863
+ return;
1864
+ }
1806
1865
  if (interactionPhase.current === 'idle') {
1807
1866
  if (followTimer.current) {
1808
1867
  cancelFollowThrough();
@@ -1815,8 +1874,16 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1815
1874
  const next = advanceInterruptGesture(interruptGesture.current, performance.now());
1816
1875
  interruptGesture.current = next.state;
1817
1876
  if (next.action === 'hint') {
1818
- dispatch({ type: 'pushToast', level: 'info',
1819
- 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);
1820
1887
  return;
1821
1888
  }
1822
1889
  endReasonRef.current.aborted = true;
@@ -1826,6 +1893,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1826
1893
  interactionPhase.current = 'idle';
1827
1894
  resetInterrupt();
1828
1895
  autoContinuationRef.current = false;
1896
+ metrics.current = freezeRunMetrics(metrics.current, Date.now());
1829
1897
  if (next.action === 'force-stop')
1830
1898
  controller.forceStop();
1831
1899
  else
@@ -1836,8 +1904,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1836
1904
  }
1837
1905
  controller.abort();
1838
1906
  if (sequence === submissionSequence.current && interactionPhase.current === 'running') {
1839
- dispatch({ type: 'pushToast', level: 'info',
1840
- text: '\u6b63\u5728\u505c\u6b62' });
1907
+ setMetricsClock(value => value + 1);
1841
1908
  }
1842
1909
  }, [controller, cancelFollowThrough, resetInterrupt]);
1843
1910
  // --- Render ------------------------------------------------------------
@@ -1855,9 +1922,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1855
1922
  // RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
1856
1923
  // handler will do on the very next keystroke.
1857
1924
  const liveServices = liveServiceCount(services);
1858
- const tokPerSec = state.status === 'running' && elapsedMs > 500
1859
- ? Math.max(0, Math.round((state.usageTotal.outputTokens - runBaselineOut.current) / (elapsedMs / 1000)))
1860
- : 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;
1861
1928
  // --- The activity line's seed (§3.2.3 / P1-7). --------------------------
1862
1929
  //
1863
1930
  // RENDER SCOPE. Without this the ref holds `0` on the first run (an arbitrary
@@ -1896,9 +1963,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1896
1963
  // milliseconds because `ToolCard` is `React.memo`'d with the default
1897
1964
  // comparator; `Transcript` passes it on only to running tool entries.
1898
1965
  const nowSec = running || compactionLive ? Math.floor(Date.now() / 1000) : undefined;
1899
- const viewportBudget = computeViewportRows(rows, draftRows);
1966
+ const frameBudget = buildFrameBudget({ rows, cols, draftRows,
1967
+ popupRows: overlay ? 0 : popupRows, statusExpanded });
1968
+ const viewportBudget = frameBudget.viewportRows;
1900
1969
  const overlayMaxRows = viewportBudget;
1901
- const composerBaseRows = 2 + clampDraftRows(rows, draftRows);
1970
+ const composerBaseRows = 0;
1902
1971
  const baseRail = buildTodoRailLayout({
1903
1972
  cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
1904
1973
  itemCount: state.todos?.items.length ?? 0, viewportBudget,
@@ -1915,11 +1984,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1915
1984
  const railLayout = buildTodoRailLayout({
1916
1985
  cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
1917
1986
  itemCount: state.todos?.items.length ?? 0, viewportBudget,
1918
- teamRows: teamLayout.rowCount, queueRows: queueLayout.rows, popupRows, composerBaseRows,
1987
+ teamRows: teamLayout.rowCount, queueRows: queueLayout.rows, popupRows: 0, composerBaseRows,
1919
1988
  });
1920
1989
  const { visible: showRail, width: railWidth, rows: railRows, contentCols } = railLayout;
1921
1990
  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: {
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: {
1923
1992
  provider: cfg.provider,
1924
1993
  model: cfg.model,
1925
1994
  baseUrl: cfg.baseUrl ?? '',
@@ -1951,12 +2020,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1951
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 }),
1952
2021
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
1953
2022
  // while the screen is open shows the new value on the next paint.
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;
1955
- 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 }));
1956
2025
  // The wordmark + getting-started card now live INSIDE the viewport, as its
1957
2026
  // first block of content, so they scroll away instead of being yanked out of
1958
2027
  // the header the moment the first message lands.
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;
2028
+ const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(viewportBudget, contentCols), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: viewportBudget, theme: theme, caps: caps })) : null;
1960
2029
  const density = cfg.density;
1961
2030
  const transcriptWindow = cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW;
1962
2031
  // The fixed status bar remains visible underneath every overlay and toast.
@@ -1985,8 +2054,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1985
2054
  });
1986
2055
  },
1987
2056
  };
1988
- // Completion belongs to the document footer; cap it to keep the editor usable.
1989
- const popupMaxRows = Math.max(1, viewportBudget - 4);
2057
+ // Completion shares the fixed editor slot and its frame budget.
2058
+ const popupMaxRows = 6;
1990
2059
  // WHICH KEY THE HINT ROW NAMES (shift-tab-mode-toggle-still-dead-on-windows,
1991
2060
  // C3-1). Derived from `vtInputWarning` rather than from a probe of its own,
1992
2061
  // because that prop is already the single answer to "can this console deliver
@@ -1997,21 +2066,47 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1997
2066
  // Both keys always WORK (they share one branch in the input handler); this
1998
2067
  // only decides which one the user is taught.
1999
2068
  const modeToggleKey = vtInputWarning ? MODE_TOGGLE_KEYS.fallback : MODE_TOGGLE_KEYS.primary;
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' }) }));
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' }) }));
2001
2106
  const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2002
2107
  const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
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'
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'
2015
2110
  ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2016
2111
  // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2017
2112
  // projection of "many sources of truth" into the props layer, and a
@@ -2029,9 +2124,9 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2029
2124
  }
2030
2125
  : {}), ...(state.todos && !showRail
2031
2126
  ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
2032
- : {}), ...(liveServices > 0 ? { servicesActive: { live: liveServices } } : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}), ...(state.compaction?.live
2033
- ? { compactionActive: { inFlight: state.compaction.inFlight } }
2034
- : {}), ...(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()
2035
2130
  ? {
2036
2131
  gaugeMarks: {
2037
2132
  warn: cfg.compaction.warnThreshold * 100,