@aragon-agent/cli 0.6.3 → 0.6.7

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 (138) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +203 -13
  3. package/dist/agent/controller.d.ts +158 -2
  4. package/dist/agent/controller.js +400 -1
  5. package/dist/agent/reducer.d.ts +102 -19
  6. package/dist/agent/reducer.js +162 -71
  7. package/dist/agent/system-prompt.d.ts +14 -0
  8. package/dist/agent/system-prompt.js +2 -0
  9. package/dist/agent/usage.d.ts +34 -0
  10. package/dist/agent/usage.js +43 -0
  11. package/dist/cli.js +132 -23
  12. package/dist/commands/builtins.js +101 -1
  13. package/dist/compaction/child.js +5 -0
  14. package/dist/compaction/command.js +14 -1
  15. package/dist/compaction/compactor.d.ts +94 -24
  16. package/dist/compaction/compactor.js +247 -98
  17. package/dist/compaction/context-command.d.ts +33 -0
  18. package/dist/compaction/context-command.js +166 -0
  19. package/dist/compaction/meter.d.ts +251 -0
  20. package/dist/compaction/meter.js +428 -0
  21. package/dist/compaction/pressure.d.ts +6 -0
  22. package/dist/compaction/pressure.js +4 -0
  23. package/dist/compaction/types.d.ts +77 -0
  24. package/dist/compaction/wiring.d.ts +56 -0
  25. package/dist/compaction/wiring.js +107 -25
  26. package/dist/config/cli-commands.js +5 -0
  27. package/dist/config/env.d.ts +1 -1
  28. package/dist/config/env.js +63 -2
  29. package/dist/config/load.d.ts +28 -0
  30. package/dist/config/load.js +83 -1
  31. package/dist/config/schema.d.ts +129 -0
  32. package/dist/config/schema.js +69 -0
  33. package/dist/config/store.js +47 -1
  34. package/dist/exec/events.d.ts +138 -1
  35. package/dist/exec/events.js +42 -0
  36. package/dist/exec/index.js +43 -6
  37. package/dist/exec/progress-reporter.d.ts +23 -0
  38. package/dist/exec/progress-reporter.js +88 -0
  39. package/dist/exec/runner.d.ts +34 -0
  40. package/dist/exec/runner.js +137 -25
  41. package/dist/fast/reviewer.d.ts +19 -1
  42. package/dist/fast/reviewer.js +22 -0
  43. package/dist/fast/types.d.ts +15 -0
  44. package/dist/fast/wiring.d.ts +10 -1
  45. package/dist/fast/wiring.js +18 -1
  46. package/dist/input/limits.d.ts +72 -0
  47. package/dist/input/limits.js +63 -0
  48. package/dist/input/paste-parse.d.ts +71 -0
  49. package/dist/input/paste-parse.js +95 -0
  50. package/dist/input/stdin-filter.d.ts +81 -0
  51. package/dist/input/stdin-filter.js +379 -0
  52. package/dist/logging/install.d.ts +2 -0
  53. package/dist/logging/install.js +53 -1
  54. package/dist/proc/classify.d.ts +52 -0
  55. package/dist/proc/classify.js +143 -0
  56. package/dist/proc/kill-tree.d.ts +42 -0
  57. package/dist/proc/kill-tree.js +119 -0
  58. package/dist/proc/limits.d.ts +84 -0
  59. package/dist/proc/limits.js +84 -0
  60. package/dist/proc/log-ring.d.ts +68 -0
  61. package/dist/proc/log-ring.js +102 -0
  62. package/dist/proc/prompt.d.ts +24 -0
  63. package/dist/proc/prompt.js +49 -0
  64. package/dist/proc/readiness.d.ts +102 -0
  65. package/dist/proc/readiness.js +219 -0
  66. package/dist/proc/supervisor.d.ts +103 -0
  67. package/dist/proc/supervisor.js +465 -0
  68. package/dist/proc/types.d.ts +175 -0
  69. package/dist/proc/types.js +15 -0
  70. package/dist/session/persist.js +36 -1
  71. package/dist/todo/limits.d.ts +4 -17
  72. package/dist/todo/limits.js +4 -17
  73. package/dist/todo/panel-rows.d.ts +8 -6
  74. package/dist/todo/panel-rows.js +19 -6
  75. package/dist/tools/bash-tool.d.ts +51 -2
  76. package/dist/tools/bash-tool.js +321 -51
  77. package/dist/tools/fs-tools.d.ts +17 -0
  78. package/dist/tools/index.d.ts +55 -2
  79. package/dist/tools/index.js +39 -1
  80. package/dist/tools/proc-tools.d.ts +25 -0
  81. package/dist/tools/proc-tools.js +102 -0
  82. package/dist/ui/App.d.ts +15 -2
  83. package/dist/ui/App.js +330 -69
  84. package/dist/ui/AutocompletePopup.d.ts +3 -1
  85. package/dist/ui/AutocompletePopup.js +9 -11
  86. package/dist/ui/BottomStatusRow.d.ts +1 -1
  87. package/dist/ui/Composer.d.ts +56 -1
  88. package/dist/ui/Composer.js +50 -6
  89. package/dist/ui/PromptInput.d.ts +52 -3
  90. package/dist/ui/PromptInput.js +201 -55
  91. package/dist/ui/StatusBar.d.ts +40 -15
  92. package/dist/ui/StatusBar.js +51 -9
  93. package/dist/ui/TeamPanel.d.ts +3 -1
  94. package/dist/ui/TeamPanel.js +13 -9
  95. package/dist/ui/TodoPanel.d.ts +5 -5
  96. package/dist/ui/TodoPanel.js +56 -24
  97. package/dist/ui/Transcript.js +29 -1
  98. package/dist/ui/UpdateLine.d.ts +1 -1
  99. package/dist/ui/capabilities.js +5 -5
  100. package/dist/ui/composer-limits.d.ts +31 -0
  101. package/dist/ui/composer-limits.js +50 -0
  102. package/dist/ui/composer-rows.d.ts +73 -0
  103. package/dist/ui/composer-rows.js +201 -0
  104. package/dist/ui/editor-reducer.d.ts +37 -0
  105. package/dist/ui/editor-reducer.js +88 -12
  106. package/dist/ui/entries/ServiceCard.d.ts +66 -0
  107. package/dist/ui/entries/ServiceCard.js +116 -0
  108. package/dist/ui/entries/UserEntry.d.ts +17 -3
  109. package/dist/ui/entries/UserEntry.js +34 -5
  110. package/dist/ui/exit-snapshot.d.ts +9 -0
  111. package/dist/ui/layout/ScrollViewport.d.ts +1 -1
  112. package/dist/ui/layout/autocomplete.d.ts +18 -0
  113. package/dist/ui/layout/autocomplete.js +26 -0
  114. package/dist/ui/layout/budget.d.ts +34 -6
  115. package/dist/ui/layout/budget.js +32 -7
  116. package/dist/ui/layout/rail.d.ts +4 -53
  117. package/dist/ui/layout/rail.js +10 -62
  118. package/dist/ui/layout/team-panel.d.ts +18 -0
  119. package/dist/ui/layout/team-panel.js +22 -0
  120. package/dist/ui/layout/todo-layout.d.ts +24 -0
  121. package/dist/ui/layout/todo-layout.js +27 -0
  122. package/dist/ui/layout/virtual-window.js +51 -1
  123. package/dist/ui/overlays/HelpOverlay.js +3 -0
  124. package/dist/ui/overlays/PlanReviewOverlay.js +10 -2
  125. package/dist/ui/overlays/QuestionOverlay.js +5 -1
  126. package/dist/ui/overlays/SettingsScreen.js +14 -1
  127. package/dist/ui/paste-frames.d.ts +87 -0
  128. package/dist/ui/paste-frames.js +112 -0
  129. package/dist/ui/paste-tokens.d.ts +90 -0
  130. package/dist/ui/paste-tokens.js +147 -0
  131. package/dist/ui/screen.d.ts +16 -0
  132. package/dist/ui/screen.js +45 -2
  133. package/dist/ui/selection/selection-controller.d.ts +1 -1
  134. package/dist/ui/transcript-text.js +55 -2
  135. package/dist/ui/use-wheel-routing.d.ts +1 -1
  136. package/package.json +2 -2
  137. package/dist/input/stdin-mouse-filter.d.ts +0 -47
  138. package/dist/input/stdin-mouse-filter.js +0 -153
