@aragon-agent/cli 0.6.33 → 0.6.35

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 (76) hide show
  1. package/CHANGELOG.md +2305 -2242
  2. package/README.md +119 -18
  3. package/dist/agent/controller.d.ts +29 -0
  4. package/dist/agent/controller.js +42 -1
  5. package/dist/agent/kit-apply.d.ts +56 -0
  6. package/dist/agent/kit-apply.js +209 -0
  7. package/dist/agent/model-profile-settings.d.ts +28 -0
  8. package/dist/agent/model-profile-settings.js +142 -29
  9. package/dist/agent/reducer.d.ts +1 -1
  10. package/dist/commands/builtins.js +4 -0
  11. package/dist/commands/kit-command.d.ts +20 -0
  12. package/dist/commands/kit-command.js +131 -0
  13. package/dist/commands/perf.d.ts +10 -0
  14. package/dist/commands/perf.js +1 -1
  15. package/dist/config/kit-resolution.d.ts +93 -0
  16. package/dist/config/kit-resolution.js +226 -0
  17. package/dist/config/model-kits.d.ts +224 -0
  18. package/dist/config/model-kits.js +302 -0
  19. package/dist/config/model-profile-store.d.ts +25 -0
  20. package/dist/config/model-profiles.d.ts +41 -3
  21. package/dist/config/model-profiles.js +46 -5
  22. package/dist/config/schema.d.ts +16 -0
  23. package/dist/input/keymap.js +18 -4
  24. package/dist/ui/App.js +135 -15
  25. package/dist/ui/Header.d.ts +9 -1
  26. package/dist/ui/Header.js +8 -2
  27. package/dist/ui/PromptInput.js +1 -1
  28. package/dist/ui/ScrollbackShell.d.ts +38 -13
  29. package/dist/ui/ScrollbackShell.js +4 -3
  30. package/dist/ui/StatusBar.d.ts +13 -0
  31. package/dist/ui/StatusBar.js +10 -3
  32. package/dist/ui/TeamPanel.d.ts +12 -0
  33. package/dist/ui/TeamPanel.js +10 -31
  34. package/dist/ui/Transcript.js +22 -2
  35. package/dist/ui/entries/TeamCard.d.ts +18 -2
  36. package/dist/ui/entries/TeamCard.js +47 -13
  37. package/dist/ui/layout/header-hints.d.ts +11 -1
  38. package/dist/ui/layout/header-hints.js +35 -3
  39. package/dist/ui/layout/kit-chip.d.ts +50 -0
  40. package/dist/ui/layout/kit-chip.js +81 -0
  41. package/dist/ui/layout/virtual-window.js +9 -0
  42. package/dist/ui/overlays/HelpOverlay.js +7 -0
  43. package/dist/ui/overlays/KitEditor.d.ts +23 -0
  44. package/dist/ui/overlays/KitEditor.js +101 -0
  45. package/dist/ui/overlays/KitManager.d.ts +23 -0
  46. package/dist/ui/overlays/KitManager.js +104 -0
  47. package/dist/ui/overlays/KitPicker.d.ts +49 -0
  48. package/dist/ui/overlays/KitPicker.js +58 -0
  49. package/dist/ui/overlays/ModelProfilePicker.js +16 -6
  50. package/dist/ui/overlays/SettingsScreen.js +139 -16
  51. package/dist/ui/overlays/kit-picker-state.d.ts +87 -0
  52. package/dist/ui/overlays/kit-picker-state.js +136 -0
  53. package/dist/ui/overlays/kit-settings-state.d.ts +129 -0
  54. package/dist/ui/overlays/kit-settings-state.js +267 -0
  55. package/dist/ui/overlays/model-profile-state.d.ts +26 -3
  56. package/dist/ui/overlays/model-profile-state.js +59 -6
  57. package/dist/ui/scrollback/LiveTail.d.ts +44 -10
  58. package/dist/ui/scrollback/LiveTail.js +20 -4
  59. package/dist/ui/scrollback/limits.d.ts +16 -0
  60. package/dist/ui/scrollback/limits.js +16 -0
  61. package/dist/ui/scrollback/live-preview.js +10 -3
  62. package/dist/ui/scrollback/overflow-print.d.ts +108 -0
  63. package/dist/ui/scrollback/overflow-print.js +120 -0
  64. package/dist/ui/scrollback/renderer.d.ts +11 -1
  65. package/dist/ui/scrollback/renderer.js +53 -25
  66. package/dist/ui/scrollback/tail-anchor.d.ts +68 -0
  67. package/dist/ui/scrollback/tail-anchor.js +77 -0
  68. package/dist/ui/scrollback/tail-window.d.ts +21 -8
  69. package/dist/ui/scrollback/tail-window.js +26 -10
  70. package/dist/ui/scrollback/use-scrollback-view.d.ts +38 -10
  71. package/dist/ui/scrollback/use-scrollback-view.js +372 -85
  72. package/dist/ui/team-status.d.ts +141 -0
  73. package/dist/ui/team-status.js +224 -0
  74. package/dist/ui/transcript-text.js +13 -4
  75. package/dist/ui/use-wheel-routing.js +2 -2
  76. package/package.json +2 -2
package/dist/ui/App.js CHANGED
@@ -47,6 +47,7 @@ import { TODO_LIMITS } from '../todo/limits.js';
47
47
  import { ScrollViewport } from './layout/ScrollViewport.js';
48
48
  import { buildTodoRailLayout } from './layout/todo-layout.js';
49
49
  import { buildTeamPanelLayout } from './layout/team-panel.js';
50
+ import { isLiveTeamCard, rosterCounts } from './team-status.js';
50
51
  import { advanceBudget, decideFollowThrough, emptyBudget, } from '../todo/follow-through.js';
51
52
  import { useTerminalSize } from './layout/useTerminalSize.js';
52
53
  import { useHeightStore } from './use-height-store.js';
@@ -62,6 +63,9 @@ import { publishSessionView } from './session-bridge.js';
62
63
  import { OverlayFrame, overlayPageSize } from './layout/OverlayFrame.js';
63
64
  import { helpRows } from './overlays/HelpOverlay.js';
64
65
  import { ModelPicker } from './overlays/ModelPicker.js';
66
+ import { KitPicker } from './overlays/KitPicker.js';
67
+ import { INACTIVE_KIT } from '../config/kit-resolution.js';
68
+ import { kitApplyMessage, kitNoteMessages } from '../agent/kit-apply.js';
65
69
  import { createModelProbe } from '../agent/model-probe.js';
66
70
  import { SettingsScreen, compactionSettingsFrom, fastSettingsFrom, } from './overlays/SettingsScreen.js';
67
71
  import { ConfirmDialog } from './overlays/ConfirmDialog.js';
@@ -1724,6 +1728,39 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1724
1728
  dispatch({ type: 'setOverlay', overlay: null });
