@aragon-agent/cli 0.6.1 → 0.6.3

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 (127) hide show
  1. package/CHANGELOG.md +216 -0
  2. package/README.md +241 -18
  3. package/dist/agent/agent-mode.d.ts +39 -0
  4. package/dist/agent/agent-mode.js +36 -0
  5. package/dist/agent/controller.d.ts +74 -1
  6. package/dist/agent/controller.js +200 -1
  7. package/dist/agent/headless.d.ts +8 -0
  8. package/dist/agent/headless.js +47 -0
  9. package/dist/agent/reducer.d.ts +120 -0
  10. package/dist/agent/reducer.js +201 -2
  11. package/dist/cli.js +297 -6
  12. package/dist/commands/builtins.js +73 -23
  13. package/dist/commands/registry.d.ts +27 -0
  14. package/dist/compaction/archive.d.ts +175 -0
  15. package/dist/compaction/archive.js +267 -0
  16. package/dist/compaction/child.d.ts +97 -0
  17. package/dist/compaction/child.js +113 -0
  18. package/dist/compaction/command.d.ts +41 -0
  19. package/dist/compaction/command.js +342 -0
  20. package/dist/compaction/compactor.d.ts +268 -0
  21. package/dist/compaction/compactor.js +689 -0
  22. package/dist/compaction/digest.d.ts +62 -0
  23. package/dist/compaction/digest.js +164 -0
  24. package/dist/compaction/limits.d.ts +165 -0
  25. package/dist/compaction/limits.js +172 -0
  26. package/dist/compaction/pressure.d.ts +135 -0
  27. package/dist/compaction/pressure.js +167 -0
  28. package/dist/compaction/summarize-call.d.ts +70 -0
  29. package/dist/compaction/summarize-call.js +86 -0
  30. package/dist/compaction/summary-prompt.d.ts +85 -0
  31. package/dist/compaction/summary-prompt.js +197 -0
  32. package/dist/compaction/tail-budget.d.ts +52 -0
  33. package/dist/compaction/tail-budget.js +67 -0
  34. package/dist/compaction/types.d.ts +179 -0
  35. package/dist/compaction/types.js +22 -0
  36. package/dist/compaction/wiring.d.ts +255 -0
  37. package/dist/compaction/wiring.js +556 -0
  38. package/dist/config/app-paths.d.ts +14 -0
  39. package/dist/config/app-paths.js +16 -0
  40. package/dist/config/cli-commands.d.ts +41 -0
  41. package/dist/config/cli-commands.js +77 -1
  42. package/dist/config/env.d.ts +4 -1
  43. package/dist/config/env.js +92 -2
  44. package/dist/config/load.d.ts +31 -0
  45. package/dist/config/load.js +63 -1
  46. package/dist/config/schema.d.ts +159 -0
  47. package/dist/config/schema.js +137 -0
  48. package/dist/config/store.js +17 -1
  49. package/dist/config/ui-state.d.ts +44 -6
  50. package/dist/config/ui-state.js +21 -12
  51. package/dist/diagnostics/doctor.js +41 -1
  52. package/dist/exec/events.d.ts +50 -1
  53. package/dist/exec/index.js +8 -4
  54. package/dist/exec/runner.js +44 -2
  55. package/dist/input/mouse-events.d.ts +31 -5
  56. package/dist/input/mouse-events.js +86 -27
  57. package/dist/input/stdin-mouse-filter.d.ts +8 -3
  58. package/dist/logging/install.d.ts +51 -1
  59. package/dist/logging/install.js +28 -0
  60. package/dist/logging/logger.d.ts +1 -1
  61. package/dist/session/cli-commands.js +23 -1
  62. package/dist/session/persist.js +19 -0
  63. package/dist/session/store.d.ts +18 -0
  64. package/dist/session/store.js +59 -2
  65. package/dist/team/report.js +7 -1
  66. package/dist/team/runtime.d.ts +10 -0
  67. package/dist/team/runtime.js +7 -0
  68. package/dist/team/subagent.d.ts +31 -1
  69. package/dist/team/subagent.js +42 -15
  70. package/dist/team/types.d.ts +8 -0
  71. package/dist/ui/ActivityLine.d.ts +19 -1
  72. package/dist/ui/ActivityLine.js +10 -2
  73. package/dist/ui/App.d.ts +48 -1
  74. package/dist/ui/App.js +317 -17
  75. package/dist/ui/Composer.d.ts +26 -1
  76. package/dist/ui/Composer.js +6 -6
  77. package/dist/ui/PromptInput.d.ts +34 -1
  78. package/dist/ui/PromptInput.js +54 -3
  79. package/dist/ui/StatusBar.d.ts +40 -0
  80. package/dist/ui/StatusBar.js +13 -4
  81. package/dist/ui/TeamPanel.js +7 -1
  82. package/dist/ui/Transcript.d.ts +21 -1
  83. package/dist/ui/Transcript.js +32 -2
  84. package/dist/ui/clipboard.d.ts +52 -0
  85. package/dist/ui/clipboard.js +91 -0
  86. package/dist/ui/entries/CompactionCard.d.ts +75 -0
  87. package/dist/ui/entries/CompactionCard.js +158 -0
  88. package/dist/ui/frame-differ.d.ts +46 -0
  89. package/dist/ui/frame-differ.js +94 -10
  90. package/dist/ui/gauge.d.ts +16 -1
  91. package/dist/ui/gauge.js +25 -6
  92. package/dist/ui/glyphs.d.ts +11 -0
  93. package/dist/ui/glyphs.js +2 -0
  94. package/dist/ui/layout/ScrollViewport.d.ts +43 -6
  95. package/dist/ui/layout/ScrollViewport.js +128 -21
  96. package/dist/ui/layout/budget.d.ts +10 -3
  97. package/dist/ui/layout/budget.js +10 -3
  98. package/dist/ui/layout/follow-state.d.ts +137 -0
  99. package/dist/ui/layout/follow-state.js +150 -0
  100. package/dist/ui/layout/virtual-window.d.ts +11 -0
  101. package/dist/ui/layout/virtual-window.js +34 -0
  102. package/dist/ui/overlays/HelpOverlay.js +18 -2
  103. package/dist/ui/overlays/SettingsScreen.d.ts +47 -1
  104. package/dist/ui/overlays/SettingsScreen.js +60 -1
  105. package/dist/ui/palettes.d.ts +16 -0
  106. package/dist/ui/palettes.js +6 -0
  107. package/dist/ui/screen.d.ts +22 -0
  108. package/dist/ui/screen.js +70 -8
  109. package/dist/ui/selection/highlight.d.ts +39 -0
  110. package/dist/ui/selection/highlight.js +64 -0
  111. package/dist/ui/selection/screen-mirror.d.ts +24 -0
  112. package/dist/ui/selection/screen-mirror.js +35 -0
  113. package/dist/ui/selection/selection-controller.d.ts +88 -0
  114. package/dist/ui/selection/selection-controller.js +282 -0
  115. package/dist/ui/selection/selection.d.ts +90 -0
  116. package/dist/ui/selection/selection.js +223 -0
  117. package/dist/ui/stdout-frame-writer.d.ts +33 -0
  118. package/dist/ui/stdout-frame-writer.js +15 -0
  119. package/dist/ui/theme.d.ts +11 -0
  120. package/dist/ui/theme.js +2 -0
  121. package/dist/ui/transcript-text.js +23 -0
  122. package/dist/ui/use-startup-notices.d.ts +62 -5
  123. package/dist/ui/use-startup-notices.js +81 -17
  124. package/dist/ui/use-wheel-routing.js +8 -0
  125. package/dist/ui/win-vt-force.d.ts +136 -0
  126. package/dist/ui/win-vt-force.js +237 -0
  127. package/package.json +4 -2
