claudeup 6.6.0 → 6.8.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 (31) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/conventions-manager.test.ts +45 -45
  3. package/src/__tests__/enabled-not-installed.test.ts +1 -1
  4. package/src/__tests__/mate-availability.test.ts +156 -0
  5. package/src/__tests__/mate-catalog.test.ts +295 -0
  6. package/src/__tests__/model-visuals.test.tsx +1698 -25
  7. package/src/__tests__/models-adapter.test.ts +21 -6
  8. package/src/__tests__/models-cli.test.ts +100 -0
  9. package/src/__tests__/models-core.test.ts +273 -111
  10. package/src/__tests__/models-manager.test.ts +15 -12
  11. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  12. package/src/__tests__/models-screen-state.test.ts +57 -1
  13. package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
  14. package/src/__tests__/resolver.test.ts +3 -3
  15. package/src/cli/doctor.ts +8 -13
  16. package/src/cli/models.ts +97 -13
  17. package/src/data/models-presets.ts +72 -13
  18. package/src/data/predefined-profiles.ts +7 -7
  19. package/src/services/claude-settings.ts +1 -1
  20. package/src/services/community-styles.ts +1 -1
  21. package/src/services/mate-availability.ts +133 -0
  22. package/src/services/mate-catalog.ts +265 -0
  23. package/src/services/models-core.ts +371 -30
  24. package/src/ui/adapters/modelsAdapter.ts +58 -15
  25. package/src/ui/components/layout/ScreenLayout.tsx +6 -1
  26. package/src/ui/renderers/modelRenderers.tsx +413 -108
  27. package/src/ui/renderers/modelVisuals.tsx +694 -145
  28. package/src/ui/screens/ModelsScreen.tsx +74 -10
  29. package/src/ui/state/reducer.ts +20 -0
  30. package/src/ui/state/types.ts +31 -0
  31. package/src/ui/theme-mode.ts +128 -14
@@ -1,36 +1,66 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { BUILT_IN_PRESETS, findPreset } from "../data/models-presets.js";
2
+ import { type CapturedFrame, rgbToHex } from "@opentui/core";
3
+ import { testRender } from "@opentui/react/test-utils";
4
+ import { act } from "react";
5
+ import {
6
+ BUILT_IN_PRESETS,
7
+ WORKFLOWS,
8
+ findPreset,
9
+ } from "../data/models-presets.js";
3
10
  import {
4
11
  EFFORTS,
5
12
  type Effort,
6
13
  GRADES,
14
+ MATES,
7
15
  MODEL_ALIASES,
8
16
  type ModelsConfig,
17
+ type ModelsStatus,
18
+ UNBOUND_MATE,
19
+ modelLabel,
9
20
  } from "../services/models-core.js";
21
+ import type { ModelsPresetItem } from "../ui/adapters/modelsAdapter.js";
10
22
  import {
11
23
  detailPanelWidth,
12
24
  listPanelWidth,
13
25
  } from "../ui/components/layout/ScreenLayout.js";
14
26
  import {
27
+ AgentSpread,
28
+ SPREAD_LABEL_CELLS,
29
+ SUBAGENT_ROW_LABEL,
30
+ WORKFLOW_GAP_ROWS,
31
+ WORKFLOW_ROWS,
15
32
  planModelsSummary,
33
+ renderModelsSummary,
16
34
  shortConfigPath,
35
+ specModelColumn,
17
36
  } from "../ui/renderers/modelRenderers.js";
