@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
@@ -12,6 +12,7 @@ import { QueuedEntry } from './entries/QueuedEntry.js';
12
12
  import { AssistantEntry } from './entries/AssistantEntry.js';
13
13
  import { ToolCard, statusColor, toolEntryGlyph } from './entries/ToolCard.js';
14
14
  import { TeamCard, teamCardColor } from './entries/TeamCard.js';
15
+ import { isLiveTeamCard, rosterCounts } from './team-status.js';
15
16
  import { TodoCard, todoCardColor } from './entries/TodoCard.js';
16
17
  import { RetryCard, retryCardColor } from './entries/RetryCard.js';
17
18
  import { FastCard, fastCardColor } from './entries/FastCard.js';
@@ -42,6 +43,12 @@ export function nowSecFor(entry, nowSec) {
42
43
  // function protects is unchanged for every other card, forever.
43
44
  if (entry.kind === 'compaction' && entry.live)
44
45
  return nowSec;
46
+ // THE FOURTH CONSUMER IS A LIVE DISPATCH CARD (tui-subagent-double-roster A-1):
47
+ // a child's elapsed time must advance while it works, and `TeamCard` reads this
48
+ // same 1 Hz clock instead of a second `Date.now()` per render. A suppressed card
49
+ // never mounts, so this only keeps the reading honest for a directly mounted one.
50
+ if (entry.kind === 'team' && entry.active)
51
+ return nowSec;
45
52
  // THE THIRD CONSUMER IS A LIVE JOB (long-task-jobs D-8), and it is the reason
46
53
  // this stays a single 1 Hz clock: a task's elapsed time and its `no output`
47
54
  // badge both have to move, and a per-card `setInterval` would be one timer per
@@ -96,8 +103,21 @@ function EntryViewImpl({ entry, prev, expanded, thinkingVisible, reducedMotion,
96
103
  // comparator sees a fresh `[]` as a changed prop, which is why that
97
104
  // file already keeps a shared `EMPTY_LINES`.
98
105
  live: entry.live, lastOutputAt: entry.lastOutputAt, nowSec: nowSec, theme: theme, caps: caps }));
99
- case 'team':
100
- return frame(glyphs.teamAgent, teamCardColor(entry.runs, entry.active, entry.aborted, theme), _jsx(TeamCard, { requested: entry.requested, runs: entry.runs, aborted: entry.aborted, durationMs: entry.durationMs, active: entry.active, expanded: expanded, reducedMotion: reducedMotion, theme: theme, caps: caps }));
106
+ case 'team': {
107
+ // THE LIVE DISPATCH HAS EXACTLY ONE SURFACE (tui-subagent-double-roster A-2):
108
+ // while `active`, the roster (`TeamPanel`) is the only live subagent block, so
109
+ // the transcript draws nothing for this entry and the card appears - once - as
110
+ // history the moment the dispatch settles. Returning BEFORE `frame(...)` draws
111
+ // neither the rail marker nor the separation row.
112
+ //
113
+ // `renderer.tsx`'s offscreen measurement goes through THIS function too, which is
114
+ // what makes the native print pump's `measureWindow` agree with the frame for
115
+ // free. `estimateEntryRows` carries the same predicate (0 rows), so no other
116
+ // arithmetic has to know about the suppression.
117
+ if (isLiveTeamCard(entry))
118
+ return null;
119
+ return frame(glyphs.teamAgent, teamCardColor(rosterCounts(entry.runs), entry.active, entry.aborted, theme), _jsx(TeamCard, { requested: entry.requested, runs: entry.runs, aborted: entry.aborted, durationMs: entry.durationMs, active: entry.active, expanded: expanded, reducedMotion: reducedMotion, theme: theme, caps: caps, nowSec: nowSecFor(entry, nowSec) }));
120
+ }
101
121
  case 'todo':
102
122
  return frame(toolGlyph(glyphs, 'todo_write'), todoCardColor(entry.doneCount, entry.total, entry.live, theme), _jsx(TodoCard, { items: entry.items, doneCount: entry.doneCount, total: entry.total, live: entry.live, interrupted: entry.interrupted, theme: theme, caps: caps }));
103
123
  case 'retry':
@@ -15,6 +15,7 @@ import React from 'react';
15
15
  import type { Theme } from '../theme.js';
16
16
  import type { TermCapabilities } from '../capabilities.js';
17
17
  import type { SubagentRun } from '../../team/types.js';