package/dist/ui/App.js CHANGED
@@ -11,14 +11,15 @@ import { Box, Text, useApp, useInput, useStdout } from 'ink';
11
11
  import { initialViewState, reduceEvent, viewReducer, } from '../agent/reducer.js';
12
12
  import { mergeDeltas } from '../agent/coalesce.js';
13
13
  import { computeCost } from '../agent/usage.js';
14
+ import { isApproximate } from '../compaction/pressure.js';
14
15
  import { parseMaxTokensInput, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
15
16
  import { updatePersistedConfig } from '../config/store.js';
16
17
  import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
17
- import { bumpSubmitCount, setMouseNoticeSeen, setVtInputNoticeSeen } from '../config/ui-state.js';
18
+ import { bumpSubmitCount, setMouseNoticeVersion, setVtInputNoticeVersion, } from '../config/ui-state.js';
18
19
  import { getLogger } from '../logging/logger.js';
19
20
  import { registerSecret } from '../logging/secret-registry.js';
20
21
  import { detectCapabilities } from './capabilities.js';
21
- import { useStartupNotices } from './use-startup-notices.js';
22
+ import { useStartupNotices, MOUSE_NOTICE_VERSION, VT_INPUT_NOTICE_VERSION, } from './use-startup-notices.js';
22
23
  import { getTheme } from './theme.js';
23
24
  import { pickGlyphs } from './glyphs.js';
24
25
  import { Header } from './Header.js';
@@ -60,14 +61,15 @@ import { publishExitSnapshot } from './exit-snapshot.js';
60
61
  import { OverlayFrame } from './layout/OverlayFrame.js';
61
62
  import { helpRows } from './overlays/HelpOverlay.js';
62
63
  import { ModelPicker } from './overlays/ModelPicker.js';
63
- import { SettingsScreen, fastSettingsFrom, readFastSettings, } from './overlays/SettingsScreen.js';
64
+ import { SettingsScreen, compactionSettingsFrom, fastSettingsFrom, readCompactionSettings, readFastSettings, } from './overlays/SettingsScreen.js';
64
65
  import { ConfirmDialog } from './overlays/ConfirmDialog.js';
65
66
  import { QuestionOverlay } from './overlays/QuestionOverlay.js';
66
67
  import { PlanReviewOverlay } from './overlays/PlanReviewOverlay.js';
67
68
  import { CommandRegistry, runSlashInput } from '../commands/registry.js';
68
69
  import { registerBuiltinCommands } from '../commands/builtins.js';
69
70
  import { makeSkillsCommand, registerSkillCommands } from '../commands/skills.js';
70
- import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
71
+ import { MODE_LABEL, MODE_TOGGLE_KEYS, nextMode } from '../agent/agent-mode.js';
72
+ import { emptyTailState } from './layout/follow-state.js';
71
73
  /**
72
74
  * Decide what the `Max tokens` field means, WITHOUT letting a bad value in it
73
75
  * discard the rest of the form.
@@ -110,7 +112,7 @@ export function resolveSettingsMaxTokens(raw, previous) {
110
112
  function seedViewState(thinkingVisible) {
111
113
  return initialViewState({ thinkingVisible });
112
114
  }
113
- export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, }) {
115
+ export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, terminal, }) {
114
116
  const { exit } = useApp();
115
117
  const { stdout } = useStdout();
116
118
  const fullscreen = mode === 'fullscreen';
@@ -133,6 +135,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
133
135
  // function reference.
134
136
  const [state, dispatch] = useReducer(viewReducer, cfg.showThinking, seedViewState);
135
137
  const [elapsedMs, setElapsedMs] = useState(0);
138
+ /**
139
+ * Forces the render that re-reads `Date.now()` for the live compaction card
140
+ * while the run is IDLE (hardening W5). The value itself is never read - the
141
+ * card's elapsed figure is derived from `nowSec` like the running tool card's.
142
+ */
143
+ const [, setCompactionClock] = useState(0);
136
144
  const [confirmState, setConfirmState] = useState(null);
137
145
  /**
138
146
  * The updater's latest snapshot, or `null` while there is no service
@@ -152,6 +160,15 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
152
160
  const [scrollIntent, setScrollIntent] = useState();
153
161
  const [pinToBottomNonce, setPinToBottomNonce] = useState(0);
154
162
  const [scrolledLines, setScrolledLines] = useState(0);
163
+ /**
164
+ * True while a selection drag holds the viewport
165
+ * (tui-selection-and-scroll-follow §4.3.1 / §4.4).
166
+ *
167
+ * REACT STATE RATHER THAN A REF, because `ScrollViewport` has to re-arm its
168
+ * resume timer on both edges and a ref cannot drive an effect. The controller
169
+ * is the owner; this is a mirror, pushed by `onHoldChange`.
170
+ */
171
+ const [selectionHold, setSelectionHold] = useState(false);
155
172
  // I-5 (§4.13): Ctrl+L cannot repaint by writing escapes — Ink dedupes
156
173
  // identical output twice over — so it bumps this instead, and StatusBar turns
157
174
  // it into a one-byte, zero-width change that both dedupe gates let through.
@@ -211,6 +228,16 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
211
228
  * would make reading the render budget cost a render.
212
229
  */
213
230
  const mountedCount = useRef(0);
231
+ /**
232
+ * Rows the transcript has appended AT ITS TAIL — Rule A's only legal input
233
+ * (tui-selection-and-scroll-follow §4.3.1a).
234
+ *
235
+ * Owned here because it has TWO readers one level down each: `TranscriptList`
236
+ * writes it during its render, and `ScrollViewport` reads it in the layout
237
+ * effect that runs afterwards. A ref rather than state, exactly like
238
+ * `mountedCount` above and for the same reason.
239
+ */
240
+ const tailSink = useRef(emptyTailState());
214
241
  // --- Follow-through (todo-plan-followthrough §3.4). ---------------------
215
242
  //
216
243
  // THREE REFS AND NO VIEW STATE, DELIBERATELY (§5.2). Every field added to
@@ -604,6 +631,97 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
604
631
  }
