@aragon-agent/cli 0.6.7 → 0.6.9

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 (117) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +107 -77
  3. package/dist/agent/controller.d.ts +11 -7
  4. package/dist/agent/controller.js +35 -48
  5. package/dist/agent/headless.d.ts +2 -1
  6. package/dist/agent/prompt-options.d.ts +15 -0
  7. package/dist/agent/prompt-options.js +1 -0
  8. package/dist/agent/reducer.d.ts +7 -113
  9. package/dist/agent/reducer.js +0 -62
  10. package/dist/cli.js +111 -182
  11. package/dist/commands/builtins.js +3 -1
  12. package/dist/commands/perf.d.ts +32 -2
  13. package/dist/commands/perf.js +32 -2
  14. package/dist/commands/registry.d.ts +4 -5
  15. package/dist/config/env.js +0 -130
  16. package/dist/config/load.d.ts +0 -2
  17. package/dist/config/load.js +2 -31
  18. package/dist/config/schema.d.ts +1 -14
  19. package/dist/config/schema.js +0 -1
  20. package/dist/config/store.js +2 -0
  21. package/dist/input/interrupt-gesture.d.ts +18 -0
  22. package/dist/input/interrupt-gesture.js +19 -0
  23. package/dist/input/pointer-router.d.ts +11 -0
  24. package/dist/input/pointer-router.js +14 -0
  25. package/dist/logging/install.d.ts +2 -2
  26. package/dist/logging/install.js +2 -2
  27. package/dist/todo/follow-through.d.ts +0 -4
  28. package/dist/todo/follow-through.js +0 -4
  29. package/dist/todo/limits.d.ts +0 -10
  30. package/dist/todo/limits.js +0 -10
  31. package/dist/todo/prompt.d.ts +0 -15
  32. package/dist/todo/store.d.ts +8 -13
  33. package/dist/todo/store.js +13 -14
  34. package/dist/todo/types.d.ts +2 -1
  35. package/dist/ui/ActivityLine.d.ts +26 -34
  36. package/dist/ui/ActivityLine.js +30 -19
  37. package/dist/ui/App.d.ts +3 -41
  38. package/dist/ui/App.js +223 -247
  39. package/dist/ui/BottomStatusRow.d.ts +1 -3
  40. package/dist/ui/BottomStatusRow.js +4 -8
  41. package/dist/ui/Composer.d.ts +38 -5
  42. package/dist/ui/Composer.js +45 -23
  43. package/dist/ui/Header.d.ts +2 -16
  44. package/dist/ui/Header.js +3 -18
  45. package/dist/ui/Logo.d.ts +1 -17
  46. package/dist/ui/Logo.js +0 -16
  47. package/dist/ui/ModeChip.d.ts +7 -18
  48. package/dist/ui/ModeChip.js +9 -0
  49. package/dist/ui/PromptCaret.d.ts +11 -0
  50. package/dist/ui/PromptCaret.js +31 -0
  51. package/dist/ui/PromptInput.d.ts +10 -33
  52. package/dist/ui/PromptInput.js +37 -20
  53. package/dist/ui/StatusBar.d.ts +1 -11
  54. package/dist/ui/StatusBar.js +14 -8
  55. package/dist/ui/ToastStack.d.ts +2 -18
  56. package/dist/ui/ToastStack.js +4 -11
  57. package/dist/ui/Transcript.d.ts +5 -71
  58. package/dist/ui/Transcript.js +10 -199
  59. package/dist/ui/composer-rows.js +50 -9
  60. package/dist/ui/entries/AssistantEntry.d.ts +0 -23
  61. package/dist/ui/entries/AssistantEntry.js +3 -4
  62. package/dist/ui/entries/DiffView.d.ts +1 -7
  63. package/dist/ui/entries/DiffView.js +2 -2
  64. package/dist/ui/entries/ToolCard.d.ts +0 -6
  65. package/dist/ui/entries/ToolCard.js +4 -13
  66. package/dist/ui/frame-differ.d.ts +13 -0
  67. package/dist/ui/frame-differ.js +37 -24
  68. package/dist/ui/frame-observer.d.ts +17 -0
  69. package/dist/ui/frame-observer.js +81 -0
  70. package/dist/ui/frame-parser.d.ts +7 -0
  71. package/dist/ui/frame-parser.js +18 -0
  72. package/dist/ui/ink-runtime.d.ts +1 -0
  73. package/dist/ui/ink-runtime.js +21 -0
  74. package/dist/ui/layout/AppShell.d.ts +4 -58
  75. package/dist/ui/layout/AppShell.js +3 -6
  76. package/dist/ui/layout/OverlayFrame.d.ts +1 -25
  77. package/dist/ui/layout/OverlayFrame.js +0 -24
  78. package/dist/ui/layout/ScrollIndicator.d.ts +4 -3
  79. package/dist/ui/layout/ScrollIndicator.js +4 -4
  80. package/dist/ui/layout/ScrollViewport.d.ts +16 -66
  81. package/dist/ui/layout/ScrollViewport.js +195 -185
  82. package/dist/ui/layout/budget.d.ts +3 -51
  83. package/dist/ui/layout/budget.js +4 -53
  84. package/dist/ui/layout/document-layout.d.ts +13 -0
  85. package/dist/ui/layout/document-layout.js +11 -0
  86. package/dist/ui/layout/follow-state.d.ts +1 -0
  87. package/dist/ui/layout/follow-state.js +1 -3
  88. package/dist/ui/layout/frame.d.ts +0 -22
  89. package/dist/ui/layout/frame.js +0 -36
  90. package/dist/ui/layout/scroll-indicator.d.ts +1 -1
  91. package/dist/ui/layout/scroll-indicator.js +3 -2
  92. package/dist/ui/layout/scrollbar-geometry.d.ts +17 -0
  93. package/dist/ui/layout/scrollbar-geometry.js +13 -0
  94. package/dist/ui/layout/todo-layout.d.ts +2 -3
  95. package/dist/ui/layout/todo-layout.js +9 -9
  96. package/dist/ui/layout/viewport-geometry.d.ts +2 -1
  97. package/dist/ui/layout/viewport-geometry.js +2 -1
  98. package/dist/ui/layout/virtual-window.d.ts +1 -0
  99. package/dist/ui/layout/virtual-window.js +1 -1
  100. package/dist/ui/live-clamp.d.ts +1 -29
  101. package/dist/ui/live-clamp.js +1 -29
  102. package/dist/ui/overlays/HelpOverlay.js +6 -2
  103. package/dist/ui/run-status-row.d.ts +61 -0
  104. package/dist/ui/run-status-row.js +70 -0
  105. package/dist/ui/screen.d.ts +1 -1
  106. package/dist/ui/screen.js +4 -6
  107. package/dist/ui/scrollbar-controller.d.ts +30 -0
  108. package/dist/ui/scrollbar-controller.js +124 -0
  109. package/dist/ui/transcript-text.d.ts +0 -8
  110. package/dist/ui/transcript-text.js +0 -8
  111. package/dist/ui/use-startup-notices.d.ts +1 -57
  112. package/dist/ui/use-startup-notices.js +3 -54
  113. package/dist/ui/use-wheel-routing.d.ts +3 -1
  114. package/dist/ui/use-wheel-routing.js +6 -0
  115. package/package.json +2 -2
  116. package/dist/ui/TodoStrip.d.ts +0 -36
  117. package/dist/ui/TodoStrip.js +0 -32