18
+ import { type RosterCounts } from '../team-status.js';
18
19
  export interface TeamCardProps {
19
20
  requested: number;
20
21
  runs: SubagentRun[];
@@ -25,9 +26,24 @@ export interface TeamCardProps {
25
26
  reducedMotion?: boolean;
26
27
  theme: Theme;
27
28
  caps: TermCapabilities;
29
+ /**
30
+ * Wall-clock seconds, set only while the dispatch is live (`nowSecFor`). WITHOUT
31
+ * IT a running child's elapsed time came from `(endedAt ?? 0) - startedAt` - a
32
+ * negative number clamped to `0ms`, the readout that contradicted the roster
33
+ * beside it (A-1 / E4).
34
+ */
35
+ nowSec?: number;
28
36
  }
29
- /** The card's aggregate colour: any failure dominates. */
30
- export declare function teamCardColor(runs: SubagentRun[], active: boolean, aborted: boolean, theme: Theme): string | undefined;
37
+ /**
38
+ * The card's aggregate colour.
39
+ *
40
+ * FAILURES DOMINATE, THEN A CANCELLATION, THEN A DISPATCH THAT NEVER SETTLED.
41
+ * The old version asked `runs.some((r) => !isOk(r))`, so an entry whose children
42
+ * were still working - and one the user had cancelled - was painted `toolError`
43
+ * (A-1 / E8). `RosterCounts` is passed IN rather than recomputed, so this colour
44
+ * and the headline cannot disagree inside one card.
45
+ */
46
+ export declare function teamCardColor(counts: RosterCounts, active: boolean, aborted: boolean, theme: Theme): string | undefined;
31
47
  declare function TeamCardImpl(props: TeamCardProps): React.ReactElement;
32
48
  /**
33
49
  * `React.memo` with the DEFAULT comparator (tui-render-performance L2 / R3).
@@ -18,37 +18,71 @@ import Spinner from 'ink-spinner';
18
18
  import { pickGlyphs } from '../glyphs.js';
19
19
  import { railBorderProps } from '../layout/Gutter.js';
20
20
  import { formatDuration } from '../../agent/usage.js';
21
- function isOk(run) {
22
- return run.phase === 'done' && !run.error;
23
- }
24
- /** The card's aggregate colour: any failure dominates. */
25
- export function teamCardColor(runs, active, aborted, theme) {
21
+ import { childElapsedMs, childStatus, outcomePhrase, rosterCounts, statusColor, statusMarkerCell, } from '../team-status.js';
22
+ /**
23
+ * The card's aggregate colour.
24
+ *
25
+ * FAILURES DOMINATE, THEN A CANCELLATION, THEN A DISPATCH THAT NEVER SETTLED.
26
+ * The old version asked `runs.some((r) => !isOk(r))`, so an entry whose children
27
+ * were still working - and one the user had cancelled - was painted `toolError`
28
+ * (A-1 / E8). `RosterCounts` is passed IN rather than recomputed, so this colour
29
+ * and the headline cannot disagree inside one card.
30
+ */
31
+ export function teamCardColor(counts, active, aborted, theme) {
26
32
  if (active)
27
33
  return theme.toolRunning;
28
- if (aborted || runs.some((r) => !isOk(r)))
34
+ if (counts.failed > 0)
29
35
  return theme.toolError;
36
+ if (aborted || counts.aborted > 0 || counts.queued > 0 || counts.running > 0) {
37
+ return theme.noticeWarn;
38
+ }
30
39
  return theme.toolDone;
31
40
  }
41
+ /** `nowSec` is seconds (`App`'s 1 Hz clock); a card measures in milliseconds. */
42
+ function liveClockMs(nowSec) {
43
+ return nowSec === undefined ? Date.now() : nowSec * 1000;
44
+ }
45
+ /**
46
+ * The elapsed column for one child: a MEASUREMENT, or an empty cell.
47
+ *
48
+ * `liveNow` is defined only while the dispatch is live, and that distinction is the
49
+ * whole point. A child restored by `/resume` keeps its `startedAt` and has no
50
+ * `endedAt` - `session/persist.ts` settles the ENTRY, it does not invent an end time
51
+ * for the run - so a figure computed against `Date.now()` would claim hours of work
52
+ * that never happened, next to an `interrupted (session resumed)` headline that says
53
+ * the opposite. An unmeasurable duration is left blank instead.
54
+ */
55
+ function elapsedCell(run, liveNow) {
56
+ const end = run.endedAt ?? liveNow;
57
+ if (run.startedAt === undefined || end === undefined)
58
+ return '';
59
+ return formatDuration(childElapsedMs(run, end));
60
+ }
32
61
  function TeamCardImpl(props) {
33
62
  const { requested, runs, aborted, durationMs, active, expanded, reducedMotion, theme, caps } = props;
34
63
  const glyphs = pickGlyphs(caps);
35
- const color = teamCardColor(runs, active, aborted, theme);
36
- const ok = runs.filter(isOk).length;
37
- const failed = runs.length - ok;
64
+ // ONE COUNTING AUTHORITY (A-1): `ok`/`failed` used to be `isOk(...)` and
65
+ // `runs.length - ok`, which counted a cancelled child - and a still-working one -
66
+ // as a failed one (E3 / E8).
67
+ const counts = rosterCounts(runs);
68
+ const color = teamCardColor(counts, active, aborted, theme);
69
+ // A CLOCK ONLY WHILE THE DISPATCH IS LIVE: a settled card has nothing left to time,
70
+ // and a clock in the settled path is how a resumed dispatch came to read like a run
71
+ // that is still going (`elapsedCell`).
72
+ const liveNow = active ? liveClockMs(props.nowSec) : undefined;
38
73
  // A dispatch entry restored from `/resume` is settled and aborted but has no
39
74
  // duration: nothing measured it, because the process it belonged to is gone.
40
75
  // Saying so plainly beats rendering `0.0s (0 ok, 3 failed)`, which reads like
41
76
  // a dispatch that ran and lost (§5.3 / P1-5).
42
77
  const interrupted = !active && aborted && durationMs === undefined;
43
- const settled = runs.filter((r) => r.phase === 'done' || r.phase === 'failed' || r.phase === 'aborted').length;
44
78
  const headline = interrupted
45
79
  ? 'interrupted (session resumed)'
46
80
  : active
47
- ? `${runs.length - settled} running`
48
- : `${formatDuration(durationMs ?? 0)} (${ok} ok, ${failed} failed)`;
81
+ ? `${counts.queued + counts.running} running`
82
+ : `${formatDuration(durationMs ?? 0)} (${outcomePhrase(counts)})`;
49
83
  const count = `${runs.length} ${runs.length === 1 ? 'subagent' : 'subagents'}`;
50
84
  const dropped = requested > runs.length ? ` (${runs.length} of ${requested} requested)` : '';
51
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: "team" }), _jsxs(Text, { color: theme.muted, children: [' ', count, dropped, ' '] }), active && !reducedMotion && caps.unicode ? (_jsxs(Text, { color: color, children: [_jsx(Spinner, { type: "dots" }), " ", headline] })) : (_jsxs(Text, { color: color, children: [active ? `${glyphs.spinnerStill} ` : '', headline] }))] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [runs.map((run) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: isOk(run) ? theme.toolDone : theme.toolError, children: isOk(run) ? glyphs.toolDone : glyphs.toolError }), _jsxs(Text, { color: theme.primary, children: [' ', run.label.padEnd(8).slice(0, 8)] }), _jsxs(Text, { color: theme.muted, children: [' ', run.description, ' ', run.startedAt ? formatDuration(Math.max(0, (run.endedAt ?? 0) - run.startedAt)) : '', ` ${run.turns} turns`, run.filesTouched.length > 0 ? ` ${run.filesTouched.length} files` : '', run.error ? ` ${run.error}` : ''] }), run.retry && (_jsxs(Text, { color: theme.noticeWarn, bold: true, children: [' ', "retry ", run.retry.attempt, "/", run.retry.maxRetries] }))] }, run.label))), expanded &&
85
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "row", children: [_jsx(Text, { color: theme.accent, bold: true, children: "team" }), _jsxs(Text, { color: theme.muted, children: [' ', count, dropped, ' '] }), active && !reducedMotion && caps.unicode ? (_jsxs(Text, { color: color, children: [_jsx(Spinner, { type: "dots" }), " ", headline] })) : (_jsxs(Text, { color: color, children: [active ? `${glyphs.spinnerStill} ` : '', headline] }))] }), _jsxs(Box, { flexDirection: "column", flexShrink: 0, paddingLeft: 1, ...railBorderProps(glyphs.railVertical, theme.border), children: [runs.map((run) => (_jsxs(Text, { wrap: "truncate", children: [_jsx(Text, { color: statusColor(childStatus(run), theme), children: statusMarkerCell(childStatus(run), glyphs) }), _jsxs(Text, { color: theme.primary, children: [' ', run.label.padEnd(8).slice(0, 8)] }), _jsxs(Text, { color: theme.muted, children: [' ', run.description, ' ', elapsedCell(run, liveNow), ` ${run.turns} turns`, run.filesTouched.length > 0 ? ` ${run.filesTouched.length} files` : '', run.error ? ` ${run.error}` : ''] }), run.retry && (_jsxs(Text, { color: theme.noticeWarn, bold: true, children: [' ', "retry ", run.retry.attempt, "/", run.retry.maxRetries] }))] }, run.label))), expanded &&
52
86
  runs.map((run) => (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { color: theme.accent, children: [run.label, " ", glyphs.midDot, " ", run.description] }), _jsx(Text, { color: theme.muted, children: run.summary ?? '(no summary)' })] }, `s-${run.label}`))), !expanded && !active && runs.length > 0 && (_jsxs(Text, { color: theme.muted, children: ["+", runs.length, " ", runs.length === 1 ? 'summary' : 'summaries', " (Ctrl+O)"] })), expanded && _jsx(Text, { color: theme.muted, children: "(Ctrl+O to collapse)" })] })] }));