package/dist/ui/App.js CHANGED
@@ -11,12 +11,12 @@ 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';
15
14
  import { parseMaxTokensInput, DEFAULT_TRANSCRIPT_RETAIN, DEFAULT_TRANSCRIPT_WINDOW, } from '../config/schema.js';
16
15
  import { updatePersistedConfig } from '../config/store.js';
17
16
  import { appendPrompt, loadPromptHistory } from '../config/prompt-history.js';
18
17
  import { bumpSubmitCount, setMouseNoticeVersion, setVtInputNoticeVersion, } from '../config/ui-state.js';
19
18
  import { getLogger } from '../logging/logger.js';
19
+ import { isTerminalStatus } from '../proc/types.js';
20
20
  import { registerSecret } from '../logging/secret-registry.js';
21
21
  import { detectCapabilities } from './capabilities.js';
22
22
  import { useStartupNotices, MOUSE_NOTICE_VERSION, VT_INPUT_NOTICE_VERSION, } from './use-startup-notices.js';
@@ -46,8 +46,8 @@ import { shouldRenderUpdateLine } from '../update/types.js';
46
46
  import { UPDATE_LIMITS } from '../update/limits.js';
47
47
  import { AppShell } from './layout/AppShell.js';
48
48
  import { ScrollViewport } from './layout/ScrollViewport.js';
49
- import { todoRailRows, todoRailWidth } from './layout/rail.js';
50
- import { TODO_LIMITS } from '../todo/limits.js';
49
+ import { buildTodoRailLayout } from './layout/todo-layout.js';
50
+ import { buildTeamPanelLayout } from './layout/team-panel.js';
51
51
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
52
52
  import { useTerminalSize } from './layout/useTerminalSize.js';
53
53
  import { useHeightStore } from './use-height-store.js';
@@ -105,6 +105,24 @@ export function resolveSettingsMaxTokens(raw, previous) {
105
105
  toast: ['warn', 'Max tokens must be a number or "auto".'],
106
106
  };
107
107
  }