605
632
  });
606
633
  }, [controller]);
634
+ // --- Compaction event stream (context-auto-compaction §5.2). -------------
635
+ //
636
+ // A SIXTH SUBSCRIPTION, for the reason the team, todo and fast ones record:
637
+ // `CompactionEvent` is deliberately not a member of core's `AgentEvent` union,
638
+ // so it cannot arrive through `controller.subscribe`.
639
+ //
640
+ // AND IT IS THE **SOLE** DISPATCHER OF THE FIVE COMPACTION ACTIONS (D-20 /
641
+ // P1-6). `reduceEvent` gains no cases for core's `compaction_start` /
642
+ // `compaction_end` — its `default: return []` is already correct for them —
643
+ // because one compaction arriving through two channels is two transcript cards
644
+ // and a doubled `tokensReclaimed`, with nothing naming the authority. This
645
+ // stream is the right authority because it is strictly richer: the wiring IS
646
+ // the `ContextManager`, so it knows the summarizer's model, its cost and the
647
+ // session totals, and it learns `applied` by subscribing to the core event one
648
+ // layer earlier.
649
+ //
650
+ // `subscribeCompaction` returns a no-op unsubscribe when compaction was off at
651
+ // construction, so this can subscribe unconditionally and an ordinary session
652
+ // pays one closure for the whole run.
653
+ useEffect(() => {
654
+ // Seed the chip and the gauge marks from the snapshot resolved at
655
+ // construction, so a compaction-enabled session shows them on the FIRST
656
+ // frame rather than after the first turn.
657
+ if (controller.isCompactionRegistered()) {
658
+ dispatch({ type: 'compactionSnapshot', snapshot: controller.getCompactionSnapshot() });
659
+ }
660
+ return controller.subscribeCompaction((event) => {
661
+ switch (event.type) {
662
+ case 'compaction_start':
663
+ dispatch({
664
+ type: 'compactionStart',
665
+ index: event.index,
666
+ trigger: event.trigger,
667
+ model: event.model,
668
+ });
669
+ break;
670
+ case 'compaction_end': {
671
+ dispatch({ type: 'compactionEnd', record: event.record });
672
+ // THE GAUGE MUST FALL IMMEDIATELY (§6.2 / AC-5), and it must fall to a
673
+ // number in the SAME UNIT as the one it replaces (P1-11). The value
674
+ // being overwritten came from `occupiedTokens(usage)` — a provider
675
+ // measurement that includes the tool schemas — while
676
+ // `record.tokensAfter` comes from core's `estimatePromptTokens`, which
677
+ // never counts them. Writing the raw estimate makes the bar fall too
678
+ // far and then visibly climb again on the next `turn_end` for no reason
679
+ // the user can see, which is the same is-this-thing-working doubt the
680
+ // immediate drop exists to prevent, one turn later.
681
+ if (event.record.applied) {
682
+ const offset = controller.getCompactionSnapshot().pressure.estimateOffset ?? 0;
683
+ dispatch({ type: 'contextTokensEstimated', tokens: event.record.tokensAfter + offset });
684
+ }
685
+ break;
686
+ }
687
+ case 'usage': {
688
+ // Compaction spend is REAL SPEND at the SUMMARIZER's price (§6.4 /
689
+ // AC-15), which may be neither the lead's nor the fast tier's — so the
690
+ // ref is ASKED FOR rather than assumed. `getModelInfo()` here would
691
+ // price a Haiku summarization at the lead's Opus rates, which is the
692
+ // same class of lie as the `$0.00` `pricingUnknown` exists to prevent,
693
+ // just in the other direction.
694
+ const snapshot = controller.getCompactionSnapshot();
695
+ const ref = controller.getCompactionSummarizerRef();
696
+ const costDelta = snapshot.pricingUnknown || !ref
697
+ ? 0
698
+ : computeCost(event.usage, controller.getModelInfoFor(ref).cost);
699
+ dispatch({ type: 'compactionUsage', usage: event.usage, costDelta });
700
+ break;
701
+ }
702
+ case 'snapshot':
703
+ dispatch({ type: 'compactionSnapshot', snapshot: event.snapshot });
704
+ // ONE NUMBER ON SCREEN AND IN THE TRIGGER (R-11 / hardening §3.2.4).
705
+ // The checkpoint's occupancy is a measured base PLUS an estimate of
706
+ // what was appended after it, and without this the gauge would still
707
+ // show the bare base from the last `turn_end` - so a user watching
708
+ // 76 % would see compaction fire and conclude the feature is broken.
709
+ //
710
+ // `contextTokensEstimated` IS REUSED RATHER THAN A SECOND WRITER ADDED:
711
+ // it is the one existing action for "this figure is derived", and it
712
+ // already sets the `~`. Gated on `isApproximate`, which is precisely
713
+ // the case it was built for - a wholly measured figure belongs to
714
+ // `turnEnd`.
715
+ if (isApproximate(event.snapshot.pressure)) {
716
+ dispatch({
717
+ type: 'contextTokensEstimated',
718
+ tokens: event.snapshot.pressure.occupied,
719
+ });
720
+ }
721
+ break;
722
+ }
723
+ });
724
+ }, [controller]);
607
725
  // --- Live tool output (agent-activity-presentation-live §3.3.2). --------
