@aragon-agent/cli 0.6.8 → 0.6.10

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 (97) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +89 -44
  3. package/dist/agent/controller.d.ts +1 -1
  4. package/dist/agent/reducer.d.ts +31 -113
  5. package/dist/agent/reducer.js +41 -63
  6. package/dist/cli.js +53 -155
  7. package/dist/commands/builtins.d.ts +2 -1
  8. package/dist/commands/builtins.js +47 -2
  9. package/dist/commands/perf.d.ts +32 -2
  10. package/dist/commands/perf.js +32 -2
  11. package/dist/commands/registry.d.ts +2 -2
  12. package/dist/config/env.js +0 -130
  13. package/dist/config/load.d.ts +0 -2
  14. package/dist/config/load.js +2 -31
  15. package/dist/config/schema.d.ts +1 -14
  16. package/dist/config/schema.js +0 -1
  17. package/dist/config/store.js +2 -0
  18. package/dist/input/enter-sequences.d.ts +63 -0
  19. package/dist/input/enter-sequences.js +151 -0
  20. package/dist/input/limits.d.ts +11 -0
  21. package/dist/input/limits.js +11 -0
  22. package/dist/input/stdin-filter.js +19 -1
  23. package/dist/logging/install.d.ts +2 -2
  24. package/dist/logging/install.js +2 -2
  25. package/dist/session/persist.js +20 -0
  26. package/dist/todo/follow-through.d.ts +0 -4
  27. package/dist/todo/follow-through.js +0 -4
  28. package/dist/todo/limits.d.ts +0 -10
  29. package/dist/todo/limits.js +0 -10
  30. package/dist/todo/prompt.d.ts +0 -15
  31. package/dist/ui/ActivityLine.d.ts +26 -34
  32. package/dist/ui/ActivityLine.js +30 -19
  33. package/dist/ui/App.d.ts +1 -41
  34. package/dist/ui/App.js +94 -129
  35. package/dist/ui/BottomStatusRow.d.ts +1 -3
  36. package/dist/ui/BottomStatusRow.js +4 -8
  37. package/dist/ui/Composer.d.ts +28 -1
  38. package/dist/ui/Composer.js +46 -17
  39. package/dist/ui/Header.d.ts +2 -16
  40. package/dist/ui/Header.js +3 -18
  41. package/dist/ui/Logo.d.ts +1 -17
  42. package/dist/ui/Logo.js +0 -16
  43. package/dist/ui/ModeChip.d.ts +7 -18
  44. package/dist/ui/ModeChip.js +9 -0
  45. package/dist/ui/PromptInput.d.ts +2 -28
  46. package/dist/ui/PromptInput.js +38 -9
  47. package/dist/ui/StatusBar.d.ts +0 -11
  48. package/dist/ui/ToastStack.d.ts +2 -18
  49. package/dist/ui/ToastStack.js +4 -11
  50. package/dist/ui/Transcript.d.ts +5 -71
  51. package/dist/ui/Transcript.js +15 -199
  52. package/dist/ui/enter-frames.d.ts +63 -0
  53. package/dist/ui/enter-frames.js +136 -0
  54. package/dist/ui/entries/AssistantEntry.d.ts +0 -23
  55. package/dist/ui/entries/AssistantEntry.js +3 -4
  56. package/dist/ui/entries/DiffView.d.ts +1 -7
  57. package/dist/ui/entries/DiffView.js +2 -2
  58. package/dist/ui/entries/QueuedEntry.d.ts +27 -0
  59. package/dist/ui/entries/QueuedEntry.js +30 -0
  60. package/dist/ui/entries/ToolCard.d.ts +0 -6
  61. package/dist/ui/entries/ToolCard.js +4 -13
  62. package/dist/ui/frame-differ.d.ts +13 -0
  63. package/dist/ui/frame-differ.js +36 -6
  64. package/dist/ui/glyphs.d.ts +9 -0
  65. package/dist/ui/glyphs.js +2 -0
  66. package/dist/ui/ink-runtime.d.ts +1 -0
  67. package/dist/ui/ink-runtime.js +21 -0
  68. package/dist/ui/layout/AppShell.d.ts +1 -10
  69. package/dist/ui/layout/AppShell.js +2 -5
  70. package/dist/ui/layout/OverlayFrame.d.ts +1 -25
  71. package/dist/ui/layout/OverlayFrame.js +0 -24
  72. package/dist/ui/layout/ScrollViewport.d.ts +3 -0
  73. package/dist/ui/layout/ScrollViewport.js +17 -5
  74. package/dist/ui/layout/frame.d.ts +0 -22
  75. package/dist/ui/layout/frame.js +0 -36
  76. package/dist/ui/layout/todo-layout.d.ts +1 -3
  77. package/dist/ui/layout/todo-layout.js +7 -8
  78. package/dist/ui/layout/viewport-geometry.d.ts +1 -1
  79. package/dist/ui/layout/viewport-geometry.js +1 -1
  80. package/dist/ui/live-clamp.d.ts +1 -29
  81. package/dist/ui/live-clamp.js +1 -29
  82. package/dist/ui/overlays/HelpOverlay.js +3 -1
  83. package/dist/ui/overlays/PlanReviewOverlay.js +5 -1
  84. package/dist/ui/overlays/QuestionOverlay.js +7 -2
  85. package/dist/ui/overlays/SettingsScreen.js +8 -1
  86. package/dist/ui/run-status-row.d.ts +61 -0
  87. package/dist/ui/run-status-row.js +70 -0
  88. package/dist/ui/selection/selection-controller.d.ts +23 -7
  89. package/dist/ui/selection/selection-controller.js +68 -16
  90. package/dist/ui/transcript-text.d.ts +0 -8
  91. package/dist/ui/transcript-text.js +0 -8
  92. package/dist/ui/use-startup-notices.d.ts +6 -58
  93. package/dist/ui/use-startup-notices.js +10 -57
  94. package/dist/ui/use-wheel-routing.d.ts +1 -1
  95. package/package.json +2 -2
  96. package/dist/ui/TodoStrip.d.ts +0 -36
  97. package/dist/ui/TodoStrip.js +0 -32
