@aragon-agent/cli 0.6.34 → 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 (64) hide show
  1. package/README.md +3071 -3007
  2. package/dist/agent/controller.d.ts +29 -0
  3. package/dist/agent/controller.js +42 -1
  4. package/dist/agent/kit-apply.d.ts +56 -0
  5. package/dist/agent/kit-apply.js +209 -0
  6. package/dist/agent/model-profile-settings.d.ts +28 -0
  7. package/dist/agent/model-profile-settings.js +142 -29
  8. package/dist/agent/reducer.d.ts +1 -1
  9. package/dist/commands/builtins.js +4 -0
  10. package/dist/commands/kit-command.d.ts +20 -0
  11. package/dist/commands/kit-command.js +131 -0
  12. package/dist/config/kit-resolution.d.ts +93 -0
  13. package/dist/config/kit-resolution.js +226 -0
  14. package/dist/config/model-kits.d.ts +224 -0
  15. package/dist/config/model-kits.js +302 -0
  16. package/dist/config/model-profile-store.d.ts +25 -0
  17. package/dist/config/model-profiles.d.ts +41 -3
  18. package/dist/config/model-profiles.js +46 -5
  19. package/dist/config/schema.d.ts +16 -0
  20. package/dist/input/keymap.js +18 -4
  21. package/dist/ui/App.js +108 -12
  22. package/dist/ui/Header.d.ts +9 -1
  23. package/dist/ui/Header.js +8 -2
  24. package/dist/ui/PromptInput.js +1 -1
  25. package/dist/ui/ScrollbackShell.d.ts +18 -3
  26. package/dist/ui/ScrollbackShell.js +1 -1
  27. package/dist/ui/StatusBar.d.ts +13 -0
  28. package/dist/ui/StatusBar.js +10 -3
  29. package/dist/ui/TeamPanel.d.ts +12 -0
  30. package/dist/ui/TeamPanel.js +10 -31
  31. package/dist/ui/Transcript.js +22 -2
  32. package/dist/ui/entries/TeamCard.d.ts +18 -2
  33. package/dist/ui/entries/TeamCard.js +47 -13
  34. package/dist/ui/layout/header-hints.d.ts +11 -1
  35. package/dist/ui/layout/header-hints.js +35 -3
  36. package/dist/ui/layout/kit-chip.d.ts +50 -0
  37. package/dist/ui/layout/kit-chip.js +81 -0
  38. package/dist/ui/layout/virtual-window.js +9 -0
  39. package/dist/ui/overlays/HelpOverlay.js +7 -0
  40. package/dist/ui/overlays/KitEditor.d.ts +23 -0
  41. package/dist/ui/overlays/KitEditor.js +101 -0
  42. package/dist/ui/overlays/KitManager.d.ts +23 -0
  43. package/dist/ui/overlays/KitManager.js +104 -0
  44. package/dist/ui/overlays/KitPicker.d.ts +49 -0
  45. package/dist/ui/overlays/KitPicker.js +58 -0
  46. package/dist/ui/overlays/ModelProfilePicker.js +16 -6
  47. package/dist/ui/overlays/SettingsScreen.js +139 -16
  48. package/dist/ui/overlays/kit-picker-state.d.ts +87 -0
  49. package/dist/ui/overlays/kit-picker-state.js +136 -0
  50. package/dist/ui/overlays/kit-settings-state.d.ts +129 -0
  51. package/dist/ui/overlays/kit-settings-state.js +267 -0
  52. package/dist/ui/overlays/model-profile-state.d.ts +26 -3
  53. package/dist/ui/overlays/model-profile-state.js +59 -6
  54. package/dist/ui/scrollback/LiveTail.d.ts +2 -2
  55. package/dist/ui/scrollback/LiveTail.js +7 -2
  56. package/dist/ui/scrollback/live-preview.js +10 -3
  57. package/dist/ui/scrollback/tail-window.d.ts +12 -0
  58. package/dist/ui/scrollback/tail-window.js +17 -2
  59. package/dist/ui/scrollback/use-scrollback-view.js +7 -0
  60. package/dist/ui/team-status.d.ts +141 -0
  61. package/dist/ui/team-status.js +224 -0
  62. package/dist/ui/transcript-text.js +13 -4
  63. package/dist/ui/use-wheel-routing.js +2 -2
  64. package/package.json +2 -2