1725
1729
  toast('info', `Model set to ${provider}:${model}.`);
1726
1730
  };
1731
+ /**
1732
+ * `ctrl+K` chose a row (agent-model-kits §4.7 / AC-25).
1733
+ *
1734
+ * `null` is `Current setup`, which is the RELEASE action, not a no-op: it runs
1735
+ * `clearSessionKit` so the live connections go back to the file's bindings.
1736
+ * Both paths close the overlay, and both report through the same toast funnel -
1737
+ * including every `KitNote`, because a kit that turns the fast tier on spends
1738
+ * money and must never do it silently (§4.2 / F-23).
1739
+ */
1740
+ const handleKitChoose = (kitId) => {
1741
+ // NO KIT PORT, NO CRASH: a legacy host can still press the key, and the only
1742
+ // honest answer is a sentence rather than a stack trace.
1743
+ const port = controller;
1744
+ if (typeof port.applySessionKit !== 'function' || typeof port.clearSessionKit !== 'function') {
1745
+ dispatch({ type: 'setOverlay', overlay: null });
1746
+ toast('error', 'This build cannot switch kits.');
1747
+ return;
1748
+ }
1749
+ const result = kitId === null
1750
+ ? controller.clearSessionKit() : controller.applySessionKit(kitId);
1751
+ dispatch({ type: 'setOverlay', overlay: null });
1752
+ // The config is already updated synchronously, so this reads the kit that is
1753
+ // in effect RIGHT NOW rather than the id the user asked for - if the apply
1754
+ // fell back to anything, the toast says so.
1755
+ const applied = controller.getActiveKit().name;
1756
+ const text = !result.ok ? kitApplyMessage(result)
1757
+ : kitId === null ? "Kit released; using the config file's settings."
1758
+ : `Kit ${applied} applied for this session.`;
1759
+ const notes = kitNoteMessages(result.notes);
1760
+ // A NOTE IS A WARNING, NOT A SUCCESS (F-23): the toast colour is the only
1761
+ // signal a user gets that applying a kit can start spending money.
1762
+ toast(result.ok ? (notes.length ? 'warn' : 'success') : 'error', [text, ...notes].join(' '));
1763
+ };
1727
1764
  const closeConfirm = () => {
1728
1765
  setConfirmState(null);
1729
1766
  dispatch({ type: 'setOverlay', overlay: null });
@@ -1967,6 +2004,24 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
1967
2004
  setStatusExpanded(value => !value);
1968
2005
  return;
1969
2006
  }
2007
+ // Ctrl+K: the Agent-kit picker (agent-model-kits D-15 / §4.7).
2008
+ //
2009
+ // NOT AN INPUT FRAME, unlike Ctrl+I below. Ctrl+I collides with the Tab byte,
2010
+ // so a stdin translator has to disambiguate it; 0x0B is unambiguous in every
2011
+ // encoding this app supports (utf8 byte, win32-input-mode's pass-through of
2012
+ // control chars, and CSI-u's `ctrlByte` fold), so the raw byte IS the key.
2013
+ //
2014
+ // `killToEnd` HAS MOVED TO Alt+K FIRST (keymap.ts): `PromptInput`'s
2015
+ // recognizer runs before its `key.ctrl` early-return, so leaving the old
2016
+ // binding would delete the draft in one listener while this one opened the
2017
+ // overlay. Two listeners both see the event; `return` only leaves one.
2018
+ const isCtrlK = (key.ctrl && (input === 'k' || input === 'K')) || input === '\x0b';
2019
+ if (isCtrlK) {
2020
+ if (tooSmall || stateRef.current.overlay)
2021
+ return;
2022
+ dispatch({ type: 'setOverlay', overlay: 'kit' });
2023
+ return;
2024
+ }
1970
2025
  // Ctrl+I arrives as INDEX_KEY_FRAME, never as key.ctrl + 'i': legacy
1971
2026
  // terminals encode it as the Tab byte and the stdin filter's translators
1972
2027
  // are the only layer that can tell them apart (see input/limits.ts).
@@ -2004,7 +2059,8 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2004
2059
  const overlay = stateRef.current.overlay;
2005
2060
  // Modes A only. `model` / `confirm` manage their own keys; registering an
2006
2061
  // arrow handler for them would double every keypress (R-12).
2007
- const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan' || overlay === 'queue';
2062
+ const controlled = overlay === 'help' || overlay === 'settings' || overlay === 'plan'
2063
+ || overlay === 'queue' || overlay === 'kit';
2008
2064
  if (key.pageUp) {
2009
2065
  if (controlled)
2010
2066
  setOverlayScroll((n) => Math.max(0, n - overlayPage));
@@ -2102,12 +2158,36 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2102
2158
  // `ctrl+o log` hint, the keystroke would silently expand AN OLDER TOOL
2103
2159
  // CARD, and nothing would error. The terminal one-row record is skipped -
2104
2160
  // it has nothing to reveal, and expanding it would consume the press.
2105
- const last = [...stateRef.current.entries]
2161
+ //
2162
+ // TWO TIERS, AND THE ORDER IS THE WHOLE FIX (tui-final-reply-truncated-
2163
+ // unscrollable B1). The closed list above answers "which card is this key for",
2164
+ // and it must keep answering it EXACTLY as before: a settled answer is almost
2165
+ // always the newest entry, so adding `assistant` to THIS filter would take the
2166
+ // press away from the tool card the user just watched finish. Only when no card
2167
+ // is asking for the key does it fall through to the Agent's last answer - which
2168
+ // is the escape hatch `docs/plans/tui-full-height-live-region/spec.md` §13
2169
+ // (known limitation 2) already promised and never wired up.
2170
+ const entries = stateRef.current.entries;
2171
+ const card = [...entries]
2106
2172
  .reverse()
2107
- .find((e) => e.kind === 'tool' ||
2108
- e.kind === 'team' ||
2109
- e.kind === 'compaction' ||
2110
- (e.kind === 'service' && !e.terminal));
2173
+ .find((e) =>
2174
+ // THE LIVE DISPATCH CARD IS NOT EXPANDABLE (tui-subagent-double-roster
2175
+ // A-2'): it is suppressed on screen, so expanding it would print a frozen
2176
+ // copy of a card that is still being rewritten. Once it settles it becomes
2177
+ // an ordinary history card and this key reaches it again.
2178
+ !isLiveTeamCard(e) &&
2179
+ (e.kind === 'tool' ||
2180
+ e.kind === 'team' ||
2181
+ e.kind === 'compaction' ||
2182
+ (e.kind === 'service' && !e.terminal)));
2183
+ // NATIVE MODE ONLY. The fallback exists because native mode cannot scroll inside the
2184
+ // frame - the answer's head is reachable only if it is printed. Full-screen mode has a
2185
+ // viewport that already reaches it, and it has no `expanded` rendering for an answer,
2186
+ // so falling through there would consume the press and show nothing.
2187
+ const answer = card === undefined && native
2188
+ ? [...entries].reverse().find((e) => e.kind === 'assistant' && !e.streaming)
2189
+ : undefined;
2190
+ const last = card ?? answer;
2111
2191
  // Native mode cannot rewrite what is already printed, so it PRINTS an expanded
2112
2192
  // copy as a new block instead of toggling the old one (spec 4.7).
2113
2193
  if (last && native) {
@@ -2206,6 +2286,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2206
2286
  const overlay = state.overlay;
2207
2287
  const settingsDraft = useMemo(() => overlay === 'settings'
2208
2288
  ? controller.createModelSettingsDraft() : undefined, [controller, overlay]);
2289
+ // THE FILE'S BINDINGS, FOR THE KIT PICKER'S FIRST ROW (§4.6 / D-13). While a
2290
+ // session kit is held the live config mirrors the KIT (I-6), so `Current setup`
2291
+ // can only describe what choosing it restores by reading the file - one read per
2292
+ // opening, the same price `settingsDraft` one line up already pays.
2293
+ const kitDiskConfig = useMemo(() => overlay === 'kit'
2294
+ ? controller.createModelSettingsDraft().baseline : undefined, [controller, overlay]);
2209
2295
  // ONE PROBE PER APP (model-profile-connection-test 5 row 9): the port needs the
2210
2296
  // controller's provider registry, which the overlay has no business owning.
2211
2297
  // Built only while the settings overlay is open, so no other screen pays for it.
@@ -2219,6 +2305,22 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2219
2305
  return createModelProbe({ registry: controller.getProviderRegistry() });
2220
2306
  }, [controller, overlay]);
