claudeup 6.3.2 → 6.5.0
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.
- package/package.json +4 -4
- package/src/__tests__/catalog-notice.test.ts +3 -3
- package/src/__tests__/cli-live.test.ts +9 -2
- package/src/__tests__/cli-update-view.test.ts +2 -2
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/gap-fill-versions.test.ts +24 -24
- package/src/__tests__/gitignore-prerun.test.ts +6 -13
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/manifest.test.ts +134 -0
- package/src/__tests__/marketplace-badge.test.ts +1 -1
- package/src/__tests__/marketplaces.test.ts +0 -1
- package/src/__tests__/model-visuals.test.tsx +793 -0
- package/src/__tests__/models-adapter.test.ts +317 -0
- package/src/__tests__/models-cli.test.ts +173 -0
- package/src/__tests__/models-core.test.ts +640 -0
- package/src/__tests__/models-manager.test.ts +497 -0
- package/src/__tests__/models-screen-state.test.ts +259 -0
- package/src/__tests__/moved-marketplace.test.ts +7 -8
- package/src/__tests__/plugin-contents.test.ts +1 -1
- package/src/__tests__/profile-adopt.test.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +48 -2
- package/src/__tests__/resolver.test.ts +43 -6
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/symlink-manager.test.ts +65 -1
- package/src/__tests__/tabbar-layout.test.ts +40 -2
- package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
- package/src/__tests__/version-snapshot.test.ts +4 -4
- package/src/cli/doctor.ts +90 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/models.ts +214 -0
- package/src/cli/router.ts +12 -0
- package/src/data/gitignore-defaults.ts +4 -1
- package/src/data/gitignore-reasons.ts +0 -4
- package/src/data/marketplaces.ts +1 -15
- package/src/data/models-presets.ts +270 -0
- package/src/data/predefined-profiles.ts +12 -21
- package/src/data/settings-catalog.ts +11 -4
- package/src/main.tsx +51 -82
- package/src/services/hook-registration.ts +218 -0
- package/src/services/manifest.ts +84 -0
- package/src/services/models-core.ts +628 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-manager.ts +2 -3
- package/src/services/profile-materializer.ts +17 -0
- package/src/services/resolver.ts +13 -2
- package/src/services/settings-file.ts +69 -0
- package/src/services/styles-manager.ts +23 -45
- package/src/services/symlink-manager.ts +57 -11
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +21 -0
- package/src/types/index.ts +14 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/modelsAdapter.ts +170 -0
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/TabBar.tsx +9 -4
- package/src/ui/components/layout/FooterHints.tsx +20 -3
- package/src/ui/components/layout/ScreenLayout.tsx +87 -7
- package/src/ui/components/primitives/MetaText.tsx +27 -1
- package/src/ui/renderers/modelRenderers.tsx +1004 -0
- package/src/ui/renderers/modelVisuals.tsx +853 -0
- package/src/ui/renderers/skillRenderers.tsx +13 -3
- package/src/ui/renderers/styleRenderers.tsx +7 -3
- package/src/ui/screens/ModelsScreen.tsx +478 -0
- package/src/ui/screens/PluginsScreen.tsx +1 -1
- package/src/ui/screens/StylesScreen.tsx +8 -13
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +94 -0
- package/src/ui/state/types.ts +65 -2
- package/src/ui/theme-mode.ts +116 -0
- package/src/ui/theme.ts +26 -0
|
@@ -0,0 +1,1004 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { WORKFLOWS } from "../../data/models-presets.js";
|
|
3
|
+
import {
|
|
4
|
+
type Effort,
|
|
5
|
+
GRADES,
|
|
6
|
+
type ModelsConfig,
|
|
7
|
+
type ModelsState,
|
|
8
|
+
type ModelsStatus,
|
|
9
|
+
} from "../../services/models-core.js";
|
|
10
|
+
import type {
|
|
11
|
+
ModelsBrowserItem,
|
|
12
|
+
ModelsPresetItem,
|
|
13
|
+
ModelsStatusItem,
|
|
14
|
+
} from "../adapters/modelsAdapter.js";
|
|
15
|
+
import {
|
|
16
|
+
DetailSection,
|
|
17
|
+
KeyValueLine,
|
|
18
|
+
ListCategoryRow,
|
|
19
|
+
MetaText,
|
|
20
|
+
SelectableRow,
|
|
21
|
+
} from "../components/primitives/index.js";
|
|
22
|
+
import { effortInk, trackFill } from "../theme-mode.js";
|
|
23
|
+
import { type UiColor, theme } from "../theme.js";
|
|
24
|
+
import {
|
|
25
|
+
type AgentRow,
|
|
26
|
+
type AgentTableLayout,
|
|
27
|
+
EffortGlyph,
|
|
28
|
+
ModelBadge,
|
|
29
|
+
ModelText,
|
|
30
|
+
NONE_CELL,
|
|
31
|
+
PRESET_MARKERS,
|
|
32
|
+
type PresetRowInput,
|
|
33
|
+
type PresetRowLayout,
|
|
34
|
+
agentDistribution,
|
|
35
|
+
agentRows,
|
|
36
|
+
agentTableHeaders,
|
|
37
|
+
effortLabel,
|
|
38
|
+
effortWord,
|
|
39
|
+
fitAgentTable,
|
|
40
|
+
fitPresetRows,
|
|
41
|
+
modelBadgeWidth,
|
|
42
|
+
modelBarFill,
|
|
43
|
+
modelPad,
|
|
44
|
+
modelWidth,
|
|
45
|
+
modelsInUse,
|
|
46
|
+
padCell,
|
|
47
|
+
presetMarkerWidth,
|
|
48
|
+
spendRuns,
|
|
49
|
+
workflowDistribution,
|
|
50
|
+
} from "./modelVisuals.js";
|
|
51
|
+
import { wrapText } from "./styleRenderers.js";
|
|
52
|
+
|
|
53
|
+
// ─── State vocabulary ─────────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One sentence per state, and each says what is HAPPENING, not what the field
|
|
57
|
+
* is called. "unhooked" on its own is a word nobody can act on; "the config is
|
|
58
|
+
* here, but no hook runs it" names the missing piece.
|
|
59
|
+
*
|
|
60
|
+
* The state WORD is not repeated here — it is drawn as a badge beside the
|
|
61
|
+
* sentence, which is the shape a discrete status wants and which also keeps an
|
|
62
|
+
* ambiguous-width glyph out of a line whose width has to be exact.
|
|
63
|
+
*/
|
|
64
|
+
const STATE_HEADLINE: Record<ModelsState, string> = {
|
|
65
|
+
off: "no .claude/models.json, so every subagent inherits the session model",
|
|
66
|
+
on: "every Agent call is routed through this config",
|
|
67
|
+
stale: "settings no longer match the config, so part of it is not in force",
|
|
68
|
+
invalid: "the config does not validate, and nothing is routed",
|
|
69
|
+
unhooked: "the config is here, but no hook runs it, so nothing is routed",
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const STATE_TONE: Record<ModelsState, UiColor> = {
|
|
73
|
+
off: theme.colors.muted,
|
|
74
|
+
on: theme.colors.success,
|
|
75
|
+
stale: theme.colors.warning,
|
|
76
|
+
invalid: theme.colors.danger,
|
|
77
|
+
unhooked: theme.colors.danger,
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Badge tone for the header row. `unhooked` is red with `invalid`: both route nothing. */
|
|
81
|
+
const STATE_CATEGORY: Record<ModelsState, "gray" | "green" | "yellow" | "red"> =
|
|
82
|
+
{
|
|
83
|
+
off: "gray",
|
|
84
|
+
on: "green",
|
|
85
|
+
stale: "yellow",
|
|
86
|
+
invalid: "red",
|
|
87
|
+
unhooked: "red",
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* How many validation errors to draw before summarising the rest.
|
|
92
|
+
*
|
|
93
|
+
* MEASURED on a real config written before the tier rename: 23 errors, each wrapping to two
|
|
94
|
+
* lines, which filled the whole detail panel and pushed the one useful line — what to press
|
|
95
|
+
* — off the bottom of the screen. Every error after the first few says the same thing about
|
|
96
|
+
* a different key, so the list stops being information and becomes a wall.
|
|
97
|
+
*/
|
|
98
|
+
const MAX_DRIFT_LINES = 6;
|
|
99
|
+
|
|
100
|
+
/** What the user should press next, per state. Empty when there is nothing to fix. */
|
|
101
|
+
const STATE_FIX: Record<ModelsState, string> = {
|
|
102
|
+
off: "Pick a preset below and press a to start routing.",
|
|
103
|
+
on: "",
|
|
104
|
+
stale: "Press a on the active preset to write the settings again.",
|
|
105
|
+
invalid:
|
|
106
|
+
"Fix .claude/models.json by hand, or press a on a preset to overwrite it.",
|
|
107
|
+
unhooked: "Press a on a preset — applying one registers the hook.",
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A badge as a `<span>`, for a row that also carries text.
|
|
114
|
+
*
|
|
115
|
+
* The ONLY filled block this screen still paints outside the selection and the
|
|
116
|
+
* distribution bar — a discrete status is exactly what a fill is for, and there
|
|
117
|
+
* is one of them on screen rather than seventeen.
|
|
118
|
+
*
|
|
119
|
+
* Only ever called from inside a `<text>`. A `<span>` with no `<text>` parent
|
|
120
|
+
* swaps the entire UI for an error page and still exits 0, so this is a rule
|
|
121
|
+
* the type system cannot enforce and a test cannot see.
|
|
122
|
+
*/
|
|
123
|
+
function Chip({
|
|
124
|
+
label,
|
|
125
|
+
tone,
|
|
126
|
+
}: {
|
|
127
|
+
label: string;
|
|
128
|
+
tone: keyof typeof theme.category;
|
|
129
|
+
}): React.ReactNode {
|
|
130
|
+
const colors = theme.category[tone];
|
|
131
|
+
return (
|
|
132
|
+
<span bg={colors.bg} fg={colors.fg}>
|
|
133
|
+
<strong>{` ${label} `}</strong>
|
|
134
|
+
</span>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A paragraph, pre-wrapped to the panel.
|
|
140
|
+
*
|
|
141
|
+
* OpenTUI folds a long line rather than dropping it, but it folds at the box's
|
|
142
|
+
* width with no regard for words — so pre-wrapping is what keeps a sentence
|
|
143
|
+
* breaking between words instead of mid-token.
|
|
144
|
+
*/
|
|
145
|
+
function Wrapped({
|
|
146
|
+
text,
|
|
147
|
+
width,
|
|
148
|
+
tone,
|
|
149
|
+
}: {
|
|
150
|
+
text: string;
|
|
151
|
+
width?: number;
|
|
152
|
+
/** `UiColor`, not `string`: the terminal's own foreground is an RGBA. */
|
|
153
|
+
tone?: UiColor;
|
|
154
|
+
}): React.ReactNode {
|
|
155
|
+
return (
|
|
156
|
+
<box flexDirection="column">
|
|
157
|
+
{wrapText(text, width ?? 48).map((line, index) => (
|
|
158
|
+
<text
|
|
159
|
+
key={`${index}:${line.slice(0, 12)}`}
|
|
160
|
+
fg={tone ?? theme.colors.muted}
|
|
161
|
+
>
|
|
162
|
+
{line || " "}
|
|
163
|
+
</text>
|
|
164
|
+
))}
|
|
165
|
+
</box>
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The state block: what is routed, what drifted, and what to press.
|
|
171
|
+
*
|
|
172
|
+
* Rendered above the preset on EVERY row, not only on the header. The question
|
|
173
|
+
* a reader has while looking at a preset is "is this the one that is running",
|
|
174
|
+
* and answering it one row away means switching rows to find out.
|
|
175
|
+
*/
|
|
176
|
+
function StateBlock({
|
|
177
|
+
status,
|
|
178
|
+
width,
|
|
179
|
+
}: {
|
|
180
|
+
status: ModelsStatus | null;
|
|
181
|
+
width?: number;
|
|
182
|
+
}): React.ReactNode {
|
|
183
|
+
if (!status) {
|
|
184
|
+
return <text fg={theme.colors.muted}>Reading the routing…</text>;
|
|
185
|
+
}
|
|
186
|
+
const fix = STATE_FIX[status.state];
|
|
187
|
+
const isInvalid = status.state === "invalid";
|
|
188
|
+
return (
|
|
189
|
+
<box flexDirection="column">
|
|
190
|
+
<text fg={theme.colors.text}>
|
|
191
|
+
<Chip label={status.state} tone={STATE_CATEGORY[status.state]} />
|
|
192
|
+
</text>
|
|
193
|
+
<Wrapped
|
|
194
|
+
text={STATE_HEADLINE[status.state]}
|
|
195
|
+
width={width}
|
|
196
|
+
tone={STATE_TONE[status.state]}
|
|
197
|
+
/>
|
|
198
|
+
{status.drift.length > 0 ? (
|
|
199
|
+
<box flexDirection="column" marginTop={1}>
|
|
200
|
+
<text fg={theme.colors.text}>
|
|
201
|
+
<Chip
|
|
202
|
+
label={`${isInvalid ? "errors" : "drift"} ${status.drift.length}`}
|
|
203
|
+
tone={isInvalid ? "red" : "yellow"}
|
|
204
|
+
/>
|
|
205
|
+
</text>
|
|
206
|
+
{status.drift.slice(0, MAX_DRIFT_LINES).map((line) => (
|
|
207
|
+
<Wrapped
|
|
208
|
+
key={line}
|
|
209
|
+
text={line}
|
|
210
|
+
width={width}
|
|
211
|
+
tone={isInvalid ? theme.colors.danger : theme.colors.warning}
|
|
212
|
+
/>
|
|
213
|
+
))}
|
|
214
|
+
{status.drift.length > MAX_DRIFT_LINES ? (
|
|
215
|
+
// `Wrapped`, not `MetaText`: MetaText returns a bare <span>, and a <span>
|
|
216
|
+
// with no <text> parent swaps the whole UI for an error page while still
|
|
217
|
+
// exiting 0. Typecheck and every unit test pass; only running it shows it.
|
|
218
|
+
<Wrapped
|
|
219
|
+
text={`…and ${status.drift.length - MAX_DRIFT_LINES} more — run claudeup models status`}
|
|
220
|
+
width={width}
|
|
221
|
+
/>
|
|
222
|
+
) : null}
|
|
223
|
+
</box>
|
|
224
|
+
) : null}
|
|
225
|
+
{status.warnings.length > 0 ? (
|
|
226
|
+
<box flexDirection="column" marginTop={1}>
|
|
227
|
+
<text fg={theme.colors.text}>
|
|
228
|
+
<Chip label={`warnings ${status.warnings.length}`} tone="yellow" />
|
|
229
|
+
</text>
|
|
230
|
+
{status.warnings.map((line) => (
|
|
231
|
+
<Wrapped
|
|
232
|
+
key={line}
|
|
233
|
+
text={line}
|
|
234
|
+
width={width}
|
|
235
|
+
tone={theme.colors.warning}
|
|
236
|
+
/>
|
|
237
|
+
))}
|
|
238
|
+
</box>
|
|
239
|
+
) : null}
|
|
240
|
+
{fix ? (
|
|
241
|
+
<box marginTop={1}>
|
|
242
|
+
<Wrapped text={fix} width={width} />
|
|
243
|
+
</box>
|
|
244
|
+
) : null}
|
|
245
|
+
</box>
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ─── The grade table ──────────────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/** Gutter for the row label, matching `KeyValueLine` so the columns line up. */
|
|
252
|
+
const SPEC_LABEL_WIDTH = 10;
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The widest model column in a config, so every name in the table ends at the
|
|
256
|
+
* same column and the effort glyphs beside them form a straight edge.
|
|
257
|
+
*/
|
|
258
|
+
function specModelColumn(config: ModelsConfig): number {
|
|
259
|
+
return Math.max(
|
|
260
|
+
modelWidth(config.main.model),
|
|
261
|
+
...GRADES.map((grade) => modelWidth(config.grades[grade].model)),
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* One line of the grade table: `critical fable ▇ xhigh`.
|
|
267
|
+
*
|
|
268
|
+
* Effort is a bounded ordinal — four named levels, ordered — so it gets a ramp
|
|
269
|
+
* glyph and the word, rather than being parenthesised after the model name
|
|
270
|
+
* where its ordering is invisible. The glyph takes the MODEL's hue, so shape
|
|
271
|
+
* carries the level and colour still carries only one thing.
|
|
272
|
+
*/
|
|
273
|
+
function SpecRow({
|
|
274
|
+
label,
|
|
275
|
+
model,
|
|
276
|
+
effort,
|
|
277
|
+
modelColumn,
|
|
278
|
+
highlight,
|
|
279
|
+
}: {
|
|
280
|
+
label: string;
|
|
281
|
+
model: string;
|
|
282
|
+
effort: Effort | undefined;
|
|
283
|
+
modelColumn: number;
|
|
284
|
+
/** The grade an unassigned agent falls to — worth reading first. */
|
|
285
|
+
highlight?: boolean;
|
|
286
|
+
}): React.ReactNode {
|
|
287
|
+
return (
|
|
288
|
+
<text fg={theme.colors.text}>
|
|
289
|
+
<span fg={highlight ? theme.colors.text : theme.colors.muted}>
|
|
290
|
+
{`${label} `.padEnd(SPEC_LABEL_WIDTH)}
|
|
291
|
+
</span>
|
|
292
|
+
{/* A CHIP here, text in the agent table below. Four of them read as a key;
|
|
293
|
+
seventeen read as a solid column, which is what this screen was reported for. */}
|
|
294
|
+
<ModelBadge model={model} />
|
|
295
|
+
<span
|
|
296
|
+
fg={theme.colors.border}
|
|
297
|
+
>{`${modelPad(model, modelColumn)} `}</span>
|
|
298
|
+
<EffortGlyph effort={effort} model={model} />
|
|
299
|
+
<span
|
|
300
|
+
fg={effort ? (effortInk(effort) as UiColor) : theme.colors.muted}
|
|
301
|
+
>{` ${effortWord(effort)}`}</span>
|
|
302
|
+
</text>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ─── Agent table ──────────────────────────────────────────────────────────────
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Every agent, one row, four aligned columns — and NOT ONE FILLED BLOCK.
|
|
310
|
+
*
|
|
311
|
+
* This replaced a paragraph of grouped names with a "+4 more" on the end. The
|
|
312
|
+
* question a reader brings is "what does MY agent get", and a sampled list
|
|
313
|
+
* cannot answer it; the detail pane scrolls (`^U/^D`, `PgUp/PgDn`), so the only
|
|
314
|
+
* real constraint is width, and `fitAgentTable` spends that in a fixed order
|
|
315
|
+
* rather than wrapping a cell.
|
|
316
|
+
*
|
|
317
|
+
* The model was a filled chip and the effort a filled four-cell meter. MEASURED
|
|
318
|
+
* on both a light and a dark terminal, seventeen such rows stacked into two
|
|
319
|
+
* solid vertical colour columns — two painted bars the height of the table,
|
|
320
|
+
* which read as slabs rather than as data. Coloured TEXT plus one ramp glyph
|
|
321
|
+
* says the same thing and leaves a table you can scan a column of.
|
|
322
|
+
*
|
|
323
|
+
* Colour stays on the model: the name and the glyph carry it, the agent name
|
|
324
|
+
* does not. A name is an identifier — colouring it made six agent names six
|
|
325
|
+
* different hues, with red (which means danger everywhere else here) landing on
|
|
326
|
+
* `dev:debugger` for no reason at all.
|
|
327
|
+
*/
|
|
328
|
+
function AgentTable({
|
|
329
|
+
config,
|
|
330
|
+
width,
|
|
331
|
+
}: {
|
|
332
|
+
config: ModelsConfig;
|
|
333
|
+
width: number;
|
|
334
|
+
}): React.ReactNode {
|
|
335
|
+
const rows = agentRows(config);
|
|
336
|
+
if (rows.length === 0) return null;
|
|
337
|
+
const layout = fitAgentTable(rows, width);
|
|
338
|
+
const headers = agentTableHeaders(layout);
|
|
339
|
+
|
|
340
|
+
return (
|
|
341
|
+
<box flexDirection="column">
|
|
342
|
+
<text fg={theme.colors.muted}>
|
|
343
|
+
{headers.map((cell, index) => (
|
|
344
|
+
<span key={cell.key} fg={theme.colors.muted}>
|
|
345
|
+
{index > 0 ? ` ${cell.text}` : cell.text}
|
|
346
|
+
</span>
|
|
347
|
+
))}
|
|
348
|
+
</text>
|
|
349
|
+
{rows.map((row) => (
|
|
350
|
+
<AgentTableRow key={row.name} row={row} layout={layout} />
|
|
351
|
+
))}
|
|
352
|
+
</box>
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function AgentTableRow({
|
|
357
|
+
row,
|
|
358
|
+
layout,
|
|
359
|
+
}: {
|
|
360
|
+
row: AgentRow;
|
|
361
|
+
layout: AgentTableLayout;
|
|
362
|
+
}): React.ReactNode {
|
|
363
|
+
const label = effortLabel(row.effort, layout.effort);
|
|
364
|
+
return (
|
|
365
|
+
<text fg={theme.colors.text}>
|
|
366
|
+
<span fg={row.inherit ? theme.colors.dim : theme.colors.text}>
|
|
367
|
+
{padCell(row.name, layout.name)}
|
|
368
|
+
</span>
|
|
369
|
+
<span fg={theme.colors.border}> </span>
|
|
370
|
+
{layout.grade > 0 ? (
|
|
371
|
+
<span fg={theme.colors.muted}>{padCell(row.key, layout.grade)}</span>
|
|
372
|
+
) : null}
|
|
373
|
+
{layout.grade > 0 ? <span fg={theme.colors.border}> </span> : null}
|
|
374
|
+
{row.inherit ? (
|
|
375
|
+
<span fg={theme.colors.dim}>{padCell(NONE_CELL, layout.model)}</span>
|
|
376
|
+
) : (
|
|
377
|
+
<ModelText model={row.model} />
|
|
378
|
+
)}
|
|
379
|
+
{row.inherit ? null : (
|
|
380
|
+
<span fg={theme.colors.border}>
|
|
381
|
+
{modelPad(row.model, layout.model)}
|
|
382
|
+
</span>
|
|
383
|
+
)}
|
|
384
|
+
<span fg={theme.colors.border}> </span>
|
|
385
|
+
{row.inherit ? (
|
|
386
|
+
<span fg={theme.colors.dim}>{NONE_CELL}</span>
|
|
387
|
+
) : (
|
|
388
|
+
<EffortGlyph effort={row.effort} model={row.model} />
|
|
389
|
+
)}
|
|
390
|
+
{/* The word takes the SAME ink as the dots beside it. Dim grey made the pair read
|
|
391
|
+
as two things — a coloured meter and an unrelated caption — when it is one
|
|
392
|
+
reading with its value spelt out. */}
|
|
393
|
+
{!row.inherit && label ? (
|
|
394
|
+
<span
|
|
395
|
+
fg={effortInk(row.effort ?? "low") as UiColor}
|
|
396
|
+
>{` ${label}`}</span>
|
|
397
|
+
) : null}
|
|
398
|
+
</text>
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* How the routed agents are spread, as one bar and a legend.
|
|
404
|
+
*
|
|
405
|
+
* A category distribution, so a stacked bar rather than the list of counts it
|
|
406
|
+
* would otherwise be — and now the ONLY graphic on the screen, which is what
|
|
407
|
+
* lets it be saturated without competing with anything. It lives UNDER THE
|
|
408
|
+
* PRESET LIST, not in the detail pane, because a distribution is most useful
|
|
409
|
+
* exactly where you are choosing between presets. It follows the cursor, so
|
|
410
|
+
* moving between presets redraws it.
|
|
411
|
+
*/
|
|
412
|
+
export function AgentSpread({
|
|
413
|
+
config,
|
|
414
|
+
width,
|
|
415
|
+
}: {
|
|
416
|
+
config: ModelsConfig;
|
|
417
|
+
width: number;
|
|
418
|
+
}): React.ReactNode {
|
|
419
|
+
// One bar per WORKFLOW, not one bar for every agent that exists.
|
|
420
|
+
//
|
|
421
|
+
// Nobody runs all seventeen agents; they run `/dev:dev`, `/dev:debug` or
|
|
422
|
+
// `/dev:investigate`, and each reaches for a different handful. A single lump-sum bar
|
|
423
|
+
// answered a question nobody asks and hid the one they do: what does THIS command cost
|
|
424
|
+
// under THIS preset.
|
|
425
|
+
const label = Math.max(...WORKFLOWS.map((w) => w.name.length)) + 1;
|
|
426
|
+
const cells = Math.max(8, width - label);
|
|
427
|
+
// Where the right half starts, so the two words sit under the halves they name. Mirrors
|
|
428
|
+
// the split in `spendRuns`, which would drift if either recomputed it differently.
|
|
429
|
+
const leftCells = Math.floor((cells - 1) / 2);
|
|
430
|
+
|
|
431
|
+
return (
|
|
432
|
+
<box flexDirection="column" marginTop={1}>
|
|
433
|
+
<text fg={theme.colors.muted}>workflow</text>
|
|
434
|
+
{WORKFLOWS.map((workflow) => {
|
|
435
|
+
const segments = workflowDistribution(config, workflow.agents);
|
|
436
|
+
const runs = spendRuns(config, segments, cells);
|
|
437
|
+
return (
|
|
438
|
+
<text key={workflow.name} fg={theme.colors.text}>
|
|
439
|
+
<span fg={theme.colors.muted}>{padCell(workflow.name, label)}</span>
|
|
440
|
+
{runs.map((run) =>
|
|
441
|
+
run.kind === "track" ? (
|
|
442
|
+
<span key={run.id} fg={trackFill()}>
|
|
443
|
+
{"░".repeat(run.cells)}
|
|
444
|
+
</span>
|
|
445
|
+
) : (
|
|
446
|
+
<span key={run.id} fg={run.colour}>
|
|
447
|
+
{"█".repeat(run.cells)}
|
|
448
|
+
</span>
|
|
449
|
+
),
|
|
450
|
+
)}
|
|
451
|
+
</text>
|
|
452
|
+
);
|
|
453
|
+
})}
|
|
454
|
+
{/*
|
|
455
|
+
* TWO WORDS, one per half — not a key.
|
|
456
|
+
*
|
|
457
|
+
* This line used to read `main opus · smart fable 5 · default sonnet 2 · cheap
|
|
458
|
+
* fable 8`, which restated the tier table sitting three columns to the right,
|
|
459
|
+
* wrapped to two lines at a normal pane width, and buried the one thing the bar
|
|
460
|
+
* is for: this half is the thread, that half is everything it dispatches. The
|
|
461
|
+
* colours already say which model a run is; the table says which model a tier is.
|
|
462
|
+
*/}
|
|
463
|
+
<text fg={theme.colors.muted}>
|
|
464
|
+
<span>{" ".repeat(label)}</span>
|
|
465
|
+
<span fg={modelBarFill(config.main.model)}>
|
|
466
|
+
{padCell("main", leftCells + 1)}
|
|
467
|
+
</span>
|
|
468
|
+
<span>subagents</span>
|
|
469
|
+
</text>
|
|
470
|
+
</box>
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ─── Status renderer ──────────────────────────────────────────────────────────
|
|
475
|
+
|
|
476
|
+
/**
|
|
477
|
+
* Deliberately NOT an `ItemRenderer`.
|
|
478
|
+
*
|
|
479
|
+
* Its detail needs the drift lines and warnings, which the item does not carry —
|
|
480
|
+
* they belong to the project rather than to a row, and copying them onto the
|
|
481
|
+
* item is how the two would end up disagreeing. So the row reads the item and
|
|
482
|
+
* the detail reads the status, and neither pretends to be the other.
|
|
483
|
+
*/
|
|
484
|
+
function renderStatusRow(
|
|
485
|
+
item: ModelsStatusItem,
|
|
486
|
+
isSelected: boolean,
|
|
487
|
+
): React.ReactNode {
|
|
488
|
+
const parts = [
|
|
489
|
+
item.preset,
|
|
490
|
+
item.driftCount > 0 ? `${item.driftCount} drift` : null,
|
|
491
|
+
item.warningCount > 0
|
|
492
|
+
? `${item.warningCount} warning${item.warningCount === 1 ? "" : "s"}`
|
|
493
|
+
: null,
|
|
494
|
+
].filter((part): part is string => part !== null);
|
|
495
|
+
|
|
496
|
+
return (
|
|
497
|
+
<ListCategoryRow
|
|
498
|
+
selected={isSelected}
|
|
499
|
+
title={`Model tiers: ${item.state}`}
|
|
500
|
+
badge={parts.length > 0 ? parts.join(" · ") : undefined}
|
|
501
|
+
tone={STATE_CATEGORY[item.state]}
|
|
502
|
+
// A section label, not a collapsible group — the cursor skips it, so a
|
|
503
|
+
// ▶ would advertise an interaction that is not there.
|
|
504
|
+
expandable={false}
|
|
505
|
+
/>
|
|
506
|
+
);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function renderStatusDetail(
|
|
510
|
+
status: ModelsStatus | null,
|
|
511
|
+
width?: number,
|
|
512
|
+
): React.ReactNode {
|
|
513
|
+
return (
|
|
514
|
+
<box flexDirection="column">
|
|
515
|
+
<text fg={theme.colors.accent}>
|
|
516
|
+
<strong>Model tiers</strong>
|
|
517
|
+
</text>
|
|
518
|
+
<box marginTop={1}>
|
|
519
|
+
<StateBlock status={status} width={width} />
|
|
520
|
+
</box>
|
|
521
|
+
<box marginTop={1}>
|
|
522
|
+
<Wrapped
|
|
523
|
+
width={width}
|
|
524
|
+
text="Tiers say how much model you are spending: smart is the strongest, and deliberately not the orchestrator's. Move down to a preset to see what runs where."
|
|
525
|
+
/>
|
|
526
|
+
</box>
|
|
527
|
+
</box>
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// ─── Preset rows: ONE layout for the whole list ───────────────────────────────
|
|
532
|
+
|
|
533
|
+
/** A preset row reduced to what the column maths needs. */
|
|
534
|
+
function presetRowInput(item: ModelsPresetItem): PresetRowInput {
|
|
535
|
+
return {
|
|
536
|
+
label: item.label,
|
|
537
|
+
main: item.config.main.model,
|
|
538
|
+
smart: item.config.grades.smart.model,
|
|
539
|
+
markers: presetMarkerWidth(item.isDefault, item.custom),
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* The list's shared column layout, measured ONCE from every row.
|
|
545
|
+
*
|
|
546
|
+
* The screen computes this and hands the same object to every row. Rows used to
|
|
547
|
+
* fit themselves individually, and on a 120×30 terminal that meant the selected
|
|
548
|
+
* row dropped its `main` / `critical` labels while its three neighbours kept
|
|
549
|
+
* theirs — the same field labelled on some rows and not others, with nothing
|
|
550
|
+
* lining up down the column.
|
|
551
|
+
*/
|
|
552
|
+
export function fitPresetList(
|
|
553
|
+
items: ModelsBrowserItem[],
|
|
554
|
+
width: number,
|
|
555
|
+
): PresetRowLayout {
|
|
556
|
+
const rows = items
|
|
557
|
+
.filter((item): item is ModelsPresetItem => item.kind === "preset")
|
|
558
|
+
.map(presetRowInput);
|
|
559
|
+
return fitPresetRows(rows, width);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* The two models a row summarises, as spans for ONE `<text>`.
|
|
564
|
+
*
|
|
565
|
+
* Coloured text rather than two filled chips: a chip is a painted block, and
|
|
566
|
+
* two per row down a four-row list is two painted columns. The word already
|
|
567
|
+
* names the model; the hue only has to tie it to the bar below.
|
|
568
|
+
*/
|
|
569
|
+
function summarySpans(
|
|
570
|
+
config: ModelsConfig,
|
|
571
|
+
layout: PresetRowLayout,
|
|
572
|
+
selected: boolean,
|
|
573
|
+
): React.ReactNode[] {
|
|
574
|
+
if (layout.detail === "none") return [];
|
|
575
|
+
const label = selected ? theme.selection.dim : theme.colors.muted;
|
|
576
|
+
const rule = selected ? theme.selection.dim : theme.colors.border;
|
|
577
|
+
const main = config.main.model;
|
|
578
|
+
const smart = config.grades.smart.model;
|
|
579
|
+
|
|
580
|
+
const parts: React.ReactNode[] = [
|
|
581
|
+
<span key="gap" fg={rule}>
|
|
582
|
+
{" "}
|
|
583
|
+
</span>,
|
|
584
|
+
];
|
|
585
|
+
if (layout.detail === "labelled") {
|
|
586
|
+
parts.push(
|
|
587
|
+
<span key="main-label" fg={label}>
|
|
588
|
+
{"main "}
|
|
589
|
+
</span>,
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
parts.push(<ModelText key="main" model={main} selected={selected} />);
|
|
593
|
+
parts.push(
|
|
594
|
+
<span key="main-pad" fg={rule}>
|
|
595
|
+
{modelPad(main, layout.main)}
|
|
596
|
+
</span>,
|
|
597
|
+
);
|
|
598
|
+
// The narrowest rung that still says anything: one model, no separator to
|
|
599
|
+
// dangle after it.
|
|
600
|
+
if (layout.detail === "main") return parts;
|
|
601
|
+
parts.push(
|
|
602
|
+
<span key="sep" fg={rule}>
|
|
603
|
+
{" · "}
|
|
604
|
+
</span>,
|
|
605
|
+
);
|
|
606
|
+
if (layout.detail === "labelled" || layout.detail === "smart") {
|
|
607
|
+
parts.push(
|
|
608
|
+
<span key="critical-label" fg={label}>
|
|
609
|
+
{"smart "}
|
|
610
|
+
</span>,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
parts.push(<ModelText key="smart" model={smart} selected={selected} />);
|
|
614
|
+
parts.push(
|
|
615
|
+
<span key="smart-pad" fg={rule}>
|
|
616
|
+
{modelPad(smart, layout.smart)}
|
|
617
|
+
</span>,
|
|
618
|
+
);
|
|
619
|
+
return parts;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// ─── Preset renderer ──────────────────────────────────────────────────────────
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* One preset row, drawn against the layout the whole list shares.
|
|
626
|
+
*
|
|
627
|
+
* Deliberately NOT an `ItemRenderer.renderRow`: that signature takes a width
|
|
628
|
+
* and nothing else, so a renderer wearing it can only fit itself — which is
|
|
629
|
+
* precisely the defect this row was reported for. The layout is an argument.
|
|
630
|
+
*/
|
|
631
|
+
function renderPresetRow(
|
|
632
|
+
item: ModelsPresetItem,
|
|
633
|
+
isSelected: boolean,
|
|
634
|
+
layout: PresetRowLayout,
|
|
635
|
+
): React.ReactNode {
|
|
636
|
+
// A NAME and its markers, nothing else.
|
|
637
|
+
//
|
|
638
|
+
// The row used to carry `[●]` plus `opus · smart fable` — a checkbox and a two-model
|
|
639
|
+
// summary of what the detail pane is already showing in full, three lines to the right.
|
|
640
|
+
// Repeating it made the list the same width as the detail and twice as busy, and the
|
|
641
|
+
// checkbox competed with the cursor for "which one is this". The cursor marks where you
|
|
642
|
+
// are; `●` marks what is running; the detail says what it does.
|
|
643
|
+
return (
|
|
644
|
+
<SelectableRow selected={isSelected} indent={1}>
|
|
645
|
+
<span
|
|
646
|
+
fg={isSelected ? theme.selection.fg : theme.colors.success}
|
|
647
|
+
>{`${item.active ? "●" : " "} `}</span>
|
|
648
|
+
<span fg={isSelected ? theme.selection.fg : theme.colors.text}>
|
|
649
|
+
{padCell(item.label, layout.name)}
|
|
650
|
+
</span>
|
|
651
|
+
{item.isDefault ? (
|
|
652
|
+
<MetaText text={PRESET_MARKERS.default} selected={isSelected} />
|
|
653
|
+
) : null}
|
|
654
|
+
{item.custom ? (
|
|
655
|
+
<MetaText
|
|
656
|
+
text={PRESET_MARKERS.custom}
|
|
657
|
+
tone="warning"
|
|
658
|
+
selected={isSelected}
|
|
659
|
+
/>
|
|
660
|
+
) : null}
|
|
661
|
+
</SelectableRow>
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function renderPresetDetail(
|
|
666
|
+
item: ModelsPresetItem,
|
|
667
|
+
width?: number,
|
|
668
|
+
): React.ReactNode {
|
|
669
|
+
const { config } = item;
|
|
670
|
+
const modelColumn = specModelColumn(config);
|
|
671
|
+
const barWidth = Math.max(8, width ?? 48);
|
|
672
|
+
|
|
673
|
+
return (
|
|
674
|
+
<box flexDirection="column">
|
|
675
|
+
<text fg={theme.colors.accent}>
|
|
676
|
+
<strong>{item.label}</strong>
|
|
677
|
+
</text>
|
|
678
|
+
|
|
679
|
+
{/*
|
|
680
|
+
* `Active` / `Default` / `Source` used to be three key-value lines here, and they
|
|
681
|
+
* are gone. Every one of them is already answered a few characters to the left:
|
|
682
|
+
* the active preset carries `●` in the list, the default carries `(default)`, and
|
|
683
|
+
* a hand-edited config carries its own marker. Restating them as sentences pushed
|
|
684
|
+
* the tier table — the thing you opened this pane to read — below the fold.
|
|
685
|
+
*
|
|
686
|
+
* `item.custom` keeps its warning, because THAT one is not on the row: it changes
|
|
687
|
+
* what applying does.
|
|
688
|
+
*/}
|
|
689
|
+
{item.custom ? (
|
|
690
|
+
<box marginTop={1}>
|
|
691
|
+
<Wrapped
|
|
692
|
+
width={width}
|
|
693
|
+
tone={theme.colors.warning}
|
|
694
|
+
text="This project's own config, not a built-in — applying re-writes the settings it implies."
|
|
695
|
+
/>
|
|
696
|
+
</box>
|
|
697
|
+
) : null}
|
|
698
|
+
|
|
699
|
+
<DetailSection title="Models">
|
|
700
|
+
<box flexDirection="column">
|
|
701
|
+
<text fg={theme.colors.muted}>
|
|
702
|
+
{`${padCell("tier", SPEC_LABEL_WIDTH)}${padCell("model", modelColumn + 4)}effort`}
|
|
703
|
+
</text>
|
|
704
|
+
<SpecRow
|
|
705
|
+
label="main"
|
|
706
|
+
model={config.main.model}
|
|
707
|
+
effort={config.main.effort}
|
|
708
|
+
modelColumn={modelColumn}
|
|
709
|
+
highlight
|
|
710
|
+
/>
|
|
711
|
+
{GRADES.map((grade) => (
|
|
712
|
+
<SpecRow
|
|
713
|
+
key={grade}
|
|
714
|
+
label={grade}
|
|
715
|
+
model={config.grades[grade].model}
|
|
716
|
+
effort={config.grades[grade].effort}
|
|
717
|
+
modelColumn={modelColumn}
|
|
718
|
+
highlight={grade === config.fallback}
|
|
719
|
+
/>
|
|
720
|
+
))}
|
|
721
|
+
</box>
|
|
722
|
+
</DetailSection>
|
|
723
|
+
|
|
724
|
+
<DetailSection title="Agents">
|
|
725
|
+
<box flexDirection="column">
|
|
726
|
+
<AgentTable config={config} width={barWidth} />
|
|
727
|
+
<box marginTop={1}>
|
|
728
|
+
<Wrapped
|
|
729
|
+
width={width}
|
|
730
|
+
text={`Anything unlisted runs at ${config.fallback}, including agents claudeup has never seen.`}
|
|
731
|
+
/>
|
|
732
|
+
</box>
|
|
733
|
+
</box>
|
|
734
|
+
</DetailSection>
|
|
735
|
+
</box>
|
|
736
|
+
);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// ─── The list panel's own footer ──────────────────────────────────────────────
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* A path shortened from the LEFT, keeping the tail.
|
|
743
|
+
*
|
|
744
|
+
* `.claude/models.json` is the part that identifies the file; the machine's home
|
|
745
|
+
* directory in front of it is the part nobody reads. Truncating from the right
|
|
746
|
+
* would keep exactly the wrong half.
|
|
747
|
+
*/
|
|
748
|
+
export function shortConfigPath(
|
|
749
|
+
configPath: string,
|
|
750
|
+
projectPath: string,
|
|
751
|
+
max: number,
|
|
752
|
+
): string {
|
|
753
|
+
const base = projectPath.endsWith("/") ? projectPath : `${projectPath}/`;
|
|
754
|
+
const relative = configPath.startsWith(base)
|
|
755
|
+
? configPath.slice(base.length)
|
|
756
|
+
: configPath;
|
|
757
|
+
if (relative.length <= max) return relative;
|
|
758
|
+
return `…${relative.slice(relative.length - Math.max(1, max - 1))}`;
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* The key to every colour on this screen: one model name, in its own hue.
|
|
763
|
+
*
|
|
764
|
+
* The bar is coloured by MODEL and labelled by GRADE, so without this row the
|
|
765
|
+
* hues have no key — a reader can see that two runs differ and not what they
|
|
766
|
+
* differ into. Names are dropped from the right when the panel is too narrow,
|
|
767
|
+
* because a key that overflows its panel is worse than a shorter key.
|
|
768
|
+
*
|
|
769
|
+
* These were filled chips. A chip here would be the only chip left on the
|
|
770
|
+
* screen and would teach a vocabulary nothing else uses — every model
|
|
771
|
+
* everywhere else is now its name in its colour, so the key is too.
|
|
772
|
+
*/
|
|
773
|
+
function ModelKey({
|
|
774
|
+
presets,
|
|
775
|
+
width,
|
|
776
|
+
}: {
|
|
777
|
+
presets: ModelsPresetItem[];
|
|
778
|
+
width: number;
|
|
779
|
+
}): React.ReactNode {
|
|
780
|
+
const label = "models ";
|
|
781
|
+
const models = modelsInUse(presets.map((preset) => preset.config));
|
|
782
|
+
const shown: string[] = [];
|
|
783
|
+
let used = label.length;
|
|
784
|
+
for (const model of models) {
|
|
785
|
+
// A badge costs its name plus its own two padding cells, plus one separating
|
|
786
|
+
// space. Measured with `modelBadgeWidth` rather than `modelWidth` so the row
|
|
787
|
+
// cannot overflow by exactly the padding the chips draw.
|
|
788
|
+
const cost = modelBadgeWidth(model) + (shown.length > 0 ? 1 : 0);
|
|
789
|
+
if (used + cost > width) break;
|
|
790
|
+
shown.push(model);
|
|
791
|
+
used += cost;
|
|
792
|
+
}
|
|
793
|
+
if (shown.length === 0) return null;
|
|
794
|
+
|
|
795
|
+
const parts: React.ReactNode[] = [
|
|
796
|
+
<span key="label" fg={theme.colors.muted}>
|
|
797
|
+
{label}
|
|
798
|
+
</span>,
|
|
799
|
+
];
|
|
800
|
+
shown.forEach((model, index) => {
|
|
801
|
+
// No `·` between chips: a fill already separates them, and a separator
|
|
802
|
+
// between two blocks reads as a third thing.
|
|
803
|
+
if (index > 0) parts.push(<span key={`gap:${model}`}> </span>);
|
|
804
|
+
parts.push(<ModelBadge key={model} model={model} />);
|
|
805
|
+
});
|
|
806
|
+
return <text fg={theme.colors.text}>{parts}</text>;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/** Which blocks of the list-panel summary fit, and how many rows they cost. */
|
|
810
|
+
export interface SummaryPlan {
|
|
811
|
+
spread: boolean;
|
|
812
|
+
key: boolean;
|
|
813
|
+
facts: boolean;
|
|
814
|
+
rows: number;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/** Rows each block costs, its own leading blank line included. */
|
|
818
|
+
const SUMMARY_COST = { rule: 1, spread: 4, key: 2, facts: 3 } as const;
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Decide what the panel has room for, most valuable first.
|
|
822
|
+
*
|
|
823
|
+
* NOT cosmetic trimming. MEASURED at 80×24 before this existed: the blocks
|
|
824
|
+
* asked for about 17 rows in the 12 the list had left, and Yoga answered by
|
|
825
|
+
* SHRINKING them — two preset names composited into one, `off` printed on top
|
|
826
|
+
* of `—`, and two blocks vanished. An over-tall panel does not scroll and does
|
|
827
|
+
* not clip, it overprints.
|
|
828
|
+
*
|
|
829
|
+
* Drop order is by value: the distribution and the project's own facts are what
|
|
830
|
+
* a short terminal keeps, the colour key is what a tall one adds.
|
|
831
|
+
*
|
|
832
|
+
* There used to be a fourth block here — one bar per preset, stacked, so the
|
|
833
|
+
* presets could be compared. MEASURED on a dark 120×30 terminal it rendered as
|
|
834
|
+
* a purple bar, then ONE teal rectangle spanning the two presets that share a
|
|
835
|
+
* colour, then grey: three blocks where four comparisons were meant, because
|
|
836
|
+
* adjacent same-coloured bars had nothing between them. It was noise, and the
|
|
837
|
+
* rows it cost are rows the list now keeps.
|
|
838
|
+
*/
|
|
839
|
+
export function planModelsSummary(
|
|
840
|
+
available: number,
|
|
841
|
+
hasConfigPath: boolean,
|
|
842
|
+
): SummaryPlan {
|
|
843
|
+
const plan: SummaryPlan = {
|
|
844
|
+
spread: false,
|
|
845
|
+
key: false,
|
|
846
|
+
facts: false,
|
|
847
|
+
rows: 0,
|
|
848
|
+
};
|
|
849
|
+
// The leading blank line plus the rule: the block cannot exist for less.
|
|
850
|
+
const base = 1 + SUMMARY_COST.rule;
|
|
851
|
+
if (available < base) return plan;
|
|
852
|
+
plan.rows = base;
|
|
853
|
+
|
|
854
|
+
const factsCost = SUMMARY_COST.facts + (hasConfigPath ? 1 : 0);
|
|
855
|
+
|
|
856
|
+
// A strict cascade, not a greedy fill: once something does not fit, nothing
|
|
857
|
+
// below it is considered either. Otherwise a cheap block slips in past an
|
|
858
|
+
// expensive one and the panel shows the colour key for a bar it did not draw.
|
|
859
|
+
let stopped = false;
|
|
860
|
+
const take = (cost: number, enabled: boolean) => {
|
|
861
|
+
if (stopped) return false;
|
|
862
|
+
if (!enabled || cost === 0) return false;
|
|
863
|
+
if (plan.rows + cost > available) {
|
|
864
|
+
stopped = true;
|
|
865
|
+
return false;
|
|
866
|
+
}
|
|
867
|
+
plan.rows += cost;
|
|
868
|
+
return true;
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
plan.spread = take(SUMMARY_COST.spread, true);
|
|
872
|
+
plan.facts = take(factsCost, true);
|
|
873
|
+
plan.key = take(SUMMARY_COST.key, true);
|
|
874
|
+
return plan;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/**
|
|
878
|
+
* What sits under the preset list: the selected preset's spread, the colour key,
|
|
879
|
+
* then the project's routing facts.
|
|
880
|
+
*
|
|
881
|
+
* The panel held four rows of text and roughly thirty-five unpainted ones at
|
|
882
|
+
* 145×45, and the screen's one graphic was buried at the bottom of a detail
|
|
883
|
+
* pane nobody had scrolled to. This is that widget, moved to where the choice is
|
|
884
|
+
* being made.
|
|
885
|
+
*
|
|
886
|
+
* `available` is rows, and it is not advisory — see `planModelsSummary`.
|
|
887
|
+
*/
|
|
888
|
+
export function renderModelsSummary({
|
|
889
|
+
items,
|
|
890
|
+
selected,
|
|
891
|
+
status,
|
|
892
|
+
configPath,
|
|
893
|
+
width,
|
|
894
|
+
available,
|
|
895
|
+
}: {
|
|
896
|
+
items: ModelsBrowserItem[];
|
|
897
|
+
selected: ModelsBrowserItem | undefined;
|
|
898
|
+
status: ModelsStatus | null;
|
|
899
|
+
configPath: string | null;
|
|
900
|
+
width: number;
|
|
901
|
+
/** Rows left under the list. Blocks are dropped until the block fits. */
|
|
902
|
+
available: number;
|
|
903
|
+
}): React.ReactNode {
|
|
904
|
+
if (!status) return null;
|
|
905
|
+
const preset = selected?.kind === "preset" ? selected : undefined;
|
|
906
|
+
const presets = items.filter(
|
|
907
|
+
(item): item is ModelsPresetItem => item.kind === "preset",
|
|
908
|
+
);
|
|
909
|
+
const plan = planModelsSummary(available, Boolean(configPath));
|
|
910
|
+
if (plan.rows === 0) return null;
|
|
911
|
+
|
|
912
|
+
// One column of indent, so this block's left edge lines up with the rows
|
|
913
|
+
// above it — every list row carries `indent={1}`.
|
|
914
|
+
const inner = Math.max(8, width - 1);
|
|
915
|
+
|
|
916
|
+
return (
|
|
917
|
+
<box flexDirection="column" marginTop={1} paddingLeft={1} flexShrink={0}>
|
|
918
|
+
<text fg={theme.colors.border}>{"─".repeat(inner)}</text>
|
|
919
|
+
{plan.spread && preset ? (
|
|
920
|
+
<AgentSpread config={preset.config} width={inner} />
|
|
921
|
+
) : null}
|
|
922
|
+
{plan.key ? (
|
|
923
|
+
<box marginTop={1}>
|
|
924
|
+
<ModelKey presets={presets} width={inner} />
|
|
925
|
+
</box>
|
|
926
|
+
) : null}
|
|
927
|
+
{/*
|
|
928
|
+
* The `state / preset / config` block that used to sit here is GONE.
|
|
929
|
+
*
|
|
930
|
+
* All three were already on screen: the state is a chip in the panel header AND in
|
|
931
|
+
* the title bar, the preset is the row the cursor is on, and the path is a detail
|
|
932
|
+
* nobody acts on from a list. Restating them under the chart made the column read
|
|
933
|
+
* as a form to fill in rather than a summary of what is running.
|
|
934
|
+
*
|
|
935
|
+
* `plan.facts` still exists in the layout plan: it is the rung that decides whether
|
|
936
|
+
* the chart and the key fit at all, and deleting it would change the fitting maths
|
|
937
|
+
* rather than only what is drawn.
|
|
938
|
+
*/}
|
|
939
|
+
</box>
|
|
940
|
+
);
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
// ─── Dispatch ─────────────────────────────────────────────────────────────────
|
|
944
|
+
|
|
945
|
+
/**
|
|
946
|
+
* A row, drawn against the layout the WHOLE list shares.
|
|
947
|
+
*
|
|
948
|
+
* `layout` is not optional and is not derived here on purpose: deriving it
|
|
949
|
+
* per-row is exactly how four presets ended up in two different formats.
|
|
950
|
+
*/
|
|
951
|
+
export function renderModelRow(
|
|
952
|
+
item: ModelsBrowserItem,
|
|
953
|
+
_index: number,
|
|
954
|
+
isSelected: boolean,
|
|
955
|
+
layout: PresetRowLayout,
|
|
956
|
+
): React.ReactNode {
|
|
957
|
+
if (item.kind === "status") {
|
|
958
|
+
return renderStatusRow(item, isSelected);
|
|
959
|
+
}
|
|
960
|
+
return renderPresetRow(item, isSelected, layout);
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* The detail pane.
|
|
965
|
+
*
|
|
966
|
+
* `status` is passed alongside the item rather than copied onto it: the drift
|
|
967
|
+
* lines belong to the project, not to whichever preset the cursor is on, and
|
|
968
|
+
* duplicating them per row is how the two would end up disagreeing.
|
|
969
|
+
*/
|
|
970
|
+
export function renderModelDetail(
|
|
971
|
+
item: ModelsBrowserItem | undefined,
|
|
972
|
+
status: ModelsStatus | null,
|
|
973
|
+
width?: number,
|
|
974
|
+
): React.ReactNode {
|
|
975
|
+
if (!item) {
|
|
976
|
+
return (
|
|
977
|
+
<box flexDirection="column">
|
|
978
|
+
<text fg={theme.colors.muted}>No preset selected.</text>
|
|
979
|
+
</box>
|
|
980
|
+
);
|
|
981
|
+
}
|
|
982
|
+
if (item.kind === "status") {
|
|
983
|
+
return renderStatusDetail(status, width);
|
|
984
|
+
}
|
|
985
|
+
// The state block only when there is something to DO about it.
|
|
986
|
+
//
|
|
987
|
+
// `on` and `off` are already stated twice — in the title bar and on the list's own
|
|
988
|
+
// header row — so repeating "on / every Agent call is routed through this config"
|
|
989
|
+
// above every preset spent four rows restating the obvious and pushed the tier table
|
|
990
|
+
// down. `stale`, `invalid` and `unhooked` stay, because each one carries lines the
|
|
991
|
+
// reader has seen nowhere else and a fix to press.
|
|
992
|
+
const needsAttention =
|
|
993
|
+
status !== null && status.state !== "on" && status.state !== "off";
|
|
994
|
+
return (
|
|
995
|
+
<box flexDirection="column">
|
|
996
|
+
{needsAttention ? (
|
|
997
|
+
<box marginBottom={1}>
|
|
998
|
+
<StateBlock status={status} width={width} />
|
|
999
|
+
</box>
|
|
1000
|
+
) : null}
|
|
1001
|
+
{renderPresetDetail(item, width)}
|
|
1002
|
+
</box>
|
|
1003
|
+
);
|
|
1004
|
+
}
|