108
+ /**
109
+ * How long rung two of the Esc ladder stays armed after rung one (§5.5).
110
+ *
111
+ * Four seconds: long enough that a user who watched the abort fail can react,
112
+ * short enough that a stray Esc minutes later cannot force-stop a healthy run.
113
+ * `runEnd` / `agent_end` disarm it too, so the window is an upper bound rather
114
+ * than the only protection (R-9).
115
+ */
116
+ const ESC_ARM_MS = 4000;
117
+ /** Services in a non-terminal state, from the live snapshot map. */
118
+ function liveServiceCount(services) {
119
+ let n = 0;
120
+ for (const service of Object.values(services)) {
121
+ if (!isTerminalStatus(service.status))
122
+ n += 1;
123
+ }
124
+ return n;
125
+ }
108
126
  /**
109
127
  * The lazy `useReducer` initializer. Module scope, so the reference is stable
110
128
  * across renders and React never re-runs it.
@@ -112,7 +130,7 @@ export function resolveSettingsMaxTokens(raw, previous) {
112
130
  function seedViewState(thinkingVisible) {
113
131
  return initialViewState({ thinkingVisible });
114
132
  }
115
- export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, terminal, }) {
133
+ export function App({ controller, version, mode, initialOverlay, initialPrompt, confirmBridge, humanInputBridge, mouseSource, vtInputWarning, updateBridge, pasteBridge, terminal, }) {
116
134
  const { exit } = useApp();
117
135
  const { stdout } = useStdout();
118
136
  const fullscreen = mode === 'fullscreen';
@@ -198,6 +216,37 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
198
216
  confirmRef.current = confirmState;
199
217
  const ctrlCArmed = useRef(false);
200
218
  const ctrlCTimer = useRef(null);
219
+ /**
220
+ * Rung two of Esc is armed (background-service-supervision §3.6).
221
+ *
222
+ * CLEARED BY `runEnd` / `agent_end` AS WELL AS BY ITS OWN TIMER, and both
223
+ * halves matter: a healthy run that ended between the two presses must not be
224
+ * force-stoppable retroactively, and in every healthy run the second press
225
+ * lands on the idle branch and does nothing at all (R-9). Rung two only ever
226
+ * fires while the run is STILL running after rung one - i.e. only when the
227
+ * first press provably failed.
228
+ */
229
+ const escArmed = useRef(false);
230
+ const escArmTimer = useRef(null);
231
+ /**
232
+ * Live service snapshots, keyed by id, for the status chip and the Ctrl+C rung.
233
+ *
234
+ * A `useState` MAP rather than a ref, because the chip has to re-render when it
235
+ * changes; and separate from `ViewState`, because a service is not a
236
+ * transcript entry - the transcript records EVENTS about a service (D-11) and
237
+ * this is the live state those events are about.
238
+ */
239
+ const [services, setServices] = useState({});
240
+ /**
241
+ * Read by the Ctrl+C handler, which `useInput` registers ONCE.
242
+ *
243
+ * The same reason `stateRef` exists two lines up: a value captured in that
244
+ * closure would be pinned to the mount render, so the key handler would
245
+ * believe there are never any services and Ctrl+C would arm exit while a dev
246
+ * server was up.
247
+ */
248
+ const servicesRef = useRef(services);
249
+ servicesRef.current = services;
201
250
  const runBaselineOut = useRef(0);
202
251
  /**
203
252
  * Epoch ms the current run began — the activity phrase's seed.
@@ -282,6 +331,20 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
282
331
  aborted: false,
283
332
  errored: false,
284
333
  });
334
+ /**
335
+ * Disarm rung two of the Esc ladder.
336
+ *
337
+ * A `useCallback` so the event subscription's dependency list can name it, and
338
+ * so it is one function rather than three copies of "clear the flag and the
339
+ * timer" that can drift.
340
+ */
341
+ const disarmEsc = useCallback(() => {
342
+ escArmed.current = false;
343
+ if (escArmTimer.current) {
344
+ clearTimeout(escArmTimer.current);
345
+ escArmTimer.current = null;
346
+ }
347
+ }, []);
285
348
  /**
286
349
  * Disarm an armed continuation. Idempotent; NEVER touches the budget — a
287
350
  * cancelled continuation was never issued, so it must not be charged (AC-18).
@@ -368,7 +431,28 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
368
431
  */
369
432
  const patchSource = useMemo(() => ({ take: (id) => controller.takeFilePatch(id) }), [controller]);
370
433
  useEffect(() => {
434
+ // CAPTURED AT SUBSCRIBE TIME AND COMPARED ON EVERY EVENT (I-7 / P1-7).
435
+ //
436
+ // `forceStop()` bumps the controller's generation, and everything the engine
437
+ // emits while it unwinds - `turn_start`, `tool_call_start`, `turn_end`,
438
+ // `agent_end` - belongs to the run the user just abandoned. `runStart` /
439
+ // `turnStart` would set `status: 'running'` again, bouncing the view straight
440
+ // back out of the `idle` rung two just gave them, and stray entries would
441
+ // append to a transcript they believe is finished.
442
+ //
443
+ // A LOCAL `let`, RE-READ RATHER THAN CAPTURED ONCE: the generation this
444
+ // listener considers current advances with every submit, so a force-stop
445
+ // makes stale events inert WITHOUT making the next run's events inert too.
446
+ let generation = controller.runGeneration;
371
447
  const unsubscribe = controller.subscribe((event) => {
448
+ if (controller.runGeneration !== generation) {
449
+ // The engine is unwinding a force-stopped run. Adopt the new generation
450
+ // only once it has finished doing so, so a late `agent_end` cannot be
451
+ // mistaken for the next run's.
452
+ if (event.type === 'agent_end')
453
+ generation = controller.runGeneration;
454
+ return;
455
+ }
372
456
  const cost = controller.getModelInfo().cost;
373
457
  for (const action of reduceEvent(event, cost, patchSource)) {
374
458
  // MUTATED HERE, ONE INSTANT BEFORE THE ACTION THAT SETS
@@ -395,6 +479,11 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
395
479
  }
396
480
  if (event.type !== 'agent_end')
397
481
  return;
482
+ // RUNG TWO DISARMS WITH THE RUN (§3.6 / R-9). A healthy run that ended
483
+ // between the two Esc presses must not be force-stoppable retroactively:
484
+ // the second press then lands on the idle branch, which is what makes the
485
+ // ladder free to take rather than a footgun.
486
+ disarmEsc();
398
487
  // NO OVERLAY OUTLIVES ITS RUN (P1-4). `ctx.signal` already covers the
399
488
  // abort path, so this is DELIBERATE REDUNDANCY: it makes "the card cannot
400
489
  // survive the run that opened it" a property of the App's lifecycle
@@ -484,7 +573,37 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
484
573
  cancelFollowThrough();
485
574
  unsubscribe();
486
575
  };
487
- }, [controller, patchSource, flushPending, cancelPendingHuman, cancelFollowThrough]);
576
+ }, [controller, patchSource, flushPending, cancelPendingHuman, cancelFollowThrough, disarmEsc]);
577
+ // --- Background services (background-service-supervision §3.8). ---------
578
+ //
579
+ // A SEPARATE SUBSCRIPTION, not a branch in the one above: `ProcEvent` is
580
+ // deliberately not a member of core's `AgentEvent` union (I-5 / D-1), so it
581
+ // cannot arrive through `controller.subscribe`.
582
+ //
583
+ // TWO SINKS PER EVENT, and they are not redundant. `setServices` holds the
584
+ // LIVE state the status chip, the composer hint and the Ctrl+C rung read;
585
+ // `dispatch` writes the TRANSCRIPT, which records events about a service
586
+ // rather than mirroring it (D-11). Feeding one from the other would make the
587
+ // chip lie about a card that has already been printed into `<Static>`.
588
+ //
589
+ // NOT ROUTED THROUGH THE STREAMING COALESCER. `output` is already coalesced at
590
+ // the SOURCE, inside the supervisor, because `/bg` and `bash_output` consume
591
+ // the same event (§6); a second buffer here would only add latency.
592
+ useEffect(() => {
593
+ return controller.subscribeProc((event) => {
594
+ const service = event.service;
595
+ setServices((prev) => ({ ...prev, [service.id]: service }));
596
+ if (event.type === 'started') {
597
+ dispatch({ type: 'serviceStart', service });
598
+ return;
599
+ }
600
+ if (event.type === 'exited' || event.type === 'stopped') {
601
+ dispatch({ type: 'serviceEnd', service });
602
+ return;
603
+ }
604
+ dispatch({ type: 'serviceUpdate', service });
605
+ });
606
+ }, [controller]);
488
607
  // --- Team-mode event stream (team-subagents §5.2 / §3.9). --------------