package/dist/ui/App.js CHANGED
@@ -7,7 +7,7 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } 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
11
  import { advanceInterruptGesture, cancelInterruptConfirmation, } from '../input/interrupt-gesture.js';
12
12
  import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
13
13
  import { mergeDeltas } from '../agent/coalesce.js';
@@ -26,14 +26,12 @@ import { pickGlyphs } from './glyphs.js';
26
26
  import { Header } from './Header.js';
27
27
  import { pickHeaderVariant, pickOpenerVariant } from './Logo.js';
28
28
  import { SessionOpener } from './SessionOpener.js';
29
- import { Transcript, TranscriptList } from './Transcript.js';
30
- import { PromptInput } from './PromptInput.js';
29
+ import { TranscriptList } from './Transcript.js';
31
30
  import { Composer } from './Composer.js';
32
31
  import { StatusBar } from './StatusBar.js';
33
32
  import { secondsLeft } from '../agent/retry-view.js';
34
33
  import { TeamPanel } from './TeamPanel.js';
35
34
  import { TodoPanel } from './TodoPanel.js';
36
- import { TodoStrip } from './TodoStrip.js';
37
35
  import { ActivityLine, liveSpinner } from './ActivityLine.js';
38
36
  import { BottomStatusRow } from './BottomStatusRow.js';
39
37
  import { UpdateLine } from './UpdateLine.js';
@@ -53,7 +51,7 @@ import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow
53
51
  import { useTerminalSize } from './layout/useTerminalSize.js';
54
52
  import { useHeightStore } from './use-height-store.js';
55
53
  import { useRenderGovernor } from './use-render-governor.js';
56
- import { setPerfResetHook, setPerfSnapshotProvider } from '../commands/perf.js';
54
+ import { readScrollbarPerf, setPerfResetHook, setPerfSnapshotProvider, } from '../commands/perf.js';
57
55
  import { MIN_FULLSCREEN_ROWS, MIN_FULLSCREEN_COLS } from './layout/frame.js';
58
56
  import { HINT_MIN_ROWS, clampDraftRows, viewportRows as computeViewportRows } from './layout/budget.js';
59
57
  import { OVERLAY_PAGE, useWheelRouting } from './use-wheel-routing.js';
@@ -70,6 +68,7 @@ import { CommandRegistry, runSlashInput } from '../commands/registry.js';
70
68
  import { registerBuiltinCommands } from '../commands/builtins.js';
71
69
  import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
72
70
  import { MODE_LABEL, MODE_TOGGLE_KEYS, nextMode } from '../agent/agent-mode.js';
71
+ import { copyText } from './clipboard.js';
73
72
  import { emptyTailState } from './layout/follow-state.js';
74
73
  /**
75
74
  * Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
@@ -122,14 +121,18 @@ function liveServiceCount(services) {
122
121
  function seedViewState(thinkingVisible) {
123
122
  return initialViewState({ thinkingVisible });
124
123
  }
125
- export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }) {
124
+ export function App({ controller, version, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }) {
126
125
  const { exit } = useApp();
127
126
  const { stdout } = useStdout();
128
- const fullscreen = mode === 'fullscreen';
129
127
  const { rows, cols } = useTerminalSize();
130
- const tooSmall = fullscreen && (rows < MIN_FULLSCREEN_ROWS || cols < MIN_FULLSCREEN_COLS);
128
+ const tooSmall = (rows < MIN_FULLSCREEN_ROWS || cols < MIN_FULLSCREEN_COLS);
131
129
  const composerRef = useRef(null);
130
+ const activityRef = useRef(null);
132
131
  const [cursorVisible, setCursorVisible] = useState(true);
132
+ // Whether the run status row above the input is inside the viewport. It is
133
+ // `true` while the row is not mounted (`elementVisible(null)`), so it is NEVER
134
+ // a decision on its own - always AND it with `runRowEnabled` (`runRowShown`).
135
+ const [activityRowVisible, setActivityRowVisible] = useState(true);
133
136
  // --- Render governor (tui-render-performance L4). ------------------------
134
137
  //
135
138
  // FIRST, BEFORE ANY OTHER WORK IN THIS BODY. The hook stamps
@@ -236,16 +239,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
236
239
  const servicesRef = useRef(services);
237
240
  servicesRef.current = services;
238
241
  const runBaselineOut = useRef(0);
239
- /**
240
- * Epoch ms the current run began — the activity phrase's seed.
241
- *
242
- * ASSIGNED IN RENDER SCOPE, NOT IN THE ELAPSED EFFECT (P1-7). That effect runs
243
- * AFTER the render that first sees `status === 'running'`, so a ref it wrote
244
- * would be one frame late — and the first frame of a run is exactly when the
245
- * phrase is chosen. `Transcript.tsx` updates `highWater` / `prevLen` during
246
- * render for the same reason: a value derived from a transition is needed by
247
- * the very render that observes the transition.
248
- */
249
242
  const runStartedAt = useRef(0);
250
243
  const toastTimers = useRef(new Map());