608
726
  //
609
727
  // A FIFTH SUBSCRIPTION, and THE ONE THAT DOES GO THROUGH THE STREAMING
@@ -647,6 +765,17 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
647
765
  const id = setInterval(() => setElapsedMs(Date.now() - startedAt), 200);
648
766
  return () => clearInterval(id);
649
767
  }, [state.status]);
768
+ // --- Live compaction elapsed tick (hardening §3.6 / W5). ---------------
769
+ //
770
+ // ONLY WHILE A CARD IS LIVE AND THE RUN IS NOT, so an ordinary session has no
771
+ // timer and an in-loop compaction reuses the 200 ms elapsed ticker rather than
772
+ // adding a second one. 1 Hz, because the card shows whole seconds.
773
+ useEffect(() => {
774
+ if (state.compactionEntryId === undefined || state.status === 'running')
775
+ return undefined;
776
+ const id = setInterval(() => setCompactionClock(Date.now()), 1000);
777
+ return () => clearInterval(id);
778
+ }, [state.compactionEntryId, state.status]);
650
779
  // --- Retry countdown tick (llm-api-retry-backoff §6.5). ----------------
651
780
  //
652
781
  // ONLY WHILE A BACKOFF IS ACTUALLY WAITING, and the effect's teardown clears it —
@@ -697,9 +826,17 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
697
826
  // so on a console that reports nothing there is no source and no advice.
698
827
  useStartupNotices(dispatch, {
699
828
  enabled: !!mouseSource,
829
+ // NOT re-derived here (see `MouseNoticeOptions`): `cli.tsx` computes
830
+ // `mouseFilter !== null && cfg.mouseSelect` once and four things read it,
831
+ // so a second derivation is a second chance for them to disagree.
832
+ selectEnabled: terminal?.mouseSelect ?? false,
700
833
  // `state.json`, not `config.json`: whether a one-shot notice has been shown
701
834
  // is bookkeeping, and the comment on the old persisted key said so.
702
- onSeen: () => setMouseNoticeSeen(true),
835
+ //
836
+ // THE VERSION, NOT `true` (P1-8). The gate compares against
837
+ // `MOUSE_NOTICE_VERSION`, so storing anything else either replays the
838
+ // notice forever or silences a future revision of the text.
839
+ onSeen: () => setMouseNoticeVersion(MOUSE_NOTICE_VERSION),
703
840
  },
704
841
  // Present only when `loadConfig` had to raise the user's own
705
842
  // `transcriptRetain` to `transcriptWindow` (tui-render-performance §5.1).
@@ -707,7 +844,10 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
707
844
  ? { requested: cfg.transcriptRetainRequested, resolved: cfg.transcriptRetain }
708
845
  : undefined,
709
846
  // Its own one-shot key, NOT `mouseNoticeSeen`: everyone this notice is for
710
- // has already run aragon at least once and already has that flag set.
847
+ // has already run aragon at least once and already has that flag set. That
848
+ // key is now a VERSION for the same reason, one turn later — the audience
849
+ // for the text that finally names the fallback key is, again, exactly the
850
+ // set of users who already have the old flag on disk.
711
851
  //
712
852
  // `fullscreen` goes with it because only half of the damage is mode-
713
853
  // independent: `Shift+Tab` is broken either way, but inline never asked for
@@ -717,7 +857,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
717
857
  ? {
718
858
  nodeVersion: vtInputWarning.nodeVersion,
719
859
  fullscreen,
720
- onSeen: () => setVtInputNoticeSeen(true),
860
+ onSeen: () => setVtInputNoticeVersion(VT_INPUT_NOTICE_VERSION),
721
861
  }
722
862
  : undefined);
723
863
  // --- Exit snapshot: the only way state reaches cli.tsx (§4.4). ----------
@@ -885,6 +1025,83 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
885
1025
  // every render and defeat the ref guard's purpose rather than its mechanism.
886
1026
  // eslint-disable-next-line react-hooks/exhaustive-deps
887
1027
  }, [updateSnapshot?.phase, updateSnapshot?.latestVersion]);