@@ -1,9 +1,11 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { ADAPTER_PROVIDERS, clampContextWindow, isAutoToken } from './schema.js';
3
+ import { normalizeKits, validateKits } from './model-kits.js';
3
4
  const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
4
5
  /** Return a fresh empty library without initiating a disk migration. */
5
6
  export function emptyModelProfiles() {
6
- return { version: 1, entries: [], mainId: null, fastId: null };
7
+ return { version: 1, entries: [], mainId: null, fastId: null, kits: [],
8
+ defaultKitId: null, kitsVersion: 1 };
7
9
  }
8
10
  /** Return independently mutable role state. */
9
11
  export function emptyModelProfileState(invalid = false) {
@@ -69,7 +71,16 @@ function validateEntry(value, index) {
69
71
  }
70
72
  return issues;
71
73
  }
72
- /** Validate untrusted input; diagnostics never include supplied values. */
74
+ /**
75
+ * Validate untrusted input; diagnostics never include supplied values.
76
+ *
77
+ * THE KIT SECTION IS STRUCTURAL ONLY (agent-model-kits RV-P1-1). `mainId` /
78
+ * `fastId` above are `missing_reference` because a dangling ROLE BINDING leaves
79
+ * the whole library unreadable (there is no "nothing bound" recovery path once
80
+ * a bound id is gone), while a dangling KIT SLOT degrades at resolution time
81
+ * into `Profile missing` and a refused apply - the same discipline
82
+ * `team.modelProfileId` already follows (`schema.ts` clampTeamConfig).
83
+ */
73
84
  export function validateModelProfiles(value) {
74
85
  if (value === undefined)
75
86
  return [];
@@ -99,6 +110,7 @@ export function validateModelProfiles(value) {
99
110
  message: 'Select an existing profile or Current custom.' });
100
111
  }
101
112
  }
113
+ issues.push(...validateKits(value.kits));
102
114
  return issues;
103
115
  }
104
116
  /**
@@ -116,19 +128,29 @@ export function normalizeProfileContextWindow(value) {
116
128
  return null;
117
129
  return clampContextWindow(value, null);
118
130
  }
119
- /** Normalize a valid library into a safe field whitelist; throws a fixed error if invalid. */
131
+ /**
132
+ * Normalize a valid library into a safe field whitelist; throws a fixed error if invalid.
133
+ *
134
+ * THE THREE KIT KEYS ARE LISTED EXPLICITLY, NEVER `...spread` (R-2): this
135
+ * literal IS the whitelist (F-3), so a key missing from it disappears at the
136
+ * next write - a save that only touched `thinking` would silently delete every
137
+ * kit the user owns. `normalizeKits` is TOTAL, so a hand-edited kit degrades
138
+ * instead of taking the library (and the credentials in it) with it.
139
+ */
120
140
  export function normalizeModelProfiles(value) {
121
141
  if (validateModelProfiles(value).length)
122
142
  throw new Error('Invalid model profiles.');
123
143
  if (value === undefined)
124
144
  return emptyModelProfiles();
125
145
  const library = value;
146
+ const { kits, defaultKitId, kitsVersion } = normalizeKits(library.kits, library.defaultKitId);
126
147
  return { version: 1, mainId: library.mainId, fastId: library.fastId,
127
148
  entries: library.entries.map((entry) => ({ id: entry.id, name: entry.name.trim()
128
149
  || [...entry.model.trim()].slice(0, 80).join(''), provider: entry.provider,
129
150
  model: entry.model.trim(), baseUrl: entry.baseUrl?.trim() || null,
130
151
  apiKey: entry.apiKey?.trim() || null,
131
- contextWindow: normalizeProfileContextWindow(entry.contextWindow) })) };
152
+ contextWindow: normalizeProfileContextWindow(entry.contextWindow) })),
153
+ kits, defaultKitId, kitsVersion };
132
154
  }
