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.
Files changed (72) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/catalog-notice.test.ts +3 -3
  3. package/src/__tests__/cli-live.test.ts +9 -2
  4. package/src/__tests__/cli-update-view.test.ts +2 -2
  5. package/src/__tests__/footer-hints.test.ts +40 -0
  6. package/src/__tests__/gap-fill-versions.test.ts +24 -24
  7. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  8. package/src/__tests__/hook-import-policy.test.ts +90 -0
  9. package/src/__tests__/hook-process.test.ts +256 -0
  10. package/src/__tests__/hook-registration.test.ts +224 -0
  11. package/src/__tests__/manifest.test.ts +134 -0
  12. package/src/__tests__/marketplace-badge.test.ts +1 -1
  13. package/src/__tests__/marketplaces.test.ts +0 -1
  14. package/src/__tests__/model-visuals.test.tsx +793 -0
  15. package/src/__tests__/models-adapter.test.ts +317 -0
  16. package/src/__tests__/models-cli.test.ts +173 -0
  17. package/src/__tests__/models-core.test.ts +640 -0
  18. package/src/__tests__/models-manager.test.ts +497 -0
  19. package/src/__tests__/models-screen-state.test.ts +259 -0
  20. package/src/__tests__/moved-marketplace.test.ts +7 -8
  21. package/src/__tests__/plugin-contents.test.ts +1 -1
  22. package/src/__tests__/profile-adopt.test.ts +1 -1
  23. package/src/__tests__/profile-materializer.test.ts +48 -2
  24. package/src/__tests__/resolver.test.ts +43 -6
  25. package/src/__tests__/settings-file.test.ts +179 -0
  26. package/src/__tests__/symlink-manager.test.ts +65 -1
  27. package/src/__tests__/tabbar-layout.test.ts +40 -2
  28. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  29. package/src/__tests__/version-snapshot.test.ts +4 -4
  30. package/src/cli/doctor.ts +90 -0
  31. package/src/cli/hook.ts +129 -0
  32. package/src/cli/models.ts +214 -0
  33. package/src/cli/router.ts +12 -0
  34. package/src/data/gitignore-defaults.ts +4 -1
  35. package/src/data/gitignore-reasons.ts +0 -4
  36. package/src/data/marketplaces.ts +1 -15
  37. package/src/data/models-presets.ts +270 -0
  38. package/src/data/predefined-profiles.ts +12 -21
  39. package/src/data/settings-catalog.ts +11 -4
  40. package/src/main.tsx +51 -82
  41. package/src/services/hook-registration.ts +218 -0
  42. package/src/services/manifest.ts +84 -0
  43. package/src/services/models-core.ts +628 -0
  44. package/src/services/models-manager.ts +606 -0
  45. package/src/services/plugin-manager.ts +2 -3
  46. package/src/services/profile-materializer.ts +17 -0
  47. package/src/services/resolver.ts +13 -2
  48. package/src/services/settings-file.ts +69 -0
  49. package/src/services/styles-manager.ts +23 -45
  50. package/src/services/symlink-manager.ts +57 -11
  51. package/src/tui.tsx +112 -0
  52. package/src/types/bun.d.ts +21 -0
  53. package/src/types/index.ts +14 -0
  54. package/src/ui/App.tsx +15 -3
  55. package/src/ui/adapters/modelsAdapter.ts +170 -0
  56. package/src/ui/adapters/pluginsAdapter.ts +1 -1
  57. package/src/ui/components/TabBar.tsx +9 -4
  58. package/src/ui/components/layout/FooterHints.tsx +20 -3
  59. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  60. package/src/ui/components/primitives/MetaText.tsx +27 -1
  61. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  62. package/src/ui/renderers/modelVisuals.tsx +853 -0
  63. package/src/ui/renderers/skillRenderers.tsx +13 -3
  64. package/src/ui/renderers/styleRenderers.tsx +7 -3
  65. package/src/ui/screens/ModelsScreen.tsx +478 -0
  66. package/src/ui/screens/PluginsScreen.tsx +1 -1
  67. package/src/ui/screens/StylesScreen.tsx +8 -13
  68. package/src/ui/screens/index.ts +1 -0
  69. package/src/ui/state/reducer.ts +94 -0
  70. package/src/ui/state/types.ts +65 -2
  71. package/src/ui/theme-mode.ts +116 -0
  72. 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
+ }