package/dist/ui/App.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * App — the root Ink component. Owns the view reducer, subscribes to the
4
4
  * controller's event stream (with a streaming coalescer), wires global
@@ -7,7 +7,8 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
7
7
  */
8
8
  import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react';
9
9
  import process from 'node:process';
10
- import { Box, Text, useApp, useInput, useStdout } from 'ink';
10
+ import { Text, useApp, useInput, useStdout } from 'ink';
11
+ import { advanceInterruptGesture, cancelInterruptConfirmation, } from '../input/interrupt-gesture.js';
11
12
  import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
12
13
  import { mergeDeltas } from '../agent/coalesce.js';
13
14
  import { computeCost } from '../agent/usage.js';
@@ -25,14 +26,12 @@ import { pickGlyphs } from './glyphs.js';
25
26
  import { Header } from './Header.js';
26
27
  import { pickHeaderVariant, pickOpenerVariant } from './Logo.js';
27
28
  import { SessionOpener } from './SessionOpener.js';
28
- import { Transcript, TranscriptList } from './Transcript.js';
29
- import { PromptInput } from './PromptInput.js';
29
+ import { TranscriptList } from './Transcript.js';
30
30
  import { Composer } from './Composer.js';
31
31
  import { StatusBar } from './StatusBar.js';
32
32
  import { secondsLeft } from '../agent/retry-view.js';
33
33
  import { TeamPanel } from './TeamPanel.js';
34
34
  import { TodoPanel } from './TodoPanel.js';
35
- import { TodoStrip } from './TodoStrip.js';
36
35
  import { ActivityLine, liveSpinner } from './ActivityLine.js';
37
36
  import { BottomStatusRow } from './BottomStatusRow.js';
38
37
  import { UpdateLine } from './UpdateLine.js';
@@ -52,9 +51,9 @@ import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow
52
51
  import { useTerminalSize } from './layout/useTerminalSize.js';
53
52
  import { useHeightStore } from './use-height-store.js';
54
53
  import { useRenderGovernor } from './use-render-governor.js';
55
- import { setPerfResetHook, setPerfSnapshotProvider } from '../commands/perf.js';
56
- import { frameHeight, MIN_FULLSCREEN_ROWS } from './layout/frame.js';
57
- import { HINT_MIN_ROWS, viewportRows as computeViewportRows } from './layout/budget.js';
54
+ import { readScrollbarPerf, setPerfResetHook, setPerfSnapshotProvider, } from '../commands/perf.js';
55
+ import { MIN_FULLSCREEN_ROWS, MIN_FULLSCREEN_COLS } from './layout/frame.js';
56
+ import { HINT_MIN_ROWS, clampDraftRows, viewportRows as computeViewportRows } from './layout/budget.js';
58
57
  import { OVERLAY_PAGE, useWheelRouting } from './use-wheel-routing.js';
59
58
  import { installConsoleBridge } from './console-bridge.js';
60
59
  import { publishExitSnapshot } from './exit-snapshot.js';
@@ -105,15 +104,6 @@ export function resolveSettingsMaxTokens(raw, previous) {
105
104
  toast: ['warn', 'Max tokens must be a number or "auto".'],
106
105
  };
107
106
  }
108
- /**
109
- * How long rung two of the Esc ladder stays armed after rung one (§5.5).
110
- *
111
- * Four seconds: long enough that a user who watched the abort fail can react,
112
- * short enough that a stray Esc minutes later cannot force-stop a healthy run.
113
- * `runEnd` / `agent_end` disarm it too, so the window is an upper bound rather
114
- * than the only protection (R-9).
115
- */
116
- const ESC_ARM_MS = 4000;
117
107
  /** Services in a non-terminal state, from the live snapshot map. */