1028
+ // --- Selection (tui-selection-and-scroll-follow §4.4). -------------------
1029
+ //
1030
+ // The controller is built by `cli.tsx` before `render()`, so what it is
1031
+ // missing when it starts is exactly the two things that only exist once this
1032
+ // component is mounted: somewhere to put a toast, and a way to ask for a React
1033
+ // redraw when the frame differ has no cache to repaint from. Both are assigned
1034
+ // here and cleared on unmount, the same shape `updateBridge` uses.
1035
+ const selectionBridge = terminal?.selection;
1036
+ useEffect(() => {
1037
+ if (!selectionBridge)
1038
+ return undefined;
1039
+ selectionBridge.onCopied = (via, lines, chars) => {
1040
+ // IT NAMES THE MECHANISM RATHER THAN CLAIMING SUCCESS (R-7). Neither OSC 52
1041
+ // nor a spawned `xclip` is detectable: tmux without `set -g set-clipboard
1042
+ // on` swallows the first silently, and the second is best-effort by
1043
+ // construction. A toast that said "copied successfully" would be guessing.
1044
+ const noun = lines === 1 ? 'line' : 'lines';
1045
+ if (via === 'none') {
1046
+ toast('warn', 'Nothing could be copied - no clipboard mechanism is available.');
1047
+ return;
1048
+ }
1049
+ const how = via === 'osc52' ? 'terminal clipboard' : 'system clipboard';
1050
+ toast('success', `Sent ${lines} ${noun} (${chars} chars) to the ${how}.`);
1051
+ };
1052
+ // I-5 of `frame-differ.ts`: writing escapes here would be a foreign write.
1053
+ // The nonce is the carrier Ctrl+L already uses (§4.13).
1054
+ selectionBridge.requestRedraw = () => setRedrawNonce((n) => n + 1);
1055
+ const unsubscribe = selectionBridge.controller?.onHoldChange(setSelectionHold);
1056
+ return () => {
1057
+ selectionBridge.onCopied = null;
1058
+ selectionBridge.requestRedraw = null;
1059
+ unsubscribe?.();
1060
+ };
1061
+ // `toast` is a stable dispatch wrapper; depending on it would rebuild this
1062
+ // subscription on every render.
1063
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1064
+ }, [selectionBridge]);
1065
+ const selectionController = selectionBridge?.controller ?? null;
1066
+ /**
1067
+ * I-9, the DERIVED half — and the one that matters (D-14 / P1-4).
1068
+ *
1069
+ * `shiftUp` changes exactly when the rows on screen move: a wheel notch, a key,
1070
+ * PgDn, the bottom snap, `pinToBottomNonce`, the idle auto-resume, a height
1071
+ * correction above the reading position. An enumerated list of gestures was
1072
+ * already incomplete on the day it was written — it missed this feature's OWN
1073
+ * auto-resume, which would have left a five-second-old highlight sitting over
1074
+ * rows that had scrolled out from under it — and a derived condition cannot be
1075
+ * forgotten by the next person who adds a way to move the viewport.
1076
+ *
1077
+ * Rule A holds `shiftUp` CONSTANT while a drag is in progress, which is why a
1078
+ * selection survives output streaming in below it and only that.
1079
+ *
1080
+ * Referentially stable, because `ScrollViewport` publishes it from an effect
1081
+ * keyed on the callback.
1082
+ */
1083
+ const onViewportShiftChange = useCallback(() => {
1084
+ selectionController?.clear();
1085
+ }, [selectionController]);
1086
+ // I-9, the ENUMERATED half. `shiftUp` covers every movement of the rows and is
1087
+ // wired straight into `ScrollViewport` below; these three are the cases that
1088
+ // change the screen while leaving `shiftUp` untouched — a key that scrolls
1089
+ // nothing, an overlay swap, and a re-wrap (P1-7).
1090
+ useEffect(() => {
1091
+ if (!selectionController)
1092
+ return;
1093
+ // Screen coordinates stop meaning anything the moment every line re-wraps,
1094
+ // and a resize is exactly that (N6).
1095
+ selectionController.clear();
1096
+ }, [selectionController, rows, cols]);
1097
+ useEffect(() => {
1098
+ if (!selectionController)
1099
+ return;
1100
+ // An overlay replaces the viewport, so a selection over it would be a
1101
+ // highlight over rows that are no longer there (N4).
1102
+ selectionController.setEnabled(state.overlay === null);
1103
+ selectionController.clear();
1104
+ }, [selectionController, state.overlay]);
888
1105
  const persistConfig = (patch) => {
889
1106
  try {
890
1107
  updatePersistedConfig(patch);
@@ -948,6 +1165,19 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
948
1165
  // cache, and `/update` would report "disabled" on a session where it is on.
949
1166
  // `?? undefined` because the bridge holds `null` and the port is optional.
950
1167
  update: updateBridge?.service ?? undefined,
1168
+ // Read at command time so `/mouse` reports the LIVE state rather than
1169
+ // whatever it was when this closure was first built — `/mouse off` followed
1170
+ // by a bare `/mouse` has to say "off".
1171
+ ...(terminal?.setMouseCapture
1172
+ ? {
1173
+ mouse: {
1174
+ captured: () => terminal.isMouseCaptured?.() ?? true,
1175
+ selectEnabled: () => terminal.mouseSelect,
1176
+ setCapture: (on) => terminal.setMouseCapture?.(on),
1177
+ },
1178
+ }
1179
+ : {}),
1180
+ ...(terminal?.writeForeign ? { writeForeign: terminal.writeForeign } : {}),
951
1181
  });