53
87
  }
54
88
  /**
@@ -46,7 +46,7 @@ export declare const HEADER_HINT_LIMITS: {
46
46
  */
47
47
  readonly separatorCells: 2;
48
48
  };
49
- export type HeaderHintId = 'branch' | 'index' | 'detail';
49
+ export type HeaderHintId = 'branch' | 'index' | 'kit' | 'detail';
50
50
  export interface HeaderHintClause {
51
51
  id: HeaderHintId;
52
52
  text: string;
@@ -74,4 +74,14 @@ export declare function planHeaderHints(input: {
74
74
  statusExpanded: boolean;
75
75
  overlayOpen: boolean;
76
76
  scrolledLines: number;
77
+ /**
78
+ * Whether the `^K switch kit` clause exists at all (default `true`).
79
+ *
80
+ * `App` ALWAYS PASSES TRUE, including for a user with no kits: the whole point
81
+ * of the requirement is discoverability of `ctrl+K`, and an empty library is
82
+ * exactly the state that needs it (the picker's own `No kits yet` row tells
83
+ * them where to create one). `false` exists for tests and for the future
84
+ * switch, and it degrades the six expanded rungs back into today's four.
85
+ */
86
+ kitHint?: boolean;
77
87
  }): HeaderHintPlan;
@@ -49,6 +49,15 @@ export const HEADER_HINT_LIMITS = {
49
49
  /** The verbatim clause text (spec 3.8, appendix A). */
50
50
  const BRANCH_CLAUSE = { collapsed: '^L branch', expanded: '^L new branch' };
51
51
  const INDEX_CLAUSE = { collapsed: '^I Index', expanded: '^I build index' };
52
+ /**
53
+ * THE KIT CLAUSE HAS NO COLLAPSED FORM (agent-model-kits D-11).
54
+ *
55
+ * The requirement says the `ctrl+k` hint appears AFTER `ctrl+G`; the collapsed
56
+ * row is the narrowest budget in the header, and `^K` is a low-frequency action.
57
+ * Spending 13 columns of the everyday row on it would push `provider:model`
58
+ * leftward on every launch for a key pressed twice a session.
59
+ */
60
+ const KIT_CLAUSE = { expanded: '^K switch kit' };
52
61
  const DETAIL_CLAUSE = { collapsed: '^G more', expanded: '^G less' };
53
62
  /** L3 gives the key name and nothing else: discoverability at the last width. */
54
63
  const BRANCH_KEY_ONLY = '^L';
@@ -77,7 +86,7 @@ export function planHeaderHints(input) {
77
86
  const detail = input.statusExpanded ? DETAILS_ON : DETAILS_OFF;
78
87
  return buildPlan([{ id: 'detail', text: detail }], scrollCells, true);
79
88
  }
80
- const ladder = ladderFor(input.statusExpanded);
89
+ const ladder = ladderFor(input.statusExpanded, input.kitHint !== false);
81
90
  // THE SEPARATOR IS SPENT OUT OF THE SAME ROW (defect 12): left half + gap + this
82
91
  // column have to add up to `columns`, so the gap comes off the budget instead of
83
92
  // being assumed free.
@@ -94,8 +103,18 @@ export function planHeaderHints(input) {
94
103
  // shrinks (to zero), and `overflow="hidden"` keeps that from overflowing.
95
104
  return buildPlan(ladder[ladder.length - 1] ?? [], scrollCells, false);
96
105
  }
97
- /** The four rungs, widest first. Built per call because the widths are text-derived. */
98
- function ladderFor(statusExpanded) {
106
+ /**
107
+ * The rungs, widest first. Built per call because the widths are text-derived.
108
+ *
109
+ * THE EXPANDED ORDER `branch -> index -> kit -> detail` IS A REGRESSION
110
+ * CONSTRAINT, NOT A PREFERENCE (RV-P1-2). With `kit` AFTER `index`, rungs E2,
111
+ * E4, E5 and E6 are exactly today's four expanded rungs, so every existing
112
+ * width assertion for 48-78 columns keeps its old answer and `^I` never
113
+ * disappears from a row where it used to fit. Putting `^K` first would have cost
114
+ * `^I` everything from 64 to 78 columns - a permanent loss, paid for a
115
+ * low-frequency key.
116
+ */
117
+ function ladderFor(statusExpanded, kitHint) {
99
118
  const branch = {
100
119
  id: 'branch',
101
120
  text: statusExpanded ? BRANCH_CLAUSE.expanded : BRANCH_CLAUSE.collapsed,
@@ -108,8 +127,21 @@ function ladderFor(statusExpanded) {
108
127
  id: 'detail',
109
128
  text: statusExpanded ? DETAIL_CLAUSE.expanded : DETAIL_CLAUSE.collapsed,
110
129
  };
130
+ const base = [
131
+ [branch, index, detail],
132
+ [branch, detail],
133
+ [{ id: 'branch', text: BRANCH_KEY_ONLY }, detail],
134
+ [detail],
135
+ ];
136
+ // THE COLLAPSED LADDER IS UNTOUCHED (D-11): `^K` has no collapsed form, so a
137
+ // user who never presses `ctrl+G` sees the row they always saw (I-9).
138
+ if (!statusExpanded || !kitHint)
139
+ return base;
140
+ const kit = { id: 'kit', text: KIT_CLAUSE.expanded };
111
141
  return [
142
+ [branch, index, kit, detail],
112
143
  [branch, index, detail],
144
+ [branch, kit, detail],
113
145
  [branch, detail],
114
146
  [{ id: 'branch', text: BRANCH_KEY_ONLY }, detail],
115
147
  [detail],
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The status bar's kit chip: pure width planning, no JSX
3
+ * (agent-model-kits §4.8 / D-12).
4
+ *
5
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY A PLANNER, AND WHY IT IS THE SAME SHAPE AS `session-chip.ts`: the status
8
+ * row is measured cell by cell, and anything appended "just to the right" eats
9
+ * `phase` - the one field a user needs while a run is in flight. The chip
10
+ * therefore competes for the SAME budget as every other field, and the rung that
11
+ * survives is computed rather than hand-tuned.
12
+ *
13
+ * ONE COMPARISON, NOT A THRESHOLD TABLE: `columns - (cells + gap) >=
14
+ * minStatusCells`. The width table in the spec is what that comparison WORKS
15
+ * OUT TO. `minStatusCells` is deliberately the same 34 as the session chip's -
16
+ * both answer "how much room must the left half keep", and two numbers for one
17
+ * question is how a "fit" and a "don't fit" end up disagreeing.
18
+ */
19
+ export declare const KIT_CHIP_LIMITS: {
20
+ /** Blank cells between the chip and the field run. */
21
+ readonly gapCells: 2;
22
+ /** The floor the left half must keep; asserted equal to the session chip's. */
23
+ readonly minStatusCells: 34;
24
+ /** Below this the `kit` label is dropped before the name is shortened. */
25
+ readonly labelMinCols: 72;
26
+ /** The shortest unique prefix worth showing; below it the chip hides. */
27
+ readonly minNameChars: 4;
28
+ };
29
+ export interface KitChipPlan {
30
+ visible: boolean;
31
+ text: string;
32
+ cells: number;
33
+ gap: number;
34
+ }
35
+ export interface KitChipInput {
36
+ columns: number;
37
+ /**
38
+ * The kit in effect, or absent/null when there is none.
39
+ *
40
+ * ABSENT IS NOT EMPTY: with no kit the plan is hidden and the row's output is
41
+ * byte-identical to the pre-feature build (I-9).
42
+ */
43
+ kit?: {
44
+ name: string;
45
+ modified: boolean;
46
+ otherNames: readonly string[];
47
+ } | null | undefined;
48
+ }
49
+ /** Plan the chip for `columns` and this session's kit. */
50
+ export declare function planKitChip(input: KitChipInput): KitChipPlan;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The status bar's kit chip: pure width planning, no JSX
3
+ * (agent-model-kits §4.8 / D-12).
4
+ *
5
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * WHY A PLANNER, AND WHY IT IS THE SAME SHAPE AS `session-chip.ts`: the status
8
+ * row is measured cell by cell, and anything appended "just to the right" eats
9
+ * `phase` - the one field a user needs while a run is in flight. The chip
10
+ * therefore competes for the SAME budget as every other field, and the rung that
11
+ * survives is computed rather than hand-tuned.
12
+ *
13
+ * ONE COMPARISON, NOT A THRESHOLD TABLE: `columns - (cells + gap) >=
14
+ * minStatusCells`. The width table in the spec is what that comparison WORKS
15
+ * OUT TO. `minStatusCells` is deliberately the same 34 as the session chip's -
16
+ * both answer "how much room must the left half keep", and two numbers for one
17
+ * question is how a "fit" and a "don't fit" end up disagreeing.
18
+ */
19
+ import stringWidth from 'string-width';
20
+ export const KIT_CHIP_LIMITS = {
21
+ /** Blank cells between the chip and the field run. */
22
+ gapCells: 2,
23
+ /** The floor the left half must keep; asserted equal to the session chip's. */
24
+ minStatusCells: 34,
25
+ /** Below this the `kit` label is dropped before the name is shortened. */
26
+ labelMinCols: 72,
27
+ /** The shortest unique prefix worth showing; below it the chip hides. */
28
+ minNameChars: 4,
29
+ };
30
+ const HIDDEN = { visible: false, text: '', cells: 0, gap: 0 };
31
+ /** The shortest prefix of `name` that no sibling name also starts with. */
32
+ function uniqueLength(name, others) {
33
+ const points = [...name];
34
+ const start = Math.min(KIT_CHIP_LIMITS.minNameChars, points.length);
35
+ const shared = (length) => {
36
+ const prefix = points.slice(0, length).join('');
37
+ return others.some((other) => [...other].slice(0, length).join('') === prefix);
38
+ };
39
+ let length = start;
40
+ while (length < points.length && shared(length))
41
+ length += 1;
42
+ return Math.max(length, start);
43
+ }
44
+ /** Plan the chip for `columns` and this session's kit. */
45
+ export function planKitChip(input) {
46
+ const kit = input.kit;
47
+ if (!kit)
48
+ return HIDDEN;
49
+ const name = kit.name.trim();
50
+ if (!name)
51
+ return HIDDEN;
52
+ const columns = Number.isFinite(input.columns) ? Math.floor(input.columns) : 80;
53
+ const gap = KIT_CHIP_LIMITS.gapCells;
54
+ const fits = (text) => columns - (stringWidth(text) + gap) >= KIT_CHIP_LIMITS.minStatusCells;
55
+ const mark = kit.modified ? '~' : '';
56
+ const plan = (text) => ({ visible: true, text, cells: stringWidth(text), gap });
57
+ // Widest rung first, then drop the label, then shorten the name, then hide.
58
+ // THE ORDER IS THE DECISION: a reader cares WHICH kit is in effect before they
59
+ // care that it is a kit, so the name is the last thing to go.
60
+ //
61
+ // `KIT_CHIP_LIMITS.labelMinCols` NEEDS NO BRANCH: a labelled rung is 45 cells
62
+ // for a 40-character name, so `fits` already rejects it below ~81 columns. The
63
+ // constant is the documented intent (labels belong to wide rows), measured
64
+ // rather than hard-coded into the ladder.
65
+ const points = [...name];
66
+ const shortest = uniqueLength(name, kit.otherNames);
67
+ const rungs = [];
68
+ if (kit.modified)
69
+ rungs.push(`kit ${name}~`);
70
+ rungs.push(`kit ${name}`);
71
+ if (kit.modified)
72
+ rungs.push(`${name}~`);
73
+ rungs.push(name);
74
+ for (let length = points.length - 1; length >= shortest; length -= 1) {
75
+ rungs.push(`${points.slice(0, length).join('')}${mark}`);
76
+ }
77
+ for (const text of rungs)
78
+ if (fits(text))
79
+ return plan(text);
80
+ return HIDDEN;
81
+ }
@@ -38,6 +38,7 @@ import { COMPACTION_LIMITS } from '../../compaction/limits.js';
38
38
  import { GUTTER_WIDTH } from './Gutter.js';
39
39
  import { expandTabs } from '../display-tab.js';
40
40
  import { USER_ENTRY_MAX_ROWS } from '../composer-limits.js';
41
+ import { isLiveTeamCard } from '../team-status.js';
41
42
  export const VIRTUAL_LIMITS = {
42
43
  /**
43
44
  * Entries kept mounted on each side of the visible band.
@@ -315,6 +316,14 @@ export function estimateEntryRows(entry, cols, density, expanded, thinkingVisibl
315
316
  return separation + 1 + shown + 1;
316
317
  }
317
318
  case 'team':
319
+ // A LIVE DISPATCH CARD IS NOT DRAWN AT ALL (tui-subagent-double-roster A-2):
320
+ // the roster is the only live subagent surface, so this entry owes the window
321
+ // ZERO rows. Estimating it at its real height while `EntryView` renders nothing
322
+ // would make `selectWindow` reserve four rows for a card that is not on screen -
323
+ // blank space at the bottom of every dispatch frame, and a window boundary that
324
+ // disagrees with what the printer measures.
325
+ if (isLiveTeamCard(entry))
326
+ return 0;
318
327
  return separation + 1 + entry.runs.length + (expanded ? entry.runs.length * 3 + 1 : 1);
319
328
  case 'todo':
320
329
  return separation + 1 + entry.items.length;
@@ -46,6 +46,12 @@ function allKeyRows(times) {
46
46
  ['Ctrl+R', 'Redraw the frame'],
47
47
  ['Ctrl+T', 'Show/hide thinking (off by default)'],
48
48
  ['Ctrl+I', 'Build the project index (bundled skill; asks first; needs keyboard enhancement)'],
49
+ // THE KIT KEYS (agent-model-kits D-11 / D-15): `Ctrl+K` moved to the picker
50
+ // and `killToEnd` moved to `Alt+K`. BOTH rows are listed, because an existing
51
+ // user will press the old key by muscle memory and needs to find out where it
52
+ // went - and because `^K` is only advertised in the expanded header row.
53
+ ['Ctrl+K', 'Agent kits: pick a preset for this session (see /kit)'],
54
+ ['Alt+K', 'Delete from the cursor to the end of the line (was Ctrl+K)'],
49
55
  ['Ctrl+G', 'Expand/collapse status details; disabled while an overlay is open'],
50
56
  ['PgUp / PgDn', 'Scroll a page (transcript, or this overlay)'],
51
57
  ['Shift+Up / Shift+Down', 'Scroll the transcript a line (full-screen mode)'],
@@ -85,6 +91,7 @@ const COMMANDS = [
85
91
  ['/max-tokens [n|auto]', 'Output token cap; no argument reports the effective one'],
86
92
  ['/team [on|off|max n]', 'Team subagents: status, switch, fan-out width'],
87
93
  ['/fast [on|off|model|review]', 'Fast model tier: status, switch, model, review cadence'],
94
+ ['/kit [list|use <name>]', 'Agent kits: what is in effect, list them, use one for this session'],
88
95
  [
89
96
  '/todo [on|off|panel|follow|clear|continue]',
90
97
  'Todo planning: status, switches, the rail, follow-through',
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Settings > Kit pack > editor (agent-model-kits §4.5).
3
+ *
4
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
5
+ *
6
+ * FOUR ROWS AND TWO ACTIONS, and every slot is an explicit decision (D-8): the
7
+ * Main slot is required, the Fast slot is a two-state choice (`off` is a
8
+ * decision, not an omission), and the Subagent slot reuses the existing
9
+ * `Same as main` / `Fast tier model` vocabulary so the summary sentence the root
10
+ * page already prints (`Fast tier (off - falls back to main)`) needs no second
11
+ * wording. Nothing is written until the ROOT page's Save.
12
+ */
13
+ import React from 'react';
14
+ import type { ModelProfileState } from './model-profile-state.js';
15
+ import type { KitAction, KitDraft } from './kit-settings-state.js';
16
+ import { type ProfileFrameProps } from './ModelProfilePicker.js';
17
+ interface KitEditorProps extends ProfileFrameProps {
18
+ draft: KitDraft;
19
+ state: ModelProfileState;
20
+ dispatch: React.Dispatch<KitAction>;
21
+ }
22
+ export declare function KitEditor(props: KitEditorProps): React.ReactElement;
23
+ export {};
@@ -0,0 +1,101 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * Settings > Kit pack > editor (agent-model-kits §4.5).
4
+ *
5
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
6
+ *
7
+ * FOUR ROWS AND TWO ACTIONS, and every slot is an explicit decision (D-8): the
8
+ * Main slot is required, the Fast slot is a two-state choice (`off` is a
9
+ * decision, not an omission), and the Subagent slot reuses the existing
10
+ * `Same as main` / `Fast tier model` vocabulary so the summary sentence the root
11
+ * page already prints (`Fast tier (off - falls back to main)`) needs no second
12
+ * wording. Nothing is written until the ROOT page's Save.
13
+ */
14
+ import { useState } from 'react';
15
+ import { Text, useInput } from 'ink';
16
+ import { pickGlyphs } from '../glyphs.js';
17
+ import { OverlayFrame } from '../layout/OverlayFrame.js';
18
+ import { stripPasteFrames } from '../paste-frames.js';
19
+ import { stripEnterFrames } from '../enter-frames.js';
20
+ import { clipProfileText, useProfileScroll } from './ModelProfilePicker.js';
21
+ const ROWS = ['name', 'main', 'subagent', 'fast', 'save', 'cancel'];
22
+ function slotLabel(draft, state) {
23
+ const entries = state.draft.profiles?.entries ?? [];
24
+ const name = (id) => {
25
+ const entry = id ? entries.find((item) => item.id === id) : undefined;
26
+ // `Profile missing` rather than a blank: a kit can be pointed at a profile
27
+ // that was removed on disk, and the editor is where that gets fixed.
28
+ return entry ? `${entry.provider}:${entry.model} (${entry.name})` : id ? 'Profile missing'
29
+ : '(not set)';
30
+ };
31
+ const subagent = draft.subagent.model === 'main' ? 'Same as main'
32
+ : draft.subagent.model === 'fast' ? 'Fast tier model'
33
+ : name(draft.subagent.modelProfileId);
34
+ return { main: name(draft.mainProfileId), fast: name(draft.fastProfileId),
35
+ subagent };
36
+ }
37
+ export function KitEditor(props) {
38
+ const glyphs = pickGlyphs(props.caps);
39
+ const { draft, dispatch } = props;
40
+ const [index, setIndex] = useState(0);
41
+ const row = ROWS[index];
42
+ const offset = useProfileScroll(props, index);
43
+ const labels = slotLabel(draft, props.state);
44
+ const filled = [draft.mainProfileId, draft.fastProfileId,
45
+ draft.subagent.model !== 'profile' || draft.subagent.modelProfileId]
46
+ .filter(Boolean).length;
47
+ function activate(target) {
48
+ if (target === 'main')
49
+ dispatch({ type: 'kit-slot', slot: 'main' });
50
+ if (target === 'fast')
51
+ dispatch({ type: 'kit-slot', slot: 'fast' });
52
+ if (target === 'subagent')
53
+ dispatch({ type: 'kit-slot', slot: 'subagent' });
54
+ if (target === 'save')
55
+ dispatch({ type: 'kit-save' });
56
+ if (target === 'cancel')
57
+ dispatch({ type: 'kit-cancel' });
58
+ }
59
+ useInput((input, key) => {
60
+ if (props.isActive === false)
61
+ return;
62
+ if (key.escape) {
63
+ dispatch({ type: 'kit-cancel' });
64
+ return;
65
+ }
66
+ if (key.upArrow || (key.tab && key.shift)) {
67
+ setIndex((index - 1 + ROWS.length) % ROWS.length);
68
+ return;
69
+ }
70
+ if (key.downArrow || key.tab) {
71
+ setIndex((index + 1) % ROWS.length);
72
+ return;
73
+ }
74
+ if (key.return) {
75
+ activate(row);
76
+ return;
77
+ }
78
+ if (row !== 'name' || key.ctrl || key.meta)
79
+ return;
80
+ // THE NAME IS THE ONLY TEXT FIELD, and control characters are stripped by the
81
+ // reducer rather than refused here - a pasted name with a newline should
82
+ // become a name, not an error the user cannot see.
83
+ const text = stripPasteFrames(stripEnterFrames(input));
84
+ if (key.backspace || key.delete) {
85
+ dispatch({ type: 'kit-name', value: [...draft.name].slice(0, -1).join('') });
86
+ return;
87
+ }
88
+ if (text)
89
+ dispatch({ type: 'kit-name', value: `${draft.name}${text}` });
90
+ });
91
+ const lines = [`Name ${draft.name}`,
92
+ `Main profile ${labels.main}`,
93
+ `Subagent ${labels.subagent}`,
94
+ `Fast tier ${draft.fastProfileId ? labels.fast : 'off (this kit turns it off)'}`,
95
+ `Save (${filled} slots set)`, 'Cancel'];
96
+ const rows = lines.map((line, at) => (_jsx(Text, { wrap: "truncate", color: at === index ? props.theme.accent : props.theme.muted, children: `${at === index ? glyphs.caret : ' '} ${clipProfileText(line, props.cols - 8)}` }, at)));
97
+ if (draft.error) {
98
+ rows.push(_jsx(Text, { wrap: "truncate", color: props.theme.noticeError, children: clipProfileText(draft.error, props.cols - 8) }, "error"));
99
+ }
100
+ return _jsx(OverlayFrame, { ...props, title: draft.id ? 'Edit kit' : 'New kit', hint: "Enter choose | type to rename | Esc back", rows: rows, scrollOffset: offset });
101
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Settings > Kit pack: the manager page (agent-model-kits §4.5 / D-9).
3
+ *
4
+ * ASCII ONLY - `src/ui/**` is inside `glyphs.test.ts::inScope`.
5
+ *
6
+ * CRUD LIVES HERE AND NOWHERE ELSE: `New kit` and `e` open the editor, `d` asks
7
+ * a confirmation that NAMES the consequences, `s` runs exit A (three slots plus
8
+ * the persisted pointer, because a default that only moved a pointer would not
9
+ * be in effect at the next launch - RV-P0-3), and `Enter` applies a kit to the
10
+ * transaction draft. Nothing on this page writes the config file.
11
+ */
12
+ import React from 'react';
13
+ import { type ProfileFrameProps } from './ModelProfilePicker.js';
14
+ import type { KitAction, KitManagerRow } from './kit-settings-state.js';
15
+ interface KitManagerProps extends ProfileFrameProps {
16
+ rows: KitManagerRow[];
17
+ error?: string;
18
+ dispatch: React.Dispatch<KitAction>;
19
+ /** Leave the kit pages entirely: `Esc` here means "back to Settings". */
20
+ onBack: () => void;
21
+ }
22
+ export declare function KitManager(props: KitManagerProps): React.ReactElement;
23
+ export {};