2221
2307
  const hasKey = controller.hasApiKey();
2308
+ // THE ACTIVE KIT, DERIVED PER FRAME (D-5 / F-22): `controller.getActiveKit()` is
2309
+ // pure, so it cannot go stale the way a cached copy would after `ctrl+K`,
2310
+ // `/model` or a saved default. Absent when no kit is in effect, which is what
2311
+ // keeps the status row byte-identical for users who own none (I-9).
2312
+ //
2313
+ // A TEST DOUBLE OR AN EMBEDDED HOST MAY PREDATE THIS PORT: the same tolerance
2314
+ // the probe port gets one screen over ("absent means the rows read
2315
+ // unresolved") applies here - no kit, no crash.
2316
+ const activeKit = typeof controller.getActiveKit === 'function'
2317
+ ? controller.getActiveKit() : INACTIVE_KIT;
2318
+ const kitChip = activeKit.source === 'none' ? undefined : {
2319
+ name: activeKit.name,
2320
+ modified: activeKit.modified,
2321
+ otherNames: (cfg.modelProfiles?.kits ?? [])
2322
+ .filter((kit) => kit.id !== activeKit.id).map((kit) => kit.name),
2323
+ };
2222
2324
  const empty = state.entries.length === 0;
2223
2325
  // RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
2224
2326
  // handler will do on the very next keystroke.
@@ -2322,7 +2424,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2322
2424
  // ALWAYS CALLED, INERT WHEN `enabled` IS FALSE: hooks cannot be conditional, and
2323
2425
  // an inert hook is what keeps `scrollMode: 'app'` byte-identical (I-S9).
2324
2426
  const viewCols = native ? Math.max(1, cols - 1) : cols;
2325
- const header = (_jsx(Header, { columns: viewCols, statusExpanded: statusExpanded, scrolledLines: scrolledLines, overlayOpen: overlay !== null, cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: pickHeaderVariant(cols), theme: theme, caps: caps }));
2427
+ const header = (_jsx(Header, { columns: viewCols, statusExpanded: statusExpanded, scrolledLines: scrolledLines, overlayOpen: overlay !== null, cwd: controller.getCwd(), provider: cfg.provider, model: cfg.model, hasKey: hasKey, variant: pickHeaderVariant(cols), theme: theme, caps: caps,
2428
+ // ALWAYS TRUE (agent-model-kits §4.7 / RV-P1-2): the requirement is that
2429
+ // `ctrl+G` reveals `ctrl+K`, and the user with an empty library needs that
2430
+ // hint most - the picker itself carries the "create one in /settings" row.
2431
+ kitHint: true }));
2326
2432
  // The header and the opener are SLOTS OF THE SHELL, not printed bytes (D3/D4): at
2327
2433
  // `H = rows - 1` a printed row is pushed off the screen by the very next frame, so
2328
2434
  // `printBanner()` was a way of making the banner invisible. Neither is passed here.
@@ -2339,6 +2445,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2339
2445
  itemCount: state.todos?.items.length ?? 0,
2340
2446
  panelEnabled: cfg.todo.panel,
2341
2447
  teamRowsNatural: teamLayout.rowCount,
2448
+ // The top give-way measures a window entry's height, and an expanded tool card is a
2449
+ // different height from a collapsed one - a forgotten value would print rows the region
2450
+ // is not drawing.
2451
+ expandedToolIds: state.expandedToolIds,
2342
2452
  });
2343
2453
  scrollbackViewRef.current = scrollbackView;
2344
2454
  // The two hooks `cli.tsx` calls from OUTSIDE React: the exit summary (every exit
@@ -2369,7 +2479,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2369
2479
  const openerCols = nativeBudget ? nativeBudget.previewCols : contentCols;
2370
2480
  const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(openerRows, openerCols), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: openerRows, theme: theme, caps: caps })) : null;
2371
2481
  const overlayMaxRows = nativeBudget ? nativeBudget.overlayRows : viewportBudget;