133
155
  /** Create an entry with a stable UUID and a frozen default name. */
134
156
  export function createModelProfile(input) {
@@ -139,9 +161,28 @@ export function createModelProfile(input) {
139
161
  export function duplicateModelProfile(entry) {
140
162
  return { ...entry, id: randomUUID(), name: `${[...entry.name].slice(0, 73).join('')} (copy)` };
141
163
  }
142
- /** Remove an unused entry; binding references must be changed explicitly first. */
164
+ /** Ids of the kits whose three slots name `id`, for the reference message. */
165
+ export function kitNamesForProfile(library, id) {
166
+ return (library.kits ?? []).filter((kit) => kit.mainProfileId === id
167
+ || kit.fastProfileId === id
168
+ || (kit.subagent.model === 'profile' && kit.subagent.modelProfileId === id))
169
+ .map((kit) => kit.name);
170
+ }
171
+ /**
172
+ * Remove an unused entry; binding references must be changed explicitly first.
173
+ *
174
+ * The message NAMES the kits that would be orphaned (D-9): a throw that only
175
+ * says "still in use" sends the user hunting through three separate screens for
176
+ * the reference it means, and the kits are the reference nobody thinks of.
177
+ * (The UI never reaches this throw - it unbinds, and deletes the kits, inside
178
+ * the draft first - so this is the library-level backstop, F-25.)
179
+ */
143
180
  export function removeModelProfile(library, id) {
144
181
  if (library.mainId === id || library.fastId === id)
145
182
  throw new Error('Profile is still in use.');
183
+ const kits = kitNamesForProfile(library, id);
184
+ if (kits.length) {
185
+ throw new Error(`Profile is used by kit ${kits.map((name) => `"${name}"`).join(', ')}.`);
186
+ }
146
187
  return { ...library, entries: library.entries.filter((entry) => entry.id !== id) };
147
188
  }
@@ -1044,6 +1044,22 @@ export interface CliConfig {
1044
1044
  modelSettingsRestartRequired?: boolean;
1045
1045
  modelProfiles?: import('./model-profiles.js').ModelProfilesConfig;
1046
1046
  modelProfileState?: import('./model-profiles.js').ModelProfilesRuntime;
1047
+ /**
1048
+ * The SESSION kit (agent-model-kits D-5 / §7.2). RUNTIME-ONLY.
1049
+ *
1050
+ * IT IS NOT IN `PersistedConfig` AND IT NEVER WILL BE (I-5 / I-14): `ctrl+K`
1051
+ * and `/kit use` promise "this session only", and that promise is worth more
1052
+ * than a saved convenience - it is what makes "one keystroke changed my
1053
+ * defaults" structurally impossible rather than merely unlikely.
1054
+ *
1055
+ * `held` is the only fact worth storing: WHICH slots still belong to the kit.
1056
+ * The bindings are derivable, and a stored copy of them would be a second
1057
+ * truth (I-7: every held slot must have been written into the live config).
1058
+ */
1059
+ sessionKit?: {
1060
+ id: string;
1061
+ held: import('./model-kits.js').KitSlot[];
1062
+ };
1047
1063
  apiKeyOverrideTarget?: {
1048
1064
  provider: string;
1049
1065
  baseUrl: string | null;
@@ -7,7 +7,15 @@
7
7
  * does not own so the caller can drop unknown control bytes instead of
8
8
  * inserting them into the buffer.
9
9
  */
10
- /** Map a Ctrl-modified key to an intent, if any. */
10
+ /**
11
+ * Map a Ctrl-modified key to an intent, if any.
12
+ *
13
+ * `Ctrl+K` IS DELIBERATELY NOT HERE (agent-model-kits D-15). That byte belongs
14
+ * to the Agent-kit picker on the main screen, and this recognizer runs BEFORE
15
+ * `PromptInput`'s `if (key.ctrl) return` - so keeping the binding would delete
16
+ * the user's draft in one listener while the other opened the overlay. The
17
+ * capability moved to `Alt+K` (`fromMeta`) instead of being dropped.
18
+ */
11
19
  function fromCtrl(input, key) {
12
20
  if (key.leftArrow)
13
21
  return 'wordLeft';
@@ -22,8 +30,6 @@ function fromCtrl(input, key) {
22
30
  return 'deleteWordBack';
23
31
  case 'u':
24
32
  return 'killToStart';
25
- case 'k':
26
- return 'killToEnd';
27
33
  default:
28
34
  return null;
29
35
  }
@@ -34,6 +40,11 @@ function fromMeta(input) {
34
40
  return 'wordLeft';
35
41
  if (input === 'f')
36
42
  return 'wordRight';
43
+ // `killToEnd` LIVES HERE NOW (agent-model-kits D-15). `Alt+K` keeps the
44
+ // capability, and it keeps the kill family together: `M-k` is kill-sentence in
45
+ // the Emacs tradition this keymap already borrows `Ctrl+W` and `Alt+B/F` from.
46
+ if (input === 'k')
47
+ return 'killToEnd';
37
48
  if (input === '\x7f' || input === '\b')
38
49
  return 'deleteWordBack';
39
50
  return null;
@@ -63,7 +74,10 @@ function fromRaw(input) {
63
74
  return 'deleteWordBack';
64
75
  case '\x15': // Ctrl+U (raw)
65
76
  return 'killToStart';
66
- case '\x0b': // Ctrl+K (raw)
77
+ case '\x1bk':
78
+ // Alt+K (raw ESC + k). `\x0b` (Ctrl+K) is deliberately ABSENT: it belongs
79
+ // to the Agent-kit picker, and a case for it here would be a dead binding
80
+ // that still looks alive to every future reader.
67
81
  return 'killToEnd';
68
82
  default:
69
83
  return null;
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));
@@ -2114,10 +2170,16 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2114
2170
  const entries = stateRef.current.entries;
2115
2171
  const card = [...entries]
2116
2172
  .reverse()
2117
- .find((e) => e.kind === 'tool' ||
2118
- e.kind === 'team' ||
2119
- e.kind === 'compaction' ||
2120
- (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)));
2121
2183
  // NATIVE MODE ONLY. The fallback exists because native mode cannot scroll inside the
2122
2184
  // frame - the answer's head is reachable only if it is printed. Full-screen mode has a
2123
2185
  // viewport that already reaches it, and it has no `expanded` rendering for an answer,
@@ -2224,6 +2286,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2224
2286
  const overlay = state.overlay;
2225
2287
  const settingsDraft = useMemo(() => overlay === 'settings'
2226
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]);
2227
2295
  // ONE PROBE PER APP (model-profile-connection-test 5 row 9): the port needs the
2228
2296
  // controller's provider registry, which the overlay has no business owning.
2229
2297
  // Built only while the settings overlay is open, so no other screen pays for it.
@@ -2237,6 +2305,22 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2237
2305
  return createModelProbe({ registry: controller.getProviderRegistry() });
2238
2306
  }, [controller, overlay]);
2239
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
+ };
2240
2324
  const empty = state.entries.length === 0;