489
608
  //
490
609
  // A SEPARATE SUBSCRIPTION, not a branch in the one above: `TeamEvent` is
@@ -631,6 +750,27 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
631
750
  }
632
751
  });
633
752
  }, [controller]);
753
+ // --- Context occupancy (context-usage-gauge-accuracy §3.3 / I-1). --------
754
+ //
755
+ // A SEVENTH SUBSCRIPTION, AND THE SOLE DISPATCHER OF `contextUsage`. The gauge
756
+ // used to be written from three reducer branches fed by two upstreams, which
757
+ // is what let a stale `snapshot` bounce the bar back to the pre-compaction
758
+ // figure one statement after a compaction correctly dropped it (P0-1). One
759
+ // writer makes that unrepresentable.
760
+ //
761
+ // IT SUBSCRIBES UNCONDITIONALLY. `subscribeContextUsage` is not a compaction
762
+ // forwarder - the meter exists in every session, including one started with
763
+ // `--no-compaction`, which is precisely the session whose gauge previously had
764
+ // one sample per turn and read 0 % after a `/resume`.
765
+ useEffect(() => {
766
+ // SEED ON MOUNT so a resumed session shows a real occupancy on frame 1
767
+ // rather than after its first completed turn. `getContextUsage()` measures
768
+ // on demand, so this works before any event has ever arrived.
769
+ dispatch({ type: 'contextUsage', snapshot: controller.getContextUsage() });
770
+ return controller.subscribeContextUsage((snapshot) => {
771
+ dispatch({ type: 'contextUsage', snapshot });
772
+ });
773
+ }, [controller]);
634
774
  // --- Compaction event stream (context-auto-compaction §5.2). -------------
635
775
  //
636
776
  // A SIXTH SUBSCRIPTION, for the reason the team, todo and fast ones record:
@@ -667,23 +807,20 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
667
807
  model: event.model,
668
808
  });
669
809
  break;
670
- case 'compaction_end': {
810
+ case 'compaction_end':
811
+ // THE CARD, AND NOTHING ELSE (context-usage-gauge-accuracy §3.3).
812
+ //
813
+ // A `contextTokensEstimated` dispatch used to live here, hand-building
814
+ // the post-compaction occupancy out of `record.tokensAfter` plus the
815
+ // snapshot's `estimateOffset`. It is gone because the gauge now has ONE
816
+ // writer: `ContextMeter` learns about the splice from
817
+ // `CompactionWiring.settlePending` itself and re-measures, so the bar
818
+ // falls in the same frame WITHOUT this branch and without the `snapshot`
819
+ // branch below racing it. Those two dispatches were the second and
820
+ // third writers of one number, and the loser of that race was the
821
+ // correct value (P0-1).
671
822
  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
823
  break;
686
- }
687
824
  case 'usage': {
688
825
  // Compaction spend is REAL SPEND at the SUMMARIZER's price (§6.4 /
689
826
  // AC-15), which may be neither the lead's nor the fast tier's — so the
@@ -700,24 +837,12 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
700
837
  break;
701
838
  }
702
839
  case 'snapshot':
840
+ // THE CHIP, THE COUNTS AND THE SUMMARIZER (§3.3). The occupancy that
841
+ // rides along on `snapshot.pressure` is deliberately NOT dispatched
842
+ // here: the meter that produced it publishes it on its own channel, and
843
+ // a second dispatch of the same number is the second writer this
844
+ // feature removed.
703
845
  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
846
  break;
722
847
  }
723
848
  });
@@ -983,6 +1108,60 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
983
1108
  // --- Helpers -----------------------------------------------------------
984
1109
  const notify = (level, text) => dispatch({ type: 'notice', level, text });
985
1110
  const toast = (level, text) => dispatch({ type: 'pushToast', level, text });