251
244
  /** Every outstanding `gate.request()`. Held in a ref, never in state: it is
@@ -499,14 +492,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
499
492
  budgetRef.current = advanceBudget(budgetRef.current, todos, autoContinuationRef.current);
500
493
  autoContinuationRef.current = false;
501
494
  const decision = decideFollowThrough({
502
- // FROM THE CONTROLLER, NOT FROM `cfg` (P1-1). This effect's dependency
503
- // list never changes, so it closes over the `cfg` object from the render
504
- // in which it mounted — and `setTodoConfig` builds a NEW object, which
505
- // that closure will never see. `cfg.todo.followThrough` would therefore
506
- // be pinned to the launch value for the whole session and
507
- // `/todo follow auto` would report success and do nothing. Note the
508
- // asymmetry with `showStrip` further down, which is right to use `cfg`
509
- // because it runs in render scope where `cfg` is re-read every frame.
510
495
  mode: controller.getTodoConfig().followThrough,
511
496
  snapshot: todos,
512
497
  runEnd: { ...endReasonRef.current },
@@ -570,21 +555,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
570
555
  unsubscribe();
571
556
  };
572
557
  }, [controller, patchSource, flushPending, cancelPendingHuman, cancelFollowThrough, resetInterrupt]);
573
- // --- Background services (background-service-supervision §3.8). ---------
574
- //
575
- // A SEPARATE SUBSCRIPTION, not a branch in the one above: `ProcEvent` is
576
- // deliberately not a member of core's `AgentEvent` union (I-5 / D-1), so it
577
- // cannot arrive through `controller.subscribe`.
578
- //
579
- // TWO SINKS PER EVENT, and they are not redundant. `setServices` holds the
580
- // LIVE state the status chip, the composer hint and the Ctrl+C rung read;
581
- // `dispatch` writes the TRANSCRIPT, which records events about a service
582
- // rather than mirroring it (D-11). Feeding one from the other would make the
583
- // chip lie about a card that has already been printed into `<Static>`.
584
- //
585
- // NOT ROUTED THROUGH THE STREAMING COALESCER. `output` is already coalesced at
586
- // the SOURCE, inside the supervisor, because `/bg` and `bash_output` consume
587
- // the same event (§6); a second buffer here would only add latency.
588
558
  useEffect(() => {
589
559
  return controller.subscribeProc((event) => {
590
560
  const service = event.service;
@@ -666,9 +636,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
666
636
  });
667
637
  return () => {
668
638
  unsubscribeTeam();
669
- // Nothing else disposes the runtime on an inline-mode exit or a crash
670
- // that unmounts the tree, and an orphaned child holds the event loop open
671
- // (R-15). `cli.tsx` calls this too; `dispose()` is idempotent.
672
639
  controller.dispose();
673
640
  };
674
641
  }, [controller]);
@@ -937,10 +904,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
937
904
  // Ink's own patchConsole writes straight to stdout, which shifts the fixed
938
905
  // frame by a row and breaks its line accounting for good.
939
906
  useEffect(() => {
940
- if (!fullscreen)
941
- return undefined;
942
907
  return installConsoleBridge((level, text) => dispatch({ type: 'notice', level, text }));
943
- }, [fullscreen]);
908
+ }, []);
944
909
  // `enabled` is `mouseSource`, not `cfg.mouse`: a user whose terminal drops
945
910
  // the events must never be shown advice about a mode not in effect (§6.1).
946
911
  // `cli.tsx::wantMouse` now folds the platform check into the same expression,
@@ -963,21 +928,9 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
963
928
  // `transcriptRetain` to `transcriptWindow` (tui-render-performance §5.1).
964
929
  cfg.transcriptRetainRequested !== undefined
965
930
  ? { requested: cfg.transcriptRetainRequested, resolved: cfg.transcriptRetain }
966
- : undefined,
967
- // Its own one-shot key, NOT `mouseNoticeSeen`: everyone this notice is for
968
- // has already run aragon at least once and already has that flag set. That
969
- // key is now a VERSION for the same reason, one turn later — the audience
970
- // for the text that finally names the fallback key is, again, exactly the
971
- // set of users who already have the old flag on disk.
972
- //
973
- // `fullscreen` goes with it because only half of the damage is mode-
974
- // independent: `Shift+Tab` is broken either way, but inline never asked for
975
- // wheel reports and does not own the scroll keys, so it must not be told
976
- // about either.
977
- vtInputWarning
931
+ : undefined, vtInputWarning
978
932
  ? {
979
933
  nodeVersion: vtInputWarning.nodeVersion,
980
- fullscreen,
981
934
  onSeen: () => setVtInputNoticeVersion(VT_INPUT_NOTICE_VERSION),
982
935
  }
983
936
  : undefined);
@@ -1415,7 +1368,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1415
1368
  cancelConfirmation();
1416
1369
  if (interactionPhase.current === 'running') {
1417
1370
  controller.steer(message);
1418
- toast('info', 'Steering queued.');
1371
+ // THE TRANSCRIPT ENTRY IS THE FEEDBACK (tui-shift-enter-copy-queue
1372
+ // 5.3). A 2.5 s toast cannot satisfy "until the message is picked
1373
+ // up": the `queued` entry stays visible and is rewritten in place
1374
+ // as the user message it became on the next `turn_start`. Keeping
1375
+ // the toast too would confirm one event twice.
1376
+ dispatch({ type: 'steerQueued', text: message });
1419
1377
  return;
1420
1378
  }
1421
1379
  if (interactionPhase.current === 'starting') {
@@ -1584,11 +1542,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1584
1542
  }
1585
1543
  resolveHuman({ kind: 'planDecision', decision: verdict.decision, feedback: verdict.feedback });
1586
1544
  };
1587
- // --- Mouse wheel routing (wheel-scrolls-transcript-only §4.1). Table and
1588
- // coalescer live in the hook (D-13); only the wiring it cannot own stays here.
1589
- // Inline arrows are fine — the hook re-reads its options through a ref every
1590
- // render. The `Math.max(0, …)` mirrors the keyboard branch below, and for the
1591
- // same reason: `OverlayFrame` only ever reports a clamp DOWNWARD.
1592
1545
  const { clearCoalescer } = useWheelRouting({
1593
1546
  mouseSource,
1594
1547
  isPointerCaptured: () => terminal?.scrollbar?.controller?.isCaptured() ?? false,
@@ -1655,6 +1608,27 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1655
1608
  // `Esc` IS DELIBERATELY NOT GIVEN A SECOND MEANING (§4.4.6). It aborts a
1656
1609
  // run, and an emergency exit is the last key that should silently absorb a
1657
1610
  // press; it clears the selection here like every other key and nothing more.
1611
+ //
1612
+ // THE ONE EXCEPTION, AND IT SITS AHEAD OF THE UNCONDITIONAL CLEAR
1613
+ // (tui-shift-enter-copy-queue 4.3): Ctrl+C over a RELEASED, non-empty
1614
+ // selection means COPY. Selecting is browsing; copying is committing,
1615
+ // and the commit key is the one a terminal user already reaches for.
1616
+ // `takeSelection` runs BEFORE `copyText` because the copy writes OSC
1617
+ // 52 through `writeForeign`, which invalidates the differ and would
1618
+ // otherwise re-clear this controller a tick later (R7). A failed copy
1619
+ // (`via === 'none'`) still reports through the shared toast funnel and
1620
+ // returns: the selection is already consumed, and the NEXT Ctrl+C
1621
+ // walks the original ladder.
1622
+ if (key.ctrl &&
1623
+ (input === 'c' || input === 'C') &&
1624
+ (selectionController?.hasPendingSelection() ?? false)) {
1625
+ const payload = selectionController.takeSelection();
1626
+ if (payload) {
1627
+ const via = copyText(payload.text, terminal?.writeForeign);
1628
+ selectionBridge?.onCopied?.(via, payload.lines, payload.text.length);
1629
+ return; // copied -- no service stop, no arm, no clear
1630
+ }
1631
+ }
1658
1632
  selectionController?.clear();
1659
1633
  if (key.ctrl && (input === 'c' || input === 'C')) {
1660
1634
  // RUNG ONE: Ctrl+C acquires the meaning a terminal user already expects
@@ -1734,7 +1708,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1734
1708
  return;
1735
1709
  }
1736
1710
  if (key.ctrl && (input === 'l' || input === 'L')) {
1737
- if (fullscreen) {
1711
+ {
1738
1712
  // Writing \x1B[2J here would erase the screen and then leave it blank:
1739
1713
  // Ink sees no output change and skips the repaint (ink.js:132 +
1740
1714
  // log-update.js:13), possibly forever on an idle empty session. Bump
@@ -1750,7 +1724,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1750
1724
  }
1751
1725
  return;
1752
1726
  }
1753
- if (fullscreen) {
1727
+ {
1754
1728
  if (key.pageUp) {
1755
1729
  scrollBy('pageUp');
1756
1730
  return;
@@ -1771,17 +1745,10 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1771
1745
  if (key.ctrl && (input === 't' || input === 'T')) {
1772
1746
  const revealing = !stateRef.current.thinkingVisible;
1773
1747
  dispatch({ type: 'toggleThinking' });
1774
- // THE ACK IS MODE-AWARE FOR THE SAME REASON THE MARKER IS (D-16 / P0-2).
1775
- // Inline mode cannot repaint what `<Static>` has already printed, so the
1776
- // toast says what it DID rather than what the user hoped. The hide
1777
- // direction needs no qualifier: hiding applies to everything drawn from
1778
- // here on, and nothing is claimed about scrollback either way.
1779
1748
  if (!revealing)
1780
1749
  toast('info', 'Thinking hidden.');
1781
- else if (fullscreen)
1782
- toast('info', 'Thinking shown.');
1783
1750
  else
1784
- toast('info', 'Thinking shown for new output.');
1751
+ toast('info', 'Thinking shown.');
1785
1752
  return;
1786
1753
  }
1787
1754
  if (key.ctrl && (input === 'o' || input === 'O')) {
@@ -1927,40 +1894,25 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1927
1894
  // milliseconds because `ToolCard` is `React.memo`'d with the default
1928
1895
  // comparator; `Transcript` passes it on only to running tool entries.
1929
1896
  const nowSec = running || compactionLive ? Math.floor(Date.now() / 1000) : undefined;
1930
- // Row budget for the viewport and everything sized against it (§4.2a). The
1931
- // inline path has no fixed frame, hence no height to fit into: `Infinity`
1932
- // tells `OverlayFrame` to render everything and show no position indicator.
1933
- // `draftRows` is the number `PromptInput` just reported for the rows it is
1934
- // ACTUALLY RENDERING (I-8). Defaulting it to 1 is what makes the inline path
1935
- // and every test that never types byte-identical to before.
1936
1897
  const viewportBudget = computeViewportRows(rows, draftRows);
1937
- const overlayMaxRows = fullscreen ? viewportBudget : Number.POSITIVE_INFINITY;
1898
+ const overlayMaxRows = viewportBudget;
1938
1899
  // Renderers consume these same projections. Team capacity does not depend on
1939
1900
  // popup occupancy, so a menu cannot toggle the team's collapsed state.
1940
- const composerBaseRows = 2 + clampDraftRows(rows, draftRows)
1941
- + Number(rows >= HINT_MIN_ROWS && cfg.hints);
1901
+ // THE ONE CONDITION for "the hint row exists": it sizes the footer below AND
1902
+ // decides whether the run status row may replace that hint row while running,
1903
+ // so starting a run can never add or remove a footer row.
1904
+ const hintRowEnabled = rows >= HINT_MIN_ROWS && cfg.hints;
1905
+ const composerBaseRows = 2 + clampDraftRows(rows, draftRows) + Number(hintRowEnabled);
1942
1906
  const teamLayout = buildTeamPanelLayout({
1943
1907
  snapshot: state.team, terminalRows: rows, availableRows: Math.max(0, viewportBudget - composerBaseRows),
1944
1908
  });
1945
1909
  const railLayout = buildTodoRailLayout({
1946
- mode, cols: fullscreen ? cols - 1 : cols, panelEnabled: cfg.todo.panel, overlayOpen: false,
1910
+ cols: cols - 1, panelEnabled: cfg.todo.panel, overlayOpen: false,
1947
1911
  itemCount: state.todos?.items.length ?? 0, viewportBudget,
1948
1912
  teamRows: teamLayout.rowCount, popupRows, composerBaseRows,
1949
1913
  });
1950
1914
  // Transcript and composer share contentCols; fixed chrome and overlays use terminal width.
1951
1915
  const { visible: showRail, width: railWidth, rows: railRows, contentCols } = railLayout;
1952
- // The inline plan strip (todo-plan-followthrough §3.7 / W2).
1953
- //
1954
- // DELIBERATELY NOT `!showRail`, which is true for FOUR different reasons and
1955
- // three of them mean "do not draw this". `cfg.todo.panel` is included because
1956
- // `--no-todo-panel` means "keep the planning, drop the display" and a strip is
1957
- // a display. Overlays are NOT consulted: inline mode's overlays are part of
1958
- // the document flow, not a modal that owns the screen.
1959
- //
1960
- // `cfg` is the right read here and `controller.getTodoConfig()` would be
1961
- // needless — this runs in RENDER scope, where `cfg` is re-read every frame.
1962
- // The asymmetry with the `agent_end` handler above is the whole of P1-1.
1963
- const showStrip = !fullscreen && cfg.todo.panel && state.todos !== null;
1964
1916
  const overlayNode = overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: cols, rows: helpRows(theme, caps), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: cols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { initial: {
1965
1917
  provider: cfg.provider,
1966
1918
  model: cfg.model,
@@ -1994,14 +1946,11 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1994
1946
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
1995
1947
  // while the screen is open shows the new value on the next paint.
1996
1948
  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;
1997
- // Full-screen: a constant one-row brand bar at every size, which is what makes
1998
- // the viewport monotonic and the first submit jump-free (§4.2). Inline keeps
1999
- // its v0.3.0 ternary and never calls `pickHeaderVariant`.
2000
- 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 }));
1949
+ const header = (_jsx(Header, { cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: pickHeaderVariant(cols), theme: theme, caps: caps }));
2001
1950
  // The wordmark + getting-started card now live INSIDE the viewport, as its
2002
1951
  // first block of content, so they scroll away instead of being yanked out of
2003
1952
  // the header the moment the first message lands.
2004
- 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;
1953
+ const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(viewportBudget, contentCols, caps), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: viewportBudget, theme: theme, caps: caps })) : null;
2005
1954
  const density = cfg.density;
2006
1955
  const transcriptWindow = cfg.transcriptWindow ?? DEFAULT_TRANSCRIPT_WINDOW;
2007
1956
  // --- One spinner per run (single-spinner-while-running D-1 .. D-5). ------
@@ -2039,6 +1988,26 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
2039
1988
  // threading the raw flag into a fifth consumer is the one silent way back to
2040
1989
  // two spinners (R-2).
2041
1990
  const viewReducedMotion = reducedMotion || activityVisible;
1991
+ // The run status row (spinner + phrase + queue/interrupt hint) replaces the idle
1992
+ // hint row ABOVE the input while running; it exists only when that hint row
1993
+ // would, so the footer height is identical with and without it. `running`, not
1994
+ // `activityVisible`: an overlay must not flip the Composer between two layouts.
1995
+ const runRowEnabled = running && hintRowEnabled;
1996
+ // The raw `reducedMotion` flag lives here and nowhere else (D-5).
1997
+ const runActivity = {
1998
+ startedAt: runStartedAt.current,
1999
+ elapsedMs,
2000
+ reducedMotion,
2001
+ runningTool,
2002
+ // THE ROW MUST NEVER SAY SOMETHING FALSE (§6.1). Compaction runs BEFORE
2003
+ // `turn_start`, so `status` is `'running'` with no tool in flight - precisely
2004
+ // the window in which the rotating phrase would claim the model is thinking
2005
+ // while it is actually waiting on a 30k-token summarization.
2006
+ compacting: state.compaction?.inFlight === true,
2007
+ };
2008
+ // The fixed bottom row takes over the life signal once the run row scrolls out
2009
+ // of the viewport; exactly one of the two ever animates.
2010
+ const runRowShown = runRowEnabled && activityRowVisible;
2042
2011
  // Assigned during render so `/perf` reads the CURRENT frame (see `perfRef`).
2043
2012
  // `mountedEntries` is the same number `render-budget.test.tsx` asserts (AC-9).
2044
2013
  perfRef.current = {
@@ -2053,12 +2022,18 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
2053
2022
  heightsMeasured: heights.stats().measured,
2054
2023
  heightsEstimated: heights.stats().estimated,
2055
2024
  cols: contentCols,
2056
- mode: fullscreen ? 'fullscreen' : 'inline',
2057
- viewportRows: fullscreen ? viewportBudget : rows,
2025
+ mode: 'fullscreen',
2026
+ viewportRows: viewportBudget,
2058
2027
  offset: scrolledLines,
2028
+ // A getter: the bridge's `frameReady` and geometry change outside React renders.
2029
+ get scrollbar() {
2030
+ return readScrollbarPerf({
2031
+ bridge: terminal?.scrollbar, diffRender: cfg.diffRender, unicode: caps.unicode,
2032
+ });
2033
+ },
2059
2034
  };
2060
2035
  // Completion belongs to the document footer; cap it to keep the editor usable.
2061
- const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
2036
+ const popupMaxRows = Math.max(1, viewportBudget - 4);
2062
2037
  // WHICH KEY THE HINT ROW NAMES (shift-tab-mode-toggle-still-dead-on-windows,
2063
2038
  // C3-1). Derived from `vtInputWarning` rather than from a probe of its own,
2064
2039
  // because that prop is already the single answer to "can this console deliver
@@ -2069,29 +2044,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
2069
2044
  // Both keys always WORK (they share one branch in the input handler); this
2070
2045
  // only decides which one the user is taught.
2071
2046
  const modeToggleKey = vtInputWarning ? MODE_TOGGLE_KEYS.fallback : MODE_TOGGLE_KEYS.primary;
2072
- const composer = fullscreen ? (_jsx(Composer, { measureRef: composerRef, 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' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { reducedMotion: cfg.reducedMotion ?? false, onEscape: handleComposerEscape, onEscapeDismiss: cancelConfirmation, isActive: overlay === null && !tooSmall, 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' }) }) }));
2073
- const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: fullscreen ? teamLayout : undefined, rows: rows, cols: fullscreen ? contentCols : cols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2047
+ const composer = (_jsx(Composer, { measureRef: composerRef, runRow: runRowEnabled
2048
+ ? { activity: runActivity, live: runRowShown && !overlayNode, rowRef: activityRef }
2049
+ : null, cols: contentCols, cursorVisible: cursorVisible, onInteraction: returnToComposer, reducedMotion: cfg.reducedMotion ?? false, onEscape: handleComposerEscape, onEscapeDismiss: cancelConfirmation, isActive: overlay === null && !tooSmall, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, services: liveServices, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, popupMaxHeight: railLayout.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, scrolledLines: scrolledLines, onDraftRows: onDraftRows, onNotice: notify, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }));
2050
+ const team = state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: teamLayout, rows: rows, cols: contentCols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2074
2051
  const rail = showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2075
- const viewport = fullscreen ? (_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, 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, 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] }));
2076
- 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."] }), mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, team: team, strip: showStrip && state.todos ? (_jsx(TodoStrip, { snapshot: state.todos, cols: cols, theme: theme, caps: caps })) : null, toast: _jsx(BottomStatusRow, { mode: mode, toasts: state.toasts,
2077
- // `!overlayNode` suppresses the line while an overlay owns the screen:
2078
- // the full-screen branch replaces the whole viewport with the overlay,
2079
- // and a working line under a settings screen is noise attached to a
2080
- // surface the run is not visible on. (Inline overlays are part of the
2081
- // document flow, so there the suppression is conservative rather than
2082
- // necessary — P2-5.)
2083
- //
2084
- // THE CONDITION IS `activityVisible`, THE SAME CONST THE SUPPRESSION
2085
- // SIGNAL IS DERIVED FROM (D-4): inlining it here again is how the two
2086
- // drift apart, and the drift is silent in whichever branch is not
2087
- // edited.
2088
- activity: activityVisible ? (_jsx(ActivityLine, { startedAt: runStartedAt.current, elapsedMs: elapsedMs, reducedMotion: reducedMotion, runningTool: runningTool,
2089
- // THE ROW MUST NEVER SAY SOMETHING FALSE (§6.1). Compaction runs
2090
- // BEFORE `turn_start`, so `status` is `'running'` with no tool in
2091
- // flight — precisely the window in which the rotating phrase
2092
- // would claim the model is thinking while it is actually waiting
2093
- // on a 30k-token summarization.
2094
- compacting: state.compaction?.inFlight === true, theme: theme, caps: caps })) : null,
2052
+ const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: tooSmall ? null : overlayNode, footer: _jsxs(_Fragment, { children: [team, composer] }), rail: rail, scrollbar: terminal?.scrollbar, composerRef: composerRef, onComposerVisibilityChange: setCursorVisible, activityRef: activityRef, onActivityVisibilityChange: setActivityRowVisible, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: state.entries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }));
2053
+ return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, toast: _jsx(BottomStatusRow, { toasts: state.toasts, activity: activityVisible && !runRowShown ? (_jsx(ActivityLine, { ...runActivity, theme: theme, caps: caps })) : null,
2095
2054
  // THE FORM THE LIFE SIGNAL TAKES WHEN A TOAST HAS THE ROW
2096
2055
  // (`activity-spinner-vanishes-behind-toast`). Passed UNCONDITIONALLY:
2097
2056
  // `BottomStatusRow` reads it only when `activity` above is non-null,
@@ -2111,7 +2070,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
2111
2070
  // branch replaces the whole viewport with the overlay, and an update
2112
2071
  // notice under a settings screen is noise attached to a surface it is
2113
2072
  // not visible on.
2114
- 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, { interruptHint: interruptGesture.current.phase === 'stopping'
2073
+ //
2074
+ // `!running` (cli-auto-update D-2): the update notice stays silent for
2075
+ // the whole run. It used to be guaranteed by the activity line
2076
+ // out-ranking it inside this row; with the activity line moved above
2077
+ // the input, the row can be free during a run, so the silence is stated
2078
+ // here instead of inherited.
2079
+ update: updateSnapshot && !overlayNode && !running && shouldRenderUpdateLine(updateSnapshot) ? (_jsx(UpdateLine, { snapshot: updateSnapshot, compact: cols < UPDATE_LIMITS.statusCompactCols, theme: theme, caps: caps })) : null, theme: theme }), status: _jsx(StatusBar, { interruptHint: interruptGesture.current.phase === 'stopping'
2115
2080
  ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2116
2081
  // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2117
2082
  // projection of "many sources of truth" into the props layer, and a
@@ -54,9 +54,7 @@
54
54
  import React from 'react';
55
55
  import type { Theme } from './theme.js';
56
56
  import type { Toast } from '../agent/reducer.js';
57
- import type { RenderMode } from './layout/frame.js';
58
57
  export interface BottomStatusRowProps {
59
- mode: RenderMode;
60
58
  toasts: Toast[];
61
59
  /** `<ActivityLine>` while a run owns the screen, else `null`. */