2241
2325
  // RENDER SCOPE, so the chip and the composer hint agree with what the Ctrl+C
2242
2326
  // handler will do on the very next keystroke.
@@ -2340,7 +2424,11 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2340
2424
  // ALWAYS CALLED, INERT WHEN `enabled` IS FALSE: hooks cannot be conditional, and
2341
2425
  // an inert hook is what keeps `scrollMode: 'app'` byte-identical (I-S9).
2342
2426
  const viewCols = native ? Math.max(1, cols - 1) : cols;
2343
- 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 }));
2344
2432
  // The header and the opener are SLOTS OF THE SHELL, not printed bytes (D3/D4): at
2345
2433
  // `H = rows - 1` a printed row is pushed off the screen by the very next frame, so
2346
2434
  // `printBanner()` was a way of making the banner invisible. Neither is passed here.
@@ -2391,7 +2479,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2391
2479
  const openerCols = nativeBudget ? nativeBudget.previewCols : contentCols;
2392
2480
  const opener = empty ? (_jsx(SessionOpener, { variant: pickOpenerVariant(openerRows, openerCols), version: version, cwd: controller.getCwd(), hasKey: hasKey, viewportRows: openerRows, theme: theme, caps: caps })) : null;
2393
2481
  const overlayMaxRows = nativeBudget ? nativeBudget.overlayRows : viewportBudget;