2372
- const overlayNode = overlay === 'queue' ? (_jsx(QueueOverlay, { pending: state.pendingSteering, cols: viewCols, maxRows: overlayMaxRows, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: viewCols, rows: helpRows(theme, caps, native), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { isActive: !tooSmall, registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: viewCols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'settings' ? (_jsx(SettingsScreen, { isActive: !tooSmall, initial: {
2482
+ const overlayNode = overlay === 'queue' ? (_jsx(QueueOverlay, { pending: state.pendingSteering, cols: viewCols, maxRows: overlayMaxRows, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'help' ? (_jsx(OverlayFrame, { title: "Help", hint: `Esc close ${glyphs.midDot} PgUp/PgDn scroll`, maxRows: overlayMaxRows, cols: viewCols, rows: helpRows(theme, caps, native), scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps })) : overlay === 'model' ? (_jsx(ModelPicker, { isActive: !tooSmall, registry: controller.getModelRegistry(), currentProvider: cfg.provider, currentModel: cfg.model, maxRows: overlayMaxRows, cols: viewCols, theme: theme, caps: caps, onSelect: handleModelSelect })) : overlay === 'kit' ? (_jsx(KitPicker, { isActive: !tooSmall, library: cfg.modelProfiles, config: cfg, ...(kitDiskConfig ? { diskConfig: kitDiskConfig } : {}), active: activeKit,
2483
+ // ONLY THE APPLY IS BLOCKED while work is in flight (§4.7): a list a
2484
+ // user cannot even OPEN reads as a broken key.
2485
+ busy: controller.isModelSettingsBusy(), maxRows: overlayMaxRows, cols: viewCols, scrollOffset: overlayScroll, onScrollClamp: setOverlayScroll, theme: theme, caps: caps, onChoose: handleKitChoose, onClose: () => dispatch({ type: 'setOverlay', overlay: null }) })) : overlay === 'settings' ? (_jsx(SettingsScreen, { isActive: !tooSmall, initial: {
2373
2486
  provider: cfg.provider,
2374
2487
  model: cfg.model,
2375
2488
  baseUrl: cfg.baseUrl ?? '',
@@ -2436,9 +2549,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2436
2549
  contentCols: nativeBudget.contentCols,
2437
2550
  // The tail window, joined here because it belongs to the VIEW pipeline rather
2438
2551
  // than to the band: `window=<start>/<n>` is what makes the visible-vs-printed
2439
- // gap observable (known limitation 5).
2552
+ // gap observable (known limitation 5), and `head=` counts the rows the top
2553
+ // give-way printed for it, so the mechanism is never invisible.
2440
2554
  windowStart: scrollbackView.windowStart,
2441
2555
  windowEntries: scrollbackView.tailEntries.length,
2556
+ headRows: scrollbackView.headRows,
2442
2557
  }
2443
2558
  : null,
2444
2559
  // A getter: the bridge's `frameReady` and geometry change outside React renders.
@@ -2500,8 +2615,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2500
2615
  compacting: compactionLive || !!state.compaction?.inFlight,
2501
2616
  mode: state.agentMode, pendingMode: state.pendingAgentMode,
2502
2617
  servicesActive: { live: liveServices }, ecoRung: governor.rung,
2503
- teamActive: state.team ? { running: state.team.runs.filter(item => !['queued', 'done', 'failed', 'aborted'].includes(item.phase)).length,
2504
- total: state.team.runs.length } : undefined,
2618
+ // THE STATUS-DETAIL ROW COUNTS LIKE THE ROSTER AND THE CHIP (A-1): this was the
2619
+ // last inline phase filter, left behind when the StatusBar chip converged, and it
2620
+ // feeds the `Team n/m` on the second status row.
2621
+ teamActive: state.team
2622
+ ? { running: rosterCounts(state.team.runs).running, total: state.team.runs.length }
2623
+ : undefined,
2505
2624
  todoActive: state.todos ? { done: state.todos.doneCount, total: state.todos.total } : undefined,
2506
2625
  fastActive: state.fast?.live ? { inFlight: state.fast.inFlight } : undefined,
2507
2626
  ...feedbackProjection,
@@ -2534,7 +2653,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2534
2653
  const rail = fullscreenRailVisible && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2535
2654
  const viewport = (_jsxs(ScrollViewport, { active: overlay === null && !tooSmall, overlay: overlayNode, footer: _jsxs(_Fragment, { children: [team, _jsx(QueuePanel, { layout: queueLayout, theme: theme })] }), rail: rail, scrollbar: terminal?.scrollbar, resetKey: scrollResetKey, intent: scrollIntent, pinToBottomNonce: pinToBottomNonce, onScrolledLinesChange: setScrolledLines, onViewportShiftChange: onViewportShiftChange, onGeometryChange: onGeometryChange, geometryKey: `${rows}:${cols}:${viewportBudget}:${frameBudget.composerSlotRows}:${frameBudget.statusRows}:${contentCols}:${overlay}:${tooSmall}`, tailRowsRef: tailSink, hold: selectionHold, resumeMs: cfg.scrollResumeMs, showScrollIndicator: true, cols: contentCols, theme: theme, caps: caps, children: [opener, _jsx(TranscriptList, { entries: transcriptEntries, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: density, nowSec: nowSec, theme: theme, caps: caps, windowSize: transcriptWindow, cols: contentCols, heights: heights, mountedSink: mountedCount, tailSink: tailSink })] }));
2536
2655
  const detailsNode = _jsx(BottomStatusRow, { plan: details, columns: viewCols, theme: theme });
2537
- const statusNode = (_jsx(StatusBar, { columns: viewCols, speedKnown: speedKnown, ...feedbackProjection, interruptPhase: interruptGesture.current.phase, pendingSteering: state.pendingSteering, runPhase: state.runPhase, activeTool: state.activeTool, ...(wait ? { wait } : {}), runOutcome: state.runOutcome, reducedMotion: reducedMotion, ...(sessionId ? { sessionId } : {}), waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
2656
+ const statusNode = (_jsx(StatusBar, { columns: viewCols, speedKnown: speedKnown, ...feedbackProjection, interruptPhase: interruptGesture.current.phase, pendingSteering: state.pendingSteering, runPhase: state.runPhase, activeTool: state.activeTool, ...(wait ? { wait } : {}), runOutcome: state.runOutcome, reducedMotion: reducedMotion, ...(sessionId ? { sessionId } : {}), ...(kitChip ? { kit: kitChip } : {}), waitingForConfirmation: !!humanRequest || !!confirmState, stopping: interruptGesture.current.phase === 'stopping', interruptHint: interruptGesture.current.phase === 'stopping'
2538
2657
  ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2539
2658
  // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2540
2659
  // projection of "many sources of truth" into the props layer, and a
@@ -2544,9 +2663,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2544
2663
  // `contextWindow` override reach the bar.
2545
2664
  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
2546
2665
  ? {
2666
+ // `rosterCounts`, NOT a fourth inline phase filter (A-1): the chip and
2667
+ // the roster header above it must never disagree.
2547
2668
  teamActive: {
2548
- running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
2549
- r.phase !== 'failed' && r.phase !== 'aborted').length,
2669
+ running: rosterCounts(state.team.runs).running,
2550
2670
  total: state.team.runs.length,
2551
2671
  },
2552
2672
  }
@@ -2584,7 +2704,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2584
2704
  // fallback for a frame with no preview rows. The WHOLE list is passed: the window
2585
2705
  // belongs to `tail-window.ts`, and `prev` must come from the same list.
2586
2706
  tail: nativeBudget.band.previewRows > 0
2587
- ? (_jsx(LiveTail, { entries: state.entries, maxEntries: SCROLLBACK_LIMITS.liveTailMaxEntries, rows: nativeBudget.band.previewRows, cols: nativeBudget.previewCols, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: cfg.density, nowSec: nowSec, theme: theme, caps: caps }))
2707
+ ? (_jsx(LiveTail, { entries: state.entries, maxEntries: SCROLLBACK_LIMITS.liveTailMaxEntries, rows: nativeBudget.band.previewRows, cols: nativeBudget.previewCols, expandedToolIds: state.expandedToolIds, thinkingVisible: state.thinkingVisible, reducedMotion: viewReducedMotion, density: cfg.density, nowSec: nowSec, theme: theme, caps: caps, measuredRows: scrollbackView.measuredTailRows }))
2588
2708
  : undefined, team: nativeTeam, rail: nativeRail, composer: composer, status: statusNode, details: detailsNode, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), theme: theme }));
2589
2709
  }
2590
2710
  return (_jsx(AppShell, { inactive: tooSmall, placeholder: _jsxs(Text, { wrap: "truncate", color: theme.noticeWarn, children: [glyphs.warn, " Terminal too small - needs at least 40 columns and 12 rows."] }), rows: rows, cols: cols, header: header, viewport: viewport, viewportRows: frameBudget.viewportRows, composer: composer, composerSlotRows: frameBudget.composerSlotRows, statusRows: frameBudget.statusRows, details: detailsNode, status: statusNode }));
@@ -15,6 +15,14 @@ interface HeaderProps {
15
15
  statusExpanded?: boolean;
16
16
  scrolledLines?: number;
17
17
  overlayOpen?: boolean;
18
+ /**
19
+ * Whether the expanded status row teaches `ctrl+K` (agent-model-kits §4.7).
20
+ *
21
+ * DEFAULTS TO TRUE because that is the requirement - `ctrl+G` must reveal the
22
+ * kit key - and because a default of `false` would leave the one caller that
23
+ * forgets the prop silently teaching nothing.
24
+ */
25
+ kitHint?: boolean;
18
26
  }
19
- export declare function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns, statusExpanded, scrolledLines, overlayOpen }: HeaderProps): React.ReactElement;
27
+ export declare function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns, statusExpanded, scrolledLines, overlayOpen, kitHint }: HeaderProps): React.ReactElement;
20
28
  export {};
package/dist/ui/Header.js CHANGED
@@ -3,13 +3,19 @@ import { basename } from 'node:path';
3
3
  import { Box, Text } from 'ink';
4
4
  import { Logo } from './Logo.js';
5
5
  import { planHeaderHints, HEADER_HINT_LIMITS } from './layout/header-hints.js';
6
- export function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns = 80, statusExpanded = false, scrolledLines = 0, overlayOpen = false }) {
6
+ export function Header({ cwd, provider, model, hasKey, variant, theme, caps, columns = 80, statusExpanded = false, scrolledLines = 0, overlayOpen = false, kitHint = true }) {
7
7
  // The right-hand hints are planned, not assembled (tui-branch-terminal-shortcut
8
8
  // 3.9 / D-11). `plan.text` is exactly `plan.cells` wide and already carries the
9
9
  // scroll chip's own trailing spacing, so this component can no longer clip a
10
10
  // hint by guessing one cell short - and the ladder that decides which hints
11
11
  // survive a narrow terminal lives in a pure function with its own tests.
12
- const plan = planHeaderHints({ columns, statusExpanded, overlayOpen, scrolledLines });
12
+ //
13
+ // `kitHint` is the `^K switch kit` clause (agent-model-kits §4.7): `App`
14
+ // passes `true` always, because the requirement is that `ctrl+G` reveals the
15
+ // key - and the user with no kits needs that hint most.
16
+ const plan = planHeaderHints({
17
+ columns, statusExpanded, overlayOpen, scrolledLines, kitHint,
18
+ });
13
19
  // THE TWO HALVES ARE SEPARATED BY CELLS, NOT BY LUCK (defect 12). The left half
14
20
  // is `wrap="truncate"`, which draws its ellipsis in the LAST cell it owns; with
15
21
  // the right half starting in the very next cell a narrow terminal read
@@ -596,7 +596,7 @@ export function PromptInput({ cols: colsProp, terminalRows: terminalRowsProp, st
596
596
  return;
597
597
  }
598
598
  if (key.ctrl)
599
- return; // App owns Ctrl+C / L / T / O.
599
+ return; // App owns Ctrl+C / L / T / O, and now Ctrl+K (the kit picker).
600
600
  if (key.return) {
601
601
  if (key.meta || key.shift) {
602
602
  insert('\n');
@@ -19,30 +19,51 @@
19
19
  * default mode showed neither. The band is a row pool inside the live region:
20
20
  *
21
21
  * +---------------------------------------------+-----------------+
22
- * | team roster (height = band.teamRows) | TODO rail |
22
+ * | tail window (height = band.previewRows) | TODO rail |
23
23
  * +---------------------------------------------+ (height = |
24
- * | tail window (height = band.previewRows) | band.railRows) |
24
+ * | team roster (height = band.teamRows) | band.railRows) |
25
25
  * +---------------------------------------------+-----------------+
26
26
  *
27
+ * THE ROSTER IS THE LEFT COLUMN'S BOTTOM SLOT, not its top one
28
+ * (tui-subagent-double-roster A-3). `band.ts` allocates the same two heights either
29
+ * way - only the drawing order changed - so every row number, and every test that
30
+ * asserts one, is untouched. What moves is the reading: the roster is transient
31
+ * status about the run in progress, so it belongs against the composer (where the
32
+ * user's eyes already are, and where full-screen mode's viewport `footer` puts it),
33
+ * and the conversation's own tail stays above it. As a bonus the rows a short tail
34
+ * does not use now fall between the tail and the roster instead of between the tail
35
+ * and the input box, which is the "blank space above the composer" shape the
36
+ * plan-A-prime work removed one level up.
37
+ *
27
38
  * Every box in it has an explicit `height` and `width` plus `overflow="hidden"`, and
28
39
  * that is a correctness requirement rather than tidiness: a box that grows with its
29
40
  * content would make the region taller than `budget.liveRows`, and a frame as tall as
30
41
  * the terminal makes Ink erase the user's scrollback.
31
42
  *
32
- * TWO SLOTS ARE NOT BOTTOM-ALIGNED, AND ONLY TWO (D3 / D4 / I-F7):
43
+ * THE BAND'S PREVIEW BOX IS TOP-ALIGNED; THE OTHER SLOTS ARE FIXED
44
+ * (D3 / D4 / I-F7; tui-first-reply-bottom-aligned, plan A-prime):
33
45
  * - `header` is the fixed 1-row slot at the very top. It is NOT printed into the
34
46
  * scrollback any more: at `H = rows - 1` any printed row is pushed off the screen by
35
47
  * the next frame, so "print the header" would mean "the header is never visible".
36
- * - `opener` is the empty-session card, and it is the one thing inside the band that is
37
- * TOP-aligned: a 12-row card bottom-aligned in a 24-row column would leave 12 blank
38
- * rows in the upper half of the screen - the original defect, still inside the
39
- * region, where the `gap` assertion could not see it. `opener` and `tail` are
40
- * mutually exclusive by construction (`App` keys them off `entries.length === 0`),
41
- * and they share one box because two boxes would be one more height to get wrong.
48
+ * - the band's preview box is TOP-aligned unconditionally, which is why the alignment is
49
+ * no longer a function of `opener`. `opener` is a 12-row card in a 24-row column, so
50
+ * bottom-aligning it left 12 blank rows in the upper half of the screen - the original
51
+ * defect, still inside the region, where the `gap` assertion could not see it
52
+ * (RV-P0-1). `tail` had the same shape for a short conversation: four rows pressed
53
+ * against the composer with every unused row ABOVE them, which is the defect plan
54
+ * A-prime fixes. `tail` therefore decides its own alignment one level down
55
+ * (`scrollback/tail-anchor.ts`): top while its content fits, and the old `flex-end`
56
+ * only on the frames where the content overflows and the newest row must stay visible.
57
+ * `opener` and `tail` share this box because two boxes would be one more height to
58
+ * get wrong, and they are mutually exclusive by construction (`App` keys them off
59
+ * `entries.length === 0`).
42
60
  *
43
61
  * `liveTailLines` STAYS. It is the plain-text fallback for the frames where the real
44
62
  * tail is not mounted (and the floor of the measurement path); `tail` wins when both
45
- * are given, so the two can never be drawn on top of each other.
63
+ * are given, so the two can never be drawn on top of each other. It is sliced to
64
+ * `budget.liveTailRows` below, which is the same number as `band.previewRows`: that slice
65
+ * is what makes the unconditional `flex-start` safe here, because a fallback taller than
66
+ * the box would clip its NEWEST line - the one direction this box must never clip.
46
67
  */
47
68
  import React from 'react';
48
69
  import type { Theme } from './theme.js';
@@ -60,13 +81,17 @@ export interface ScrollbackShellProps {
60
81
  /** Plain preview rows: the fallback when `tail` is absent (and what `/perf` counts). */
61
82
  liveTailLines: readonly string[];
62
83
  /**
63
- * The empty-session card. Mutually exclusive with `tail`, TOP-aligned, and the only
64
- * thing in this shell that is not bottom-aligned or a fixed slot (RV-P0-1).
84
+ * The empty-session card. Mutually exclusive with `tail`, TOP-aligned like the rest of
85
+ * the band's preview box (RV-P0-1).
65
86
  */
66
87
  opener?: React.ReactNode;
67
88
  /** Real components for the tail window (`LiveTail`). */
68
89
  tail?: React.ReactNode;
69
- /** The subagent roster, drawn at the top of the band's left column. */
90
+ /**
91
+ * The subagent roster: the BOTTOM slot of the band's left column, i.e. the row
92
+ * closest to the composer. It is the only live subagent surface during a
93
+ * dispatch - the transcript's `team` card is suppressed while it is active.
94
+ */
70
95
  team?: React.ReactNode;
71
96
  /** The TODO rail, drawn as the band's right-hand column. */
72
97
  rail?: React.ReactNode;
@@ -6,13 +6,14 @@ export function ScrollbackShell({ budget, header, overlay, liveTailLines, opener
6
6
  return (_jsx(Box, { flexDirection: "column", height: budget.liveRows, width: width, overflow: "hidden", flexShrink: 0, children: budget.liveRows > 0 ? placeholder : null }));
7
7
  }
8
8
  const band = budget.band;
9
- // `opener ?? tail ?? lines` is ONE slot, not three: whichever is mounted decides the
10
- // alignment of the box, so the two can never both be drawn and fight for the height.
9
+ // `opener ?? tail ?? lines` is ONE slot, not three: they are mutually exclusive, so
10
+ // the two can never both be drawn and fight for the height. This box's alignment is
11
+ // fixed (`flex-start`); `tail` alone may fall back to `flex-end` when it overflows.
11
12
  const leftColumn = opener ?? tail;
12
13
  const tailLines = liveTailLines.slice(-budget.liveTailRows);
13
14
  // The overlay and the band are alternatives, not siblings: the band reports
14
15
  // `bandRows = remaining` for an overlay frame, so drawing both would double the
15
16
  // region's height and take the frame to the terminal height (the `ESC[3J` case).
16
- const top = band.reason === 'overlay' ? (_jsx(Box, { flexDirection: "column", height: budget.overlayRows, flexShrink: 0, overflow: "hidden", children: overlay })) : band.bandRows > 0 ? (_jsxs(Box, { flexDirection: "row", height: band.bandRows, flexShrink: 0, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", width: budget.previewCols, flexShrink: 0, overflow: "hidden", children: [band.teamRows > 0 && (_jsx(Box, { height: band.teamRows, flexShrink: 0, overflow: "hidden", children: team })), band.previewRows > 0 && (_jsx(Box, { flexDirection: "column", height: band.previewRows, flexShrink: 0, justifyContent: opener ? 'flex-start' : 'flex-end', overflow: "hidden", children: leftColumn ?? tailLines.map((line, index) => (_jsx(Text, { wrap: "truncate", color: theme.muted, children: line }, index))) }))] }), band.railRows > 0 && (_jsx(Box, { height: band.railRows, width: band.railCols, flexShrink: 0, overflow: "hidden", children: rail }))] })) : null;
17
+ const top = band.reason === 'overlay' ? (_jsx(Box, { flexDirection: "column", height: budget.overlayRows, flexShrink: 0, overflow: "hidden", children: overlay })) : band.bandRows > 0 ? (_jsxs(Box, { flexDirection: "row", height: band.bandRows, flexShrink: 0, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", width: budget.previewCols, flexShrink: 0, overflow: "hidden", children: [band.previewRows > 0 && (_jsx(Box, { flexDirection: "column", height: band.previewRows, flexShrink: 0, justifyContent: "flex-start", overflow: "hidden", children: leftColumn ?? tailLines.map((line, index) => (_jsx(Text, { wrap: "truncate", color: theme.muted, children: line }, index))) })), band.teamRows > 0 && (_jsx(Box, { height: band.teamRows, flexShrink: 0, overflow: "hidden", children: team }))] }), band.railRows > 0 && (_jsx(Box, { height: band.railRows, width: band.railCols, flexShrink: 0, overflow: "hidden", children: rail }))] })) : null;
17
18
  return (_jsxs(Box, { flexDirection: "column", height: budget.liveRows, width: width, overflow: "hidden", flexShrink: 0, children: [budget.headerRows > 0 && (_jsx(Box, { height: budget.headerRows, flexShrink: 0, overflow: "hidden", children: header })), top, _jsx(Box, { flexDirection: "column", height: budget.composerSlotRows, justifyContent: "flex-end", flexShrink: 0, overflow: "hidden", children: composer }), _jsx(Box, { height: budget.statusRows > 0 ? 1 : 0, flexShrink: 0, overflow: "hidden", children: status }), budget.statusRows === 2 && (_jsx(Box, { height: 1, flexShrink: 0, overflow: "hidden", children: details }))] }));
18
19
  }
@@ -50,6 +50,19 @@ export interface StatusBarProps {
50
50
  * existing test render exactly what they rendered before (I-6).
51
51
  */
52
52
  sessionId?: string;
53
+ /**
54
+ * The agent kit in effect, or absent when there is none (agent-model-kits
55
+ * §4.8 / D-12).
56
+ *
57
+ * OPTIONAL AND ABSENT BY DEFAULT, for the same reason `sessionId` is: with no
58
+ * kit the plan is hidden, `kitCells` is 0, and this row's output is
59
+ * byte-identical to the pre-feature build (I-9).
60
+ */
61
+ kit?: {
62
+ name: string;
63
+ modified: boolean;
64
+ otherNames: readonly string[];
65
+ } | null;
53
66
  agentMode?: AgentMode;
54
67
  pendingAgentMode?: AgentMode | null;
55
68
  teamActive?: {
@@ -6,6 +6,7 @@ import { pickGlyphs } from './glyphs.js';
6
6
  import { liveSpinner } from './ActivityLine.js';
7
7
  import { planPrimaryStatusFields } from './layout/status-layout.js';
8
8
  import { planSessionChip } from './layout/session-chip.js';
9
+ import { planKitChip } from './layout/kit-chip.js';
9
10
  /** An invisible leading byte change forces Ink to repaint on Ctrl+L. */
10
11
  const redrawChar = (nonce) => nonce % 2 ? String.fromCharCode(0x00a0) : ' ';
11
12
  export function StatusBar(props) {
@@ -21,8 +22,14 @@ export function StatusBar(props) {
21
22
  ...(props.sessionId ? { sessionId: props.sessionId } : {}),
22
23
  });
23
24
  const chipCells = chip.visible ? chip.cells + chip.gap : 0;
25
+ // THE KIT CHIP IS PLANNED SECOND, OUT OF WHAT THE SESSION CHIP LEFT (D-12):
26
+ // the session id is never truncated, so it is not the thing that gives way.
27
+ // With no kit the plan is hidden and `kitCells` is 0, which keeps this row
28
+ // byte-identical to the build before the feature (I-9).
29
+ const kit = planKitChip({ columns: cols - chipCells, kit: props.kit });
30
+ const kitCells = kit.visible ? kit.cells + kit.gap : 0;
24
31
  const plan = planPrimaryStatusFields({
25
- reserveRight: chipCells,
32
+ reserveRight: chipCells + kitCells,
26
33
  columns: cols, phase, pendingCount: props.pendingSteering?.length ?? 0,
27
34
  context: props.context, usageTotal: props.usageTotal,
28
35
  thinkingLevel: props.thinkingLevel, elapsedMs: props.elapsedMs, tokPerSec: props.tokPerSec,
@@ -39,7 +46,7 @@ export function StatusBar(props) {
39
46
  const tones = { normal: props.theme.primary, muted: props.theme.muted,
40
47
  warning: props.theme.noticeWarn, error: props.theme.noticeError,
41
48
  accent: props.theme.accent };
42
- return _jsxs(Box, { width: cols, height: 1, flexShrink: 0, flexDirection: "row", overflow: "hidden", children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { children: redrawChar(props.redrawNonce ?? 0) }) }), _jsx(Box, { width: Math.max(0, cols - 1 - chipCells), flexShrink: 0, flexDirection: "row", overflow: "hidden", children: plan.fields.map((item, index) => _jsxs(React.Fragment, { children: [index > 0 && _jsx(Box, { width: plan.separatorCells, flexShrink: 0, children: _jsx(Text, { color: props.theme.muted, children: plan.separator }) }), _jsx(Box, { width: item.cells, flexShrink: 0, children: _jsx(Text, { color: tones[item.tone], wrap: "truncate", children: item.id === 'phase' && active
49
+ return _jsxs(Box, { width: cols, height: 1, flexShrink: 0, flexDirection: "row", overflow: "hidden", children: [_jsx(Box, { width: 1, flexShrink: 0, children: _jsx(Text, { children: redrawChar(props.redrawNonce ?? 0) }) }), _jsx(Box, { width: Math.max(0, cols - 1 - chipCells - kitCells), flexShrink: 0, flexDirection: "row", overflow: "hidden", children: plan.fields.map((item, index) => _jsxs(React.Fragment, { children: [index > 0 && _jsx(Box, { width: plan.separatorCells, flexShrink: 0, children: _jsx(Text, { color: props.theme.muted, children: plan.separator }) }), _jsx(Box, { width: item.cells, flexShrink: 0, children: _jsx(Text, { color: tones[item.tone], wrap: "truncate", children: item.id === 'phase' && active
43
50
  ? _jsxs(_Fragment, { children: [spinner ?? glyph, item.text.slice(glyph.length)] })
44
- : item.text }) })] }, item.id)) }), chip.visible && _jsx(Box, { width: chipCells, flexShrink: 0, justifyContent: "flex-end", overflow: "hidden", children: _jsx(Text, { color: props.theme.muted, wrap: "truncate", children: chip.text }) })] });
51
+ : item.text }) })] }, item.id)) }), kit.visible && _jsx(Box, { width: kitCells, flexShrink: 0, justifyContent: "flex-end", overflow: "hidden", children: _jsx(Text, { color: props.theme.muted, wrap: "truncate", children: kit.text }) }), chip.visible && _jsx(Box, { width: chipCells, flexShrink: 0, justifyContent: "flex-end", overflow: "hidden", children: _jsx(Text, { color: props.theme.muted, wrap: "truncate", children: chip.text }) })] });
45
52
  }
@@ -15,6 +15,18 @@
15
15
  * with the other transient rows — toast, composer, status bar — rather than
16
16
  * inside the scrolling transcript, where it would be interleaved with history
17
17
  * and scroll out of sight exactly when the user wants to watch it.
18
+ *
19
+ * IT IS THE ONLY LIVE SUBAGENT SURFACE (tui-subagent-double-roster A-2). The
20
+ * transcript's `team` card is suppressed while its dispatch is active
21
+ * (`team-status.ts::isLiveTeamCard`), so this roster is what the user watches
22
+ * during a dispatch and the card becomes the history afterwards. Native mode
23
+ * draws it as the BOTTOM slot of the band's left column (`ScrollbackShell`),
24
+ * i.e. directly above the composer; full-screen mode draws it in the viewport
25
+ * footer, which is already above the composer.
26
+ *
27
+ * EVERY PHASE -> GLYPH / COLOUR / COUNT DECISION COMES FROM `team-status.ts`.
28
+ * The local copies this file used to carry are gone on purpose: the transcript
29
+ * card kept a second copy and the two disagreed about what "running" looks like.
18
30
  */
19
31
  import React from 'react';
20
32
  import type { Theme } from './theme.js';
@@ -2,26 +2,11 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Box, Text } from 'ink';
3
3
  import Spinner from 'ink-spinner';
4
4
  import { pickGlyphs } from './glyphs.js';
5
+ import { childElapsedMs, childStatus, isRunningStatus, rosterCounts, rosterHeadline, statusColor, statusMarkerCell, } from './team-status.js';
5
6
  import { formatDuration } from '../agent/usage.js';
6
7
  import { describeToolActivity, sanitizeActivity } from '../team/activity.js';
7
8
  import { TEAM_LIMITS } from '../team/limits.js';
8
9
  import { buildTeamPanelLayout } from './layout/team-panel.js';
9
- function isRunning(run) {
10
- return run.phase === 'starting' || run.phase === 'thinking' || run.phase === 'tool' ||
11
- run.phase === 'waiting';
12
- }
13
- function isSettled(run) {
14
- return run.phase === 'done' || run.phase === 'failed' || run.phase === 'aborted';
15
- }
16
- function statusColor(run, theme) {
17
- if (run.phase === 'failed' || run.phase === 'aborted')
18
- return theme.toolError;
19
- if (run.phase === 'done')
20
- return theme.toolDone;
21
- if (isRunning(run))
22
- return theme.toolRunning;
23
- return theme.muted;
24
- }
25
10
  /**
26
11
  * How many characters the activity column may spend at this width (P0-1).
27
12
  *
@@ -172,10 +157,12 @@ export function TeamPanel({ snapshot, rows, cols, reducedMotion, theme, caps, no
172
157
  return null;
173
158
  const glyphs = pickGlyphs(caps);
174
159
  const at = now ?? Date.now();
175
- const running = snapshot.runs.filter(isRunning).length;
176
- const done = snapshot.runs.filter(isSettled).length;
160
+ // ONE COUNTING AUTHORITY (A-1): `queued` used to fall between `isRunning` and
161
+ // `isSettled`, so a fresh dispatch read `0 running 0 done` above rows that each
162
+ // said `queued`.
163
+ const counts = rosterCounts(snapshot.runs);
177
164
  const elapsed = formatDuration(Math.max(0, at - snapshot.startedAt));
178
- const header = (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate", color: theme.accent, bold: true, children: [glyphs.teamAgent, " team", ' ', _jsxs(Text, { color: theme.muted, children: [running, " running ", glyphs.midDot, " ", done, " done ", glyphs.midDot, " ", elapsed] })] }), snapshot.messageCount > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [glyphs.teamMail, " ", snapshot.messageCount] }))] }));
165
+ const header = (_jsxs(Box, { flexDirection: "row", justifyContent: "space-between", children: [_jsxs(Text, { wrap: "truncate", color: theme.accent, bold: true, children: [glyphs.teamAgent, " team", ' ', _jsxs(Text, { color: theme.muted, children: [rosterHeadline(counts, glyphs, cols), " ", glyphs.midDot, " ", elapsed] })] }), snapshot.messageCount > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [glyphs.teamMail, " ", snapshot.messageCount] }))] }));
179
166
  // Short terminals or a long draft leave only the shared header projection,
180
167
  // which still answers whether anything is running and for how long.
181
168
  if (projection.collapsed) {
@@ -187,13 +174,14 @@ export function TeamPanel({ snapshot, rows, cols, reducedMotion, theme, caps, no
187
174
  // the requirement permits.
188
175
  const { visible, hiddenTotal, hiddenRunning } = projection;
189
176
  return (_jsxs(Box, { flexDirection: "column", flexShrink: 0, children: [header, visible.map((run) => {
190
- const color = statusColor(run, theme);
177
+ const status = childStatus(run);
178
+ const color = statusColor(status, theme);
191
179
  // One spinner per RUNNING row, and the row cap is 5 precisely because
192
180
  // five is the documented maximum number of simultaneous spinners. Both
193
181
  // `reducedMotion` and a non-Unicode terminal fall back to the same
194
182
  // static marker, exactly as `AssistantEntry` and `ToolCard` already do.
195
- const animate = isRunning(run) && !reducedMotion && caps.unicode;
196
- return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: color, children: [' ', animate ? _jsx(Spinner, { type: "dots" }) : marker(run, glyphs), ' '] }), _jsx(Text, { color: theme.primary, children: run.label.padEnd(8).slice(0, 8) }), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: "truncate", color: theme.muted, children: run.description.replace(/\s+/g, ' ') }) }), _jsx(Box, { flexShrink: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", color: color, children: [' ', activityWithBadge(run, cols, at)] }) }), _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', run.startedAt ? formatDuration(Math.max(0, (run.endedAt ?? at) - run.startedAt)) : ''] })] }, run.label));
183
+ const animate = isRunningStatus(status) && !reducedMotion && caps.unicode;
184
+ return (_jsxs(Box, { flexDirection: "row", children: [_jsxs(Text, { color: color, children: [' ', animate ? _jsx(Spinner, { type: "dots" }) : statusMarkerCell(status, glyphs), ' '] }), _jsx(Text, { color: theme.primary, children: run.label.padEnd(8).slice(0, 8) }), _jsx(Box, { flexGrow: 1, flexShrink: 1, overflow: "hidden", children: _jsx(Text, { wrap: "truncate", color: theme.muted, children: run.description.replace(/\s+/g, ' ') }) }), _jsx(Box, { flexShrink: 1, overflow: "hidden", children: _jsxs(Text, { wrap: "truncate", color: color, children: [' ', activityWithBadge(run, cols, at)] }) }), _jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', run.startedAt ? formatDuration(childElapsedMs(run, at)) : ''] })] }, run.label));
197
185
  }), hiddenTotal > 0 && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', "+", hiddenTotal, " more", hiddenRunning > 0 ? ` (${hiddenRunning} running)` : ''] })), (() => {
198
186
  // The panel's supervisor line (AC-5): one row, only when the
199
187
  // supervisor has actually acted - the per-row badges above already
@@ -202,12 +190,3 @@ export function TeamPanel({ snapshot, rows, cols, reducedMotion, theme, caps, no
202
190
  return line ? (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', line] })) : null;
203
191
  })(), snapshot.lastMessage && (_jsxs(Text, { wrap: "truncate", color: theme.muted, children: [' ', glyphs.teamMail, " ", snapshot.lastMessage.from, " ", glyphs.arrowRight, " ", snapshot.lastMessage.to, ": ", snapshot.lastMessage.subject.replace(/\s+/g, ' ')] }))] }));
204
192
  }
205
- function marker(run, glyphs) {
206
- if (run.phase === 'done')
207
- return glyphs.toolDone;
208
- if (run.phase === 'failed' || run.phase === 'aborted')
209
- return glyphs.toolError;
210
- if (run.phase === 'queued')
211
- return glyphs.toolPending;
212
- return glyphs.spinnerStill;
213
- }