62
60
  activity: React.ReactNode;
@@ -94,4 +92,4 @@ export interface BottomStatusRowProps {
94
92
  update?: React.ReactNode;
95
93
  theme: Theme;
96
94
  }
97
- export declare function BottomStatusRow({ mode, toasts, activity, activityGlyph, update, theme, }: BottomStatusRowProps): React.ReactElement | null;
95
+ export declare function BottomStatusRow({ toasts, activity, activityGlyph, update, theme, }: BottomStatusRowProps): React.ReactElement | null;
@@ -1,15 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { ToastStack } from './ToastStack.js';
4
- export function BottomStatusRow({ mode, toasts, activity, activityGlyph, update, theme, }) {
4
+ export function BottomStatusRow({ toasts, activity, activityGlyph, update, theme, }) {
5
5
  // A toast wins the row outright — but a run in flight keeps its spinner on it.
6
6
  if (toasts.length > 0) {
7
7
  const spinner = activity ? activityGlyph : null;
8
8
  // Byte-identical to the old branch whenever there is no live spinner to
9
9
  // carry: no run, an ASCII terminal, or `reducedMotion`.
10
10
  if (!spinner)
11
- return _jsx(ToastStack, { toasts: toasts, theme: theme, mode: mode });
12
- const fullscreen = mode === 'fullscreen';
11
+ return _jsx(ToastStack, { toasts: toasts, theme: theme });
13
12
  return (
14
13
  // `flexDirection` SPELLED OUT even though `row` is Yoga's default: it is
15
14
  // what keeps two children on ONE row, and the header above turns on that
@@ -17,15 +16,12 @@ export function BottomStatusRow({ mode, toasts, activity, activityGlyph, update,
17
16
  // below that declares `column` — and "make them match" is a one-word edit
18
17
  // that doubles the row, takes it from the transcript, and desynchronises
19
18
  // every `viewportRows()` consumer without failing a type or a lint.
20
- _jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, marginTop: fullscreen ? 0 : 1, children: _jsxs(Text, { color: theme.thinking, children: [fullscreen ? ' ' : '', spinner, fullscreen ? '' : ' '] }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(ToastStack, { toasts: toasts, theme: theme, mode: mode }) })] }));
19
+ _jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Box, { flexShrink: 0, children: _jsxs(Text, { color: theme.thinking, children: [' ', spinner] }) }), _jsx(Box, { flexDirection: "column", flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(ToastStack, { toasts: toasts, theme: theme }) })] }));
21
20
  }
