claudeup 6.3.0 → 6.4.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 (59) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/cli-live.test.ts +9 -2
  3. package/src/__tests__/footer-hints.test.ts +40 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  5. package/src/__tests__/hook-import-policy.test.ts +90 -0
  6. package/src/__tests__/hook-process.test.ts +256 -0
  7. package/src/__tests__/hook-registration.test.ts +224 -0
  8. package/src/__tests__/manifest.test.ts +134 -0
  9. package/src/__tests__/model-visuals.test.tsx +789 -0
  10. package/src/__tests__/models-adapter.test.ts +317 -0
  11. package/src/__tests__/models-cli.test.ts +173 -0
  12. package/src/__tests__/models-core.test.ts +640 -0
  13. package/src/__tests__/models-manager.test.ts +497 -0
  14. package/src/__tests__/models-screen-state.test.ts +259 -0
  15. package/src/__tests__/profile-materializer.test.ts +46 -0
  16. package/src/__tests__/resolver.test.ts +38 -2
  17. package/src/__tests__/settings-file.test.ts +179 -0
  18. package/src/__tests__/symlink-manager.test.ts +65 -1
  19. package/src/__tests__/tabbar-layout.test.ts +40 -2
  20. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  21. package/src/__tests__/version-snapshot.test.ts +2 -4
  22. package/src/cli/doctor.ts +90 -0
  23. package/src/cli/hook.ts +129 -0
  24. package/src/cli/models.ts +214 -0
  25. package/src/cli/router.ts +12 -0
  26. package/src/data/gitignore-defaults.ts +4 -0
  27. package/src/data/models-presets.ts +281 -0
  28. package/src/data/predefined-profiles.ts +16 -7
  29. package/src/data/settings-catalog.ts +11 -4
  30. package/src/main.tsx +51 -82
  31. package/src/services/hook-registration.ts +218 -0
  32. package/src/services/manifest.ts +84 -0
  33. package/src/services/models-core.ts +628 -0
  34. package/src/services/models-manager.ts +606 -0
  35. package/src/services/profile-materializer.ts +17 -0
  36. package/src/services/resolver.ts +11 -0
  37. package/src/services/settings-file.ts +69 -0
  38. package/src/services/styles-manager.ts +23 -45
  39. package/src/services/symlink-manager.ts +57 -11
  40. package/src/tui.tsx +112 -0
  41. package/src/types/bun.d.ts +21 -0
  42. package/src/types/index.ts +14 -0
  43. package/src/ui/App.tsx +15 -3
  44. package/src/ui/adapters/modelsAdapter.ts +170 -0
  45. package/src/ui/components/TabBar.tsx +9 -4
  46. package/src/ui/components/layout/FooterHints.tsx +20 -3
  47. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  48. package/src/ui/components/primitives/MetaText.tsx +27 -1
  49. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  50. package/src/ui/renderers/modelVisuals.tsx +853 -0
  51. package/src/ui/renderers/skillRenderers.tsx +13 -3
  52. package/src/ui/renderers/styleRenderers.tsx +7 -3
  53. package/src/ui/screens/ModelsScreen.tsx +478 -0
  54. package/src/ui/screens/StylesScreen.tsx +8 -13
  55. package/src/ui/screens/index.ts +1 -0
  56. package/src/ui/state/reducer.ts +94 -0
  57. package/src/ui/state/types.ts +65 -2
  58. package/src/ui/theme-mode.ts +116 -0
  59. package/src/ui/theme.ts +26 -0