2394
- 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: {
2395
2486
  provider: cfg.provider,
2396
2487
  model: cfg.model,
2397
2488
  baseUrl: cfg.baseUrl ?? '',
@@ -2524,8 +2615,12 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2524
2615
  compacting: compactionLive || !!state.compaction?.inFlight,
2525
2616
  mode: state.agentMode, pendingMode: state.pendingAgentMode,
2526
2617
  servicesActive: { live: liveServices }, ecoRung: governor.rung,
2527
- teamActive: state.team ? { running: state.team.runs.filter(item => !['queued', 'done', 'failed', 'aborted'].includes(item.phase)).length,
2528
- 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,
2529
2624
  todoActive: state.todos ? { done: state.todos.doneCount, total: state.todos.total } : undefined,
2530
2625
  fastActive: state.fast?.live ? { inFlight: state.fast.inFlight } : undefined,
2531
2626
  ...feedbackProjection,
@@ -2558,7 +2653,7 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2558
2653
  const rail = fullscreenRailVisible && state.todos ? (_jsx(TodoPanel, { snapshot: state.todos, width: railWidth, rows: railRows, running: running, reducedMotion: viewReducedMotion, theme: theme, caps: caps })) : null;
2559
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 })] }));
2560
2655
  const detailsNode = _jsx(BottomStatusRow, { plan: details, columns: viewCols, theme: theme });
2561
- 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'
2562
2657
  ? 'Esc force-stop' : 'Esc x2 interrupt', model: cfg.model, provider: cfg.provider, usageTotal: state.usageTotal,
2563
2658
  // ONE OBJECT INSTEAD OF FOUR PARALLEL PROPS (§4.3). The four were a
2564
2659
  // projection of "many sources of truth" into the props layer, and a
@@ -2568,9 +2663,10 @@ export function App({ controller, version, initialOverlay, initialPrompt, confir
2568
2663
  // `contextWindow` override reach the bar.
2569
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
2570
2665
  ? {
2666
+ // `rosterCounts`, NOT a fourth inline phase filter (A-1): the chip and
2667
+ // the roster header above it must never disagree.
2571
2668
  teamActive: {
2572
- running: state.team.runs.filter((r) => r.phase !== 'queued' && r.phase !== 'done' &&
2573
- r.phase !== 'failed' && r.phase !== 'aborted').length,
2669
+ running: rosterCounts(state.team.runs).running,
2574
2670
  total: state.team.runs.length,
2575
2671
  },
2576
2672
  }
@@ -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,11 +19,22 @@
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
@@ -76,7 +87,11 @@ export interface ScrollbackShellProps {
76
87
  opener?: React.ReactNode;
77
88
  /** Real components for the tail window (`LiveTail`). */
78
89
  tail?: React.ReactNode;
79
- /** 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
+ */
80
95
  team?: React.ReactNode;
81
96
  /** The TODO rail, drawn as the band's right-hand column. */
82
97
  rail?: React.ReactNode;
@@ -14,6 +14,6 @@ export function ScrollbackShell({ budget, header, overlay, liveTailLines, opener
14
14
  // The overlay and the band are alternatives, not siblings: the band reports
15
15
  // `bandRows = remaining` for an overlay frame, so drawing both would double the
16
16
  // region's height and take the frame to the terminal height (the `ESC[3J` case).
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.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: "flex-start", 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;
18
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 }))] }));
19
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';