1111
+ // --- Paste bridge (tui-paste-handling section 5.1.2 / I-15). -------------
1112
+ //
1113
+ // The filter exists before this component does, so it holds a slot rather than
1114
+ // a callback. Cleared on unmount: a rejection that somehow arrived after the
1115
+ // tree came down would otherwise dispatch into a dead reducer.
1116
+ useEffect(() => {
1117
+ if (!pasteBridge)
1118
+ return undefined;
1119
+ const bridge = pasteBridge;
1120
+ bridge.notify = (level, text) => dispatch({ type: 'notice', level, text });
1121
+ return () => {
1122
+ bridge.notify = null;
1123
+ };
1124
+ }, [pasteBridge]);
1125
+ // --- The composer's height (tui-paste-handling section 5.5 / D-12). ------
1126
+ //
1127
+ // GROWTH IS IMMEDIATE, SHRINK IS DEFERRED BY ONE TICK (R-12). Growing late
1128
+ // overdraws the frame for a frame; shrinking early makes a `Backspace` that
1129
+ // crosses a wrap boundary bounce the whole transcript, and a run of deletions
1130
+ // bounce it repeatedly. Deferring coalesces the run into one move.
1131
+ //
1132
+ // The ref mirrors the state so the decision is made OUTSIDE the updater: React
1133
+ // may call an updater more than once, and scheduling a timer from inside one
1134
+ // would arm it twice.
1135
+ const [draftRows, setDraftRows] = useState(1);
1136
+ const [popupRows, setPopupRows] = useState(0);
1137
+ const onPopupRowsChange = useCallback((next) => {
1138
+ setPopupRows(previous => previous === next ? previous : next);
1139
+ }, []);
1140
+ const draftRowsRef = useRef(1);
1141
+ const shrinkTimer = useRef(null);
1142
+ const onDraftRows = useCallback((next) => {
1143
+ const wanted = Math.max(1, next);
1144
+ if (shrinkTimer.current) {
1145
+ clearTimeout(shrinkTimer.current);
1146
+ shrinkTimer.current = null;
1147
+ }
1148
+ if (wanted === draftRowsRef.current)
1149
+ return;
1150
+ if (wanted > draftRowsRef.current) {
1151
+ draftRowsRef.current = wanted;
1152
+ setDraftRows(wanted);
1153
+ return;
1154
+ }
1155
+ shrinkTimer.current = setTimeout(() => {
1156
+ shrinkTimer.current = null;
1157
+ draftRowsRef.current = wanted;
1158
+ setDraftRows(wanted);
1159
+ }, 0);
1160
+ }, []);
1161
+ useEffect(() => () => {
1162
+ if (shrinkTimer.current)
1163
+ clearTimeout(shrinkTimer.current);
1164
+ }, []);
986
1165
  // --- Auto-update bridge (cli-auto-update section 3.8 / 6.3). ------------
987
1166
  //
988
1167
  // TWO ARRIVAL ORDERS, AND BOTH HAPPEN. The service is built by a dynamic
@@ -1119,6 +1298,16 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1119
1298
  };
1120
1299
  const doExit = () => {
1121
1300
  controller.abort();
1301
+ // G5. `force: true` means `reapSync()` SEMANTICS - immediate SIGKILL, no
1302
+ // grace ladder - because THIS FUNCTION AWAITS NOTHING AND MUST NOT START
1303
+ // (§3.6 step 3). An async `stopAll` with a 3 s escalation would still be
1304
+ // running when `exit()` tore the tree down, and every child would survive.
1305
+ //
1306
+ // IT IS DELIBERATELY REDUNDANT with the signal hook the controller
1307
+ // registered: a normal quit reaches both, and `reapSync` is idempotent
1308
+ // precisely so that is safe. The hook covers SIGINT/SIGTERM and a crash;
1309
+ // this covers `/exit` and `Ctrl+C x2`, which are not signals at all.
1310
+ void controller.stopAllServices({ force: true });
1122
1311
  exit();
1123
1312
  };