952
1182
  /**
953
1183
  * Submitting a prompt no longer touches `config.json` at all — it appends one
@@ -1076,6 +1306,23 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1076
1306
  const fastPatch = readFastSettings(values);
1077
1307
  controller.setFastConfig(fastPatch);
1078
1308
  patch.fast = fastPatch;
1309
+ // --- Context compaction (context-auto-compaction §4.5) ------------------
1310
+ //
1311
+ // THE SAME SETTER-AND-PERSIST PAIR, and the same reason. `setCompactionConfig`
1312
+ // additionally re-emits the snapshot, which is what moves the status chip and
1313
+ // the gauge's marks without waiting for the next turn.
1314
+ //
1315
+ // `enabled` here flips the LIVE switch only. Whether the wiring exists at all
1316
+ // was decided at construction and cannot be undone in this session (§3.2), so
1317
+ // a user who turns it ON in a `--no-compaction` session gets the persisted
1318
+ // value for next launch and nothing this session — which is exactly what
1319
+ // `/compact on` reports in words.
1320
+ const compactionPatch = readCompactionSettings(values);
1321
+ controller.setCompactionConfig(compactionPatch);
1322
+ if (compactionPatch.enabled !== undefined) {
1323
+ controller.setCompactionEnabled(compactionPatch.enabled);
1324
+ }
1325
+ patch.compaction = compactionPatch;
1079
1326
  persistConfig(patch);
1080
1327
  getLogger().reconfigure({ ...controller.getConfig().log, level: values.logLevel });
1081
1328
  dispatch({ type: 'setOverlay', overlay: null });
@@ -1149,6 +1396,16 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1149
1396
  }, []);
1150
1397
  // --- Global keys -------------------------------------------------------
1151
1398
  useInput((input, key) => {
1399
+ // I-9, the third clear trigger, and FIRST IN THE HANDLER so no `return`
1400
+ // below can skip it. Any key at all drops the selection: most of them move
1401
+ // the rows, and the ones that do not have still ended the gesture. It is
1402
+ // also the way out of a `hold` that lost its release (I-11), alongside the
1403
+ // watchdog.
1404
+ //
1405
+ // `Esc` IS DELIBERATELY NOT GIVEN A SECOND MEANING (§4.4.6). It aborts a
1406
+ // run, and an emergency exit is the last key that should silently absorb a
1407
+ // press; it clears the selection here like every other key and nothing more.
1408
+ selectionController?.clear();
1152
1409
  if (key.ctrl && (input === 'c' || input === 'C')) {
1153
1410
  if (ctrlCArmed.current) {
1154
1411
  doExit();
@@ -1196,7 +1453,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1196
1453
  // AFTER the overlay block, and with an EXPLICIT guard rather than relying on
1197
1454
  // ordering: that block only `return`s for the keys it recognizes, and
1198
1455
  // everything else falls through (AC-P2).
1199
- if (key.tab && key.shift) {
1456
+ //
1457
+ // TWO KEYS, ONE BRANCH (shift-tab-mode-toggle-still-dead-on-windows, C2).
1458
+ // `Ctrl+P` is not a Windows-only alias bolted on beside the real binding: it
1459
+ // is the rung that survives when `Shift+Tab` cannot be delivered at all, and
1460
+ // it shares this branch precisely so the two can never drift into different
1461
+ // behaviour. See `MODE_TOGGLE_KEYS` for why this key and not another.
1462
+ if ((key.tab && key.shift) || (key.ctrl && (input === 'p' || input === 'P'))) {
1200
1463
  if (stateRef.current.overlay)
1201
1464
  return;
1202
1465
  applyMode(nextMode(controller.getAgentMode()));
@@ -1257,13 +1520,21 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1257
1520
  // `team` joins `tool` here (team-subagents §6.3): a dispatch card collapses
1258
1521
  // its per-agent summaries behind the same key, and `toggleExpand` is keyed
1259
1522
  // on entry id so it needs no change.
1523
+ // `compaction` joins `tool` and `team` here (context-auto-compaction §6.3 /
1524
+ // C-15 / P1-8). `expandedToolIds` is keyed by entry id so the STORAGE was
1525
+ // already generic — but both surfaces that CHOOSE a target filter by kind,
1526
+ // and without this the compaction card renders its own `ctrl+o to expand`
1527
+ // hint, the keystroke silently expands an older tool card instead, and
1528
+ // nothing errors. AC-21 is the assertion that the hint does not lie.
1260
1529
  const last = [...stateRef.current.entries]
1261
1530
  .reverse()
1262
- .find((e) => e.kind === 'tool' || e.kind === 'team');
1531
+ .find((e) => e.kind === 'tool' || e.kind === 'team' || e.kind === 'compaction');
1263
1532
  if (last)
1264
1533
  dispatch({ type: 'toggleExpand', id: last.id });
1534
+ // WIDENED FROM `No tool output to expand.`: with three expandable kinds the
1535
+ // old wording would be wrong in two of the three empty cases.
1265
1536
  else
1266
- toast('info', 'No tool output to expand.');
1537
+ toast('info', 'Nothing to expand.');
1267
1538
  return;
1268
1539
  }
1269
1540
  if (key.escape) {
@@ -1323,6 +1594,10 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1323
1594
  // every single turn, which is the one moment this feature exists to make feel
1324
1595
  // calm.
1325
1596
  const running = state.status === 'running';
1597
+ // The live compaction card's clock (hardening W5). While the run is going the
1598
+ // 200 ms elapsed ticker already causes the render that re-reads this; an IDLE
1599
+ // `/compact` has no ticker at all, which is what `compactionClock` is for.
1600
+ const compactionLive = state.compactionEntryId !== undefined;
1326
1601
  if (running && runStartedAt.current === 0)
1327
1602
  runStartedAt.current = Date.now();
1328
1603
  if (!running && runStartedAt.current !== 0)
@@ -1348,7 +1623,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1348
1623
  // that re-reads it — so no timer is added (D-36 / E-15). Seconds rather than
1349
1624
  // milliseconds because `ToolCard` is `React.memo`'d with the default
1350
1625
  // comparator; `Transcript` passes it on only to running tool entries.
1351
- const nowSec = running ? Math.floor(Date.now() / 1000) : undefined;
1626
+ const nowSec = running || compactionLive ? Math.floor(Date.now() / 1000) : undefined;
1352
1627
  // Row budget for the viewport and everything sized against it (§4.2a). The
1353
1628
  // inline path has no fixed frame, hence no height to fit into: `Infinity`
1354
1629
  // tells `OverlayFrame` to render everything and show no position indicator.
@@ -1416,6 +1691,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1416
1691
  // The four fast rows, seeded through the pure mapper so the screen and
1417
1692
  // the save handler cannot disagree about what `(inherit)` means.
1418
1693
  ...fastSettingsFrom(cfg.fast),
1694
+ ...compactionSettingsFrom(cfg.compaction),
1419
1695
  }, apiKeys: cfg.apiKeys, maxRows: overlayMaxRows, cols: cols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onSave: handleSettingsSave,
1420
1696
  // Read from `cfg` (render scope, re-read every frame), so `/retry off`
1421
1697
  // while the screen is open shows the new value on the next paint.
@@ -1491,12 +1767,22 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1491
1767
  viewportRows: fullscreen ? viewportBudget : rows,
1492
1768
  offset: scrolledLines,
1493
1769
  };
1494
- const viewport = fullscreen ? (overlayNode ? (_jsx(Box, { flexDirection: "column", flexGrow: 1, overflow: "hidden", children: overlayNode })) : (_jsxs(ScrollViewport, { intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, 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 })] }))) : (_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] }));
1770
+ 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] }));
1495
1771
  // The completion popup lives inside the bottom chrome and can add up to 9
1496
1772
  // rows; unbounded, it pushes the status bar past the frame height where
1497
1773
  // `overflow: hidden` clips it away (R-14 / M-14). Cap it against the viewport.
1498
1774
  const popupMaxRows = fullscreen ? Math.max(1, viewportBudget - 4) : undefined;
1499
- 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, popupMaxRows: popupMaxRows, 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' }) }) }));
1775
+ // WHICH KEY THE HINT ROW NAMES (shift-tab-mode-toggle-still-dead-on-windows,
1776
+ // C3-1). Derived from `vtInputWarning` rather than from a probe of its own,
1777
+ // because that prop is already the single answer to "can this console deliver
1778
+ // `CSI Z`?" — `cli.tsx` computes it AFTER trying to force the bit on, so a
1779
+ // machine we just repaired is told to press `shift+tab` and a machine we could
1780
+ // not repair is told to press the key that provably survives.
1781
+ //
1782
+ // Both keys always WORK (they share one branch in the input handler); this
1783
+ // only decides which one the user is taught.
1784
+ const modeToggleKey = vtInputWarning ? MODE_TOGGLE_KEYS.fallback : MODE_TOGGLE_KEYS.primary;
1785
+ 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, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, scrolledLines: scrolledLines, 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' }) }) }));
1500
1786
  return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, 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,
1501
1787
  // `!overlayNode` suppresses the line while an overlay owns the screen:
1502
1788
  // the full-screen branch replaces the whole viewport with the overlay,
@@ -1509,7 +1795,13 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1509
1795
  // SIGNAL IS DERIVED FROM (D-4): inlining it here again is how the two
1510
1796
  // drift apart, and the drift is silent in whichever branch is not
1511
1797
  // edited.
1512
- activity: activityVisible ? (_jsx(ActivityLine, { startedAt: runStartedAt.current, elapsedMs: elapsedMs, reducedMotion: reducedMotion, runningTool: runningTool, theme: theme, caps: caps })) : null,
1798
+ activity: activityVisible ? (_jsx(ActivityLine, { startedAt: runStartedAt.current, elapsedMs: elapsedMs, reducedMotion: reducedMotion, runningTool: runningTool,
1799
+ // THE ROW MUST NEVER SAY SOMETHING FALSE (§6.1). Compaction runs
1800
+ // BEFORE `turn_start`, so `status` is `'running'` with no tool in
1801
+ // flight — precisely the window in which the rotating phrase
1802
+ // would claim the model is thinking while it is actually waiting
1803
+ // on a 30k-token summarization.
1804
+ compacting: state.compaction?.inFlight === true, theme: theme, caps: caps })) : null,
1513
1805
  // THE FORM THE LIFE SIGNAL TAKES WHEN A TOAST HAS THE ROW
1514
1806
  // (`activity-spinner-vanishes-behind-toast`). Passed UNCONDITIONALLY:
1515
1807
  // `BottomStatusRow` reads it only when `activity` above is non-null,
@@ -1529,7 +1821,7 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1529
1821
  // branch replaces the whole viewport with the overlay, and an update
1530
1822
  // notice under a settings screen is noise attached to a surface it is
1531
1823
  // not visible on.
1532
- 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, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ecoRung: governor.rung, ...(state.team
1824
+ 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, contextTokens: state.contextTokens, contextWindow: modelInfo.contextWindow, contextWindowKnown: modelKnown, contextEstimated: state.contextTokensEstimated, status: state.status, elapsedMs: elapsedMs, thinkingLevel: cfg.thinkingLevel, tokPerSec: tokPerSec, theme: theme, caps: caps, scrolledLines: scrolledLines, redrawNonce: redrawNonce, agentMode: state.agentMode, pendingAgentMode: state.pendingAgentMode, ecoRung: governor.rung, ...(state.team
1533
1825
  ? {
1534
1826
  teamActive: {
1535
1827
  running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
@@ -1539,7 +1831,15 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1539
1831
  }
1540
1832
  : {}), ...(state.todos && !showRail
1541
1833
  ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
1542
- : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}), ...(state.retry
1834
+ : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}), ...(state.compaction?.live
1835
+ ? {
1836
+ compactionActive: { inFlight: state.compaction.inFlight },
1837
+ gaugeMarks: {
1838
+ warn: cfg.compaction.warnThreshold * 100,
1839
+ high: cfg.compaction.threshold * 100,
1840
+ },
1841
+ }
1842
+ : {}), ...(state.retry
1543
1843
  ? {
1544
1844
  retryActive: {
1545
1845
  attempt: state.retry.attempt,
@@ -34,10 +34,35 @@ export interface ComposerProps {
34
34
  hintsEnabled: boolean;
35
35
  /** Mirror of the controller's effective mode; drives the chip and the border. */