22
- // Inline has no frame and therefore nothing to protect: `ToastStack`'s inline
23
- // branch is already a conditional 0-3 row stack, and these are simply one more
24
- // conditional row above it.
25
21
  if (activity)
26
22
  return _jsx(Box, { flexShrink: 0, children: activity });
27
23
  if (update)
28
24
  return _jsx(Box, { flexShrink: 0, children: update });
29
25
  // The blank full-screen row that IS the budget must keep being emitted.
30
- return _jsx(ToastStack, { toasts: toasts, theme: theme, mode: mode });
26
+ return _jsx(ToastStack, { toasts: toasts, theme: theme });
31
27
  }
@@ -18,8 +18,24 @@ import type { Theme } from './theme.js';
18
18
  import type { TermCapabilities } from './capabilities.js';
19
19
  import { pickGlyphs } from './glyphs.js';
20
20
  import { type CommandOption } from './PromptInput.js';
21
+ import { type RunActivity } from './run-status-row.js';
21
22
  import { type AgentMode } from '../agent/agent-mode.js';
23
+ /**
24
+ * The run status row (tui-scrollbar-edge-and-run-row §3.3): the spinner, the phrase
25
+ * and the `queue / interrupt / exit` clauses on ONE row above the input box.
26
+ * `App` passes it only while a run is in flight AND the idle hint row would have
27
+ * been shown, so enabling it never changes the footer's height.
28
+ */
29
+ export interface RunRowProps {
30
+ activity: RunActivity;
31
+ /** The row is in the viewport with no overlay: draw the animated spinner. */
32
+ live: boolean;
33
+ /** Measured by `ScrollViewport` to learn whether the row has scrolled away. */
34
+ rowRef?: React.RefObject<DOMElement>;
35
+ }
22
36
  export interface ComposerProps {
37
+ /** Present only while the run status row replaces the hint row below the input. */
38
+ runRow?: RunRowProps | null;
23
39
  cols?: number;
24
40
  cursorVisible?: boolean;
25
41
  onInteraction?: () => void;
@@ -96,6 +112,17 @@ export interface ComposerProps {
96
112
  onSubmit: (text: string) => void;
97
113
  onHelp?: () => void;
98
114
  }
115
+ /**
116
+ * The running-state hint as separate clauses, most urgent first.
117
+ *
118
+ * `hintText` joins them (its output is pinned by AC-43), and the run status row
119
+ * consumes the SAME array so it can drop whole clauses from the tail on a narrow
120
+ * terminal instead of cutting `interrupt` in half. One source, two consumers.
121
+ */
122
+ export declare function runningHintClauses(opts: {
123
+ glyphs: ReturnType<typeof pickGlyphs>;
124
+ services: number;
125
+ }): string[];
99
126
  /**
100
127
  * Build the hint row.
101
128
  *
@@ -125,5 +152,5 @@ declare function hintText(opts: {
125
152
  * than fishing the string out of a rendered frame.
126
153
  */
127
154
  export declare const hintTextForTest: typeof hintText;
128
- export declare function Composer({ cols, cursorVisible, onInteraction, measureRef, isActive, reducedMotion, onEscape, onEscapeDismiss, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, services, modeToggleKey, popupMaxRows, popupMaxHeight, onPopupRowsChange, scrolledLines, onDraftRows, onNotice, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
155
+ export declare function Composer({ runRow, cols, cursorVisible, onInteraction, measureRef, isActive, reducedMotion, onEscape, onEscapeDismiss, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, services, modeToggleKey, popupMaxRows, popupMaxHeight, onPopupRowsChange, scrolledLines, onDraftRows, onNotice, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
129
156
  export {};
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * Composer (spec §4.7) — the thin shell around the existing `PromptInput`.
4
4
  *
@@ -15,11 +15,33 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
15
15
  */
16
16
  import { useCallback, useState } from 'react';
17
17
  import { Box, Text } from 'ink';
18
+ import stringWidth from 'string-width';
18
19
  import { pickGlyphs } from './glyphs.js';
19
20
  import { HINT_FADE_AFTER } from '../config/schema.js';
20
21
  import { PromptInput } from './PromptInput.js';
21
- import { ModeChip } from './ModeChip.js';
22
+ import { ModeChip, modeChipCols } from './ModeChip.js';
23
+ import { ActivityLabel, resolveActivityLabel } from './ActivityLine.js';
24
+ import { RUN_ROW_LEAD, planRunRow } from './run-status-row.js';
22
25
  import { MODE_LABEL, MODE_TOGGLE_KEYS, nextMode } from '../agent/agent-mode.js';
26
+ /**
27
+ * The running-state hint as separate clauses, most urgent first.
28
+ *
29
+ * `hintText` joins them (its output is pinned by AC-43), and the run status row
30
+ * consumes the SAME array so it can drop whole clauses from the tail on a narrow
31
+ * terminal instead of cutting `interrupt` in half. One source, two consumers.
32
+ */
33
+ export function runningHintClauses(opts) {
34
+ const { glyphs, services } = opts;
35
+ // `queue`, NOT `steer` (tui-shift-enter-copy-queue 5.5): the gesture's
36
+ // consequence now has a name in the transcript (`Queue: ...`), and one
37
+ // behaviour with two names is undiscoverable.
38
+ const clauses = [`${glyphs.enterKey} queue`, `esc${glyphs.times}2 interrupt`];
39
+ // Confirmation is named even when service controls also need space.
40
+ if (services > 0)
41
+ clauses.push(`ctrl+c stop ${services}`);
42
+ clauses.push(`ctrl+c${glyphs.times}2 exit`);
43
+ return clauses;
44
+ }
23
45
  /**
24
46
  * Build the hint row.
25
47
  *
@@ -40,18 +62,8 @@ function hintText(opts) {
40
62
  // clauses are shown; the row is `wrap="truncate"` and they are ordered
41
63
  // most-urgent-first, so a terminal too narrow for all of them drops the exit
42
64
  // clause last rather than the abort key.
43
- if (running) {
44
- if (services > 0) {
45
- return [
46
- `${glyphs.enterKey} steer`,
47
- // Confirmation is named even when service controls also need space.
48
- `esc${glyphs.times}2 interrupt`,
49
- `ctrl+c stop ${services}`,
50
- `ctrl+c${glyphs.times}2 exit`,
51
- ].join(dot);
52
- }
53
- return [`${glyphs.enterKey} steer`, `esc${glyphs.times}2 interrupt`, `ctrl+c${glyphs.times}2 exit`].join(dot);
54
- }
65
+ if (running)
66
+ return runningHintClauses({ glyphs, services }).join(dot);
55
67
  // The hint names the DESTINATION, not the current state — the correct label
56
68
  // for a toggle affordance, and the reason the mode word here is the OPPOSITE
57
69
  // of the one on the chip beside it.
@@ -86,7 +98,21 @@ function hintText(opts) {
86
98
  * than fishing the string out of a rendered frame.
87
99
  */
88
100
  export const hintTextForTest = hintText;
89
- export function Composer({ cols, cursorVisible, onInteraction, measureRef, isActive, reducedMotion, onEscape, onEscapeDismiss, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, services = 0, modeToggleKey = MODE_TOGGLE_KEYS.primary, popupMaxRows, popupMaxHeight, onPopupRowsChange, scrolledLines = 0, onDraftRows, onNotice, theme, caps, onSubmit, onHelp, }) {
101
+ function RunRow({ runRow, cols, services, agentMode, theme, caps, }) {
102
+ const glyphs = pickGlyphs(caps);
103
+ const separator = ` ${glyphs.midDot} `;
104
+ const clauses = runningHintClauses({ glyphs, services });
105
+ const plan = planRunRow({
106
+ cols,
107
+ // Spinner glyph + the space after it, then the label.
108
+ labelCols: 2 + stringWidth(resolveActivityLabel(runRow.activity, glyphs)),
109
+ hintClauseCols: clauses.map((clause) => stringWidth(clause)),
110
+ separatorCols: stringWidth(separator),
111
+ chipCols: modeChipCols(agentMode),
112
+ });
113
+ return (_jsxs(Box, { ref: runRow.rowRef, flexDirection: "row", flexShrink: 0, children: [_jsx(Box, { width: RUN_ROW_LEAD, flexShrink: 0, children: _jsx(Text, { children: ' ' }) }), _jsx(Box, { width: plan.labelCols, flexShrink: 0, children: _jsx(ActivityLabel, { ...runRow.activity, spinnerLive: runRow.live, theme: theme, caps: caps }) }), plan.hintClauses > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: [separator, clauses.slice(0, plan.hintClauses).join(separator)] })), plan.chip && (_jsxs(_Fragment, { children: [_jsx(Box, { flexGrow: 1 }), _jsx(ModeChip, { mode: agentMode, theme: theme, caps: caps })] }))] }));
114
+ }
115
+ export function Composer({ runRow, cols, cursorVisible, onInteraction, measureRef, isActive, reducedMotion, onEscape, onEscapeDismiss, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, services = 0, modeToggleKey = MODE_TOGGLE_KEYS.primary, popupMaxRows, popupMaxHeight, onPopupRowsChange, scrolledLines = 0, onDraftRows, onNotice, theme, caps, onSubmit, onHelp, }) {
90
116
  const [hasDraft, setHasDraft] = useState(false);
91
117
  const onDraftChange = useCallback((next) => {
92
118
  setHasDraft(next.hasDraft);
@@ -105,8 +131,11 @@ export function Composer({ cols, cursorVisible, onInteraction, measureRef, isAct
105
131
  ? theme.focusBorder ?? theme.primary
106
132
  : theme.idleBorder ?? theme.border;
107
133
  const glyphs = pickGlyphs(caps);
108
- const visible = showHint && hintsEnabled;
109
- return (_jsxs(Box, { ref: measureRef, flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { cols: cols, cursorVisible: cursorVisible, onInteraction: onInteraction, isActive: isActive, reducedMotion: reducedMotion, onEscape: onEscape, onEscapeDismiss: onEscapeDismiss, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, popupMaxHeight: popupMaxHeight, onPopupRowsChange: onPopupRowsChange, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, bordered: { color: borderColor }, onDraftChange: onDraftChange, onNotice: onNotice, scrolledLines: scrolledLines }), visible && (
134
+ // While the run row is up it REPLACES the hint row below the input (same row
135
+ // count, so starting a run never moves the layout).
136
+ const runRowActive = running && !!runRow;
137
+ const visible = showHint && hintsEnabled && !runRowActive;
138
+ return (_jsxs(Box, { ref: measureRef, flexDirection: "column", flexShrink: 0, children: [runRowActive && (_jsx(RunRow, { runRow: runRow, cols: cols ?? process.stdout.columns ?? 80, services: services, agentMode: agentMode, theme: theme, caps: caps })), _jsx(PromptInput, { cols: cols, cursorVisible: cursorVisible, onInteraction: onInteraction, isActive: isActive, reducedMotion: reducedMotion, onEscape: onEscape, onEscapeDismiss: onEscapeDismiss, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, popupMaxHeight: popupMaxHeight, onPopupRowsChange: onPopupRowsChange, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, borderColor: borderColor, onDraftChange: onDraftChange, onNotice: onNotice, scrolledLines: scrolledLines }), visible && (
110
139
  // The chip rides the EXISTING hint row rather than adding one, so it
111
140
  // costs zero rows on a 24-row terminal.
112
141
  _jsxs(Box, { flexDirection: "row", flexShrink: 0, children: [_jsx(Text, { children: ' ' }), _jsx(ModeChip, { mode: agentMode, theme: theme, caps: caps }), _jsx(Text, { wrap: "truncate", color: theme.hintFg ?? theme.muted, children: hintText({