@@ -0,0 +1,789 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { BUILT_IN_PRESETS, findPreset } from "../data/models-presets.js";
3
+ import {
4
+ EFFORTS,
5
+ type Effort,
6
+ GRADES,
7
+ MODEL_ALIASES,
8
+ type ModelsConfig,
9
+ } from "../services/models-core.js";
10
+ import {
11
+ detailPanelWidth,
12
+ listPanelWidth,
13
+ } from "../ui/components/layout/ScreenLayout.js";
14
+ import {
15
+ planModelsSummary,
16
+ shortConfigPath,
17
+ } from "../ui/renderers/modelRenderers.js";
18
+ import {
19
+ EFFORT_GLYPH_CELLS,
20
+ EFFORT_OFF,
21
+ EFFORT_ON,
22
+ NONE_CELL,
23
+ NO_EFFORT_GLYPH,
24
+ PRESET_NAME_MAX,
25
+ type PresetRowInput,
26
+ agentDistribution,
27
+ agentRows,
28
+ agentTableHeaders,
29
+ effortGlyph,
30
+ effortLabel,
31
+ effortLevel,
32
+ fitAgentTable,
33
+ fitPresetRows,
34
+ modelBadgeWidth,
35
+ modelBarFill,
36
+ modelFg,
37
+ modelPad,
38
+ modelWidth,
39
+ modelsInUse,
40
+ padCell,
41
+ presetMarkerWidth,
42
+ } from "../ui/renderers/modelVisuals.js";
43
+ import { wrapText } from "../ui/renderers/styleRenderers.js";
44
+ import { CONTRAST_REFERENCE_KEYS, MODEL_BADGE } from "../ui/theme-mode.js";
45
+ import { theme } from "../ui/theme.js";
46
+
47
+ /**
48
+ * The Models screen's visual vocabulary, tested as data.
49
+ *
50
+ * Everything asserted here is a pure function on a config — the widgets that
51
+ * consume them return `<span>`s, which cannot be rendered without a terminal
52
+ * and, more to the point, cannot be judged from a character frame at all: a
53
+ * stacked bar is coloured SPACES. So the maths is pinned here and the colour is
54
+ * pinned by looking at a screenshot.
55
+ */
56
+
57
+ const fableAdvisor = findPreset("fable-advisor");
58
+ if (!fableAdvisor) throw new Error("the default preset went missing");
59
+
60
+ describe("the model → colour map", () => {
61
+ /** WCAG relative luminance, so the contrast claims below are measured, not asserted. */
62
+ const luminance = (hex: string): number => {
63
+ const channel = (c: number) => {
64
+ const v = c / 255;
65
+ return v <= 0.04045 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
66
+ };
67
+ const n = Number.parseInt(hex.slice(1), 16);
68
+ return (
69
+ 0.2126 * channel((n >> 16) & 255) +
70
+ 0.7152 * channel((n >> 8) & 255) +
71
+ 0.0722 * channel(n & 255)
72
+ );
73
+ };
74
+ const contrast = (a: string, b: string): number => {
75
+ const [x, y] = [luminance(a), luminance(b)];
76
+ return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
77
+ };
78
+
79
+ /** The grounds a dark terminal actually uses: pure black, our reference, One Dark. */
80
+ const DARK_GROUNDS = ["#000000", CONTRAST_REFERENCE_KEYS.dark, "#282C34"];
81
+
82
+ test("is total over every alias the Agent tool accepts, plus inherit", () => {
83
+ // A colourless model must be a build error, not a grey surprise at runtime.
84
+ expect(Object.keys(MODEL_BADGE).sort()).toEqual(
85
+ [...MODEL_ALIASES, "inherit"].sort(),
86
+ );
87
+ for (const alias of [...MODEL_ALIASES, "inherit"] as const) {
88
+ for (const mode of ["light", "dark"] as const) {
89
+ const ink = MODEL_BADGE[alias][mode];
90
+ expect(ink.bg).toMatch(/^#[0-9A-Fa-f]{6}$/);
91
+ expect(ink.fg).toMatch(/^#[0-9A-Fa-f]{6}$/);
92
+ expect(ink.bar).toMatch(/^#[0-9A-Fa-f]{6}$/);
93
+ }
94
+ }
95
+ });
96
+
97
+ test("one colour per model, and no two models share one", () => {
98
+ // Rule 1: colour is semantic. Two models on one fill means the colour stops
99
+ // answering "which model" the moment both appear on screen.
100
+ for (const mode of ["light", "dark"] as const) {
101
+ const fills = MODEL_ALIASES.map((a) => MODEL_BADGE[a][mode].bg);
102
+ expect(new Set(fills).size).toBe(MODEL_ALIASES.length);
103
+ }
104
+ });
105
+
106
+ /**
107
+ * The defect this palette was built to fix. `COMPONENT_BADGE`'s dark fills are washes
108
+ * toward black, which assumes the page IS `#1C1C1E`; against a slate ground like One
109
+ * Dark's they measure ~1.0:1 and the chip reads as a hole punched in the row.
110
+ */
111
+ test("every dark fill clears every plausible dark ground", () => {
112
+ for (const alias of [...MODEL_ALIASES, "inherit"] as const) {
113
+ for (const ground of DARK_GROUNDS) {
114
+ expect(contrast(MODEL_BADGE[alias].dark.bg, ground)).toBeGreaterThan(
115
+ 1.2,
116
+ );
117
+ }
118
+ }
119
+ });
120
+
121
+ test("ink is readable on its own fill, on both pages", () => {
122
+ for (const alias of [...MODEL_ALIASES, "inherit"] as const) {
123
+ for (const mode of ["light", "dark"] as const) {
124
+ const ink = MODEL_BADGE[alias][mode];
125
+ expect(contrast(ink.fg, ink.bg)).toBeGreaterThan(3);
126
+ }
127
+ }
128
+ });
129
+
130
+ /**
131
+ * Dark is NOT a mirror of light, and that is the whole design. Cream carries the light
132
+ * already, so a light chip stays quiet; a near-black page carries none, so the same
133
+ * construction goes grey — built once, shown, and rejected as "dark looks like the light
134
+ * theme". A dark chip is a saturated mid-tone, so its FILL is the brighter half.
135
+ */
136
+ test("a dark chip is a mid-tone fill, a light chip is a pale one", () => {
137
+ for (const alias of MODEL_ALIASES) {
138
+ const light = MODEL_BADGE[alias].light;
139
+ const dark = MODEL_BADGE[alias].dark;
140
+ // Light: the fill is barely off the page, the ink carries the colour.
141
+ expect(luminance(light.bg)).toBeGreaterThan(luminance(light.fg));
142
+ // Dark: the ink is near-white, the fill carries the colour.
143
+ expect(luminance(dark.fg)).toBeGreaterThan(luminance(dark.bg));
144
+ }
145
+ });
146
+
147
+ test("the bar is a softer step than the badge ink it sits beside", () => {
148
+ // Twenty characters of badge ink is a slab; a chart needs its own strength.
149
+ for (const alias of MODEL_ALIASES) {
150
+ const dark = MODEL_BADGE[alias].dark;
151
+ expect(luminance(dark.bar)).toBeLessThan(luminance(dark.fg));
152
+ }
153
+ });
154
+
155
+ test("a long-context variant keeps its base model's colour", () => {
156
+ expect(modelFg("opus[1m]")).toBe(modelFg("opus"));
157
+ expect(modelFg("fable[1m]")).toBe(modelFg("fable"));
158
+ });
159
+
160
+ test("anything a hand-edited config invented takes inherit's grey", () => {
161
+ for (const invented of ["gpt-9", "", "OPUS", "opus-lead"]) {
162
+ expect(modelFg(invented)).toBe(modelFg("inherit"));
163
+ }
164
+ });
165
+
166
+ test("a model costs its own name in text, and two more as a badge", () => {
167
+ // The three places that measure a model column all go through these, because a
168
+ // column measured differently from what is drawn drifts by a cell as the data
169
+ // changes. A badge adds exactly the one space of padding it draws on each side.
170
+ expect(modelWidth("opus")).toBe(4);
171
+ expect(modelBadgeWidth("opus")).toBe(6);
172
+ expect(modelPad("opus", 8)).toBe(" ");
173
+ expect(modelPad("opus", 4)).toBe("");
174
+ expect(modelPad("opus", 2)).toBe("");
175
+ });
176
+ });
177
+
178
+ describe("the effort indicator", () => {
179
+ test("always four columns, filled to the level", () => {
180
+ expect(EFFORT_GLYPH_CELLS).toBe(4);
181
+ expect(EFFORTS.map(effortLevel)).toEqual([1, 2, 3, 4]);
182
+ for (const effort of EFFORTS) {
183
+ expect([...effortGlyph(effort)]).toHaveLength(EFFORT_GLYPH_CELLS);
184
+ }
185
+ // Absent pads to the same width, so the column never jumps.
186
+ expect([...effortGlyph(undefined)]).toHaveLength(EFFORT_GLYPH_CELLS);
187
+ });
188
+
189
+ test("the filled count IS the level, and the rest stays as a track", () => {
190
+ // Drawn, not omitted: dots that simply stop read as a count ("two"), dots on a
191
+ // track read as a level ("two of four"), and the level is the point.
192
+ for (const effort of EFFORTS) {
193
+ const drawn = effortGlyph(effort);
194
+ const level = effortLevel(effort);
195
+ expect([...drawn].filter((c) => c === EFFORT_ON)).toHaveLength(level);
196
+ expect([...drawn].filter((c) => c === EFFORT_OFF)).toHaveLength(
197
+ EFFORT_GLYPH_CELLS - level,
198
+ );
199
+ }
200
+ });
201
+
202
+ test("every level draws a different row, so shape alone is the ordinal", () => {
203
+ const drawn = EFFORTS.map(effortGlyph);
204
+ expect(new Set(drawn).size).toBe(EFFORTS.length);
205
+ });
206
+
207
+ test("absent effort is visibly NOT low — a dash, not an empty track", () => {
208
+ // `low` is a decision; absent means "inherits whatever the session is on". They
209
+ // are different answers and may not share a shape, and an empty track would read
210
+ // as the bottom of the same scale.
211
+ expect(effortGlyph(undefined).trim()).toBe(NO_EFFORT_GLYPH);
212
+ expect(effortGlyph(undefined)).not.toBe(effortGlyph("low"));
213
+ expect(effortGlyph(undefined)).not.toContain(EFFORT_OFF);
214
+ expect(effortLevel(undefined)).toBe(0);
215
+ });
216
+
217
+ test("every level indexes a glyph that exists", () => {
218
+ for (const effort of [undefined, ...EFFORTS]) {
219
+ const level = effortLevel(effort as Effort | undefined);
220
+ expect(level).toBeGreaterThanOrEqual(0);
221
+ expect(level).toBeLessThanOrEqual(EFFORT_GLYPH_CELLS);
222
+ expect(effortGlyph(effort as Effort | undefined)).toBeTruthy();
223
+ }
224
+ });
225
+
226
+ test("the level is ordered the same way the vocabulary is", () => {
227
+ expect(effortLevel("low")).toBeLessThan(effortLevel("medium"));
228
+ expect(effortLevel("medium")).toBeLessThan(effortLevel("high"));
229
+ expect(effortLevel("high")).toBeLessThan(effortLevel("xhigh"));
230
+ });
231
+
232
+ test("the word shortens to its initial, and every initial is distinct", () => {
233
+ const initials = EFFORTS.map((effort) => effortLabel(effort, "short"));
234
+ expect(new Set(initials).size).toBe(EFFORTS.length);
235
+ expect(effortLabel("xhigh", "full")).toBe("xhigh");
236
+ expect(effortLabel("xhigh", "none")).toBe("");
237
+ });
238
+ });
239
+
240
+ describe("the agent distribution", () => {
241
+ test("the segments sum to the number of agents the config names", () => {
242
+ for (const preset of BUILT_IN_PRESETS) {
243
+ const total = agentDistribution(preset).reduce(
244
+ (sum, segment) => sum + segment.count,
245
+ 0,
246
+ );
247
+ expect(total).toBe(Object.keys(preset.agents).length);
248
+ }
249
+ });
250
+
251
+ test("counts the normal tier too — the bar is proportions, not exceptions", () => {
252
+ const config: ModelsConfig = {
253
+ ...fableAdvisor,
254
+ agents: { a: "normal", b: "normal", c: "smart" },
255
+ fallback: "normal",
256
+ };
257
+ const segments = agentDistribution(config);
258
+ expect(segments.map((segment) => segment.key)).toEqual([
259
+ "smart",
260
+ "normal",
261
+ "cheap",
262
+ ]);
263
+ expect(segments.map((segment) => segment.count)).toEqual([1, 2, 0]);
264
+ });
265
+
266
+ /**
267
+ * The chart is the same rows whichever preset the cursor is on.
268
+ *
269
+ * Emitting only the tiers that happen to have agents made it change height and row
270
+ * order as you moved down the list, which reads as the DATA changing shape rather
271
+ * than as one tier being empty. "Nothing runs at `normal` here" is a fact worth
272
+ * seeing, and it is invisible if the row is absent.
273
+ */
274
+ test("every tier gets a row, even at zero", () => {
275
+ const config: ModelsConfig = {
276
+ ...fableAdvisor,
277
+ agents: { only: "smart" },
278
+ };
279
+ const segments = agentDistribution(config);
280
+ for (const grade of GRADES) {
281
+ expect(segments.map((segment) => segment.key)).toContain(grade);
282
+ }
283
+ expect(segments.find((segment) => segment.key === "cheap")?.count).toBe(0);
284
+ });
285
+
286
+ test("a segment takes the colour of the model its grade routes to", () => {
287
+ const segments = agentDistribution(fableAdvisor);
288
+ const smart = segments.find((segment) => segment.key === "smart");
289
+ expect(smart?.model).toBe(fableAdvisor.grades.smart.model);
290
+ expect(smart?.fill).toBe(modelBarFill(fableAdvisor.grades.smart.model));
291
+ });
292
+
293
+ test("grades lead, in routing order, whatever order the config was written in", () => {
294
+ const config: ModelsConfig = {
295
+ ...fableAdvisor,
296
+ agents: { a: "cheap", b: { model: "inherit" }, c: "smart" },
297
+ };
298
+ expect(agentDistribution(config).map((segment) => segment.key)).toEqual([
299
+ "smart",
300
+ "normal",
301
+ "cheap",
302
+ "inherit",
303
+ ]);
304
+ });
305
+ });
306
+
307
+ describe("the agent table", () => {
308
+ test("one row per agent — every one, with no sampling", () => {
309
+ for (const preset of BUILT_IN_PRESETS) {
310
+ const rows = agentRows(preset);
311
+ expect(rows).toHaveLength(Object.keys(preset.agents).length);
312
+ expect(new Set(rows.map((row) => row.name)).size).toBe(rows.length);
313
+ expect(rows.map((row) => row.name).sort()).toEqual(
314
+ Object.keys(preset.agents).sort(),
315
+ );
316
+ }
317
+ });
318
+
319
+ test("sorted by grade in ROUTING order, then alphabetically inside it", () => {
320
+ const rows = agentRows(fableAdvisor);
321
+ const rank = (key: string) => {
322
+ const index = (GRADES as readonly string[]).indexOf(key);
323
+ return index === -1 ? GRADES.length : index;
324
+ };
325
+ for (let i = 1; i < rows.length; i++) {
326
+ const previous = rows[i - 1];
327
+ const current = rows[i];
328
+ if (!previous || !current) throw new Error("row went missing");
329
+ expect(rank(previous.key)).toBeLessThanOrEqual(rank(current.key));
330
+ if (previous.key === current.key) {
331
+ expect(previous.name.localeCompare(current.name)).toBeLessThan(0);
332
+ }
333
+ }
334
+ // critical is the strongest tier and leads; `inherit` is not a grade and trails.
335
+ expect(rows[0]?.key).toBe("smart");
336
+ expect(rows.at(-1)?.key).toBe("inherit");
337
+ });
338
+
339
+ test("each row carries the model and effort its grade actually routes", () => {
340
+ for (const row of agentRows(fableAdvisor)) {
341
+ const index = (GRADES as readonly string[]).indexOf(row.key);
342
+ if (index === -1) continue;
343
+ const grade = GRADES[index];
344
+ if (!grade) throw new Error("grade went missing");
345
+ expect(row.model).toBe(fableAdvisor.grades[grade].model);
346
+ expect(row.effort).toBe(fableAdvisor.grades[grade].effort);
347
+ }
348
+ });
349
+
350
+ test("an agent pinned to a model of its own keeps that model and effort", () => {
351
+ const config: ModelsConfig = {
352
+ ...fableAdvisor,
353
+ agents: { "x:y": { model: "haiku", effort: "low" } },
354
+ };
355
+ const row = agentRows(config)[0];
356
+ expect(row?.model).toBe("haiku");
357
+ expect(row?.effort).toBe("low");
358
+ expect(row?.inherit).toBe(false);
359
+ });
360
+
361
+ test("an inherit row sets nothing, so it reads as an em dash", () => {
362
+ const config: ModelsConfig = {
363
+ ...fableAdvisor,
364
+ agents: { "seo:editor": { model: "inherit" } },
365
+ };
366
+ const row = agentRows(config)[0];
367
+ expect(row?.inherit).toBe(true);
368
+ expect(row?.effort).toBeUndefined();
369
+ expect(NONE_CELL).toBe("—");
370
+ });
371
+ });
372
+
373
+ describe("fitting the agent table to a panel", () => {
374
+ const rows = agentRows(fableAdvisor);
375
+
376
+ test("at a real detail width every column is present", () => {
377
+ const layout = fitAgentTable(rows, detailPanelWidth(120));
378
+ expect(layout.grade).toBeGreaterThan(0);
379
+ expect(layout.effort).toBe("full");
380
+ expect(layout.total).toBeLessThanOrEqual(detailPanelWidth(120));
381
+ });
382
+
383
+ test("the table never exceeds the width it was given, at any width", () => {
384
+ for (let width = 20; width <= 200; width++) {
385
+ const layout = fitAgentTable(rows, width);
386
+ // Below the point where even the stripped table fits, the name column
387
+ // is floored rather than going negative — that case is reported by
388
+ // `total`, and it is the only one allowed to exceed.
389
+ if (layout.name > 6) expect(layout.total).toBeLessThanOrEqual(width);
390
+ expect(layout.name).toBeGreaterThanOrEqual(6);
391
+ }
392
+ });
393
+
394
+ test("the grade column is what gives way first", () => {
395
+ const full = fitAgentTable(rows, detailPanelWidth(120));
396
+ const squeezed = fitAgentTable(rows, full.total - 1);
397
+ expect(squeezed.grade).toBe(0);
398
+ // The effort word is still there — it is the SECOND thing to go.
399
+ expect(squeezed.effort).toBe("full");
400
+ });
401
+
402
+ test("the effort word shortens only after the grade column is gone", () => {
403
+ // Walk the whole range: at no width may the word be abbreviated while
404
+ // the grade column — the cheaper thing to lose — is still being drawn.
405
+ let sawShortened = false;
406
+ for (let width = 20; width <= 200; width++) {
407
+ const layout = fitAgentTable(rows, width);
408
+ if (layout.effort !== "full") {
409
+ sawShortened = true;
410
+ expect(layout.grade).toBe(0);
411
+ }
412
+ }
413
+ expect(sawShortened).toBe(true);
414
+ });
415
+
416
+ test("the name column truncates rather than wrapping", () => {
417
+ const layout = fitAgentTable(rows, 24);
418
+ expect(layout.name).toBeGreaterThanOrEqual(6);
419
+ expect(padCell("code-analysis:detective", layout.name)).toHaveLength(
420
+ layout.name,
421
+ );
422
+ });
423
+
424
+ test("every header cell is exactly its column, so the columns line up", () => {
425
+ for (const width of [40, 60, 80, 120]) {
426
+ const layout = fitAgentTable(rows, width);
427
+ const headers = agentTableHeaders(layout);
428
+ const keys = headers.map((cell) => cell.key);
429
+ expect(keys).toContain("name");
430
+ expect(keys).toContain("model");
431
+ // The grade header appears exactly when the grade column does.
432
+ expect(keys.includes("grade")).toBe(layout.grade > 0);
433
+ expect(headers.find((cell) => cell.key === "name")?.text).toHaveLength(
434
+ layout.name,
435
+ );
436
+ expect(headers.find((cell) => cell.key === "model")?.text).toHaveLength(
437
+ layout.model,
438
+ );
439
+ const grade = headers.find((cell) => cell.key === "grade");
440
+ if (grade) expect(grade.text).toHaveLength(layout.grade);
441
+ // The header row is the table's own width, gaps included.
442
+ const rendered = headers
443
+ .map((cell, index) => (index > 0 ? ` ${cell.text}` : cell.text))
444
+ .join("");
445
+ expect(rendered.length).toBeLessThanOrEqual(
446
+ Math.max(width, layout.total),
447
+ );
448
+ }
449
+ });
450
+
451
+ test("padCell is exactly the column, whether it pads or truncates", () => {
452
+ expect(padCell("ab", 5)).toBe("ab ");
453
+ expect(padCell("abcdefgh", 5)).toBe("abcd…");
454
+ expect(padCell("abcdefgh", 5)).toHaveLength(5);
455
+ });
456
+
457
+ test("the model column is the name's own width — no chip padding left in it", () => {
458
+ // The column used to carry `+2` for the fill's padding, and the header
459
+ // was indented a column to match. With the chip gone, a column still
460
+ // reserving that space leaves the header floating off its data.
461
+ for (const width of [40, 60, 80, 120]) {
462
+ const layout = fitAgentTable(rows, width);
463
+ const widest = rows.reduce(
464
+ (max, row) =>
465
+ Math.max(max, row.inherit ? NONE_CELL.length : modelWidth(row.model)),
466
+ "Model".length,
467
+ );
468
+ expect(layout.model).toBe(widest);
469
+ for (const row of rows) {
470
+ const drawn = row.inherit
471
+ ? padCell(NONE_CELL, layout.model)
472
+ : row.model + modelPad(row.model, layout.model);
473
+ expect(drawn).toHaveLength(layout.model);
474
+ }
475
+ const header = agentTableHeaders(layout).find(
476
+ (cell) => cell.key === "model",
477
+ );
478
+ expect(header?.text).toBe(padCell("Model", layout.model));
479
+ }
480
+ });
481
+ });
482
+
483
+ describe("the panel widths ScreenLayout hands out", () => {
484
+ /**
485
+ * MEASURED against real renders in tmux at each of these widths — the row
486
+ * is the width of the wrapped ruler in the panel, with the scrollbar column
487
+ * excluded. Yoga rounds a fractional percentage to whole cells, so these
488
+ * cannot be derived from the JSX; they have to be looked at.
489
+ */
490
+ const MEASURED: Array<[terminal: number, list: number, detail: number]> = [
491
+ [70, 32, 32],
492
+ [80, 37, 37],
493
+ [90, 42, 42],
494
+ [100, 47, 47],
495
+ [110, 52, 52],
496
+ [120, 57, 57],
497
+ [121, 57, 58],
498
+ [130, 62, 62],
499
+ [140, 67, 67],
500
+ [160, 76, 77],
501
+ [181, 87, 87],
502
+ [200, 96, 97],
503
+ ];
504
+
505
+ test("never asks for more columns than the panel has", () => {
506
+ // One column short folds nothing; one column over paints into the
507
+ // scrollbar and is cut at the terminal edge.
508
+ for (const [terminal, list, detail] of MEASURED) {
509
+ expect(listPanelWidth(terminal)).toBeLessThanOrEqual(list);
510
+ expect(detailPanelWidth(terminal)).toBeLessThanOrEqual(detail);
511
+ }
512
+ });
513
+
514
+ test("is never more than one column short of the panel either", () => {
515
+ for (const [terminal, list, detail] of MEASURED) {
516
+ expect(listPanelWidth(terminal)).toBeGreaterThanOrEqual(list - 1);
517
+ expect(detailPanelWidth(terminal)).toBeGreaterThanOrEqual(detail - 1);
518
+ }
519
+ });
520
+
521
+ test("the old hand-copied formula asked for MORE than the panel below 56 columns", () => {
522
+ // `Math.max(24, floor(width * 0.5) - 4)`, carried in two screens. The
523
+ // floor of 24 is the bug: it survives past the point where the panel is
524
+ // narrower than 24 columns.
525
+ const old = (width: number) => Math.max(24, Math.floor(width * 0.5) - 4);
526
+ expect(old(48)).toBeGreaterThan(detailPanelWidth(48));
527
+ expect(detailPanelWidth(48)).toBeLessThan(24);
528
+ });
529
+
530
+ test("grows with the terminal and never goes negative", () => {
531
+ for (let width = 1; width <= 400; width++) {
532
+ expect(detailPanelWidth(width)).toBeGreaterThan(0);
533
+ expect(listPanelWidth(width)).toBeGreaterThan(0);
534
+ }
535
+ expect(detailPanelWidth(200)).toBeGreaterThan(detailPanelWidth(120));
536
+ });
537
+ });
538
+
539
+ describe("wrapping to a narrow panel", () => {
540
+ test("respects a narrow width down to wrapText's own floor", () => {
541
+ const text =
542
+ "stale — settings no longer match the config, so part of it is not in force";
543
+ for (const width of [20, 24, 32, 40, 57]) {
544
+ for (const line of wrapText(text, width)) {
545
+ expect(line.length).toBeLessThanOrEqual(Math.max(20, width));
546
+ }
547
+ }
548
+ });
549
+
550
+ test("the agent table's own cells never exceed the layout, at any width", () => {
551
+ const rows = agentRows(fableAdvisor);
552
+ for (const width of [24, 40, 57, 80, 120]) {
553
+ const layout = fitAgentTable(rows, width);
554
+ for (const row of rows) {
555
+ expect(padCell(row.name, layout.name)).toHaveLength(layout.name);
556
+ if (layout.grade > 0) {
557
+ expect(padCell(row.key, layout.grade)).toHaveLength(layout.grade);
558
+ }
559
+ }
560
+ }
561
+ });
562
+ });
563
+
564
+ describe("the colour key under the bars", () => {
565
+ test("names every model the presets actually use, and nothing else", () => {
566
+ const models = modelsInUse(BUILT_IN_PRESETS);
567
+ expect(models).toContain("fable");
568
+ expect(models).toContain("opus");
569
+ expect(models).toContain("sonnet");
570
+ // NOT haiku: the presets no longer use it, and a key naming a colour that appears
571
+ // nowhere on screen is a legend for a thing that is not there.
572
+ expect(models).not.toContain("haiku");
573
+ // `seo:editor` is pinned to `inherit`, so the key has to explain that grey.
574
+ expect(models).toContain("inherit");
575
+ expect(new Set(models).size).toBe(models.length);
576
+ });
577
+
578
+ test("a long-context variant is keyed by its base model, not twice", () => {
579
+ const config: ModelsConfig = {
580
+ ...fableAdvisor,
581
+ main: { model: "opus[1m]" },
582
+ };
583
+ const models = modelsInUse([config]);
584
+ expect(models).toContain("opus");
585
+ expect(models).not.toContain("opus[1m]");
586
+ });
587
+
588
+ test("lists nothing a preset does not name", () => {
589
+ const config: ModelsConfig = {
590
+ ...fableAdvisor,
591
+ main: { model: "opus" },
592
+ grades: {
593
+ smart: { model: "opus" },
594
+ normal: { model: "opus" },
595
+ cheap: { model: "opus" },
596
+ },
597
+ agents: {},
598
+ };
599
+ expect(modelsInUse([config])).toEqual(["opus"]);
600
+ });
601
+ });
602
+
603
+ describe("the config path under the list", () => {
604
+ test("is shown relative to the project, which is the part that identifies it", () => {
605
+ expect(
606
+ shortConfigPath("/home/x/proj/.claude/models.json", "/home/x/proj", 40),
607
+ ).toBe(".claude/models.json");
608
+ });
609
+
610
+ test("truncates from the LEFT, keeping the filename", () => {
611
+ const short = shortConfigPath(
612
+ "/some/very/long/elsewhere/.claude/models.json",
613
+ "/home/x/proj",
614
+ 20,
615
+ );
616
+ expect(short.length).toBeLessThanOrEqual(20);
617
+ expect(short.endsWith("models.json")).toBe(true);
618
+ expect(short.startsWith("…")).toBe(true);
619
+ });
620
+
621
+ test("a trailing slash on the project path changes nothing", () => {
622
+ expect(
623
+ shortConfigPath("/home/x/proj/.claude/models.json", "/home/x/proj/", 40),
624
+ ).toBe(".claude/models.json");
625
+ });
626
+ });
627
+
628
+ describe("budgeting the list panel's summary", () => {
629
+ /**
630
+ * MEASURED at 80×24 before this existed: the blocks wanted about 17 rows in
631
+ * the 12 the list left, and Yoga answered by shrinking them —
632
+ * `fable-advisor` and `fable-lead` composited into `fable-leadical`, and
633
+ * `off` printed on top of `—`. An over-tall panel in a fixed-height box does
634
+ * not scroll and does not clip; it overprints.
635
+ */
636
+ test("never plans more rows than it was given", () => {
637
+ for (let available = 0; available <= 40; available++) {
638
+ for (const hasPath of [true, false]) {
639
+ const plan = planModelsSummary(available, hasPath);
640
+ expect(plan.rows).toBeLessThanOrEqual(available);
641
+ }
642
+ }
643
+ });
644
+
645
+ test("no room at all means no block, not a partial one", () => {
646
+ const plan = planModelsSummary(1, true);
647
+ expect(plan.rows).toBe(0);
648
+ expect(plan).toMatchObject({ spread: false, key: false, facts: false });
649
+ });
650
+
651
+ test("a tall panel gets everything", () => {
652
+ expect(planModelsSummary(40, true)).toMatchObject({
653
+ spread: true,
654
+ key: true,
655
+ facts: true,
656
+ });
657
+ });
658
+
659
+ test("the comparison strip is gone, not merely unplanned", () => {
660
+ // It rendered as one purple bar, then a single teal rectangle spanning
661
+ // the two presets that share a colour, then grey — three blocks where
662
+ // four comparisons were meant. There is no height at which it comes back.
663
+ for (let available = 0; available <= 60; available++) {
664
+ expect(planModelsSummary(available, true)).not.toHaveProperty("compare");
665
+ }
666
+ });
667
+
668
+ test("drops by value: the key goes before the spread or the facts", () => {
669
+ // The 80×24 case: twelve rows left under a five-row list.
670
+ const tight = planModelsSummary(11, true);
671
+ expect(tight.spread).toBe(true);
672
+ expect(tight.facts).toBe(true);
673
+ expect(tight.key).toBe(false);
674
+ });
675
+
676
+ test("there is no height at which a lesser block survives a greater one", () => {
677
+ for (let available = 0; available <= 40; available++) {
678
+ const plan = planModelsSummary(available, true);
679
+ if (plan.key) expect(plan.spread).toBe(true);
680
+ if (plan.key) expect(plan.facts).toBe(true);
681
+ if (plan.facts) expect(plan.spread).toBe(true);
682
+ }
683
+ });
684
+
685
+ test("a project with no config path costs one row less", () => {
686
+ // The facts block prints a `config` line only when there is one, and the
687
+ // budget has to know that or the panel overprints by a row.
688
+ const withPath = planModelsSummary(40, true);
689
+ const without = planModelsSummary(40, false);
690
+ expect(without.rows).toBe(withPath.rows - 1);
691
+ });
692
+ });
693
+
694
+ describe("the preset list's shared row layout", () => {
695
+ const rows: PresetRowInput[] = BUILT_IN_PRESETS.map((preset) => ({
696
+ label: preset.preset,
697
+ main: preset.main.model,
698
+ smart: preset.grades.smart.model,
699
+ markers: presetMarkerWidth(preset.preset === "fable-advisor", false),
700
+ }));
701
+
702
+ test("ONE layout for the whole list — never one per row", () => {
703
+ // The bug, off a 120×30 screenshot: the selected row dropped its `main` /
704
+ // `critical` labels while its three neighbours kept theirs. A layout
705
+ // measured from the widest row is the same object for every row, so the
706
+ // form cannot vary down the column.
707
+ const layout = fitPresetRows(rows, 57);
708
+ const widestName = Math.max(...rows.map((row) => row.label.length));
709
+ const widestMain = Math.max(...rows.map((row) => row.main.length));
710
+ expect(layout.name).toBe(Math.min(PRESET_NAME_MAX, widestName));
711
+ expect(layout.main).toBe(widestMain);
712
+ // Every row's name cell is exactly the shared column, so they align.
713
+ for (const row of rows) {
714
+ expect(padCell(row.label, layout.name)).toHaveLength(layout.name);
715
+ }
716
+ });
717
+
718
+ test("a row that alone would fit the labels still loses them with the list", () => {
719
+ // `opus-lead` is the shortest name and the narrowest pair, so on its own
720
+ // it fits a form the list as a whole cannot. Uniformly terse beats two
721
+ // formats in one column.
722
+ const short = rows.filter((row) => row.label === "opus-lead");
723
+ const width = fitPresetRows(short, 200).total;
724
+ expect(fitPresetRows(short, width).detail).toBe("labelled");
725
+ expect(fitPresetRows(rows, width).detail).not.toBe("labelled");
726
+ });
727
+
728
+ test("never asks for more columns than the panel has, at any width", () => {
729
+ for (let width = 10; width <= 200; width++) {
730
+ const layout = fitPresetRows(rows, width);
731
+ // `none` is the floor: the name and its markers are not negotiable,
732
+ // and that case is reported by `total` rather than hidden.
733
+ if (layout.detail !== "none") {
734
+ expect(layout.total).toBeLessThanOrEqual(width);
735
+ }
736
+ }
737
+ });
738
+
739
+ test("degrades in one fixed order and never skips a rung", () => {
740
+ const seen: string[] = [];
741
+ for (let width = 200; width >= 10; width--) {
742
+ const detail = fitPresetRows(rows, width).detail;
743
+ if (seen.at(-1) !== detail) seen.push(detail);
744
+ }
745
+ expect(seen).toEqual(["labelled", "smart", "compact", "main", "none"]);
746
+ });
747
+
748
+ test("`smart`'s label outlives `main`'s, and its value outlives nothing else", () => {
749
+ // `smart` is the grade this screen is about, so its label is the last
750
+ // one drawn; but at the narrowest rung the single model kept is `main` —
751
+ // a bare second model with no label beside it says nothing usable.
752
+ for (let width = 10; width <= 200; width++) {
753
+ const layout = fitPresetRows(rows, width);
754
+ if (layout.detail === "smart" || layout.detail === "compact") {
755
+ expect(layout.smart).toBeGreaterThan(0);
756
+ }
757
+ if (layout.detail === "main") {
758
+ expect(layout.main).toBeGreaterThan(0);
759
+ expect(layout.smart).toBe(0);
760
+ }
761
+ if (layout.detail === "none") expect(layout.main).toBe(0);
762
+ }
763
+ });
764
+
765
+ test("a long name truncates into the column rather than widening it", () => {
766
+ const long: PresetRowInput[] = [
767
+ {
768
+ label: "a-very-long-preset-name-indeed",
769
+ main: "opus",
770
+ smart: "fable",
771
+ markers: 0,
772
+ },
773
+ ];
774
+ const layout = fitPresetRows(long, 200);
775
+ expect(layout.name).toBe(PRESET_NAME_MAX);
776
+ expect(padCell(long[0]?.label ?? "", layout.name)).toHaveLength(
777
+ PRESET_NAME_MAX,
778
+ );
779
+ });
780
+
781
+ test("marker widths carry their own leading space", () => {
782
+ expect(presetMarkerWidth(false, false)).toBe(0);
783
+ expect(presetMarkerWidth(true, false)).toBe(" (default)".length);
784
+ expect(presetMarkerWidth(false, true)).toBe(" (yours)".length);
785
+ expect(presetMarkerWidth(true, true)).toBe(
786
+ " (default)".length + " (yours)".length,
787
+ );
788
+ });
789
+ });