36
36
  agentMode: AgentMode;
37
+ /**
38
+ * Which key this hint should NAME for the mode toggle
39
+ * (shift-tab-mode-toggle-still-dead-on-windows, C3-1).
40
+ *
41
+ * A PROP RATHER THAN A CONSTANT because on some consoles the constant is a
42
+ * lie. This row used to render `shift+tab` unconditionally, with no link to
43
+ * the capability check three files away, so the machines where that key is
44
+ * swallowed were the ones being told to press it - permanently, on every
45
+ * frame, while the one warning they got scrolled out of the transcript
46
+ * minutes earlier (analysis section 3.7). Defaults to the primary key, which
47
+ * is what every unaffected machine renders.
48
+ */
49
+ modeToggleKey?: string;
37
50
  popupMaxRows?: number;
51
+ /**
52
+ * Rows of newer output below the viewport, forwarded to the input row's chip
53
+ * (tui-selection-and-scroll-follow §4.2 / G7).
54
+ *
55
+ * IT IS STATE, NOT A FADEABLE HINT, and it therefore does NOT pass through the
56
+ * `showHint` / `hintsEnabled` gate below. Those two govern the teaching row —
57
+ * which retires keys the user has demonstrably learned and is dropped outright
58
+ * on a short terminal — and "you are 12 rows behind the newest output" is
59
+ * neither learnable nor optional. Same argument that keeps `esc abort`
60
+ * un-faded in `hintText`.
61
+ */
62
+ scrolledLines?: number;
38
63
  theme: Theme;