118
108
  function liveServiceCount(services) {
119
109
  let n = 0;
@@ -130,11 +120,18 @@ function liveServiceCount(services) {
130
120
  function seedViewState(thinkingVisible) {
131
121
  return initialViewState({ thinkingVisible });
132
122
  }
133
- export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }) {
123
+ export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }) {
134
124
  const { exit } = useApp();
135
125
  const { stdout } = useStdout();
136
- const fullscreen = mode === 'fullscreen';
137
126
  const { rows, cols } = useTerminalSize();
127
+ const tooSmall = (rows < MIN_FULLSCREEN_ROWS || cols < MIN_FULLSCREEN_COLS);
128
+ const composerRef = useRef(null);
129
+ const activityRef = useRef(null);
130
+ const [cursorVisible, setCursorVisible] = useState(true);
131
+ // Whether the run status row above the input is inside the viewport. It is
132
+ // `true` while the row is not mounted (`elementVisible(null)`), so it is NEVER
133
+ // a decision on its own - always AND it with `runRowEnabled` (`runRowShown`).
134
+ const [activityRowVisible, setActivityRowVisible] = useState(true);
138
135
  // --- Render governor (tui-render-performance L4). ------------------------
139
136
  //
140
137
  // FIRST, BEFORE ANY OTHER WORK IN THIS BODY. The hook stamps
@@ -177,6 +174,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
177
174
  // Scroll plumbing: intent goes down, one derived display number comes back.
178
175
  const [scrollIntent, setScrollIntent] = useState();
179
176
  const [pinToBottomNonce, setPinToBottomNonce] = useState(0);
177
+ const [scrollResetKey, setScrollResetKey] = useState(0);
180
178
  const [scrolledLines, setScrolledLines] = useState(0);
181
179
  /**
182
180
  * True while a selection drag holds the viewport
@@ -216,18 +214,10 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
216
214
  confirmRef.current = confirmState;
217
215
  const ctrlCArmed = useRef(false);
218
216
  const ctrlCTimer = useRef(null);
219
- /**
220
- * Rung two of Esc is armed (background-service-supervision §3.6).
221
- *
222
- * CLEARED BY `runEnd` / `agent_end` AS WELL AS BY ITS OWN TIMER, and both
223
- * halves matter: a healthy run that ended between the two presses must not be
224
- * force-stoppable retroactively, and in every healthy run the second press
225
- * lands on the idle branch and does nothing at all (R-9). Rung two only ever
226
- * fires while the run is STILL running after rung one - i.e. only when the
227
- * first press provably failed.
228
- */
229
- const escArmed = useRef(false);
230
- const escArmTimer = useRef(null);
217
+ const interruptGesture = useRef({ phase: 'ready' });
218
+ const interactionPhase = useRef(controller.isRunning() ? 'running' : 'idle');
219
+ const submissionSequence = useRef(0);
220
+ const mounted = useRef(true);
231
221
  /**
232
222
  * Live service snapshots, keyed by id, for the status chip and the Ctrl+C rung.
233
223
  *
@@ -248,16 +238,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
248
238
  const servicesRef = useRef(services);
249
239
  servicesRef.current = services;
250
240
  const runBaselineOut = useRef(0);
251
- /**
252
- * Epoch ms the current run began — the activity phrase's seed.
253
- *
254
- * ASSIGNED IN RENDER SCOPE, NOT IN THE ELAPSED EFFECT (P1-7). That effect runs
255
- * AFTER the render that first sees `status === 'running'`, so a ref it wrote
256
- * would be one frame late — and the first frame of a run is exactly when the
257
- * phrase is chosen. `Transcript.tsx` updates `highWater` / `prevLen` during
258
- * render for the same reason: a value derived from a transition is needed by
259
- * the very render that observes the transition.
260
- */
261
241
  const runStartedAt = useRef(0);
262
242
  const toastTimers = useRef(new Map());
263
243
  /** Every outstanding `gate.request()`. Held in a ref, never in state: it is
@@ -331,20 +311,27 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
331
311
  aborted: false,
332
312
  errored: false,
333
313
  });
334
- /**
335
- * Disarm rung two of the Esc ladder.
336
- *
337
- * A `useCallback` so the event subscription's dependency list can name it, and
338
- * so it is one function rather than three copies of "clear the flag and the
339
- * timer" that can drift.
340
- */
341
- const disarmEsc = useCallback(() => {
342
- escArmed.current = false;
343
- if (escArmTimer.current) {
344
- clearTimeout(escArmTimer.current);
345
- escArmTimer.current = null;
346
- }
314
+ const resetInterrupt = useCallback(() => {
315
+ interruptGesture.current = { phase: 'ready' };
347
316
  }, []);
317
+ const cancelConfirmation = useCallback(() => {
318
+ interruptGesture.current = cancelInterruptConfirmation(interruptGesture.current);
319
+ }, []);
320
+ useEffect(() => {
321
+ if (state.overlay)
322
+ cancelConfirmation();
323
+ }, [state.overlay, cancelConfirmation]);
324
+ useEffect(() => {
325
+ mounted.current = true;
326
+ return () => {
327
+ mounted.current = false;
328
+ submissionSequence.current += 1;
329
+ if (interactionPhase.current === 'starting')
330
+ controller.abort();
331
+ interactionPhase.current = 'idle';
332
+ resetInterrupt();
333
+ };
334
+ }, [controller, resetInterrupt]);
348
335
  /**
349
336
  * Disarm an armed continuation. Idempotent; NEVER touches the budget — a
350
337
  * cancelled continuation was never issued, so it must not be charged (AC-18).
@@ -437,7 +424,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
437
424
  // emits while it unwinds - `turn_start`, `tool_call_start`, `turn_end`,
438
425
  // `agent_end` - belongs to the run the user just abandoned. `runStart` /
439
426
  // `turnStart` would set `status: 'running'` again, bouncing the view straight
440
- // back out of the `idle` rung two just gave them, and stray entries would
427
+ // back out of the `idle` force-stop just gave them, and stray entries would
441
428
  // append to a transcript they believe is finished.
442
429
  //
443
430
  // A LOCAL `let`, RE-READ RATHER THAN CAPTURED ONCE: the generation this
@@ -453,6 +440,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
453
440
  generation = controller.runGeneration;
454
441
  return;
455
442
  }
443
+ if (event.type === 'agent_start')
444
+ interactionPhase.current = 'running';
445
+ if (event.type === 'agent_end') {
446
+ interactionPhase.current = 'idle';
447
+ resetInterrupt();
448
+ }
456
449
  const cost = controller.getModelInfo().cost;
457
450
  for (const action of reduceEvent(event, cost, patchSource)) {
458
451
  // MUTATED HERE, ONE INSTANT BEFORE THE ACTION THAT SETS
@@ -479,11 +472,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
479
472
  }
480
473
  if (event.type !== 'agent_end')
481
474
  return;
482
- // RUNG TWO DISARMS WITH THE RUN (§3.6 / R-9). A healthy run that ended
483
- // between the two Esc presses must not be force-stoppable retroactively:
484
- // the second press then lands on the idle branch, which is what makes the
485
- // ladder free to take rather than a footgun.
486
- disarmEsc();
487
475
  // NO OVERLAY OUTLIVES ITS RUN (P1-4). `ctx.signal` already covers the
488
476
  // abort path, so this is DELIBERATE REDUNDANCY: it makes "the card cannot
489
477
  // survive the run that opened it" a property of the App's lifecycle
@@ -503,14 +491,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
503
491
  budgetRef.current = advanceBudget(budgetRef.current, todos, autoContinuationRef.current);
504
492
  autoContinuationRef.current = false;
505
493
  const decision = decideFollowThrough({
506
- // FROM THE CONTROLLER, NOT FROM `cfg` (P1-1). This effect's dependency
507
- // list never changes, so it closes over the `cfg` object from the render
508
- // in which it mounted — and `setTodoConfig` builds a NEW object, which
509
- // that closure will never see. `cfg.todo.followThrough` would therefore
510
- // be pinned to the launch value for the whole session and
511
- // `/todo follow auto` would report success and do nothing. Note the
512
- // asymmetry with `showStrip` further down, which is right to use `cfg`
513
- // because it runs in render scope where `cfg` is re-read every frame.
514
494
  mode: controller.getTodoConfig().followThrough,
515
495
  snapshot: todos,
516
496
  runEnd: { ...endReasonRef.current },
@@ -551,7 +531,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
551
531
  done: live.doneCount,
552
532
  mode: 'auto',
553
533
  });
554
- submitRef.current(decision.message, { userInitiated: false });
534
+ submitRef.current(decision.message, { userInitiated: false, todoPolicy: 'continue' });
555
535
  }, decision.graceMs);
556
536
  }
557
537
  // A deferred `plan -> build` becomes real here and nowhere else (§3.2).
@@ -573,22 +553,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
573
553
  cancelFollowThrough();
574
554
  unsubscribe();
575
555
  };
576
- }, [controller, patchSource, flushPending, cancelPendingHuman, cancelFollowThrough, disarmEsc]);
577
- // --- Background services (background-service-supervision §3.8). ---------
578
- //
579
- // A SEPARATE SUBSCRIPTION, not a branch in the one above: `ProcEvent` is
580
- // deliberately not a member of core's `AgentEvent` union (I-5 / D-1), so it
581
- // cannot arrive through `controller.subscribe`.
582
- //
583
- // TWO SINKS PER EVENT, and they are not redundant. `setServices` holds the
584
- // LIVE state the status chip, the composer hint and the Ctrl+C rung read;
585
- // `dispatch` writes the TRANSCRIPT, which records events about a service
586
- // rather than mirroring it (D-11). Feeding one from the other would make the
587
- // chip lie about a card that has already been printed into `<Static>`.
588
- //
589
- // NOT ROUTED THROUGH THE STREAMING COALESCER. `output` is already coalesced at
590
- // the SOURCE, inside the supervisor, because `/bg` and `bash_output` consume
591
- // the same event (§6); a second buffer here would only add latency.
556
+ }, [controller, patchSource, flushPending, cancelPendingHuman, cancelFollowThrough, resetInterrupt]);
592
557
  useEffect(() => {
593
558
  return controller.subscribeProc((event) => {
594
559
  const service = event.service;
@@ -670,9 +635,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
670
635
  });
671
636
  return () => {
672
637
  unsubscribeTeam();
673
- // Nothing else disposes the runtime on an inline-mode exit or a crash
674
- // that unmounts the tree, and an orphaned child holds the event loop open
675
- // (R-15). `cli.tsx` calls this too; `dispose()` is idempotent.
676
638
  controller.dispose();
677
639
  };
678
640
  }, [controller]);
@@ -941,10 +903,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
941
903
  // Ink's own patchConsole writes straight to stdout, which shifts the fixed
942
904
  // frame by a row and breaks its line accounting for good.
943
905
  useEffect(() => {
944
- if (!fullscreen)
945
- return undefined;
946
906
  return installConsoleBridge((level, text) => dispatch({ type: 'notice', level, text }));
947
- }, [fullscreen]);
907
+ }, []);
948
908
  // `enabled` is `mouseSource`, not `cfg.mouse`: a user whose terminal drops
949
909
  // the events must never be shown advice about a mode not in effect (§6.1).
950
910
  // `cli.tsx::wantMouse` now folds the platform check into the same expression,
@@ -967,21 +927,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
967
927
  // `transcriptRetain` to `transcriptWindow` (tui-render-performance §5.1).
968
928
  cfg.transcriptRetainRequested !== undefined
969
929
  ? { requested: cfg.transcriptRetainRequested, resolved: cfg.transcriptRetain }
970
- : undefined,
971
- // Its own one-shot key, NOT `mouseNoticeSeen`: everyone this notice is for
972
- // has already run aragon at least once and already has that flag set. That
973
- // key is now a VERSION for the same reason, one turn later — the audience
974
- // for the text that finally names the fallback key is, again, exactly the
975
- // set of users who already have the old flag on disk.
976
- //
977
- // `fullscreen` goes with it because only half of the damage is mode-
978
- // independent: `Shift+Tab` is broken either way, but inline never asked for
979
- // wheel reports and does not own the scroll keys, so it must not be told
980
- // about either.
981
- vtInputWarning
930
+ : undefined, vtInputWarning
982
931
  ? {
983
932
  nodeVersion: vtInputWarning.nodeVersion,
984
- fullscreen,
985
933
  onSeen: () => setVtInputNoticeVersion(VT_INPUT_NOTICE_VERSION),
986
934
  }
987
935
  : undefined);
@@ -1278,9 +1226,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1278
1226
  return;
1279
1227
  // An overlay replaces the viewport, so a selection over it would be a
1280
1228
  // highlight over rows that are no longer there (N4).
1281
- selectionController.setEnabled(state.overlay === null);
1229
+ selectionController.setEnabled(state.overlay === null && !tooSmall);
1282
1230
  selectionController.clear();
1283
- }, [selectionController, state.overlay]);
1231
+ }, [selectionController, state.overlay, tooSmall]);
1284
1232
  const persistConfig = (patch) => {
1285
1233
  try {
1286
1234
  updatePersistedConfig(patch);
@@ -1336,7 +1284,22 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1336
1284
  args,
1337
1285
  controller,
1338
1286
  state: stateRef.current,
1339
- dispatch,
1287
+ dispatch: (action) => {
1288
+ if (action.type === 'resetConversation' || action.type === 'restoreEntries') {
1289
+ setScrollResetKey((n) => n + 1);
1290
+ const wasStarting = interactionPhase.current === 'starting';
1291
+ submissionSequence.current += 1;
1292
+ interactionPhase.current = 'idle';
1293
+ resetInterrupt();
1294
+ cancelFollowThrough();
1295
+ autoContinuationRef.current = false;
1296
+ if (wasStarting) {
1297
+ dispatch({ type: 'abortMark' });
1298
+ dispatch({ type: 'runEnd' });
1299
+ }
1300
+ }
1301
+ dispatch(action);
1302
+ },
1340
1303
  setOverlay: (overlay) => dispatch({ type: 'setOverlay', overlay }),
1341
1304
  notify,
1342
1305
  toast,
@@ -1401,11 +1364,19 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1401
1364
  // line, before the steering branch: a message typed during the grace window
1402
1365
  // must be the one that runs, whichever branch it takes.
1403
1366
  cancelFollowThrough();
1404
- if (stateRef.current.status === 'running') {
1367
+ cancelConfirmation();
1368
+ if (interactionPhase.current === 'running') {
1405
1369
  controller.steer(message);
1406
1370
  toast('info', 'Steering queued.');
1407
1371
  return;
1408
1372
  }
1373
+ if (interactionPhase.current === 'starting') {
1374
+ toast('info', 'A run is still starting. Press Esc twice to cancel.');
1375
+ return;
1376
+ }
1377
+ resetInterrupt();
1378
+ const sequence = ++submissionSequence.current;
1379
+ interactionPhase.current = 'starting';
1409
1380
  // `userInitiated: false` skips TWO things and both are wanted: the
1410
1381
  // prompt-history append, and the `submitCount` bump that drives the
1411
1382
  // composer's hint fade. An auto-continuation demonstrates nothing about what
@@ -1420,14 +1391,30 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1420
1391
  const pre = controller.preflight();
1421
1392
  if (!pre.ok) {
1422
1393
  notify('error', pre.message ?? 'Configuration error.');
1394
+ interactionPhase.current = 'idle';
1395
+ resetInterrupt();
1396
+ autoContinuationRef.current = false;
1423
1397
  dispatch({ type: 'runEnd' }); // reset status; nothing was started
1424
1398
  return;
1425
1399
  }
1426
1400
  // Fire-and-forget: events drive the UI. prompt() never rejects.
1427
- void controller.prompt(message);
1401
+ void controller.prompt(message, { todoPolicy: opts.todoPolicy ?? 'new-task' }).then((outcome) => {
1402
+ if (!mounted.current || sequence !== submissionSequence.current)
1403
+ return;
1404
+ if (outcome?.status !== 'not-started')
1405
+ return;
1406
+ interactionPhase.current = 'idle';
1407
+ resetInterrupt();
1408
+ autoContinuationRef.current = false;
1409
+ dispatch({ type: 'runEnd' });
1410
+ });
1428
1411
  };
1429
1412
  submitRef.current = submitMessage;
1430
1413
  const handleSubmit = async (raw) => {
1414
+ if (interactionPhase.current === 'running' && /^\s*\/(reset|resume)(?:\s|$)/.test(raw)) {
1415
+ notify('warn', 'Interrupt the current run before switching conversations.');
1416
+ return;
1417
+ }
1431
1418
  // Submitting is an unconditional "take me to the newest output" (§4.5).
1432
1419
  setPinToBottomNonce((n) => n + 1);
1433
1420
  const handled = await runSlashInput(registry, raw, makeCtx);
@@ -1549,17 +1536,36 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1549
1536
  }
1550
1537
  resolveHuman({ kind: 'planDecision', decision: verdict.decision, feedback: verdict.feedback });
1551
1538
  };
1552
- // --- Mouse wheel routing (wheel-scrolls-transcript-only §4.1). Table and
1553
- // coalescer live in the hook (D-13); only the wiring it cannot own stays here.
1554
- // Inline arrows are fine — the hook re-reads its options through a ref every
1555
- // render. The `Math.max(0, …)` mirrors the keyboard branch below, and for the
1556
- // same reason: `OverlayFrame` only ever reports a clamp DOWNWARD.
1557
1539
  const { clearCoalescer } = useWheelRouting({
1558
1540
  mouseSource,
1541
+ isPointerCaptured: () => terminal?.scrollbar?.controller?.isCaptured() ?? false,
1542
+ isActive: () => !tooSmall,
1559
1543
  getOverlay: () => stateRef.current.overlay,
1560
1544
  onScroll: scrollBy,
1561
1545
  onOverlayScroll: (delta) => setOverlayScroll((n) => Math.max(0, n + delta)),
1562
1546
  });
1547
+ const returnToComposer = useCallback(() => {
1548
+ clearCoalescer();
1549
+ terminal?.scrollbar?.controller?.cancel();
1550
+ if (scrolledLines > 0)
1551
+ setPinToBottomNonce((n) => n + 1);
1552
+ }, [clearCoalescer, terminal, scrolledLines]);
1553
+ useEffect(() => {
1554
+ stdout.on('resize', clearCoalescer);
1555
+ return () => { stdout.off('resize', clearCoalescer); };
1556
+ }, [stdout, clearCoalescer]);
1557
+ useEffect(() => {
1558
+ const bridge = terminal?.scrollbar;
1559
+ if (!bridge)
1560
+ return;
1561
+ bridge.onStart = () => { clearCoalescer(); terminal?.selection?.controller?.clear(); };
1562
+ bridge.requestRedraw = () => setRedrawNonce((n) => n + 1);
1563
+ return () => { bridge.onStart = undefined; bridge.requestRedraw = undefined; };
1564
+ }, [terminal, clearCoalescer]);
1565
+ useEffect(() => {
1566
+ clearCoalescer();
1567
+ terminal?.scrollbar?.controller?.cancel();
1568
+ }, [rows, cols, tooSmall, state.overlay, clearCoalescer, terminal]);
1563
1569
  // A fresh overlay always opens at the top; carrying the previous overlay's
1564
1570
  // offset over would open Help mid-list for no reason the user can see.
1565
1571
  //
@@ -1585,6 +1591,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1585
1591
  }, []);
1586
1592
  // --- Global keys -------------------------------------------------------
1587
1593
  useInput((input, key) => {
1594
+ if (!key.escape)
1595
+ cancelConfirmation();
1588
1596
  // I-9, the third clear trigger, and FIRST IN THE HANDLER so no `return`
1589
1597
  // below can skip it. Any key at all drops the selection: most of them move
1590
1598
  // the rows, and the ones that do not have still ended the gesture. It is
@@ -1631,10 +1639,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1631
1639
  }
1632
1640
  // R-P1-7: the overlay branch MUST come before the transcript scroll branch
1633
1641
  // and MUST return. `PgUp` used to fall through to `scrollBy()` while an
1634
- // overlay was open; `ScrollViewport` is unmounted then, but its
1635
- // `useEffect([intentNonce])` fires once on REMOUNT, so the stale intent was
1636
- // applied when the overlay closed and the transcript jumped a page on its
1637
- // own. Even overlays that ignore the key must swallow it here.
1642
+ // overlay was open. The stable viewport ignores inactive intents, but routing
1643
+ // must still give overlays exclusive ownership of their paging keys.
1638
1644
  if (stateRef.current.overlay) {
1639
1645
  const overlay = stateRef.current.overlay;
1640
1646
  // Modes A only. `model` / `confirm` manage their own keys; registering an
@@ -1675,7 +1681,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1675
1681
  return;
1676
1682
  }
1677
1683
  if (key.ctrl && (input === 'l' || input === 'L')) {
1678
- if (fullscreen) {
1684
+ {
1679
1685
  // Writing \x1B[2J here would erase the screen and then leave it blank:
1680
1686
  // Ink sees no output change and skips the repaint (ink.js:132 +
1681
1687
  // log-update.js:13), possibly forever on an idle empty session. Bump
@@ -1691,7 +1697,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1691
1697
  }
1692
1698
  return;
1693
1699
  }
1694
- if (fullscreen) {
1700
+ {
1695
1701
  if (key.pageUp) {
1696
1702
  scrollBy('pageUp');
1697
1703
  return;
@@ -1712,17 +1718,10 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1712
1718
  if (key.ctrl && (input === 't' || input === 'T')) {
1713
1719
  const revealing = !stateRef.current.thinkingVisible;
1714
1720
  dispatch({ type: 'toggleThinking' });
1715
- // THE ACK IS MODE-AWARE FOR THE SAME REASON THE MARKER IS (D-16 / P0-2).
1716
- // Inline mode cannot repaint what `<Static>` has already printed, so the
1717
- // toast says what it DID rather than what the user hoped. The hide
1718
- // direction needs no qualifier: hiding applies to everything drawn from
1719
- // here on, and nothing is claimed about scrollback either way.
1720
1721
  if (!revealing)
1721
1722
  toast('info', 'Thinking hidden.');
1722
- else if (fullscreen)
1723
- toast('info', 'Thinking shown.');
1724
1723
  else
1725
- toast('info', 'Thinking shown for new output.');
1724
+ toast('info', 'Thinking shown.');
1726
1725
  return;
1727
1726
  }
1728
1727
  if (key.ctrl && (input === 'o' || input === 'O')) {
@@ -1757,6 +1756,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1757
1756
  if (key.escape) {
1758
1757
  const overlay = stateRef.current.overlay;
1759
1758
  if (overlay) {
1759
+ cancelConfirmation();
1760
1760
  if (overlay === 'confirm' && confirmRef.current) {
1761
1761
  confirmRef.current.resolve(false);
1762
1762
  setConfirmState(null);
@@ -1770,65 +1770,49 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1770
1770
  dispatch({ type: 'setOverlay', overlay: null });
1771
1771
  return;
1772
1772
  }
1773
- if (stateRef.current.status === 'running') {
1774
- // RUNG TWO (§3.6 / G3). The first press asked the engine to stop; if the
1775
- // run is STILL running when the second arrives, that request provably
1776
- // failed - so this one does not ask, it takes.
1777
- //
1778
- // THREE THINGS HAPPEN, AND THE THIRD IS THE BELT-AND-BRACES.
1779
- // `controller.forceStop()` aborts, hard-kills every tracked foreground
1780
- // `bash` child (which is what actually unblocks a wedged
1781
- // `await tool.execute`) and bumps the run generation so the unwinding
1782
- // engine can no longer move the view. The local `runEnd` is dispatched
1783
- // UNCONDITIONALLY on top: even if the engine is blocked somewhere
1784
- // nothing can reach, the view returns to `idle`, the composer is usable,
1785
- // and the reducer releases every live tail. Making the view's usability
1786
- // conditional on the engine recovering is what produced the reported
1787
- // screenshot (D-6).
1788
- //
1789
- // AND THE NEXT MESSAGE MUST STILL WORK. `AgentController.prompt()` waits
1790
- // a still-running engine out and never rejects (I-8) - without that,
1791
- // "Esc, Esc, then type" would reach `Agent.prompt()`'s synchronous throw,
1792
- // become an unhandled rejection, and EXIT THE PROCESS (P0-1).
1793
- if (escArmed.current) {
1794
- disarmEsc();
1795
- controller.forceStop();
1796
- endReasonRef.current.aborted = true;
1797
- dispatch({ type: 'abortMark' });
1798
- dispatch({ type: 'runEnd' });
1799
- toast('warn', 'Force-stopped.');
1800
- return;
1801
- }
1802
- controller.abort();
1803
- // Next to `abortMark`, in the same callback, for the reason
1804
- // `endReasonRef`'s own comment gives (C-11). D-5 turns this into
1805
- // silence at `agent_end`: the user's Esc is the statement, and
1806
- // restating what they interrupted is the CLI arguing.
1807
- endReasonRef.current.aborted = true;
1808
- dispatch({ type: 'abortMark' });
1809
- escArmed.current = true;
1810
- if (escArmTimer.current)
1811
- clearTimeout(escArmTimer.current);
1812
- escArmTimer.current = setTimeout(() => {
1813
- escArmed.current = false;
1814
- escArmTimer.current = null;
1815
- }, ESC_ARM_MS);
1816
- toast('info', 'Run aborted. Esc again to force-stop.');
1817
- return;
1818
- }
1819
- // IDLE. Today this branch does nothing at all, which is what makes it free
1820
- // to take (R-4). OVERLAY PRECEDENCE IS UNCHANGED AND DELIBERATE (P2-9):
1821
- // the block above returns while an overlay is open, so with one open the
1822
- // first Esc closes the overlay and the second cancels the continuation.
1823
- // Reaching past it would make "close this dialog" silently also mean
1824
- // "abandon the plan".
1773
+ return;
1774
+ }
1775
+ });
1776
+ const handleComposerEscape = useCallback(() => {
1777
+ if (stateRef.current.overlay)
1778
+ return;
1779
+ if (interactionPhase.current === 'idle') {
1825
1780
  if (followTimer.current) {
1826
1781
  cancelFollowThrough();
1827
- toast('info', 'Auto-continue cancelled.');
1782
+ dispatch({ type: 'pushToast', level: 'info', text: 'Auto-continue cancelled.' });
1828
1783
  }
1829
1784
  return;
1830
1785
  }
1831
- });
1786
+ const sequence = submissionSequence.current;
1787
+ const phase = interactionPhase.current;
1788
+ const next = advanceInterruptGesture(interruptGesture.current, performance.now());
1789
+ interruptGesture.current = next.state;
1790
+ if (next.action === 'hint') {
1791
+ dispatch({ type: 'pushToast', level: 'info',
1792
+ text: 'Press Esc again within 1.5s to interrupt.' });
1793
+ return;
1794
+ }
1795
+ endReasonRef.current.aborted = true;
1796
+ dispatch({ type: 'abortMark' });
1797
+ if (next.action === 'force-stop' || phase === 'starting') {
1798
+ submissionSequence.current += 1;
1799
+ interactionPhase.current = 'idle';
1800
+ resetInterrupt();
1801
+ autoContinuationRef.current = false;
1802
+ if (next.action === 'force-stop')
1803
+ controller.forceStop();
1804
+ else
1805
+ controller.abort();
1806
+ dispatch({ type: 'runEnd' });
1807
+ dispatch({ type: 'pushToast', level: 'warn', text: next.action === 'force-stop' ? 'Force-stopped.' : 'Pending run cancelled.' });
1808
+ return;
1809
+ }
1810
+ controller.abort();
1811
+ if (sequence === submissionSequence.current && interactionPhase.current === 'running') {
1812
+ dispatch({ type: 'pushToast', level: 'info',
1813
+ text: 'Interrupt requested. Esc again to force-stop.' });
1814
+ }
1815
+ }, [controller, cancelFollowThrough, resetInterrupt]);
1832
1816
  // --- Render ------------------------------------------------------------
1833
1817
  // `const modelInfo = controller.getModelInfo()` AND ITS `modelKnown` COMPANION
1834
1818
  // USED TO LIVE HERE (context-usage-gauge-accuracy §4.3). They fed the status
@@ -1883,39 +1867,25 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1883
1867
  // milliseconds because `ToolCard` is `React.memo`'d with the default
1884
1868
  // comparator; `Transcript` passes it on only to running tool entries.
1885
1869
  const nowSec = running || compactionLive ? Math.floor(Date.now() / 1000) : undefined;
1886
- // Row budget for the viewport and everything sized against it (§4.2a). The
1887
- // inline path has no fixed frame, hence no height to fit into: `Infinity`
1888
- // tells `OverlayFrame` to render everything and show no position indicator.
1889
- // `draftRows` is the number `PromptInput` just reported for the rows it is
1890
- // ACTUALLY RENDERING (I-8). Defaulting it to 1 is what makes the inline path
1891
- // and every test that never types byte-identical to before.
1892
1870
  const viewportBudget = computeViewportRows(rows, draftRows);
1893
- const overlayMaxRows = fullscreen ? viewportBudget : Number.POSITIVE_INFINITY;
1871
+ const overlayMaxRows = viewportBudget;
1894
1872
  // Renderers consume these same projections. Team capacity does not depend on
1895
1873
  // popup occupancy, so a menu cannot toggle the team's collapsed state.
1874
+ // THE ONE CONDITION for "the hint row exists": it sizes the footer below AND
1875
+ // decides whether the run status row may replace that hint row while running,
1876
+ // so starting a run can never add or remove a footer row.
1877
+ const hintRowEnabled = rows >= HINT_MIN_ROWS && cfg.hints;
1878
+ const composerBaseRows = 2 + clampDraftRows(rows, draftRows) + Number(hintRowEnabled);
1896
1879
  const teamLayout = buildTeamPanelLayout({
1897
- snapshot: state.team, terminalRows: rows, availableRows: viewportBudget,
1880
+ snapshot: state.team, terminalRows: rows, availableRows: Math.max(0, viewportBudget - composerBaseRows),
1898
1881
  });
1899
1882
  const railLayout = buildTodoRailLayout({
1900
- mode, cols, panelEnabled: cfg.todo.panel, overlayOpen: overlay !== null,
1883
+ cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
1901
1884
  itemCount: state.todos?.items.length ?? 0, viewportBudget,
1902
- teamRows: teamLayout.rowCount, popupRows,
1885
+ teamRows: teamLayout.rowCount, popupRows, composerBaseRows,
1903
1886
  });
1904
- // Every viewport consumer uses contentCols; header, composer, status and
1905
- // overlays keep the full terminal width. Overlay visibility is in the gate.
1887
+ // Transcript and composer share contentCols; fixed chrome and overlays use terminal width.
1906
1888
  const { visible: showRail, width: railWidth, rows: railRows, contentCols } = railLayout;
1907
- // The inline plan strip (todo-plan-followthrough §3.7 / W2).
1908
- //
1909
- // DELIBERATELY NOT `!showRail`, which is true for FOUR different reasons and
1910
- // three of them mean "do not draw this". `cfg.todo.panel` is included because
1911
- // `--no-todo-panel` means "keep the planning, drop the display" and a strip is
1912
- // a display. Overlays are NOT consulted: inline mode's overlays are part of
1913
- // the document flow, not a modal that owns the screen.
1914
- //
1915
- // `cfg` is the right read here and `controller.getTodoConfig()` would be
1916
- // needless — this runs in RENDER scope, where `cfg` is re-read every frame.
1917
- // The asymmetry with the `agent_end` handler above is the whole of P1-1.
1918
- const showStrip = !fullscreen && cfg.todo.panel && state.todos !== null;
1919
1889
  const overlayNode = overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: cols, rows: helpRows(theme, caps), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { initial: {
1920
1890
  provider: cfg.provider,
1921
1891
  model: cfg.model,
@@ -1949,22 +1919,11 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1949
1919
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
1950
1920
  // while the screen is open shows the new value on the next paint.
1951
1921
  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;
1952
- // A resize can drop below the usable floor. Do NOT leave the alt-screen —
1953
- // entering and leaving it on every drag is worse than a placeholder — and take
1954
- // the height from `frameHeight()` like everything else, because this is the
1955
- // one path that reaches rows < 12 and a hard-coded height here would land on
1956
- // `outputHeight >= rows` exactly when the user is dragging the window (§4.11).
1957
- if (fullscreen && rows < MIN_FULLSCREEN_ROWS) {
1958
- return (_jsx(Box, { flexDirection: "column", height: frameHeight(rows), width: cols, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least ", MIN_FULLSCREEN_ROWS, " rows."] }) }));
1959
- }
1960
- // Full-screen: a constant one-row brand bar at every size, which is what makes
1961
- // the viewport monotonic and the first submit jump-free (§4.2). Inline keeps
1962
- // its v0.3.0 ternary and never calls `pickHeaderVariant`.
1963
- const header = (_jsx(Header, { version: version, cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: fullscreen ? pickHeaderVariant(cols) : empty ? 'banner' : 'bar', theme: theme, caps: caps }));
1922
+ const header = (_jsx(Header, { cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: pickHeaderVariant(cols), theme: theme, caps: caps }));
1964
1923
  // The wordmark + getting-started card now live INSIDE the viewport, as its
1965
1924
  // first block of content, so they scroll away instead of being yanked out of
1966
1925
  // the header the moment the first message lands.
1967
- const opener = empty ? (_jsx(SessionOpener, { variant: fullscreen ? pickOpenerVariant(viewportBudget, contentCols, caps) : 'none', version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: fullscreen ? viewportBudget : Number.POSITIVE_INFINITY, theme: theme, caps: caps })) : null;
1926
+ const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(viewportBudget, contentCols, caps), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: viewportBudget, theme: theme, caps: caps })) : null;
1968
1927
  const density = cfg.density;
1969
1928
  const transcriptWindow = cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW;
1970
1929
  // --- One spinner per run (single-spinner-while-running D-1 .. D-5). ------
@@ -2002,6 +1961,26 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
2002
1961
  // threading the raw flag into a fifth consumer is the one silent way back to
2003
1962
  // two spinners (R-2).
2004
1963
  const viewReducedMotion = reducedMotion || activityVisible;
1964
+ // The run status row (spinner + phrase + steer/interrupt hint) replaces the idle
1965
+ // hint row ABOVE the input while running; it exists only when that hint row
1966
+ // would, so the footer height is identical with and without it. `running`, not
1967
+ // `activityVisible`: an overlay must not flip the Composer between two layouts.
1968
+ const runRowEnabled = running && hintRowEnabled;
1969
+ // The raw `reducedMotion` flag lives here and nowhere else (D-5).
1970
+ const runActivity = {
1971
+ startedAt: runStartedAt.current,
1972
+ elapsedMs,
1973
+ reducedMotion,
1974
+ runningTool,
1975
+ // THE ROW MUST NEVER SAY SOMETHING FALSE (§6.1). Compaction runs BEFORE
1976
+ // `turn_start`, so `status` is `'running'` with no tool in flight - precisely
1977
+ // the window in which the rotating phrase would claim the model is thinking
1978
+ // while it is actually waiting on a 30k-token summarization.
1979
+ compacting: state.compaction?.inFlight === true,
1980
+ };
1981
+ // The fixed bottom row takes over the life signal once the run row scrolls out
1982
+ // of the viewport; exactly one of the two ever animates.
1983
+ const runRowShown = runRowEnabled && activityRowVisible;
2005
1984
  // Assigned during render so `/perf` reads the CURRENT frame (see `perfRef`).
2006
1985
  // `mountedEntries` is the same number `render-budget.test.tsx` asserts (AC-9).
2007
1986
  perfRef.current = {
@@ -2016,15 +1995,18 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
2016
1995
  heightsMeasured: heights.stats().measured,
2017
1996
  heightsEstimated: heights.stats().estimated,
2018
1997
  cols: contentCols,
2019
- mode: fullscreen ? 'fullscreen' : 'inline',
2020
- viewportRows: fullscreen ? viewportBudget : rows,
1998
+ mode: 'fullscreen',
1999
+ viewportRows: viewportBudget,
2021
2000
  offset: scrolledLines,
2001
+ // A getter: the bridge's `frameReady` and geometry change outside React renders.
2002
+ get scrollbar() {
2003
+ return readScrollbarPerf({
2004
+ bridge: terminal?.scrollbar, diffRender: cfg.diffRender, unicode: caps.unicode,
2005
+ });
2006
+ },
2022
2007
  };
2023
- const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, mode: mode, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }))) : (_jsxs(_Fragment, { children: [opener, _jsx(Transcript, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, mode: mode, nowSec: nowSec, theme: theme, caps: caps }), overlayNode] }));
2024
- // The completion popup lives inside the bottom chrome and can add up to 9
2025
- // rows; unbounded, it pushes the status bar past the frame height where
2026
- // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
2027
- const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
2008
+ // Completion belongs to the document footer; cap it to keep the editor usable.
2009
+ const popupMaxRows = Math.max(1, viewportBudget - 4);
2028
2010
  // WHICH KEY THE HINT ROW NAMES (shift-tab-mode-toggle-still-dead-on-windows,
2029
2011
  // C3-1). Derived from `vtInputWarning` rather than from a probe of its own,
2030
2012
  // because that prop is already the single answer to "can this console deliver
@@ -2035,26 +2017,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
2035
2017
  // Both keys always WORK (they share one branch in the input handler); this
2036
2018
  // only decides which one the user is taught.
2037
2019
  const modeToggleKey = vtInputWarning ? MODE_TOGGLE_KEYS.fallback : MODE_TOGGLE_KEYS.primary;
2038
- const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, services: liveServices, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, popupMaxHeight: railLayout.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, scrolledLines: scrolledLines, onDraftRows: onDraftRows, onNotice: notify, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
2039
- return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: fullscreen ? teamLayout : undefined, rows: rows, cols: cols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null, rail: showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null, strip: showStrip && state.todos ? (_jsx(TodoStrip, { snapshot: state.todos, cols: cols, theme: theme, caps: caps })) : null, toast: _jsx(BottomStatusRow, { mode: mode, toasts: state.toasts,
2040
- // `!overlayNode` suppresses the line while an overlay owns the screen:
2041
- // the full-screen branch replaces the whole viewport with the overlay,
2042
- // and a working line under a settings screen is noise attached to a
2043
- // surface the run is not visible on. (Inline overlays are part of the
2044
- // document flow, so there the suppression is conservative rather than
2045
- // necessary — P2-5.)
2046
- //
2047
- // THE CONDITION IS `activityVisible`, THE SAME CONST THE SUPPRESSION
2048
- // SIGNAL IS DERIVED FROM (D-4): inlining it here again is how the two
2049
- // drift apart, and the drift is silent in whichever branch is not
2050
- // edited.
2051
- activity: activityVisible ? (_jsx(ActivityLine, { startedAt: runStartedAt.current, elapsedMs: elapsedMs, reducedMotion: reducedMotion, runningTool: runningTool,
2052
- // THE ROW MUST NEVER SAY SOMETHING FALSE (§6.1). Compaction runs
2053
- // BEFORE `turn_start`, so `status` is `'running'` with no tool in
2054
- // flight — precisely the window in which the rotating phrase
2055
- // would claim the model is thinking while it is actually waiting
2056
- // on a 30k-token summarization.
2057
- compacting: state.compaction?.inFlight === true, theme: theme, caps: caps })) : null,
2020
+ const composer = (_jsx(Composer, { measureRef: composerRef, runRow: runRowEnabled
2021
+ ? { activity: runActivity, live: runRowShown && !overlayNode, rowRef: activityRef }
2022
+ : null, cols: contentCols, cursorVisible: cursorVisible, onInteraction: returnToComposer, reducedMotion: cfg.reducedMotion ?? false, onEscape: handleComposerEscape, onEscapeDismiss: cancelConfirmation, isActive: overlay === null && !tooSmall, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, services: liveServices, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, popupMaxHeight: railLayout.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, scrolledLines: scrolledLines, onDraftRows: onDraftRows, onNotice: notify, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }));
2023
+ const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2024
+ const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2025
+ const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: tooSmall ? null : overlayNode, footer: _jsxs(_Fragment, { children: [team, composer] }), rail: rail, scrollbar: terminal?.scrollbar, composerRef: composerRef, onComposerVisibilityChange: setCursorVisible, activityRef: activityRef, onActivityVisibilityChange: setActivityRowVisible, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }));
2026
+ return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, toast: _jsx(BottomStatusRow, { toasts: state.toasts, activity: activityVisible && !runRowShown ? (_jsx(ActivityLine, { ...runActivity, theme: theme, caps: caps })) : null,
2058
2027
  // THE FORM THE LIFE SIGNAL TAKES WHEN A TOAST HAS THE ROW
2059
2028
  // (`activity-spinner-vanishes-behind-toast`). Passed UNCONDITIONALLY:
2060
2029
  // `BottomStatusRow` reads it only when `activity` above is non-null,
@@ -2074,7 +2043,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
2074
2043
  // branch replaces the whole viewport with the overlay, and an update
2075
2044
  // notice under a settings screen is noise attached to a surface it is
2076
2045
  // not visible on.
2077
- update: updateSnapshot && !overlayNode && shouldRenderUpdateLine(updateSnapshot) ? (_jsx(UpdateLine, { snapshot: updateSnapshot, compact: cols < UPDATE_LIMITS.statusCompactCols, theme: theme, caps: caps })) : null, theme: theme }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2046
+ //
2047
+ // `!running` (cli-auto-update D-2): the update notice stays silent for
2048
+ // the whole run. It used to be guaranteed by the activity line
2049
+ // out-ranking it inside this row; with the activity line moved above
2050
+ // the input, the row can be free during a run, so the silence is stated
2051
+ // here instead of inherited.
2052
+ update: updateSnapshot && !overlayNode && !running && shouldRenderUpdateLine(updateSnapshot) ? (_jsx(UpdateLine, { snapshot: updateSnapshot, compact: cols < UPDATE_LIMITS.statusCompactCols, theme: theme, caps: caps })) : null, theme: theme }), status: _jsx(StatusBar, { interruptHint: interruptGesture.current.phase === 'stopping'
2053
+ ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2078
2054
  // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2079
2055
  // projection of "many sources of truth" into the props layer, and a
2080
2056
  // caller could update three of them; a single reading cannot be