18
37
  import {
38
+ BAR_DIVIDER,
19
39
  EFFORT_GLYPH_CELLS,
20
40
  EFFORT_OFF,
21
41
  EFFORT_ON,
42
+ MATES_SEGMENT,
43
+ MATE_EFFORT_NOTE,
44
+ MATE_WEFT,
22
45
  NONE_CELL,
23
46
  NO_EFFORT_GLYPH,
47
+ PRESET_MODEL_MAX,
24
48
  PRESET_NAME_MAX,
25
49
  type PresetRowInput,
26
50
  agentDistribution,
27
51
  agentRows,
28
52
  agentTableHeaders,
53
+ barSegments,
54
+ clipCell,
29
55
  effortGlyph,
30
56
  effortLabel,
31
57
  effortLevel,
58
+ effortWord,
32
59
  fitAgentTable,
60
+ fitModelKey,
33
61
  fitPresetRows,
62
+ inlineLabelWidth,
63
+ isMateSegment,
34
64
  modelBadgeWidth,
35
65
  modelBarFill,
36
66
  modelFg,
@@ -39,9 +69,17 @@ import {
39
69
  modelsInUse,
40
70
  padCell,
41
71
  presetMarkerWidth,
72
+ segmentLabel,
73
+ workflowDistribution,
42
74
  } from "../ui/renderers/modelVisuals.js";
43
75
  import { wrapText } from "../ui/renderers/styleRenderers.js";
44
- import { CONTRAST_REFERENCE_KEYS, MODEL_BADGE } from "../ui/theme-mode.js";
76
+ import {
77
+ CONTRAST_REFERENCE_KEYS,
78
+ MODEL_BADGE,
79
+ modelBadge,
80
+ resetThemeMode,
81
+ setThemeMode,
82
+ } from "../ui/theme-mode.js";
45
83
  import { theme } from "../ui/theme.js";
46
84
 
47
85
  /**
@@ -79,27 +117,48 @@ describe("the model → colour map", () => {
79
117
  /** The grounds a dark terminal actually uses: pure black, our reference, One Dark. */
80
118
  const DARK_GROUNDS = ["#000000", CONTRAST_REFERENCE_KEYS.dark, "#282C34"];
81
119
 
82
- test("is total over every alias the Agent tool accepts, plus inherit", () => {
120
+ /**
121
+ * Everything the map has to answer for: four aliases, three mate slots, the mate FAMILY
122
+ * tone, and `inherit`.
123
+ *
124
+ * `mates` is not a slot and not a routing value — it is the ink of the one collapsed run
125
+ * a workflow bar draws for all external work at once. It is in this list because it is
126
+ * painted at full size on the chart, so every contrast rule below applies to it exactly
127
+ * as it applies to a model's own tone.
128
+ */
129
+ const ALL_INKS = [
130
+ ...MODEL_ALIASES,
131
+ ...MATES,
132
+ MATES_SEGMENT,
133
+ "inherit",
134
+ ] as const;
135
+ /** The ones that carry a hue — `inherit` is grey on purpose and is excluded. */
136
+ const COLOURED = [...MODEL_ALIASES, ...MATES, MATES_SEGMENT] as const;
137
+
138
+ test("is total over every alias the Agent tool accepts, the mates, and inherit", () => {
83
139
  // 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) {
140
+ expect(Object.keys(MODEL_BADGE).sort()).toEqual([...ALL_INKS].sort());
141
+ for (const alias of ALL_INKS) {
88
142
  for (const mode of ["light", "dark"] as const) {
89
143
  const ink = MODEL_BADGE[alias][mode];
90
144
  expect(ink.bg).toMatch(/^#[0-9A-Fa-f]{6}$/);
91
145
  expect(ink.fg).toMatch(/^#[0-9A-Fa-f]{6}$/);
92
146
  expect(ink.bar).toMatch(/^#[0-9A-Fa-f]{6}$/);
147
+ expect(ink.label).toMatch(/^#[0-9A-Fa-f]{6}$/);
93
148
  }
94
149
  }
95
150
  });
96
151
 
97
152
  test("one colour per model, and no two models share one", () => {
98
153
  // Rule 1: colour is semantic. Two models on one fill means the colour stops
99
- // answering "which model" the moment both appear on screen.
154
+ // answering "which model" the moment both appear on screen. The mates are in scope:
155
+ // they are one FAMILY, which is a statement about hue, not a licence to repeat a fill
156
+ // — three slots that painted the same colour would say there is one of them.
100
157
  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);
158
+ const fills = COLOURED.map((a) => MODEL_BADGE[a][mode].bg);
159
+ expect(new Set(fills).size).toBe(COLOURED.length);
160
+ const bars = COLOURED.map((a) => MODEL_BADGE[a][mode].bar);
161
+ expect(new Set(bars).size).toBe(COLOURED.length);
103
162
  }
104
163
  });
105
164
 
@@ -109,7 +168,7 @@ describe("the model → colour map", () => {
109
168
  * Dark's they measure ~1.0:1 and the chip reads as a hole punched in the row.
110
169
  */
111
170
  test("every dark fill clears every plausible dark ground", () => {
112
- for (const alias of [...MODEL_ALIASES, "inherit"] as const) {
171
+ for (const alias of ALL_INKS) {
113
172
  for (const ground of DARK_GROUNDS) {
114
173
  expect(contrast(MODEL_BADGE[alias].dark.bg, ground)).toBeGreaterThan(
115
174
  1.2,
@@ -119,7 +178,7 @@ describe("the model → colour map", () => {
119
178
  });
120
179
 
121
180
  test("ink is readable on its own fill, on both pages", () => {
122
- for (const alias of [...MODEL_ALIASES, "inherit"] as const) {
181
+ for (const alias of ALL_INKS) {
123
182
  for (const mode of ["light", "dark"] as const) {
124
183
  const ink = MODEL_BADGE[alias][mode];
125
184
  expect(contrast(ink.fg, ink.bg)).toBeGreaterThan(3);
@@ -127,6 +186,93 @@ describe("the model → colour map", () => {
127
186
  }
128
187
  });
129
188
 
189
+ /**
190
+ * THE GUARD ON THE INLINE LABEL, and the reason `label` exists at all.
191
+ *
192
+ * A bar segment is one block of one colour with a word written on it — no chip and no
193
+ * second background, because a chip is what was reported ("do not add additional
194
+ * background colour to model segments"). That leaves the word sitting directly on `bar`,
195
+ * so the ink has to be chosen against `bar` and measured against `bar`.
196
+ *
197
+ * Without this test a palette tweak reintroduces an unreadable label in silence: the
198
+ * screen still draws, the word is still there, and it is a smudge. Which is precisely
199
+ * where `fg` was — the negative control below measures it at 1.74:1.
200
+ */
201
+ test("every model's LABEL ink clears 4.5:1 on its own bar, on both pages", () => {
202
+ const measured: Array<{ key: string; clears: boolean }> = [];
203
+ for (const alias of ALL_INKS) {
204
+ for (const mode of ["light", "dark"] as const) {
205
+ const ink = MODEL_BADGE[alias][mode];
206
+ measured.push({
207
+ key: `${alias}.${mode}`,
208
+ clears: contrast(ink.label, ink.bar) >= 4.5,
209
+ });
210
+ }
211
+ }
212
+ // Asserted as one array so a failure names every model that regressed rather than
213
+ // stopping at the first, which is what makes fixing a palette one edit.
214
+ expect(measured.filter((entry) => !entry.clears)).toEqual([]);
215
+ });
216
+
217
+ /**
218
+ * The collapsed run's own numbers, pinned rather than covered by the loop above.
219
+ *
220
+ * The loop asserts a threshold over every ink; this records what THIS one measures, so a
221
+ * palette nudge that still clears 4.5:1 but moves the tone is a visible diff rather than
222
+ * a silent one. It is the ink most worth pinning: it is the widest single block of colour
223
+ * the chart ever paints, and it is the newest.
224
+ */
225
+ test("the collapsed `mates` run's label is 4.97:1 light / 4.78:1 dark on its bar", () => {
226
+ const measured = (["light", "dark"] as const).map((mode) => ({
227
+ mode,
228
+ label: Number(
229
+ contrast(
230
+ MODEL_BADGE.mates[mode].label,
231
+ MODEL_BADGE.mates[mode].bar,
232
+ ).toFixed(2),
233
+ ),
234
+ chip: Number(
235
+ contrast(
236
+ MODEL_BADGE.mates[mode].fg,
237
+ MODEL_BADGE.mates[mode].bg,
238
+ ).toFixed(2),
239
+ ),
240
+ }));
241
+ expect(measured).toEqual([
242
+ { mode: "light", label: 4.97, chip: 5.87 },
243
+ { mode: "dark", label: 4.78, chip: 4.83 },
244
+ ]);
245
+ // And it is nobody's slot ink: a run standing for several slots may not wear one of
246
+ // their colours, which is the whole reason this entry exists.
247
+ for (const mode of ["light", "dark"] as const) {
248
+ for (const mate of MATES) {
249
+ expect(MODEL_BADGE.mates[mode].bar).not.toBe(
250
+ MODEL_BADGE[mate][mode].bar,
251
+ );
252
+ }
253
+ }
254
+ });
255
+
256
+ test("and `fg` would NOT have — the reason a fourth ink was added at all", () => {
257
+ // The negative control. `fg` is built to sit on `bg`, and on the dark page it
258
+ // collapses on `bar`: sonnet 1.74:1, opus 1.90, mate1 2.12. Deleting the chip and
259
+ // keeping `fg` would have shipped exactly that.
260
+ const worst = Math.min(
261
+ ...MODEL_ALIASES.map((alias) =>
262
+ contrast(MODEL_BADGE[alias].dark.fg, MODEL_BADGE[alias].dark.bar),
263
+ ),
264
+ );
265
+ expect(worst).toBeLessThan(2);
266
+ // And no model's label IS its `fg`, so nothing can quietly fall back to the pairing
267
+ // this replaced.
268
+ for (const alias of ALL_INKS) {
269
+ for (const mode of ["light", "dark"] as const) {
270
+ const ink = MODEL_BADGE[alias][mode];
271
+ expect(ink.label).not.toBe(ink.fg);
272
+ }
273
+ }
274
+ });
275
+
130
276
  /**
131
277
  * Dark is NOT a mirror of light, and that is the whole design. Cream carries the light
132
278
  * already, so a light chip stays quiet; a near-black page carries none, so the same
@@ -134,7 +280,7 @@ describe("the model → colour map", () => {
134
280
  * theme". A dark chip is a saturated mid-tone, so its FILL is the brighter half.
135
281
  */
136
282
  test("a dark chip is a mid-tone fill, a light chip is a pale one", () => {
137
- for (const alias of MODEL_ALIASES) {
283
+ for (const alias of COLOURED) {
138
284
  const light = MODEL_BADGE[alias].light;
139
285
  const dark = MODEL_BADGE[alias].dark;
140
286
  // Light: the fill is barely off the page, the ink carries the colour.
@@ -146,12 +292,138 @@ describe("the model → colour map", () => {
146
292
 
147
293
  test("the bar is a softer step than the badge ink it sits beside", () => {
148
294
  // Twenty characters of badge ink is a slab; a chart needs its own strength.
149
- for (const alias of MODEL_ALIASES) {
295
+ for (const alias of COLOURED) {
150
296
  const dark = MODEL_BADGE[alias].dark;
151
297
  expect(luminance(dark.bar)).toBeLessThan(luminance(dark.fg));
152
298
  }
153
299
  });
154
300
 
301
+ /**
302
+ * The mates read as ONE FAMILY and still separate as three slots.
303
+ *
304
+ * Both halves matter and they pull against each other. All three mean the same thing —
305
+ * someone else's model, reached through claudish — so three unrelated hues would say the
306
+ * opposite; but they are three genuinely different external models, so one hue would say
307
+ * there is one of them.
308
+ *
309
+ * The resolution is a shared warm band (hue 6°–41° on both pages, which nothing else on
310
+ * this screen occupies) with ~17° between neighbours. It is asserted as hue rather than
311
+ * as luminance because that IS the design: at one or two characters a luminance step of
312
+ * this size is invisible, while a hue difference of this size is not.
313
+ */
314
+ const hue = (hex: string): number => {
315
+ const n = Number.parseInt(hex.slice(1), 16);
316
+ const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
317
+ const max = Math.max(r, g, b);
318
+ const min = Math.min(r, g, b);
319
+ if (max === min) return Number.NaN; // grey has no hue at all
320
+ const delta = max - min;
321
+ const raw =
322
+ max === r
323
+ ? ((g - b) / delta) % 6
324
+ : max === g
325
+ ? (b - r) / delta + 2
326
+ : (r - g) / delta + 4;
327
+ return (raw * 60 + 360) % 360;
328
+ };
329
+
330
+ /**
331
+ * How far a colour is from grey, 0–255.
332
+ *
333
+ * `hue` cannot answer that: a near-grey has a hue angle like any other colour, so
334
+ * "is this neutral" has to be asked of the channel spread instead.
335
+ */
336
+ const chroma = (hex: string): number => {
337
+ const n = Number.parseInt(hex.slice(1), 16);
338
+ const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255];
339
+ return Math.max(r, g, b) - Math.min(r, g, b);
340
+ };
341
+
342
+ test("a label is the model's own hue deepened, not a fifth palette", () => {
343
+ // Every label keeps the hue of the bar it is written on, so a labelled segment reads
344
+ // as one colour with a darker word on it rather than as two colours meeting. This is
345
+ // what stops the contrast rule above being satisfied by painting every label black.
346
+ for (const alias of COLOURED) {
347
+ for (const mode of ["light", "dark"] as const) {
348
+ const ink = MODEL_BADGE[alias][mode];
349
+ const drift = Math.abs(hue(ink.label) - hue(ink.bar));
350
+ expect({
351
+ alias,
352
+ mode,
353
+ near: Math.min(drift, 360 - drift) < 12,
354
+ }).toEqual({ alias, mode, near: true });
355
+ }
356
+ }
357
+ // `inherit` is the exception, deliberately: it means "nobody chose", so its label is
358
+ // a NEUTRAL — asserted as chroma rather than as hue, because a near-grey still has a
359
+ // hue angle (60° here) and pinning that would read as a colour decision it is not.
360
+ for (const mode of ["light", "dark"] as const) {
361
+ expect(chroma(MODEL_BADGE.inherit[mode].label)).toBeLessThanOrEqual(12);
362
+ }
363
+ });
364
+
365
+ test("the three mates share one warm band, which no Claude model is in", () => {
366
+ for (const mode of ["light", "dark"] as const) {
367
+ // The family tone is IN the band with them — it is the ink of the run that stands
368
+ // for all three at once, so it has to read as the same family and not as a fifth
369
+ // kind of thing.
370
+ const mateHues = [...MATES, MATES_SEGMENT].map((mate) =>
371
+ hue(MODEL_BADGE[mate][mode].bg),
372
+ );
373
+ for (const angle of mateHues) {
374
+ expect(angle).toBeGreaterThanOrEqual(0);
375
+ expect(angle).toBeLessThanOrEqual(50);
376
+ }
377
+ // No Claude model may be in the band. `haiku` and `inherit` are grey, so their hue
378
+ // is NaN and every comparison against it is false — which is the right answer and
379
+ // not an accident: a grey cannot be confused with an ochre.
380
+ for (const alias of MODEL_ALIASES) {
381
+ const angle = hue(MODEL_BADGE[alias][mode].bg);
382
+ expect(angle >= 0 && angle <= 50).toBe(false);
383
+ }
384
+ }
385
+ });
386
+
387
+ test("and they still separate from each other, by hue rather than by weight", () => {
388
+ for (const mode of ["light", "dark"] as const) {
389
+ const angles = MATES.map((mate) => hue(MODEL_BADGE[mate][mode].bg));
390
+ for (let i = 1; i < angles.length; i++) {
391
+ const previous = angles[i - 1] as number;
392
+ const current = angles[i] as number;
393
+ // Ordered ochre → terracotta → red earth, and far enough apart to read.
394
+ expect(previous - current).toBeGreaterThan(10);
395
+ }
396
+ }
397
+ });
398
+
399
+ test("`kangaroo` is an earth tone, not the danger red this app already uses", () => {
400
+ // Red means "failed" everywhere else in claudeup. The reddest mate has to stay clearly
401
+ // off that meaning, so it is pinned as a desaturated, dark-of-mid fill rather than a
402
+ // bright signal red.
403
+ const dark = MODEL_BADGE.kangaroo.dark.bg;
404
+ expect(hue(dark)).toBeLessThan(15);
405
+ expect(luminance(dark)).toBeLessThan(luminance("#FF0000"));
406
+ });
407
+
408
+ test("modelBadge returns the mate's own ink, never inherit's grey", () => {
409
+ // The fallback is correct for a name this build does not know. A mate IS known, and a
410
+ // mate falling through to grey would say "claudeup does not recognise this" about a
411
+ // slot it ships — on a screen where grey already means "nobody chose".
412
+ //
413
+ // Both pages, explicitly: the resolved mode is process-wide module state, so a test
414
+ // that just called `modelBadge` would be asserting whatever the previous file left.
415
+ for (const mode of ["light", "dark"] as const) {
416
+ setThemeMode(mode);
417
+ for (const mate of MATES) {
418
+ expect(modelBadge(mate)).toEqual(MODEL_BADGE[mate][mode]);
419
+ expect(modelBadge(mate)).not.toEqual(modelBadge("inherit"));
420
+ }
421
+ // Negative control: the fallback still works for something genuinely unknown.
422
+ expect(modelBadge("mate7")).toEqual(modelBadge("inherit"));
423
+ }
424
+ resetThemeMode();
425
+ });
426
+
155
427
  test("a long-context variant keeps its base model's colour", () => {
156
428
  expect(modelFg("opus[1m]")).toBe(modelFg("opus"));
157
429
  expect(modelFg("fable[1m]")).toBe(modelFg("fable"));
@@ -372,6 +644,106 @@ describe("the agent table", () => {
372
644
  expect(row?.effort).toBeUndefined();
373
645
  expect(NONE_CELL).toBe("—");
374
646
  });
647
+
648
+ /**
649
+ * A mate row is NOT an inherit row, and the distinction is the whole point.
650
+ *
651
+ * `inherit` blanks the model cell to `—`, which is right for "nothing was decided". A
652
+ * mate WAS decided — the author sent that agent to claudish — so blanking it would hide
653
+ * real routing behind the dash that means the opposite. The flag drives the blanking, so
654
+ * this is the assertion that keeps the slot name on screen.
655
+ */
656
+ test("a mate row keeps its slot name, so it never blanks to the inherit dash", () => {
657
+ for (const mate of MATES) {
658
+ const config: ModelsConfig = {
659
+ ...fableAdvisor,
660
+ agents: { "x:y": { model: mate } },
661
+ };
662
+ const row = agentRows(config)[0];
663
+ expect(row?.model).toBe(mate);
664
+ expect(row?.key).toBe(mate);
665
+ expect(row?.inherit).toBe(false);
666
+ // The preset's mate effort, since the slot names none of its own. `fableAdvisor`
667
+ // sends its external work at `xhigh`, and an agent on a slot runs at that.
668
+ expect(row?.effort).toBe(fableAdvisor.mateEffort);
669
+ // And it takes its own ink, not the grey `inherit` would have given it.
670
+ expect(row?.fill).toBe(modelBarFill(mate));
671
+ expect(row?.fill).not.toBe(modelBarFill("inherit"));
672
+ }
673
+ });
674
+
675
+ test("a mate sorts after every grade, with `inherit`, because it is not a tier", () => {
676
+ const config: ModelsConfig = {
677
+ ...fableAdvisor,
678
+ agents: {
679
+ a: "smart",
680
+ b: { model: "kangaroo" },
681
+ c: "cheap",
682
+ d: { model: "inherit" },
683
+ },
684
+ };
685
+ expect(agentRows(config).map((row) => row.key)).toEqual([
686
+ "smart",
687
+ "cheap",
688
+ "inherit",
689
+ "kangaroo",
690
+ ]);
691
+ });
692
+
693
+ test("the effort cell says where the call went, not that it inherits the session", () => {
694
+ // Nothing of the session reaches an external model, so `inherits session` — what an
695
+ // absent effort says everywhere else — would be false on exactly these rows.
696
+ expect(effortWord(undefined, "mate1")).toBe(MATE_EFFORT_NOTE);
697
+ expect(effortWord(undefined, "opus")).toBe("inherits session");
698
+ expect(effortWord(undefined)).toBe("inherits session");
699
+
700
+ expect(effortLabel(undefined, "full", "kangaroo")).toBe(MATE_EFFORT_NOTE);
701
+ expect(effortLabel(undefined, "none", "kangaroo")).toBe("");
702
+ // `short` keeps an effort's initial because the dots beside it still carry the level.
703
+ // The note has no level and no useful initial, so it yields the column instead.
704
+ expect(effortLabel(undefined, "short", "kangaroo")).toBe("");
705
+ expect(effortLabel("medium", "short")).toBe("m");
706
+ });
707
+
708
+ test("a mate's effort reads like any other — it is in force", () => {
709
+ // An external model's effort is applied by claudish, per run, subject to a per-model
710
+ // clamp (`--effort-override` is the flag that skips it). A clamp bounds the value; it
711
+ // is not a reason to write the cell differently from the one beside it.
712
+ expect(effortWord("high", "mate1")).toBe("high");
713
+ expect(effortWord("high", "opus")).toBe("high");
714
+ });
715
+
716
+ test("an ABSENT effort means a different thing for a mate", () => {
717
+ // A Claude subagent with no effort inherits the session's. An external model has no
718
+ // session to inherit from and falls to its own catalogue preset instead, so the two
719
+ // absences cannot share a word.
720
+ expect(effortWord(undefined, "opus")).toBe("inherits session");
721
+ expect(effortWord(undefined, "mate1")).toBe(MATE_EFFORT_NOTE);
722
+ });
723
+
724
+ test("the effort word survives every width rung the table has", () => {
725
+ expect(effortLabel("high", "full", "mate1")).toBe("high");
726
+ expect(effortLabel("high", "short", "mate1")).toBe("h");
727
+ expect(effortLabel("high", "none", "mate1")).toBe("");
728
+ });
729
+
730
+ test("a mate's declared effort reaches its agent rows from the `mates` block", () => {
731
+ // Declared once on the slot rather than repeated on every agent that uses it. An
732
+ // agent-level effort still wins — it is the more specific statement.
733
+ const config: ModelsConfig = {
734
+ ...fableAdvisor,
735
+ agents: {
736
+ a: { model: "mate1" },
737
+ b: { model: "mate1", effort: "low" },
738
+ },
739
+ mates: { mate1: { model: "grok-4.6", effort: "high" } },
740
+ };
741
+ const rows = agentRows(config);
742
+ expect(rows.map((r) => ({ name: r.name, effort: r.effort }))).toEqual([
743
+ { name: "a", effort: "high" },
744
+ { name: "b", effort: "low" },
745
+ ]);
746
+ });
375
747
  });
376
748
 
377
749
  describe("fitting the agent table to a panel", () => {
@@ -420,7 +792,7 @@ describe("fitting the agent table to a panel", () => {
420
792
  test("the name column truncates rather than wrapping", () => {
421
793
  const layout = fitAgentTable(rows, 24);
422
794
  expect(layout.name).toBeGreaterThanOrEqual(6);
423
- expect(padCell("code-analysis:detective", layout.name)).toHaveLength(
795
+ expect(padCell("code-search:analyze", layout.name)).toHaveLength(
424
796
  layout.name,
425
797
  );
426
798
  });
@@ -458,22 +830,168 @@ describe("fitting the agent table to a panel", () => {
458
830
  expect(padCell("abcdefgh", 5)).toHaveLength(5);
459
831
  });
460
832
 
833
+ /**
834
+ * A mate row is where this column goes wrong, and what makes it wide changed.
835
+ *
836
+ * It used to be the SLOT NAME: `kangaroo` is 8 cells, wider than every model name
837
+ * (`sonnet`, 6) and wider than `inherit` (7). A slot is now bound to a real catalogue id
838
+ * and the column draws THAT, so the widest value is no longer 8 but up to 21
839
+ * (`gemini-omni-1.1-flash`) — and an unbound slot draws `unset`, which is 5.
840
+ *
841
+ * The column therefore has to be measured from `row.label`, the string the row actually
842
+ * draws, and — new — it has to be allowed to truncate, because at 21 cells the old
843
+ * "name column absorbs everything" rule stops being reachable on a narrow terminal.
844
+ */
845
+ describe("with a mate row in it", () => {
846
+ const mateConfig = {
847
+ ...fableAdvisor,
848
+ agents: {
849
+ "code-search:analyze": { model: "kangaroo" as const },
850
+ "dev:architect": "smart" as const,
851
+ Explore: "cheap" as const,
852
+ },
853
+ };
854
+ const mateRows = agentRows(mateConfig);
855
+ const boundRows = agentRows({
856
+ ...mateConfig,
857
+ mates: { kangaroo: { model: "grok-4.6" } },
858
+ });
859
+
860
+ test("an UNBOUND slot draws `unset`, never the slot name", () => {
861
+ // The defect this whole change exists for: the Model column printed the slot, so a
862
+ // row read `kangaroo kangaroo —`. A slot is a role. `unset` says the author chose
863
+ // to route here and has not yet said where — which is a different state from the
864
+ // `—` that means nothing was decided at all.
865
+ const row = mateRows.find((r) => r.name === "code-search:analyze");
866
+ expect({ key: row?.key, label: row?.label }).toEqual({
867
+ key: "kangaroo",
868
+ label: UNBOUND_MATE,
869
+ });
870
+ expect(row?.inherit).toBe(false);
871
+ });
872
+
873
+ test("a BOUND slot draws its catalogue id, and keeps the slot as its Tier", () => {
874
+ const row = boundRows.find((r) => r.name === "code-search:analyze");
875
+ // Tier says the role, Model says the model. Two columns, two answers.
876
+ expect({ key: row?.key, model: row?.model, label: row?.label }).toEqual({
877
+ key: "kangaroo",
878
+ model: "kangaroo",
879
+ label: "grok-4.6",
880
+ });
881
+ });
882
+
883
+ test("the model column is measured from the bound id, not the slot", () => {
884
+ for (const width of [57, 80, 120]) {
885
+ const layout = fitAgentTable(boundRows, width);
886
+ expect(layout.model).toBeGreaterThanOrEqual(modelWidth("grok-4.6"));
887
+ expect(layout.total).toBeLessThanOrEqual(width);
888
+ // Drawn exactly, with nothing clipped at these widths.
889
+ const drawn = `grok-4.6${modelPad("grok-4.6", layout.model)}`;
890
+ expect(drawn).toHaveLength(layout.model);
891
+ expect(drawn).toContain("grok-4.6");
892
+ }
893
+ });
894
+
895
+ test("a LONG binding widens the column at a normal width", () => {
896
+ const longRows = agentRows({
897
+ ...mateConfig,
898
+ mates: { kangaroo: { model: "gemini-omni-1.1-flash" } },
899
+ });
900
+ const layout = fitAgentTable(longRows, 120);
901
+ expect(layout.model).toBe(modelWidth("gemini-omni-1.1-flash"));
902
+ expect(layout.total).toBeLessThanOrEqual(120);
903
+ });
904
+
905
+ test("and TRUNCATES with a marker when even the name floor cannot pay for it", () => {
906
+ // The rung that did not exist. Every value this column could hold used to be ≤8, so
907
+ // surrendering the name column always got the table under the width. At 21 cells it
908
+ // does not: at width 30 the fixed part is already 27 and the name floor adds 7, so
909
+ // the table overran by four with no rung left to take them from.
910
+ const longRows = agentRows({
911
+ ...mateConfig,
912
+ mates: { kangaroo: { model: "gemini-omni-1.1-flash" } },
913
+ });
914
+ const layout = fitAgentTable(longRows, 30);
915
+ expect(layout.total).toBeLessThanOrEqual(30);
916
+ expect(layout.model).toBeLessThan(modelWidth("gemini-omni-1.1-flash"));
917
+ // Never a silent clip: the `…` is what stops a cut id reading as a complete one.
918
+ const drawn = clipCell("gemini-omni-1.1-flash", layout.model);
919
+ expect(drawn).toHaveLength(layout.model);
920
+ expect(drawn.endsWith("…")).toBe(true);
921
+ });
922
+
923
+ test("the effort column measures the string a mate row actually draws", () => {
924
+ // A mate row draws an effort word like any other — its own, or the preset's. The
925
+ // column has to be measured from THAT string: measuring `inherits session` would
926
+ // leave it wide of what is on it, and a mate never inherits a session. Asserted as
927
+ // the table's exact total, which is the only way to see which string was used.
928
+ const layout = fitAgentTable(mateRows, detailPanelWidth(120));
929
+ expect(layout.effort).toBe("full");
930
+ const widest = Math.max(
931
+ ...mateRows.map((row) => effortWord(row.effort, row.model).length),
932
+ );
933
+ expect(widest).toBe(
934
+ Math.max(
935
+ ...mateRows.map((row) =>
936
+ row.effort === undefined
937
+ ? MATE_EFFORT_NOTE.length
938
+ : row.effort.length,
939
+ ),
940
+ ),
941
+ );
942
+ expect(layout.total).toBe(
943
+ layout.name +
944
+ 1 +
945
+ (layout.grade > 0 ? layout.grade + 1 : 0) +
946
+ layout.model +
947
+ 1 +
948
+ EFFORT_GLYPH_CELLS +
949
+ 1 +
950
+ widest,
951
+ );
952
+ });
953
+
954
+ test("it still never exceeds the width it was given, at any width", () => {
955
+ for (let width = 20; width <= 200; width++) {
956
+ const layout = fitAgentTable(mateRows, width);
957
+ if (layout.name > 6) expect(layout.total).toBeLessThanOrEqual(width);
958
+ expect(layout.name).toBeGreaterThanOrEqual(6);
959
+ }
960
+ });
961
+
962
+ test("a long binding never exceeds its width either — the case the old floor missed", () => {
963
+ // The old ladder ran out of rungs here and returned a layout wider than the panel.
964
+ // Swept rather than spot-checked, because the failure was at one end of the range.
965
+ const longRows = agentRows({
966
+ ...mateConfig,
967
+ mates: { kangaroo: { model: "gemini-omni-1.1-flash" } },
968
+ });
969
+ const over: number[] = [];
970
+ for (let width = 30; width <= 200; width++) {
971
+ if (fitAgentTable(longRows, width).total > width) over.push(width);
972
+ }
973
+ expect(over).toEqual([]);
974
+ });
975
+ });
976
+
461
977
  test("the model column is the name's own width — no chip padding left in it", () => {
462
978
  // The column used to carry `+2` for the fill's padding, and the header
463
979
  // was indented a column to match. With the chip gone, a column still
464
980
  // reserving that space leaves the header floating off its data.
465
981
  for (const width of [40, 60, 80, 120]) {
466
982
  const layout = fitAgentTable(rows, width);
983
+ // `row.label`, which is what the row DRAWS. Identical to `row.model` for every
984
+ // value except a mate, and the whole point of the split is that those two differ.
467
985
  const widest = rows.reduce(
468
986
  (max, row) =>
469
- Math.max(max, row.inherit ? NONE_CELL.length : modelWidth(row.model)),
987
+ Math.max(max, row.inherit ? NONE_CELL.length : modelWidth(row.label)),
470
988
  "Model".length,
471
989
  );
472
990
  expect(layout.model).toBe(widest);
473
991
  for (const row of rows) {
474
992
  const drawn = row.inherit
475
993
  ? padCell(NONE_CELL, layout.model)
476
- : row.model + modelPad(row.model, layout.model);
994
+ : row.label + modelPad(row.label, layout.model);
477
995
  expect(drawn).toHaveLength(layout.model);
478
996
  }
479
997
  const header = agentTableHeaders(layout).find(
@@ -484,6 +1002,81 @@ describe("fitting the agent table to a panel", () => {
484
1002
  });
485
1003
  });
486
1004
 
1005
+ /**
1006
+ * The tier table's own column, which is measured separately from the agent table's.
1007
+ *
1008
+ * It has to know whether the mate rows are being DRAWN, because `kangaroo` is two cells wider
1009
+ * than any model name a preset can hold. Measuring without it while drawing it leaves
1010
+ * `modelPad` returning an empty string on that one row, and the effort glyphs — which the
1011
+ * whole table is aligned on — jump left on it.
1012
+ */
1013
+ describe("the tier table's model column", () => {
1014
+ test("is the widest model in the config when no mates are drawn", () => {
1015
+ const widest = Math.max(
1016
+ modelWidth(fableAdvisor.main.model),
1017
+ ...GRADES.map((grade) => modelWidth(fableAdvisor.grades[grade].model)),
1018
+ );
1019
+ expect(specModelColumn(fableAdvisor, false)).toBe(widest);
1020
+ });
1021
+
1022
+ test("with mates drawn but UNBOUND, it fits `unset` — not the slot names", () => {
1023
+ // The column measures what each row WRITES. An unbound slot writes `unset` (5), so a
1024
+ // config whose models are 4–6 cells is not widened by drawing the slot rows at all.
1025
+ // Measuring `kangaroo` (8) here would reserve three cells nothing occupies.
1026
+ const column = specModelColumn(fableAdvisor, true);
1027
+ const widestModel = Math.max(
1028
+ modelWidth(fableAdvisor.main.model),
1029
+ ...GRADES.map((grade) => modelWidth(fableAdvisor.grades[grade].model)),
1030
+ );
1031
+ expect(column).toBe(Math.max(widestModel, UNBOUND_MATE.length));
1032
+ for (const mate of MATES) {
1033
+ const drawn = modelLabel(fableAdvisor, mate);
1034
+ expect(drawn).toBe(UNBOUND_MATE);
1035
+ expect(drawn + modelPad(drawn, column)).toHaveLength(column);
1036
+ }
1037
+ });
1038
+
1039
+ test("a BOUND slot widens it to the model, and every row still ends at that column", () => {
1040
+ const config: ModelsConfig = {
1041
+ ...fableAdvisor,
1042
+ mates: {
1043
+ mate1: { model: "grok-4.6" },
1044
+ kangaroo: { model: "deepseek-v4.1-flash" },
1045
+ },
1046
+ };
1047
+ const column = specModelColumn(config, true);
1048
+ expect(column).toBe(modelWidth("deepseek-v4.1-flash"));
1049
+ // Alignment IS this: every row, mate or model, ends at the same column — which is what
1050
+ // the effort glyphs to their right are lined up on.
1051
+ for (const mate of MATES) {
1052
+ const drawn = modelLabel(config, mate);
1053
+ expect(drawn + modelPad(drawn, column)).toHaveLength(column);
1054
+ }
1055
+ for (const grade of GRADES) {
1056
+ const model = config.grades[grade].model;
1057
+ expect(model + modelPad(model, column)).toHaveLength(column);
1058
+ }
1059
+ });
1060
+
1061
+ test("a config that names a mate itself does not need the flag to fit it", () => {
1062
+ // The flag decides whether the three SLOT rows are drawn. A mate a config already
1063
+ // names arrives through `grades`, and is measured whether or not the plugin is there
1064
+ // — which is what keeps a mate-using config rendering correctly on a machine without
1065
+ // the multimodel plugin installed.
1066
+ const config: ModelsConfig = {
1067
+ ...fableAdvisor,
1068
+ grades: {
1069
+ ...fableAdvisor.grades,
1070
+ cheap: { model: "kangaroo" },
1071
+ },
1072
+ mates: { kangaroo: { model: "gemini-omni-1.1-flash" } },
1073
+ };
1074
+ expect(specModelColumn(config, false)).toBe(
1075
+ modelWidth("gemini-omni-1.1-flash"),
1076
+ );
1077
+ });
1078
+ });
1079
+
487
1080
  describe("the panel widths ScreenLayout hands out", () => {
488
1081
  /**
489
1082
  * MEASURED against real renders in tmux at each of these widths — the row
@@ -589,6 +1182,40 @@ describe("the colour key under the bars", () => {
589
1182
  expect(models).not.toContain("opus[1m]");
590
1183
  });
591
1184
 
1185
+ /**
1186
+ * A mate is keyed only when it is USED, and it sorts between the aliases and `inherit`.
1187
+ *
1188
+ * The order reads as decreasing involvement: what claudeup routes, what it hands to
1189
+ * claudish, what it leaves alone. No shipped preset names a mate, so the key is unchanged
1190
+ * for every built-in — the slots are vocabulary, and a legend for a colour that appears
1191
+ * nowhere on screen is a legend for a thing that is not there.
1192
+ */
1193
+ test("a mate appears in the key when a config names one, after the aliases", () => {
1194
+ const config: ModelsConfig = {
1195
+ ...fableAdvisor,
1196
+ agents: {
1197
+ "seo:editor": { model: "inherit" },
1198
+ "x:y": { model: "kangaroo" },
1199
+ "a:b": { model: "mate1" },
1200
+ },
1201
+ };
1202
+ expect(modelsInUse([config])).toEqual([
1203
+ "opus",
1204
+ "sonnet",
1205
+ "fable",
1206
+ "mate1",
1207
+ "kangaroo",
1208
+ "inherit",
1209
+ ]);
1210
+ });
1211
+
1212
+ test("no built-in preset puts a mate in the key", () => {
1213
+ // The vocabulary shipped; the routing did not change. Asserted so re-routing a shipped
1214
+ // agent to an external model stays a decision someone has to make on purpose.
1215
+ const models = modelsInUse(BUILT_IN_PRESETS);
1216
+ for (const mate of MATES) expect(models).not.toContain(mate);
1217
+ });
1218
+
592
1219
  test("lists nothing a preset does not name", () => {
593
1220
  const config: ModelsConfig = {
594
1221
  ...fableAdvisor,
@@ -670,13 +1297,47 @@ describe("budgeting the list panel's summary", () => {
670
1297
  });
671
1298
 
672
1299
  test("drops by value: the key goes before the spread or the facts", () => {
673
- // The 80×24 case: twelve rows left under a five-row list.
674
- const tight = planModelsSummary(11, true);
1300
+ // 16 rows, not the 15 this asserted before the workflows were separated. The two
1301
+ // blank rows between the three groups are two more rows of chart, so the height at
1302
+ // which the spread and the facts both fit moved up with it — the ORDER is what this
1303
+ // pins, and the order is unchanged: the key is still the first thing to go.
1304
+ const tight = planModelsSummary(16, true);
675
1305
  expect(tight.spread).toBe(true);
676
1306
  expect(tight.facts).toBe(true);
677
1307
  expect(tight.key).toBe(false);
678
1308
  });
679
1309
 
1310
+ test("a short panel keeps the chart and drops everything under it", () => {
1311
+ // Thirteen rows. The chart alone costs ten of them — a margin, its header, two rows
1312
+ // for each of the three workflows and a blank row between each pair — on top of the
1313
+ // two the leading blank line and the rule take. Nothing else fits, and the chart is
1314
+ // the right thing to spend them on: it is the only graphic on the screen.
1315
+ const plan = planModelsSummary(13, true);
1316
+ expect(plan.spread).toBe(true);
1317
+ expect(plan.facts).toBe(false);
1318
+ expect(plan.key).toBe(false);
1319
+ });
1320
+
1321
+ /**
1322
+ * WHAT THE SEPARATORS COST, stated rather than discovered.
1323
+ *
1324
+ * Eleven rows is what the list leaves under itself on an 80×24 terminal, and it used to
1325
+ * be exactly enough for the chart. The two blank rows between the three workflow groups
1326
+ * take it over, so at that height the panel now draws nothing at all.
1327
+ *
1328
+ * That is the honest reading of the budget and not a rounding to soften: the block sits
1329
+ * in a fixed-height box, and a box asked for more rows than it has OVERPRINTS — two
1330
+ * preset names composited into one line — rather than clipping. Drawing no chart is the
1331
+ * correct answer at a height where the chart does not fit.
1332
+ */
1333
+ test("an 80×24 panel no longer has room for the separated chart", () => {
1334
+ const plan = planModelsSummary(11, true);
1335
+ expect(plan).toMatchObject({ spread: false, facts: false, key: false });
1336
+ // And the first height at which it comes back is the chart's own cost plus the
1337
+ // blank line and the rule that precede it.
1338
+ expect(planModelsSummary(12, true).spread).toBe(true);
1339
+ });
1340
+
680
1341
  test("there is no height at which a lesser block survives a greater one", () => {
681
1342
  for (let available = 0; available <= 40; available++) {
682
1343
  const plan = planModelsSummary(available, true);
@@ -700,7 +1361,10 @@ describe("the preset list's shared row layout", () => {
700
1361
  label: preset.preset,
701
1362
  main: preset.main.model,
702
1363
  smart: preset.grades.smart.model,
703
- markers: presetMarkerWidth(preset.preset === "fable-advisor", false),
1364
+ // No built-in names a mate, so the routing value and the drawn label are the same
1365
+ // string here. They diverge only for a bound slot — the case below.
1366
+ smartLabel: preset.grades.smart.model,
1367
+ markers: presetMarkerWidth(preset.preset === "fable-advisor"),
704
1368
  }));
705
1369
 
706
1370
  test("ONE layout for the whole list — never one per row", () => {
@@ -772,6 +1436,7 @@ describe("the preset list's shared row layout", () => {
772
1436
  label: "a-very-long-preset-name-indeed",
773
1437
  main: "opus",
774
1438
  smart: "fable",
1439
+ smartLabel: "fable",
775
1440
  markers: 0,
776
1441
  },
777
1442
  ];
@@ -782,12 +1447,1020 @@ describe("the preset list's shared row layout", () => {
782
1447
  );
783
1448
  });
784
1449
 
1450
+ test("a bound mate is measured by its MODEL, not by its slot", () => {
1451
+ // `main` can never be a mate — the validator refuses one — but `smart` can. What the
1452
+ // column has to fit is the BOUND id, which is the string the row draws; measuring the
1453
+ // slot (`kangaroo`, 8) while drawing `grok-4.6` (8) happened to agree, and measuring
1454
+ // it while drawing `deepseek-v4.1-flash` (19) is an eleven-cell overrun.
1455
+ const withMate: PresetRowInput[] = [
1456
+ ...rows,
1457
+ {
1458
+ label: "outback",
1459
+ main: "opus",
1460
+ smart: "kangaroo",
1461
+ smartLabel: "grok-4.6",
1462
+ markers: 0,
1463
+ },
1464
+ ];
1465
+ const layout = fitPresetRows(withMate, 200);
1466
+ expect(layout.smart).toBe(modelWidth("grok-4.6"));
1467
+ expect(`grok-4.6${modelPad("grok-4.6", layout.smart)}`).toHaveLength(
1468
+ layout.smart,
1469
+ );
1470
+ });
1471
+
1472
+ test("a long binding is CAPPED, so one row cannot cost the list its whole column", () => {
1473
+ // Real ids run long: `gemini-omni-1.1-flash` is 21 cells, two and a half times the
1474
+ // widest slot name. Without a ceiling that one row widens the shared column, the list
1475
+ // stops fitting `compact`, and EVERY row loses its second model to one row's binding.
1476
+ const withLong: PresetRowInput[] = [
1477
+ ...rows,
1478
+ {
1479
+ label: "outback",
1480
+ main: "opus",
1481
+ smart: "kangaroo",
1482
+ smartLabel: "gemini-omni-1.1-flash",
1483
+ markers: 0,
1484
+ },
1485
+ ];
1486
+ const layout = fitPresetRows(withLong, 200);
1487
+ expect(layout.smart).toBe(PRESET_MODEL_MAX);
1488
+ // Truncated with the marker that says so — never a silent clip, which would read as a
1489
+ // complete id that simply is not in the catalogue.
1490
+ const drawn = clipCell("gemini-omni-1.1-flash", layout.smart);
1491
+ expect(drawn).toHaveLength(PRESET_MODEL_MAX);
1492
+ expect(drawn.endsWith("…")).toBe(true);
1493
+ });
1494
+
1495
+ test("a bound mate still fits whatever panel it is given, at every width", () => {
1496
+ const withMate: PresetRowInput[] = [
1497
+ ...rows,
1498
+ {
1499
+ label: "outback",
1500
+ main: "opus",
1501
+ smart: "kangaroo",
1502
+ smartLabel: "gemini-omni-1.1-flash",
1503
+ markers: 0,
1504
+ },
1505
+ ];
1506
+ for (let width = 10; width <= 200; width++) {
1507
+ const fitted = fitPresetRows(withMate, width);
1508
+ if (fitted.detail !== "none") {
1509
+ expect({ width, total: fitted.total }).toEqual({
1510
+ width,
1511
+ total: fitted.total,
1512
+ });
1513
+ expect(fitted.total).toBeLessThanOrEqual(width);
1514
+ }
1515
+ }
1516
+ });
1517
+
785
1518
  test("marker widths carry their own leading space", () => {
786
- expect(presetMarkerWidth(false, false)).toBe(0);
787
- expect(presetMarkerWidth(true, false)).toBe(" (default)".length);
788
- expect(presetMarkerWidth(false, true)).toBe(" (yours)".length);
789
- expect(presetMarkerWidth(true, true)).toBe(
790
- " (default)".length + " (yours)".length,
1519
+ // One marker, and only `(default)`. A custom row carries none: `Custom` is already the
1520
+ // whole claim, and a marker beside it repeats the label in different words.
1521
+ expect(presetMarkerWidth(false)).toBe(0);
1522
+ expect(presetMarkerWidth(true)).toBe(" (default)".length);
1523
+ });
1524
+ });
1525
+
1526
+ /**
1527
+ * The workflow bar, judged from the RENDERED FRAME rather than from a helper.
1528
+ *
1529
+ * Everything above this line is a pure function on a config, which is the right shape for
1530
+ * column maths. The bar is not one any more: its widths come out of Yoga, so an assertion
1531
+ * about a width has to come out of Yoga too. A test against an arithmetic helper would pin
1532
+ * a number this code no longer computes — the helper it used to pin (`splitCells`) is gone
1533
+ * precisely because the layout engine was already doing that job.
1534
+ *
1535
+ * So these render `AgentSpread` into a real terminal-sized renderer and read the frame back.
1536
+ * TWO passes, always: a segment's inline label is decided from the width the LAYOUT reported
1537
+ * for it, and that report arrives after the first layout. One frame is the unlabelled state
1538
+ * by construction, not a flake.
1539
+ */
1540
+ describe("the workflow bar", () => {
1541
+ /** The rungs this layout has to survive. 20 is narrower than any real pane. */
1542
+ const WIDTHS = [20, 26, 32, 40, 64, 80];
1543
+
1544
+ /** Rows before the first bar: the block's own top margin, then its header. */
1545
+ const HEADER_ROWS = 2;
1546
+
1547
+ const mateConfig: ModelsConfig = {
1548
+ ...fableAdvisor,
1549
+ agents: {
1550
+ ...fableAdvisor.agents,
1551
+ "dev:qa-engineer": { model: "mate1" },
1552
+ },
1553
+ mates: { mate1: { model: "kimi-k3" } },
1554
+ };
1555
+
1556
+ interface Frame {
1557
+ chars: string[];
1558
+ lines: CapturedFrame["lines"];
1559
+ }
1560
+
1561
+ async function draw(
1562
+ config: ModelsConfig,
1563
+ width: number,
1564
+ mode?: "light" | "dark",
1565
+ ): Promise<Frame> {
1566
+ // The palette is resolved once per process and other cases in this file set it, so
1567
+ // the page is pinned here rather than inherited from whatever ran last.
1568
+ resetThemeMode();
1569
+ if (mode) setThemeMode(mode);
1570
+ const { captureCharFrame, captureSpans, renderOnce, renderer } =
1571
+ await testRender(<AgentSpread config={config} width={width} />, {
1572
+ width,
1573
+ height: 14,
1574
+ });
1575
+ // `act` because the second pass exists to let a LAYOUT-driven state update land, and
1576
+ // an update outside act is both a warning and a frame that may not have flushed.
1577
+ await act(async () => {
1578
+ await renderOnce();
1579
+ await renderOnce();
1580
+ });
1581
+ const frame = {
1582
+ chars: captureCharFrame().split("\n"),
1583
+ lines: captureSpans().lines,
1584
+ };
1585
+ // Every renderer is torn down: thirty live ones in one file exhausts the console
1586
+ // cache's listener budget and the leak is reported as an unrelated warning.
1587
+ await act(async () => renderer.destroy());
1588
+ return frame;
1589
+ }
1590
+
1591
+ /**
1592
+ * The row a workflow's two bars land on.
1593
+ *
1594
+ * A group is its two bars plus the blank row that separates it from the group above, so
1595
+ * the stride is three and the first group — which has nothing above it to separate from
1596
+ * — is the one that pays no gap. Derived from the same two constants the renderer
1597
+ * budgets with, so a change to either moves the test with the picture.
1598
+ */
1599
+ const groupStride = WORKFLOW_ROWS + WORKFLOW_GAP_ROWS;
1600
+ const mainRow = (index: number) => HEADER_ROWS + index * groupStride;
1601
+ const subsRow = (index: number) => mainRow(index) + 1;
1602
+ /** The blank row above group `index`. Only groups after the first have one. */
1603
+ const gapRow = (index: number) => mainRow(index) - 1;
1604
+
1605
+ /**
1606
+ * Which columns of a row are BAR: painted with a segment's ground, or holding a divider.
1607
+ *
1608
+ * The page's own background is read off column 0, which is inside the label column and so
1609
+ * is never a segment. Comparing serialised RGBA rather than the object identity because a
1610
+ * capture hands back a fresh one per span.
1611
+ */
1612
+ function barColumns(line: CapturedFrame["lines"][number], chars: string) {
1613
+ const page = JSON.stringify(line.spans[0]?.bg);
1614
+ const painted: boolean[] = [];
1615
+ for (const span of line.spans) {
1616
+ const isGround = JSON.stringify(span.bg) !== page;
1617
+ for (let i = 0; i < span.width; i++) painted.push(isGround);
1618
+ }
1619
+ return painted.map(
1620
+ (ground, column) => ground || chars[column] === BAR_DIVIDER,
1621
+ );
1622
+ }
1623
+
1624
+ test("every workflow is exactly two rows, and both fill the same columns", async () => {
1625
+ for (const width of WIDTHS) {
1626
+ const frame = await draw(mateConfig, width);
1627
+ for (const [index, workflow] of WORKFLOWS.entries()) {
1628
+ const rows = [mainRow(index), subsRow(index)];
1629
+ // The pair is labelled once and indented once, so it reads as one workflow
1630
+ // rather than as two of them.
1631
+ expect(frame.chars[rows[0] as number]).toStartWith(
1632
+ padCell(workflow.name, SPREAD_LABEL_CELLS),
1633
+ );
1634
+ expect(frame.chars[rows[1] as number]).toStartWith(
1635
+ padCell(SUBAGENT_ROW_LABEL, SPREAD_LABEL_CELLS),
1636
+ );
1637
+ // One divider between neighbours and nowhere else. Asserted separately
1638
+ // because `barColumns` counts a divider AS bar, so without this a bar that
1639
+ // filled its shortfall with track — the thing the old code did by hand and
1640
+ // this layout must not need — would satisfy the width check below.
1641
+ // Counted off `barSegments`, which is what the row actually draws — the
1642
+ // mates fold into one run, so a folded pair costs one divider fewer than
1643
+ // the distribution it came from.
1644
+ const dividers = [
1645
+ 0,
1646
+ Math.max(
1647
+ 0,
1648
+ barSegments(
1649
+ workflowDistribution(mateConfig, workflow.agents),
1650
+ ).filter((segment) => segment.share > 0).length - 1,
1651
+ ),
1652
+ ];
1653
+ for (const [which, row] of rows.entries()) {
1654
+ const line = frame.lines[row];
1655
+ const chars = frame.chars[row];
1656
+ if (!line || chars === undefined) throw new Error(`no row ${row}`);
1657
+ const bar = barColumns(line, chars);
1658
+ // EXACTLY its allotted width: every column from the label to the right
1659
+ // edge is bar, and no column left of the label is. A shortfall used to be
1660
+ // paid in track by hand; it is now the layout's to get right, and this is
1661
+ // what says it did.
1662
+ expect({
1663
+ width,
1664
+ row,
1665
+ label: bar.slice(0, SPREAD_LABEL_CELLS).some(Boolean),
1666
+ unpainted: bar
1667
+ .slice(SPREAD_LABEL_CELLS, width)
1668
+ .filter((cell) => !cell).length,
1669
+ dividers: [...chars.slice(SPREAD_LABEL_CELLS, width)].filter(
1670
+ (cell) => cell === BAR_DIVIDER,
1671
+ ).length,
1672
+ }).toEqual({
1673
+ width,
1674
+ row,
1675
+ label: false,
1676
+ unpainted: 0,
1677
+ dividers: dividers[which] as number,
1678
+ });
1679
+ }
1680
+ }
1681
+ // Nothing below the last pair, so three workflows never read as six.
1682
+ expect(frame.chars[subsRow(WORKFLOWS.length - 1) + 1]?.trim()).toBe("");
1683
+ }
1684
+ });
1685
+
1686
+ /**
1687
+ * The grouping, asserted as rows rather than as an eyeball.
1688
+ *
1689
+ * Six adjacent bar rows read as one block of six: `dev`'s subagent row and `debug`'s main
1690
+ * row touched, so which two belonged together had to be worked out from the labels. The
1691
+ * blank row goes BETWEEN groups and never inside one — the pair's adjacency is the thing
1692
+ * that says they belong together, and a gap in the middle of it would say the opposite.
1693
+ */
1694
+ test("a blank row separates the workflows, and never splits one", async () => {
1695
+ for (const width of WIDTHS) {
1696
+ const frame = await draw(mateConfig, width);
1697
+ for (const index of WORKFLOWS.keys()) {
1698
+ expect({
1699
+ index,
1700
+ // Both rows of the pair are drawn, with nothing between them.
1701
+ main: frame.chars[mainRow(index)]?.trim() === "",
1702
+ subs: frame.chars[subsRow(index)]?.trim() === "",
1703
+ // And a blank row above every group but the first.
1704
+ gap: index === 0 ? null : frame.chars[gapRow(index)]?.trim(),
1705
+ }).toEqual({
1706
+ index,
1707
+ main: false,
1708
+ subs: false,
1709
+ gap: index === 0 ? null : "",
1710
+ });
1711
+ }
1712
+ // No trailing gap: a blank row under the last group separates the chart from
1713
+ // nothing, and the rows below it belong to whatever the panel draws next.
1714
+ const lastRow = subsRow(WORKFLOWS.length - 1);
1715
+ expect(frame.chars[lastRow]?.trim()).not.toBe("");
1716
+ // The whole block is exactly what `SUMMARY_COST.spread` budgets for: the margin,
1717
+ // the header, two rows per workflow and one gap between each pair.
1718
+ expect(lastRow + 1).toBe(
1719
+ 1 +
1720
+ 1 +
1721
+ WORKFLOWS.length * WORKFLOW_ROWS +
1722
+ (WORKFLOWS.length - 1) * WORKFLOW_GAP_ROWS,
1723
+ );
1724
+ }
1725
+ });
1726
+
1727
+ /**
1728
+ * ONE gutter, one alignment, and the bars starting at one column.
1729
+ *
1730
+ * Reported off a real screen: "on left dev and subagents wrongly aligned". The subagent
1731
+ * label carried a two-cell indent, so the six labels started at two different columns and
1732
+ * the pairs read as ragged rather than as groups.
1733
+ */
1734
+ test("both rows of a workflow share one gutter, and their bars start together", async () => {
1735
+ // Derived, not 11: `investigate` is the longest label today and a constant here would
1736
+ // keep passing while the picture clipped the first longer name anyone adds.
1737
+ const longest = Math.max(
1738
+ SUBAGENT_ROW_LABEL.length,
1739
+ ...WORKFLOWS.map((workflow) => workflow.name.length),
791
1740
  );
1741
+ expect(SPREAD_LABEL_CELLS).toBe(longest + 1);
1742
+ // Left-aligned, so no label carries leading whitespace — that indent is the defect.
1743
+ expect(SUBAGENT_ROW_LABEL).toBe(SUBAGENT_ROW_LABEL.trimStart());
1744
+
1745
+ for (const width of WIDTHS) {
1746
+ const frame = await draw(mateConfig, width);
1747
+ for (const [index, workflow] of WORKFLOWS.entries()) {
1748
+ const main = frame.chars[mainRow(index)] ?? "";
1749
+ const subs = frame.chars[subsRow(index)] ?? "";
1750
+ // Every label begins at column 0 and every bar begins at the same column, so
1751
+ // the pair has one straight edge on the outside and one against the chart.
1752
+ const barStarts = (line: CapturedFrame["lines"][number]) => {
1753
+ const page = JSON.stringify(line.spans[0]?.bg);
1754
+ let column = 0;
1755
+ for (const span of line.spans) {
1756
+ if (JSON.stringify(span.bg) !== page) return column;
1757
+ column += span.width;
1758
+ }
1759
+ return -1;
1760
+ };
1761
+ expect({
1762
+ index,
1763
+ width,
1764
+ mainLabel: main.slice(0, workflow.name.length),
1765
+ subsLabel: subs.slice(0, SUBAGENT_ROW_LABEL.length),
1766
+ mainBar: barStarts(
1767
+ frame.lines[mainRow(index)] as CapturedFrame["lines"][number],
1768
+ ),
1769
+ subsBar: barStarts(
1770
+ frame.lines[subsRow(index)] as CapturedFrame["lines"][number],
1771
+ ),
1772
+ }).toEqual({
1773
+ index,
1774
+ width,
1775
+ mainLabel: workflow.name,
1776
+ subsLabel: SUBAGENT_ROW_LABEL,
1777
+ mainBar: SPREAD_LABEL_CELLS,
1778
+ subsBar: SPREAD_LABEL_CELLS,
1779
+ });
1780
+ }
1781
+ }
1782
+ });
1783
+
1784
+ /**
1785
+ * The boundary, from both sides, on the main row — one segment across the whole bar, so
1786
+ * its width is the panel's width less the label column and nothing else can move it.
1787
+ */
1788
+ test("a label goes inline when it fits and is absent, never clipped, when it does not", async () => {
1789
+ // The DRAWN name, not the routing value: they differ for a long-context variant
1790
+ // (`opus[1m]` draws `opus`), and measuring the wrong one puts the boundary four
1791
+ // cells off — which is a test that passes on both sides of it.
1792
+ const label = segmentLabel(fableAdvisor.main.model);
1793
+ const fits = SPREAD_LABEL_CELLS + inlineLabelWidth(label);
1794
+
1795
+ const wide = await draw(fableAdvisor, fits);
1796
+ expect(wide.chars[mainRow(0)]).toContain(label);
1797
+
1798
+ const narrow = await draw(fableAdvisor, fits - 1);
1799
+ // Not merely "not the whole word" — no PREFIX of it either. A model id cut to
1800
+ // `opu` reads as a model that does not exist, which is the failure this rules out.
1801
+ expect(narrow.chars[mainRow(0)]).not.toContain(label.slice(0, 3));
1802
+ });
1803
+
1804
+ test("a mate segment is woven; a Claude segment is not", async () => {
1805
+ const woven = await draw(mateConfig, 80);
1806
+ // `dev` is the only workflow of the three that leaves Claude Code: it is the only one
1807
+ // with `external` phases, and the only one dispatching the re-routed agent.
1808
+ expect(woven.chars[subsRow(0)]).toContain(MATE_WEFT);
1809
+ expect(woven.chars[subsRow(1)]).not.toContain(MATE_WEFT);
1810
+ expect(woven.chars[subsRow(2)]).not.toContain(MATE_WEFT);
1811
+ // Wide enough for the label, so the weave has to survive BEHIND it. The word is
1812
+ // `mates` even with ONE slot routed — see `MATES_SEGMENT`.
1813
+ expect(woven.chars[subsRow(0)]).toContain(
1814
+ `${MATE_WEFT} ${MATES_SEGMENT} ${MATE_WEFT}`,
1815
+ );
1816
+ });
1817
+
1818
+ /**
1819
+ * The weave follows the WORKFLOW, not the preset.
1820
+ *
1821
+ * A built-in binds no slot, and `dev` still leaves Claude Code under it — the plan and
1822
+ * code reviews are phases of the command, not consequences of the routing applied to it.
1823
+ * So the control for "not woven" is a workflow with no external phases, never a preset.
1824
+ */
1825
+ test("a built-in preset still weaves `dev`, and never `debug` or `investigate`", async () => {
1826
+ const plain = await draw(fableAdvisor, 80);
1827
+ expect(plain.chars[subsRow(0)]).toContain(MATE_WEFT);
1828
+ expect(plain.chars[subsRow(1)]).not.toContain(MATE_WEFT);
1829
+ expect(plain.chars[subsRow(2)]).not.toContain(MATE_WEFT);
1830
+ });
1831
+
1832
+ /**
1833
+ * The regression guard the correction asked for, and it is asked in COLOUR.
1834
+ *
1835
+ * Under the old apportionment a mate was measured never to drop at these widths. Under
1836
+ * flex that is a fresh question — and a real one: `flexBasis: 0` with `flexGrow` alone
1837
+ * resolved a 1-in-45 share to ZERO cells at 20 columns, which is exactly a mate quietly
1838
+ * vanishing. `minWidth: 1` is what stops it.
1839
+ *
1840
+ * The assertion is on the segment's GROUND, not on its weave. A glyph test answers
1841
+ * "is the texture drawn"; only the ground answers "is the segment there at all", which
1842
+ * is what a rounding change would take away.
1843
+ */
1844
+ test("the mate segment survives at every width", async () => {
1845
+ for (const width of WIDTHS) {
1846
+ const frame = await draw(mateConfig, width);
1847
+ const line = frame.lines[subsRow(0)];
1848
+ if (!line) throw new Error(`no row at ${width}`);
1849
+ const grounds = new Set(
1850
+ line.spans.map((span) => rgbToHex(span.bg).slice(0, 7).toUpperCase()),
1851
+ );
1852
+ expect({
1853
+ width,
1854
+ // `bar`, because that is now the ground of EVERY segment including a mate's.
1855
+ // It was `bg` while a mate carried a second ground of its own; the weave is
1856
+ // a mark painted on this tone rather than a surface behind it.
1857
+ ground: grounds.has(modelBadge(MATES_SEGMENT).bar.toUpperCase()),
1858
+ woven: frame.chars[subsRow(0)]?.includes(MATE_WEFT),
1859
+ }).toEqual({ width, ground: true, woven: true });
1860
+ }
1861
+ });
1862
+
1863
+ /**
1864
+ * ONE BLOCK OF ONE COLOUR, with a word on it. No chip.
1865
+ *
1866
+ * The label used to be drawn as the model's chip — `fg` on `bg`, inset into the bar —
1867
+ * which put a visibly different rectangle inside a segment that is meant to read as one
1868
+ * thing. Reported as "do not add additional background colour to model segments".
1869
+ *
1870
+ * The assertion is on the label span's BACKGROUND: it has to be the segment's own `bar`,
1871
+ * the same fill the unlabelled cells carry, because that is what "no chip" means at the
1872
+ * pixel. The ink is the palette's `label`, which is measured against `bar` up in the
1873
+ * palette tests — `fg` here would be 1.90:1 on the dark page.
1874
+ */
1875
+ test("an inline label is written ON the fill, with no chip behind it", async () => {
1876
+ for (const mode of ["light", "dark"] as const) {
1877
+ const frame = await draw(mateConfig, 80, mode);
1878
+ const line = frame.lines[mainRow(0)];
1879
+ const chip = line?.spans.find((span) => span.text.includes("opus"));
1880
+ if (!chip) throw new Error(`no inline label on the ${mode} page`);
1881
+ expect({
1882
+ mode,
1883
+ bg: rgbToHex(chip.bg).slice(0, 7).toUpperCase(),
1884
+ fg: rgbToHex(chip.fg).slice(0, 7).toUpperCase(),
1885
+ }).toEqual({
1886
+ mode,
1887
+ bg: modelBadge("opus").bar.toUpperCase(),
1888
+ fg: modelBadge("opus").label.toUpperCase(),
1889
+ });
1890
+ // The whole point, said directly: the label's ground is the same colour as the
1891
+ // segment around it, so nothing about the label changes the block's colour.
1892
+ const ground = line?.spans.find(
1893
+ (span) => span.text.trim() === "" && span.width > 1,
1894
+ );
1895
+ expect(rgbToHex(chip.bg).slice(0, 7).toUpperCase()).toBe(
1896
+ rgbToHex(ground?.bg ?? chip.bg)
1897
+ .slice(0, 7)
1898
+ .toUpperCase(),
1899
+ );
1900
+
1901
+ // A MATE HAS ONE GROUND, exactly like every other segment, and it is `bar`.
1902
+ //
1903
+ // This is the regression this pair exists for. The mate used to ground its box in
1904
+ // `bg` and weave `bar` over it, lifting only the label cells to `bar` so the text
1905
+ // had a measured tone under it — two grounds in one run, which on screen read as a
1906
+ // solid block of colour with a paler patterned margin either side of the word.
1907
+ // Reported as "mates still has weird background, double background".
1908
+ //
1909
+ // The tone is the FAMILY's, not `mate1`'s, because the run stands for however
1910
+ // many slots the workflow reaches — see `MODEL_BADGE.mates`.
1911
+ const mateLabel = frame.lines[subsRow(0)]?.spans.find((span) =>
1912
+ span.text.includes(MATES_SEGMENT),
1913
+ );
1914
+ if (!mateLabel) throw new Error(`no mate label on the ${mode} page`);
1915
+ expect({
1916
+ mode,
1917
+ bg: rgbToHex(mateLabel.bg).slice(0, 7).toUpperCase(),
1918
+ fg: rgbToHex(mateLabel.fg).slice(0, 7).toUpperCase(),
1919
+ }).toEqual({
1920
+ mode,
1921
+ bg: modelBadge(MATES_SEGMENT).bar.toUpperCase(),
1922
+ fg: modelBadge(MATES_SEGMENT).label.toUpperCase(),
1923
+ });
1924
+
1925
+ // The weave is a pale MARK on that same ground, not a second surface showing
1926
+ // through it: `bg` ink over the `bar` the label also sits on.
1927
+ const weave = frame.lines[subsRow(0)]?.spans.find((span) =>
1928
+ span.text.includes(MATE_WEFT),
1929
+ );
1930
+ if (!weave) throw new Error(`no weave on the ${mode} page`);
1931
+ expect({
1932
+ mode,
1933
+ bg: rgbToHex(weave.bg).slice(0, 7).toUpperCase(),
1934
+ fg: rgbToHex(weave.fg).slice(0, 7).toUpperCase(),
1935
+ }).toEqual({
1936
+ mode,
1937
+ bg: modelBadge(MATES_SEGMENT).bar.toUpperCase(),
1938
+ fg: modelBadge(MATES_SEGMENT).bg.toUpperCase(),
1939
+ });
1940
+
1941
+ // Said directly, and the assertion that would have caught the original defect
1942
+ // whatever the tones were: the woven cells and the label cells share a ground.
1943
+ expect(rgbToHex(weave.bg).slice(0, 7)).toBe(
1944
+ rgbToHex(mateLabel.bg).slice(0, 7),
1945
+ );
1946
+ }
1947
+ resetThemeMode();
1948
+ });
1949
+
1950
+ // ── The mates collapse into ONE run ──────────────────────────────────────
1951
+ //
1952
+ // A workflow routing two agents to two different slots used to draw two narrow textured
1953
+ // runs labelled `mate1` and `mate2`. Neither label fitted at a real pane width, so the
1954
+ // distinction cost a segment and bought nothing — while the number the bar exists to
1955
+ // answer, how much of this workflow leaves Claude Code, was split in half.
1956
+
1957
+ /** `dev` is the workflow every case below re-routes inside. */
1958
+ const dev = WORKFLOWS[0] as (typeof WORKFLOWS)[number];
1959
+
1960
+ /** Two agents, two DIFFERENT slots. */
1961
+ const twoSlots: ModelsConfig = {
1962
+ ...fableAdvisor,
1963
+ agents: {
1964
+ ...fableAdvisor.agents,
1965
+ "dev:qa-engineer": { model: "mate1" },
1966
+ "dev:reviewer": { model: "mate2" },
1967
+ },
1968
+ mates: { mate1: { model: "kimi-k3" }, mate2: { model: "grok-4.6" } },
1969
+ };
1970
+
1971
+ /**
1972
+ * The control for the proportion claim: the same two agents on ONE slot.
1973
+ *
1974
+ * Same agent counts, same number of drawn runs, same shares — so if the fold is a SUM
1975
+ * rather than a pick, these two configs must resolve the mates run to the identical
1976
+ * width. Anything else means slot identity is leaking into the layout.
1977
+ */
1978
+ const oneSlotTwice: ModelsConfig = {
1979
+ ...fableAdvisor,
1980
+ agents: {
1981
+ ...fableAdvisor.agents,
1982
+ "dev:qa-engineer": { model: "mate1" },
1983
+ "dev:reviewer": { model: "mate1" },
1984
+ },
1985
+ mates: { mate1: { model: "kimi-k3" } },
1986
+ };
1987
+
1988
+ /**
1989
+ * Contiguous stretches of one model's ink on a row — one entry per DRAWN segment.
1990
+ *
1991
+ * Counted off the ground rather than off the weave glyph, for the reason the survival
1992
+ * test gives: a glyph answers "is the texture there", only the ground answers "is this
1993
+ * one segment or two". Both of a mate segment's tones count — the box is `bg` and its
1994
+ * label cells are lifted to `bar`.
1995
+ */
1996
+ function inkRuns(
1997
+ line: CapturedFrame["lines"][number],
1998
+ ink: { bg: string; bar: string },
1999
+ ): number[] {
2000
+ const tones = new Set([ink.bg.toUpperCase(), ink.bar.toUpperCase()]);
2001
+ const painted: boolean[] = [];
2002
+ for (const span of line.spans) {
2003
+ const on = tones.has(rgbToHex(span.bg).slice(0, 7).toUpperCase());
2004
+ for (let i = 0; i < span.width; i++) painted.push(on);
2005
+ }
2006
+ const runs: number[] = [];
2007
+ let run = 0;
2008
+ for (const cell of painted) {
2009
+ if (cell) run += 1;
2010
+ else if (run > 0) {
2011
+ runs.push(run);
2012
+ run = 0;
2013
+ }
2014
+ }
2015
+ if (run > 0) runs.push(run);
2016
+ return runs;
2017
+ }
2018
+
2019
+ test("two agents on two different slots draw ONE run, labelled `mates`", async () => {
2020
+ // The DISTRIBUTION still tells them apart — the fold belongs to the bar, not to the
2021
+ // data, and the key under the chart reads the distribution to name both slots.
2022
+ const spread = workflowDistribution(twoSlots, dev.agents);
2023
+ expect(
2024
+ spread
2025
+ .filter((segment) => isMateSegment(segment.model))
2026
+ .map((s) => s.key),
2027
+ ).toEqual(["mate1", "mate2"]);
2028
+
2029
+ // The bar folds them, and the fold is one run carrying BOTH agents.
2030
+ expect(
2031
+ barSegments(spread).filter((run) => isMateSegment(run.model)),
2032
+ ).toEqual([{ id: MATES_SEGMENT, model: MATES_SEGMENT, share: 2 }]);
2033
+
2034
+ const frame = await draw(twoSlots, 80);
2035
+ const row = frame.chars[subsRow(0)] ?? "";
2036
+ const line = frame.lines[subsRow(0)];
2037
+ if (!line) throw new Error("no subagent row");
2038
+ expect({
2039
+ runs: inkRuns(line, modelBadge(MATES_SEGMENT)).length,
2040
+ labelled: row.includes(`${MATE_WEFT} ${MATES_SEGMENT} ${MATE_WEFT}`),
2041
+ // No slot name survives on the bar — that detail moved to the key, in full.
2042
+ slots: MATES.filter((mate) => row.includes(mate)),
2043
+ }).toEqual({ runs: 1, labelled: true, slots: [] });
2044
+ });
2045
+
2046
+ test("the folded run is the SUM of its agents, not one of them", async () => {
2047
+ for (const width of WIDTHS) {
2048
+ const folded = await draw(twoSlots, width);
2049
+ const control = await draw(oneSlotTwice, width);
2050
+ const single = await draw(mateConfig, width);
2051
+ const cells = (frame: Frame) => {
2052
+ const line = frame.lines[subsRow(0)];
2053
+ if (!line) throw new Error(`no subagent row at ${width}`);
2054
+ return inkRuns(line, modelBadge(MATES_SEGMENT))[0] ?? 0;
2055
+ };
2056
+ // At the narrowest widths every run sits on its `minWidth: 1` floor and there is
2057
+ // nothing left to be proportional WITH — the documented price of the floor (see
2058
+ // the note above `BAR_DIVIDER`). So the strict claim is made where the panel has
2059
+ // room, and only the weaker ordering is claimed where it does not.
2060
+ const room = width >= 32;
2061
+ expect({
2062
+ width,
2063
+ // Two slots and one slot, same agents: identical geometry. This is the
2064
+ // proportion claim — the share is the count, and slot identity is not in it.
2065
+ sameAsControl: cells(folded) === cells(control),
2066
+ // And a real sum rather than a pick: one re-routed agent makes a strictly
2067
+ // narrower run than two, wherever the difference is worth more than a cell.
2068
+ widerThanOne: room
2069
+ ? cells(folded) > cells(single)
2070
+ : cells(folded) >= cells(single),
2071
+ }).toEqual({ width, sameAsControl: true, widerThanOne: true });
2072
+ }
2073
+ });
2074
+
2075
+ /**
2076
+ * The share is exactly the agents' share of the workflow.
2077
+ *
2078
+ * `dev` dispatches five agents; two of them are re-routed, so the run is 2/5 of the
2079
+ * FLEX space — the bar less the one-cell dividers between its runs, which Yoga never
2080
+ * gets to distribute. Measured at the widths where a fifth of a bar is more than a
2081
+ * rounding: at 80 the flex space is 65 cells and the run resolves to 26, dead on 0.400.
2082
+ */
2083
+ test("and that sum is the right fraction of the bar", async () => {
2084
+ for (const width of [40, 64, 80]) {
2085
+ const frame = await draw(twoSlots, width);
2086
+ const line = frame.lines[subsRow(0)];
2087
+ const chars = frame.chars[subsRow(0)];
2088
+ if (!line || chars === undefined) throw new Error(`no row at ${width}`);
2089
+ const runs = barSegments(
2090
+ workflowDistribution(twoSlots, dev.agents, dev.external.length),
2091
+ );
2092
+ const dividers = runs.length - 1;
2093
+ const flex = width - SPREAD_LABEL_CELLS - dividers;
2094
+ const cells = inkRuns(line, modelBadge(MATES_SEGMENT))[0] ?? 0;
2095
+ // The two re-routed agents plus the workflow's own external phases: the bar draws
2096
+ // both, because both leave Claude Code when this workflow runs.
2097
+ const share =
2098
+ (2 + dev.external.length) /
2099
+ runs.reduce((sum, run) => sum + run.share, 0);
2100
+ // The tolerance is ONE CELL, not a percentage. A bar is drawn in whole cells, so
2101
+ // the only error the layout can introduce is rounding a fractional share up or
2102
+ // down — an absolute quantity. A percentage tolerance says nothing at 80 columns
2103
+ // and forgives more than a cell at 40, which is the wrong way round.
2104
+ // The tolerance is ONE CELL PER COMPETING RUN, and it is a cell count rather than
2105
+ // a percentage because a bar is drawn in whole cells: the only error the layout
2106
+ // can introduce is rounding a fractional share, an absolute quantity.
2107
+ //
2108
+ // It scales with the number of OTHER runs, not with the width, because each of
2109
+ // them rounds independently and the largest run absorbs the accumulated deficit.
2110
+ // MEASURED at 40 columns: flex is 25 cells over `smart:1 normal:1 cheap:1 mates:4`,
2111
+ // the three single-share runs each round up, and mates lands on 13 where its exact
2112
+ // share is 14.29 — 1.29 cells light with three neighbours to pay for.
2113
+ expect({
2114
+ width,
2115
+ off: Math.abs(cells - share * flex) <= runs.length - 1,
2116
+ }).toEqual({ width, off: true });
2117
+ }
2118
+ });
2119
+
2120
+ test("a workflow with ONE mate-routed agent still says `mates`", async () => {
2121
+ // Consistency beats a special case. `mate1` here would teach that the label names a
2122
+ // slot, and the very next config over — same label, two slots — would disprove it.
2123
+ const runs = barSegments(
2124
+ workflowDistribution(mateConfig, dev.agents, dev.external.length),
2125
+ );
2126
+ expect(runs.filter((run) => isMateSegment(run.model))).toEqual([
2127
+ {
2128
+ id: MATES_SEGMENT,
2129
+ model: MATES_SEGMENT,
2130
+ share: 1 + dev.external.length,
2131
+ },
2132
+ ]);
2133
+ const frame = await draw(mateConfig, 80);
2134
+ const row = frame.chars[subsRow(0)] ?? "";
2135
+ expect(row).toContain(`${MATE_WEFT} ${MATES_SEGMENT} ${MATE_WEFT}`);
2136
+ expect(row).not.toContain("mate1");
2137
+ });
2138
+
2139
+ /**
2140
+ * THE CAPTION IS GONE, and this is the guard that keeps it gone.
2141
+ *
2142
+ * `▓ external model, via claudish` used to sit right-aligned on the header row. Reported:
2143
+ * "why do we have on top of diagram 'external models via claudish' if we already have
2144
+ * this described in the right and in the bottom in the list of models" — three statements
2145
+ * of one fact, on one screen.
2146
+ *
2147
+ * Asserted over the WHOLE frame rather than the header row alone, so relocating the
2148
+ * sentence somewhere else in the chart fails this too. Deleting was the instruction.
2149
+ */
2150
+ test("no caption above the chart explains the weave", async () => {
2151
+ const CAPTION = "external model, via claudish";
2152
+ const says = (chars: string[]) =>
2153
+ chars.some((row) => row.includes(CAPTION));
2154
+
2155
+ // THE NEGATIVE CONTROL, and it is the point of the test. An assertion that the
2156
+ // string is absent passes just as happily against a predicate that can never find
2157
+ // anything, so the predicate is first shown catching the exact row the old renderer
2158
+ // drew — `workflow`, padding, the weave glyph, the sentence.
2159
+ expect(says([`workflow${" ".repeat(40)}${MATE_WEFT} ${CAPTION}`])).toBe(
2160
+ true,
2161
+ );
2162
+
2163
+ // A config that DRAWS the weave — the only one that ever carried the caption — at a
2164
+ // width with room to spare for it.
2165
+ const woven = await draw(mateConfig, 80);
2166
+ expect(woven.chars.some((row) => row.includes(MATE_WEFT))).toBe(true);
2167
+ expect(says(woven.chars)).toBe(false);
2168
+
2169
+ const plain = await draw(fableAdvisor, 80);
2170
+ expect(says(plain.chars)).toBe(false);
2171
+ });
2172
+ });
2173
+
2174
+ /**
2175
+ * The colour key describes the CHART, which is the defect this section exists for.
2176
+ *
2177
+ * Reported as "the mate is missing from the bar". It was not: the key was built from every
2178
+ * preset in the list while the bar drew the selected one, so a project config binding an
2179
+ * agent to `mate1` put `mate1` in the key while the cursor sat on a built-in that has no
2180
+ * mate and no width at which it could grow one.
2181
+ */
2182
+ describe("the colour key names the config the chart is drawing", () => {
2183
+ const mateConfig: ModelsConfig = {
2184
+ ...fableAdvisor,
2185
+ agents: {
2186
+ ...fableAdvisor.agents,
2187
+ "dev:qa-engineer": { model: "mate1" },
2188
+ },
2189
+ mates: { mate1: { model: "kimi-k3" } },
2190
+ };
2191
+
2192
+ const status: ModelsStatus = {
2193
+ state: "on",
2194
+ preset: "outback",
2195
+ drift: [],
2196
+ warnings: [],
2197
+ };
2198
+
2199
+ const preset = (
2200
+ id: string,
2201
+ label: string,
2202
+ config: ModelsConfig,
2203
+ custom: boolean,
2204
+ ): ModelsPresetItem => ({
2205
+ id,
2206
+ kind: "preset",
2207
+ label,
2208
+ config,
2209
+ active: custom,
2210
+ isDefault: !custom,
2211
+ custom,
2212
+ });
2213
+
2214
+ const items = [
2215
+ preset("outback", "outback", mateConfig, true),
2216
+ preset("fable-advisor", "fable-advisor", fableAdvisor, false),
2217
+ ];
2218
+
2219
+ async function legendOf(selected: ModelsPresetItem): Promise<string> {
2220
+ const { captureCharFrame, renderOnce, renderer } = await testRender(
2221
+ <box>
2222
+ {renderModelsSummary({
2223
+ selected,
2224
+ status,
2225
+ configPath: null,
2226
+ width: 80,
2227
+ available: 40,
2228
+ })}
2229
+ </box>,
2230
+ { width: 80, height: 40 },
2231
+ );
2232
+ await act(async () => {
2233
+ await renderOnce();
2234
+ await renderOnce();
2235
+ });
2236
+ const line = captureCharFrame()
2237
+ .split("\n")
2238
+ .find((row) => row.trimStart().startsWith("models"));
2239
+ await act(async () => renderer.destroy());
2240
+ if (line === undefined) throw new Error("the colour key was not drawn");
2241
+ return line;
2242
+ }
2243
+
2244
+ test("names the selected config's models", async () => {
2245
+ const legend = await legendOf(items[0] as ModelsPresetItem);
2246
+ for (const model of ["opus", "sonnet", "fable", "mate1"]) {
2247
+ expect(legend).toContain(model);
2248
+ }
2249
+ });
2250
+
2251
+ test("a built-in names `mates` but no SLOT — the key describes the chart", async () => {
2252
+ // Two claims in one row, and they pull opposite ways.
2253
+ //
2254
+ // It MUST carry `mates`: a built-in binds no slot, but `dev` still leaves Claude Code
2255
+ // for its plan and code reviews, so the chart draws a textured run — and a run with
2256
+ // no chip under it is a texture the reader cannot decode.
2257
+ //
2258
+ // It must NOT carry a slot name: `mate1` belongs to the OTHER item's config, the
2259
+ // chart cannot contain it, and so neither may the key.
2260
+ const legend = await legendOf(items[1] as ModelsPresetItem);
2261
+ expect(legend).toContain(MATES_SEGMENT);
2262
+ for (const mate of MATES) expect(legend).not.toContain(mate);
2263
+ expect(legend).toContain("opus");
2264
+ });
2265
+
2266
+ /**
2267
+ * The other half of collapsing the bar's mates: the slots come back HERE, with their
2268
+ * bindings, because this is the only row on the screen wide enough for a catalogue id.
2269
+ */
2270
+ test("names every slot in use with the model it is bound to", async () => {
2271
+ const twoSlots: ModelsConfig = {
2272
+ ...fableAdvisor,
2273
+ agents: {
2274
+ ...fableAdvisor.agents,
2275
+ "dev:qa-engineer": { model: "mate1" },
2276
+ "dev:reviewer": { model: "mate2" },
2277
+ },
2278
+ mates: { mate1: { model: "kimi-k3" }, mate2: { model: "grok-4.6" } },
2279
+ };
2280
+ const legend = await legendOf(preset("outback", "outback", twoSlots, true));
2281
+ expect(legend).toContain("mate1 kimi-k3");
2282
+ expect(legend).toContain("mate2 grok-4.6");
2283
+ // A slot the config never routes to is not in the key, bound or not — the key
2284
+ // describes the chart, and the chart has no run for a slot nobody uses.
2285
+ expect(legend).not.toContain("kangaroo");
2286
+ });
2287
+
2288
+ test("an unbound slot says so, and never says its own name twice", async () => {
2289
+ const unbound: ModelsConfig = {
2290
+ ...fableAdvisor,
2291
+ agents: {
2292
+ ...fableAdvisor.agents,
2293
+ "dev:qa-engineer": { model: "mate1" },
2294
+ },
2295
+ };
2296
+ const legend = await legendOf(preset("outback", "outback", unbound, true));
2297
+ expect(legend).toContain(`mate1 ${UNBOUND_MATE}`);
2298
+ expect(legend).not.toContain("mate1 mate1");
2299
+ });
2300
+
2301
+ /**
2302
+ * The fitting, asked of the pure function so every rung is checked rather than the one
2303
+ * an 80-column render happens to land on.
2304
+ *
2305
+ * It NEVER wraps. The block is budgeted at two rows in a fixed-height panel, and an
2306
+ * over-tall block there overprints whatever is below it — see `planModelsSummary`. So
2307
+ * the bindings go first, for the whole row at once, then the slots fold into the bar's
2308
+ * own word, and only then do chips drop.
2309
+ */
2310
+ describe("and degrades without wrapping or overflowing", () => {
2311
+ /**
2312
+ * A config with one agent on `inherit`, stated outright.
2313
+ *
2314
+ * `fable-advisor` used to supply one by itself: it pinned `seo:editor`, the single
2315
+ * agent in the marketplace whose own frontmatter named a model. That plugin was
2316
+ * removed, so no shipped preset produces the value any more — but `inherit` is still
2317
+ * a model any project may set on any agent, and the chart still paints it the flat
2318
+ * grey these rungs exist to decode. Naming it here tests the key rather than the
2319
+ * preset, which is what these cases were ever about.
2320
+ */
2321
+ const withInherit: ModelsConfig = {
2322
+ ...fableAdvisor,
2323
+ agents: { ...fableAdvisor.agents, "x:pinned": { model: "inherit" } },
2324
+ };
2325
+ const twoSlots: ModelsConfig = {
2326
+ ...withInherit,
2327
+ agents: {
2328
+ ...withInherit.agents,
2329
+ "dev:qa-engineer": { model: "mate1" },
2330
+ "dev:reviewer": { model: "mate2" },
2331
+ },
2332
+ mates: { mate1: { model: "kimi-k3" }, mate2: { model: "grok-4.6" } },
2333
+ };
2334
+ const LABEL = "models ".length;
2335
+ /** What the row costs as drawn: each chip plus its padding, one space between. */
2336
+ const cost = (chips: { text: string }[]) =>
2337
+ LABEL +
2338
+ chips.reduce(
2339
+ (sum, chip, index) =>
2340
+ sum + modelBadgeWidth(chip.text) + (index ? 1 : 0),
2341
+ 0,
2342
+ );
2343
+
2344
+ /**
2345
+ * A preset that binds NO slot still needs the `mates` chip, and it must outlive
2346
+ * `inherit` when the row will not fit.
2347
+ *
2348
+ * Both halves matter. The chart draws a mates run for every workflow with external
2349
+ * phases, whatever the config says — so without the chip the reader sees a texture
2350
+ * with nothing to decode it. And chips are dropped from the right, so appending it
2351
+ * made it the first casualty at exactly the widths where the bar is too narrow to
2352
+ * label its own runs: the two failures compound instead of cancelling.
2353
+ *
2354
+ * `inherit` is the chip that can go. It explains a flat grey meaning "nothing was
2355
+ * chosen"; the weave means "this work leaves Claude Code".
2356
+ */
2357
+ test("narrow: `mates` survives and `inherit` is dropped", () => {
2358
+ const wide = fitModelKey(withInherit, 200, LABEL, true);
2359
+ expect(wide.map((c) => c.text)).toContain(MATES_SEGMENT);
2360
+ expect(wide.map((c) => c.text)).toContain("inherit");
2361
+ // `mates` sits before `inherit`, so the drop takes `inherit` first.
2362
+ expect(wide.findIndex((c) => c.text === MATES_SEGMENT)).toBeLessThan(
2363
+ wide.findIndex((c) => c.text === "inherit"),
2364
+ );
2365
+
2366
+ const tight = fitModelKey(withInherit, cost(wide) - 1, LABEL, true);
2367
+ expect(tight.map((c) => c.text)).toContain(MATES_SEGMENT);
2368
+ expect(tight.map((c) => c.text)).not.toContain("inherit");
2369
+ });
2370
+
2371
+ test("no external workflows, no chip — the negative control", () => {
2372
+ // Without the flag there is no mates run on the chart, so the key must not claim
2373
+ // one. This is what stops the chip being unconditional decoration.
2374
+ const chips = fitModelKey(fableAdvisor, 200, LABEL, false);
2375
+ expect(chips.map((c) => c.text)).not.toContain(MATES_SEGMENT);
2376
+ });
2377
+
2378
+ test("wide: every slot carries its binding", () => {
2379
+ const chips = fitModelKey(twoSlots, 120, LABEL);
2380
+ // `inherit` is in the list because the fixture pins an agent to it — the key has
2381
+ // to explain that grey too, and it sorts last as the absence of a decision
2382
+ // always does.
2383
+ expect(chips.map((chip) => chip.text)).toEqual([
2384
+ "opus",
2385
+ "sonnet",
2386
+ "fable",
2387
+ "mate1 kimi-k3",
2388
+ "mate2 grok-4.6",
2389
+ "inherit",
2390
+ ]);
2391
+ // Every mate chip wears the FAMILY ink, which is the one tone the chart paints
2392
+ // them in. A per-slot hue here would name a colour the bar cannot contain.
2393
+ expect(chips.filter((chip) => chip.ink === MATES_SEGMENT).length).toBe(2);
2394
+ });
2395
+
2396
+ test("narrower: the bindings go, ALL of them, and every colour stays", () => {
2397
+ // One cell under what the bound row needs. The whole row steps down together —
2398
+ // a key with one binding shown and one not reads as two formats.
2399
+ const full = fitModelKey(twoSlots, 120, LABEL);
2400
+ const chips = fitModelKey(twoSlots, cost(full) - 1, LABEL);
2401
+ expect(chips.map((chip) => chip.text)).toEqual([
2402
+ "opus",
2403
+ "sonnet",
2404
+ "fable",
2405
+ "mate1",
2406
+ "mate2",
2407
+ "inherit",
2408
+ ]);
2409
+ });
2410
+
2411
+ /**
2412
+ * The rung that exists because truncation named ONE slot.
2413
+ *
2414
+ * Chips drop from the right and the mates sit near the end of `modelsInUse` order, so
2415
+ * before this rung a 40-column key read `opus sonnet fable mate1` — with `mate2`
2416
+ * gone and nothing saying so, which claims `mate1` is the only external model in a
2417
+ * config that has two. Folded, the key says what the bar says.
2418
+ */
2419
+ test("narrower still: the slots fold into the bar's own word", () => {
2420
+ // Each rung found by stepping one cell under what the rung above needs, so the
2421
+ // widths are derived from the palette and the bindings rather than typed here.
2422
+ const bound = fitModelKey(twoSlots, 1000, LABEL);
2423
+ const slots = fitModelKey(twoSlots, cost(bound) - 1, LABEL);
2424
+ const chips = fitModelKey(twoSlots, cost(slots) - 1, LABEL);
2425
+ expect(chips.map((chip) => chip.text)).toEqual([
2426
+ "opus",
2427
+ "sonnet",
2428
+ "fable",
2429
+ MATES_SEGMENT,
2430
+ "inherit",
2431
+ ]);
2432
+ // One chip fewer than the slot rung, and no colour lost: the folded chip is the
2433
+ // same family ink both slots wore.
2434
+ expect(chips.length).toBe(slots.length - 1);
2435
+ expect(chips.filter((chip) => chip.ink === MATES_SEGMENT).length).toBe(1);
2436
+ });
2437
+
2438
+ test("narrowest: chips drop from the right, and the row never overflows", () => {
2439
+ for (let width = 8; width <= 120; width++) {
2440
+ const chips = fitModelKey(twoSlots, width, LABEL);
2441
+ expect({ width, over: cost(chips) > width }).toEqual({
2442
+ width,
2443
+ over: false,
2444
+ });
2445
+ // Whatever the width, a slot is never named without the other one beside it.
2446
+ // Half the mates is the misreading the fold rung exists to prevent, and the
2447
+ // assertion is on that state directly rather than on a count derived from
2448
+ // what was found — which would agree with itself.
2449
+ const named = MATES.filter((mate) =>
2450
+ chips.some((chip) => chip.text.startsWith(mate)),
2451
+ );
2452
+ expect({ width, halfTheSlots: named.length === 1 }).toEqual({
2453
+ width,
2454
+ halfTheSlots: false,
2455
+ });
2456
+ }
2457
+ // And it does drop, rather than clipping a name into something unreadable: at 20
2458
+ // columns only the first chip or two survive, whole.
2459
+ const narrow = fitModelKey(twoSlots, 20, LABEL);
2460
+ expect(narrow.length).toBeLessThan(
2461
+ fitModelKey(twoSlots, 120, LABEL).length,
2462
+ );
2463
+ for (const chip of narrow) expect(chip.text).not.toContain("…");
2464
+ });
792
2465
  });
793
2466
  });