claudeup 6.7.1 → 6.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/claudeup.js +116 -12
- package/package.json +4 -4
- package/scripts/build-binaries.ts +26 -1
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/launcher-signal.test.ts +314 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/model-visuals.test.tsx +1698 -25
- package/src/__tests__/models-adapter.test.ts +21 -6
- package/src/__tests__/models-cli.test.ts +100 -0
- package/src/__tests__/models-core.test.ts +273 -111
- package/src/__tests__/models-manager.test.ts +15 -12
- package/src/__tests__/models-presets-marketplace.test.ts +29 -2
- package/src/__tests__/models-screen-state.test.ts +57 -1
- package/src/cli/doctor.ts +8 -13
- package/src/cli/models.ts +97 -13
- package/src/cli/upgrade.ts +7 -1
- package/src/data/models-presets.ts +62 -2
- package/src/services/binary-signing.ts +78 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/models-core.ts +371 -30
- package/src/ui/adapters/modelsAdapter.ts +58 -15
- package/src/ui/components/layout/ScreenLayout.tsx +6 -1
- package/src/ui/renderers/modelRenderers.tsx +413 -108
- package/src/ui/renderers/modelVisuals.tsx +694 -145
- package/src/ui/screens/ModelsScreen.tsx +74 -10
- package/src/ui/state/reducer.ts +20 -0
- package/src/ui/state/types.ts +31 -0
- package/src/ui/theme-mode.ts +128 -14
|
@@ -1,36 +1,66 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
102
|
-
expect(new Set(fills).size).toBe(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
674
|
-
|
|
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
|
-
|
|
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
|
-
|
|
787
|
-
|
|
788
|
-
expect(presetMarkerWidth(false
|
|
789
|
-
expect(presetMarkerWidth(true
|
|
790
|
-
|
|
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
|
});
|