1124
1313
  /**
@@ -1407,6 +1596,26 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1407
1596
  // press; it clears the selection here like every other key and nothing more.
1408
1597
  selectionController?.clear();
1409
1598
  if (key.ctrl && (input === 'c' || input === 'C')) {
1599
+ // RUNG ONE: Ctrl+C acquires the meaning a terminal user already expects
1600
+ // it to have (§3.6 / G4 / D-5). While services are live it stops THEM, and
1601
+ // only once nothing is running does it fall through to the existing
1602
+ // arm/exit ladder.
1603
+ //
1604
+ // IT DOES NOT ARM EXIT. Stopping a server and quitting the app are
1605
+ // different intentions, and a user who pressed Ctrl+C to kill a dev server
1606
+ // must not find themselves one keystroke from losing the session. The
1607
+ // composer hint still names an exit clause, so quitting stays discoverable
1608
+ // (P1-4).
1609
+ //
1610
+ // THE GRACEFUL LADDER, not `force`: this path has a live event loop to run
1611
+ // the SIGTERM -> SIGKILL escalation on, and a dev server given a chance to
1612
+ // shut down cleanly releases its port.
1613
+ const live = liveServiceCount(servicesRef.current);
1614
+ if (live > 0) {
1615
+ void controller.stopAllServices();
1616
+ toast('warn', `Stopping ${live} service${live === 1 ? '' : 's'}.`);
1617
+ return;
1618
+ }
1410
1619
  if (ctrlCArmed.current) {
1411
1620
  doExit();
1412
1621
  return;
@@ -1526,9 +1735,17 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1526
1735
  // and without this the compaction card renders its own `ctrl+o to expand`
1527
1736
  // hint, the keystroke silently expands an older tool card instead, and
1528
1737
  // nothing errors. AC-21 is the assertion that the hint does not lie.
1738
+ // `service` joins the closed list (P1-3). Omitting it is not a missing
1739
+ // feature but an actively wrong one: `ServiceCard` draws its own
1740
+ // `ctrl+o log` hint, the keystroke would silently expand AN OLDER TOOL
1741
+ // CARD, and nothing would error. The terminal one-row record is skipped -
1742
+ // it has nothing to reveal, and expanding it would consume the press.
1529
1743
  const last = [...stateRef.current.entries]
1530
1744
  .reverse()
1531
- .find((e) => e.kind === 'tool' || e.kind === 'team' || e.kind === 'compaction');
1745
+ .find((e) => e.kind === 'tool' ||
1746
+ e.kind === 'team' ||
1747
+ e.kind === 'compaction' ||
1748
+ (e.kind === 'service' && !e.terminal));
1532
1749
  if (last)
1533
1750
  dispatch({ type: 'toggleExpand', id: last.id });
1534
1751
  // WIDENED FROM `No tool output to expand.`: with three expandable kinds the
@@ -1554,6 +1771,34 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1554
1771
  return;
1555
1772
  }
1556
1773
  if (stateRef.current.status === 'running') {
1774
+ // RUNG TWO (§3.6 / G3). The first press asked the engine to stop; if the
1775
+ // run is STILL running when the second arrives, that request provably
1776
+ // failed - so this one does not ask, it takes.
1777
+ //
1778
+ // THREE THINGS HAPPEN, AND THE THIRD IS THE BELT-AND-BRACES.
1779
+ // `controller.forceStop()` aborts, hard-kills every tracked foreground
1780
+ // `bash` child (which is what actually unblocks a wedged
1781
+ // `await tool.execute`) and bumps the run generation so the unwinding
1782
+ // engine can no longer move the view. The local `runEnd` is dispatched
1783
+ // UNCONDITIONALLY on top: even if the engine is blocked somewhere
1784
+ // nothing can reach, the view returns to `idle`, the composer is usable,
1785
+ // and the reducer releases every live tail. Making the view's usability
1786
+ // conditional on the engine recovering is what produced the reported
1787
+ // screenshot (D-6).
1788
+ //
1789
+ // AND THE NEXT MESSAGE MUST STILL WORK. `AgentController.prompt()` waits
1790
+ // a still-running engine out and never rejects (I-8) - without that,
1791
+ // "Esc, Esc, then type" would reach `Agent.prompt()`'s synchronous throw,
1792
+ // become an unhandled rejection, and EXIT THE PROCESS (P0-1).
1793
+ if (escArmed.current) {
1794
+ disarmEsc();
1795
+ controller.forceStop();
1796
+ endReasonRef.current.aborted = true;
1797
+ dispatch({ type: 'abortMark' });
1798
+ dispatch({ type: 'runEnd' });
1799
+ toast('warn', 'Force-stopped.');
1800
+ return;
1801
+ }
1557
1802
  controller.abort();
1558
1803
  // Next to `abortMark`, in the same callback, for the reason
1559
1804
  // `endReasonRef`'s own comment gives (C-11). D-5 turns this into
@@ -1561,7 +1806,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1561
1806
  // restating what they interrupted is the CLI arguing.
1562
1807
  endReasonRef.current.aborted = true;
1563
1808
  dispatch({ type: 'abortMark' });
1564
- toast('info', 'Run aborted.');
1809
+ escArmed.current = true;
1810
+ if (escArmTimer.current)
1811
+ clearTimeout(escArmTimer.current);
1812
+ escArmTimer.current = setTimeout(() => {
1813
+ escArmed.current = false;
1814
+ escArmTimer.current = null;
1815
+ }, ESC_ARM_MS);
1816
+ toast('info', 'Run aborted. Esc again to force-stop.');
1565
1817
  return;
1566
1818
  }
1567
1819
  // IDLE. Today this branch does nothing at all, which is what makes it free
@@ -1578,11 +1830,18 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1578
1830
  }
1579
1831
  });
1580
1832
  // --- Render ------------------------------------------------------------
1581
- const modelInfo = controller.getModelInfo();
1582
- const modelKnown = !!controller.getModelRegistry().getModel(cfg.provider, cfg.model);
1833
+ // `const modelInfo = controller.getModelInfo()` AND ITS `modelKnown` COMPANION
1834
+ // USED TO LIVE HERE (context-usage-gauge-accuracy §4.3). They fed the status
1835
+ // bar's `contextWindow` / `contextWindowKnown` props, which no longer exist:
1836
+ // the denominator and its trustworthiness are resolved inside `ContextMeter`,
1837
+ // which is what lets the user's `contextWindow` override reach the gauge at
1838
+ // all. Nothing else in this render read them.
1583
1839
  const overlay = state.overlay;
1584
1840
  const hasKey = controller.hasApiKey();
1585
1841
  const empty = state.entries.length === 0;
1842
+ // RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
1843
+ // handler will do on the very next keystroke.
1844
+ const liveServices = liveServiceCount(services);
1586
1845
  const tokPerSec = state.status === 'running' && elapsedMs > 500
1587
1846
  ? Math.max(0, Math.round((state.usageTotal.outputTokens - runBaselineOut.current) / (elapsedMs / 1000)))
1588
1847
  : 0;
@@ -1627,24 +1886,24 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1627
1886
  // Row budget for the viewport and everything sized against it (§4.2a). The
1628
1887
  // inline path has no fixed frame, hence no height to fit into: `Infinity`
1629
1888
  // tells `OverlayFrame` to render everything and show no position indicator.
1630
- const viewportBudget = computeViewportRows(rows);
1889
+ // `draftRows` is the number `PromptInput` just reported for the rows it is
1890
+ // ACTUALLY RENDERING (I-8). Defaulting it to 1 is what makes the inline path
1891
+ // and every test that never types byte-identical to before.
1892
+ const viewportBudget = computeViewportRows(rows, draftRows);
1631
1893
  const overlayMaxRows = fullscreen ? viewportBudget : Number.POSITIVE_INFINITY;
1632
- // --- The todo rail (todo-plan-execution §3.9). --------------------------
1633
- //
1634
- // ONE NUMBER FOR THE GATE AND FOR THE LAYOUT (I-10). `railRows` is both what
1635
- // decides whether the panel mounts and what the panel lays out against; a
1636
- // panel that mounts on one number and renders against another shows nothing
1637
- // and reports nothing. `todoRailRows` is NOT `viewportBudget`: the live team
1638
- // roster costs up to 8 rows inside `AppShell`'s measured bottom box that the
1639
- // budget knows nothing about (P1-3).
1640
- const railRows = todoRailRows(viewportBudget, state.team !== null);
1641
- const railWidth = todoRailWidth(cols);
1642
- const showRail = fullscreen && // non-goal 4: inline mode has no fixed frame
1643
- cfg.todo.panel && // `/todo panel off`, persisted
1644
- overlay === null && // a modal owns the screen (D-7)
1645
- state.todos !== null && // no list -> no furniture, zero layout cost
1646
- railWidth > 0 && // too narrow (under 80 columns)
1647
- railRows >= TODO_LIMITS.panelMinRows; // too short to say anything useful
1894
+ // Renderers consume these same projections. Team capacity does not depend on
1895
+ // popup occupancy, so a menu cannot toggle the team's collapsed state.
1896
+ const teamLayout = buildTeamPanelLayout({
1897
+ snapshot: state.team, terminalRows: rows, availableRows: viewportBudget,
1898
+ });
1899
+ const railLayout = buildTodoRailLayout({
1900
+ mode, cols, panelEnabled: cfg.todo.panel, overlayOpen: overlay !== null,
1901
+ itemCount: state.todos?.items.length ?? 0, viewportBudget,
1902
+ teamRows: teamLayout.rowCount, popupRows,
1903
+ });
1904
+ // Every viewport consumer uses contentCols; header, composer, status and
1905
+ // overlays keep the full terminal width. Overlay visibility is in the gate.
1906
+ const { visible: showRail, width: railWidth, rows: railRows, contentCols } = railLayout;
1648
1907
  // The inline plan strip (todo-plan-followthrough §3.7 / W2).
1649
1908
  //
1650
1909
  // DELIBERATELY NOT `!showRail`, which is true for FOUR different reasons and
@@ -1657,12 +1916,6 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1657
1916
  // needless — this runs in RENDER scope, where `cfg` is re-read every frame.
1658
1917
  // The asymmetry with the `agent_end` handler above is the whole of P1-1.
1659
1918
  const showStrip = !fullscreen && cfg.todo.panel && state.todos !== null;
1660
- // Every consumer INSIDE the viewport slot switches to this; the header,
1661
- // composer, status bar, toasts and every overlay keep the full frame width
1662
- // (C-7). Overlays keep it because `showRail` already includes `overlay ===
1663
- // null`: splitting the screen with a status panel behind a modal is both worse
1664
- // design and a second source of width-contract bugs in six components.
1665
- const contentCols = cols - (showRail ? railWidth : 0);
1666
1919
  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: {
1667
1920
  provider: cfg.provider,
1668
1921
  model: cfg.model,
@@ -1782,8 +2035,8 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1782
2035
  // Both keys always WORK (they share one branch in the input handler); this
1783
2036
  // only decides which one the user is taught.
1784
2037
  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' }) }) }));
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,
2038
+ const composer = fullscreen ? (_jsx(Composer, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), showHint: rows >= HINT_MIN_ROWS, submitCount: cfg.submitCount, hintsEnabled: cfg.hints, agentMode: state.agentMode, services: liveServices, modeToggleKey: modeToggleKey, popupMaxRows: popupMaxRows, popupMaxHeight: railLayout.popupMaxHeight, onPopupRowsChange: onPopupRowsChange, scrolledLines: scrolledLines, onDraftRows: onDraftRows, onNotice: notify, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) })) : (_jsx(Box, { marginTop: 1, children: _jsx(PromptInput, { isActive: overlay === null, running: state.status === 'running', history: promptHistory, commands: commandOptions, cwd: controller.getCwd(), agentMode: state.agentMode, theme: theme, caps: caps, onSubmit: (text) => void handleSubmit(text), onHelp: () => dispatch({ type: 'setOverlay', overlay: 'help' }) }) }));
2039
+ return (_jsx(AppShell, { mode: mode, rows: rows, cols: cols, header: header, viewport: viewport, team: state.team ? (_jsx(TeamPanel, { snapshot: state.team, layout: fullscreen ? teamLayout : undefined, rows: rows, cols: cols, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null, rail: showRail && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null, strip: showStrip && state.todos ? (_jsx(TodoStrip, { snapshot: state.todos, cols: cols, theme: theme, caps: caps })) : null, toast: _jsx(BottomStatusRow, { mode: mode, toasts: state.toasts,
1787
2040
  // `!overlayNode` suppresses the line while an overlay owns the screen:
1788
2041
  // the full-screen branch replaces the whole viewport with the overlay,
1789
2042
  // and a working line under a settings screen is noise attached to a
@@ -1821,7 +2074,14 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1821
2074
  // branch replaces the whole viewport with the overlay, and an update
1822
2075
  // notice under a settings screen is noise attached to a surface it is
1823
2076
  // not visible on.
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
2077
+ update: updateSnapshot && !overlayNode && shouldRenderUpdateLine(updateSnapshot) ? (_jsx(UpdateLine, { snapshot: updateSnapshot, compact: cols < UPDATE_LIMITS.statusCompactCols, theme: theme, caps: caps })) : null, theme: theme }), composer: composer, status: _jsx(StatusBar, { model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2078
+ // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2079
+ // projection of "many sources of truth" into the props layer, and a
2080
+ // caller could update three of them; a single reading cannot be
2081
+ // partially stale. The window and its `known` flag now travel INSIDE
2082
+ // it, resolved by the meter - which is what makes the user's
2083
+ // `contextWindow` override reach the bar.
2084
+ context: state.context, 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
1825
2085
  ? {
1826
2086
  teamActive: {
1827
2087
  running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
@@ -1831,9 +2091,10 @@ export function App({ controller, version, mode, initialOverlay, initialPrompt,
1831
2091
  }
1832
2092
  : {}), ...(state.todos && !showRail
1833
2093
  ? { todoActive: { done: state.todos.doneCount, total: state.todos.total } }
1834
- : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}), ...(state.compaction?.live
2094
+ : {}), ...(liveServices > 0 ? { servicesActive: { live: liveServices } } : {}), ...(state.fast?.live ? { fastActive: { inFlight: state.fast.inFlight } } : {}), ...(state.compaction?.live
2095
+ ? { compactionActive: { inFlight: state.compaction.inFlight } }
2096
+ : {}), ...(controller.isCompactionEnabled()
1835
2097
  ? {
1836
- compactionActive: { inFlight: state.compaction.inFlight },
1837
2098
  gaugeMarks: {
1838
2099
  warn: cfg.compaction.warnThreshold * 100,
1839
2100
  high: cfg.compaction.threshold * 100,
@@ -6,6 +6,7 @@
6
6
  import React from 'react';
7
7
  import type { Theme } from './theme.js';
8
8
  import type { TermCapabilities } from './capabilities.js';
9
+ import { type AutocompleteLayout } from './layout/autocomplete.js';
9
10
  export interface Suggestion {
10
11
  label: string;
11
12
  hint?: string;
@@ -16,6 +17,7 @@ interface AutocompletePopupProps {
16
17
  theme: Theme;
17
18
  caps: TermCapabilities;
18
19
  maxRows?: number;
20
+ layout?: AutocompleteLayout;
19
21
  }
20
- export declare function AutocompletePopup({ items, selected, theme, caps, maxRows, }: AutocompletePopupProps): React.ReactElement | null;
22
+ export declare function AutocompletePopup({ items, selected, theme, caps, maxRows, layout, }: AutocompletePopupProps): React.ReactElement | null;
21
23
  export {};
@@ -1,19 +1,17 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import { pickGlyphs } from './glyphs.js';
4
- const DEFAULT_MAX_ROWS = 6;
5
- export function AutocompletePopup({ items, selected, theme, caps, maxRows = DEFAULT_MAX_ROWS, }) {
6
- if (items.length === 0)
4
+ import { buildAutocompleteLayout } from './layout/autocomplete.js';
5
+ export function AutocompletePopup({ items, selected, theme, caps, maxRows, layout, }) {
6
+ const projection = layout ?? buildAutocompleteLayout({ itemCount: items.length, selected, maxRows });
7
+ if (projection.rowCount === 0)
7
8
  return null;
8
9
  const glyphs = pickGlyphs(caps);
9
- // Keep the highlighted row within a window of `maxRows`.
10
- const start = Math.max(0, Math.min(selected - maxRows + 1, items.length - maxRows));
11
- const windowStart = Math.max(0, start);
12
- const visible = items.slice(windowStart, windowStart + maxRows);
13
- const moreBelow = items.length - (windowStart + visible.length);
14
- return (_jsxs(Box, { flexDirection: "column", borderStyle: glyphs.boxStyle, borderColor: theme.border, paddingX: 1, children: [visible.map((item, i) => {
10
+ const { start: windowStart, count, moreBelow, showMore } = projection;
11
+ const visible = items.slice(windowStart, windowStart + count);
12
+ return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, borderStyle: glyphs.boxStyle, borderColor: theme.border, paddingX: 1, children: [visible.map((item, i) => {
15
13
  const idx = windowStart + i;
16
14
  const active = idx === selected;
17
- return (_jsxs(Text, { inverse: active, children: [_jsx(Text, { color: active ? undefined : theme.primary, children: item.label }), item.hint ? _jsxs(Text, { color: theme.muted, children: [" ", item.hint] }) : null] }, item.label));
18
- }), moreBelow > 0 && _jsxs(Text, { color: theme.muted, children: [" +", moreBelow, " more"] })] }));
15
+ return (_jsxs(Text, { inverse: active, wrap: "truncate", children: [_jsx(Text, { color: active ? undefined : theme.primary, children: item.label.replace(/\s+/g, ' ') }), item.hint ? _jsxs(Text, { color: theme.muted, children: [" ", item.hint.replace(/\s+/g, ' ')] }) : null] }, idx));
16
+ }), showMore && _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [" +", moreBelow, " more"] })] }));
19
17
  }
@@ -11,7 +11,7 @@
11
11
  * box is its only `flexShrink={1}` child — so an extra chrome row does not make
12
12
  * the frame taller, Yoga takes the row out of the TRANSCRIPT. Meanwhile
13
13
  * `viewportRows()` keeps returning the old number to `ScrollViewport`,
14
- * `selectWindow`'s spacers, `overlayMaxRows`, `popupMaxRows` and `todoRailRows`.
14
+ * `selectWindow`'s spacers, `overlayMaxRows`, `popupMaxRows` and `buildTodoRailLayout`.
15
15
  * The transcript would draw one row shorter than every consumer believes, ONLY
16
16
  * WHILE A RUN IS IN FLIGHT, so the layout would shift on submit and shift back on
17
17
  * `agent_end`.