39
64
  caps: TermCapabilities;
40
65
  onSubmit: (text: string) => void;
41
66
  onHelp?: () => void;
42
67
  }
43
- export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
68
+ export declare function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, modeToggleKey, popupMaxRows, scrolledLines, theme, caps, onSubmit, onHelp, }: ComposerProps): React.ReactElement;
@@ -21,7 +21,7 @@ import { pickGlyphs } from './glyphs.js';
21
21
  import { HINT_FADE_AFTER } from '../config/schema.js';
22
22
  import { PromptInput } from './PromptInput.js';
23
23
  import { ModeChip } from './ModeChip.js';
24
- import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
24
+ import { MODE_LABEL, MODE_TOGGLE_KEYS, nextMode } from '../agent/agent-mode.js';
25
25
  /**
26
26
  * Build the hint row.
27
27
  *
@@ -32,7 +32,7 @@ import { MODE_LABEL, nextMode } from '../agent/agent-mode.js';
32
32
  * Progressive disclosure is for teaching discoverable things, not for retiring
33
33
  * an emergency exit (§4.6 / A-14 / M-10b).
34
34
  */
35
- function hintText(running, submitCount, glyphs, mode) {
35
+ function hintText(running, submitCount, glyphs, mode, toggleKey) {
36
36
  const dot = ` ${glyphs.midDot} `;
37
37
  if (running) {
38
38
  return [`${glyphs.enterKey} steer`, 'esc abort', `ctrl+c${glyphs.times}2 exit`].join(dot);
@@ -40,7 +40,7 @@ function hintText(running, submitCount, glyphs, mode) {
40
40
  // The hint names the DESTINATION, not the current state — the correct label
41
41
  // for a toggle affordance, and the reason the mode word here is the OPPOSITE
42
42
  // of the one on the chip beside it.
43
- const toggle = `shift+tab ${MODE_LABEL[nextMode(mode)].toLowerCase()}`;
43
+ const toggle = `${toggleKey} ${MODE_LABEL[nextMode(mode)].toLowerCase()}`;
44
44
  // The faded form KEEPS the mode toggle. Progressive disclosure exists to
45
45
  // retire things the user has demonstrably learned, and `submitCount` counts
46
46
  // submissions — a user can send fifty messages without ever discovering
@@ -56,7 +56,7 @@ function hintText(running, submitCount, glyphs, mode) {
56
56
  '? help',
57
57
  ].join(dot);
58
58
  }
59
- export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, popupMaxRows, theme, caps, onSubmit, onHelp, }) {
59
+ export function Composer({ isActive, running, history, commands, cwd, showHint, submitCount, hintsEnabled, agentMode, modeToggleKey = MODE_TOGGLE_KEYS.primary, popupMaxRows, scrolledLines = 0, theme, caps, onSubmit, onHelp, }) {
60
60
  const [hasDraft, setHasDraft] = useState(false);
61
61
  const onDraftChange = useCallback((next) => setHasDraft(next), []);
62
62
  // Plan mode inserts ONE branch, above `draft` and below `running`. The
@@ -73,8 +73,8 @@ export function Composer({ isActive, running, history, commands, cwd, showHint,
73
73
  : theme.idleBorder ?? theme.border;
74
74
  const glyphs = pickGlyphs(caps);
75
75
  const visible = showHint && hintsEnabled;
76
- return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, bordered: { color: borderColor }, onDraftChange: onDraftChange }), visible && (
76
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [_jsx(PromptInput, { isActive: isActive, running: running, history: history, commands: commands, cwd: cwd, theme: theme, caps: caps, popupMaxRows: popupMaxRows, onSubmit: onSubmit, onHelp: onHelp, agentMode: agentMode, bordered: { color: borderColor }, onDraftChange: onDraftChange, scrolledLines: scrolledLines }), visible && (
77
77
  // The chip rides the EXISTING hint row rather than adding one, so it
78
78
  // costs zero rows on a 24-row terminal.
79
- _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(running, submitCount, glyphs, agentMode) })] }))] }));
79
+ _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(running, submitCount, glyphs, agentMode, modeToggleKey) })] }))] }));
80
80
  }