claudeup 6.6.0 → 6.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/conventions-manager.test.ts +45 -45
- package/src/__tests__/enabled-not-installed.test.ts +1 -1
- 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 +168 -0
- package/src/__tests__/models-screen-state.test.ts +57 -1
- package/src/__tests__/plugin-manager-fallback.test.ts +3 -5
- package/src/__tests__/resolver.test.ts +3 -3
- package/src/cli/doctor.ts +8 -13
- package/src/cli/models.ts +97 -13
- package/src/data/models-presets.ts +72 -13
- package/src/data/predefined-profiles.ts +7 -7
- package/src/services/claude-settings.ts +1 -1
- package/src/services/community-styles.ts +1 -1
- 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,12 +1,18 @@
|
|
|
1
|
+
import type { BoxRenderable } from "@opentui/core";
|
|
1
2
|
import type React from "react";
|
|
3
|
+
import { Fragment, useState } from "react";
|
|
2
4
|
import {
|
|
3
5
|
type AgentModel,
|
|
4
6
|
EFFORTS,
|
|
5
7
|
type Effort,
|
|
6
8
|
GRADES,
|
|
9
|
+
MATES,
|
|
7
10
|
MODEL_ALIASES,
|
|
8
11
|
type ModelsConfig,
|
|
9
12
|
baseAlias,
|
|
13
|
+
isMate,
|
|
14
|
+
mateEffort,
|
|
15
|
+
modelLabel,
|
|
10
16
|
} from "../../services/models-core.js";
|
|
11
17
|
import { effortInk, modelBadge, trackFill } from "../theme-mode.js";
|
|
12
18
|
import { type UiColor, theme } from "../theme.js";
|
|
@@ -59,7 +65,11 @@ export type CategoryTone = keyof typeof theme.category;
|
|
|
59
65
|
* serve this screen, its badges and its chart without three copies drifting apart.
|
|
60
66
|
*
|
|
61
67
|
* `inherit` is not a model but the absence of a routing decision, and the palette gives it
|
|
62
|
-
* grey on both pages so it recedes instead of reading as
|
|
68
|
+
* grey on both pages so it recedes instead of reading as another choice.
|
|
69
|
+
*
|
|
70
|
+
* A mate is not one of Claude Code's models either, but it IS a decision — the author sent
|
|
71
|
+
* that agent to claudish on purpose — so it takes an ink of its own from the warm family
|
|
72
|
+
* rather than the grey that means "nobody chose".
|
|
63
73
|
*/
|
|
64
74
|
export function modelFg(model: string): UiColor {
|
|
65
75
|
return modelBadge(baseAlias(model)).fg as UiColor;
|
|
@@ -77,11 +87,43 @@ export function modelBarFill(model: string): UiColor {
|
|
|
77
87
|
* `fitAgentTable`, `fitPresetRows` and `specModelColumn` all have to agree with what
|
|
78
88
|
* `ModelText` actually draws, and one of them disagreeing is a column that drifts by a cell
|
|
79
89
|
* as the data changes.
|
|
90
|
+
*
|
|
91
|
+
* Takes the string that is DRAWN, which for a mate is its bound id and not the slot. Pass it
|
|
92
|
+
* `modelLabel(config, model)`, never the routing value — measuring `mate1` (5) while drawing
|
|
93
|
+
* `gemini-omni-1.1-flash` (21) is a sixteen-cell overrun, not a one-cell drift.
|
|
80
94
|
*/
|
|
81
95
|
export function modelWidth(model: string): number {
|
|
82
96
|
return model.length;
|
|
83
97
|
}
|
|
84
98
|
|
|
99
|
+
// ─── Identity vs label: two things a "model" means on this screen ─────────────
|
|
100
|
+
//
|
|
101
|
+
// A slot is a ROLE and the id it is bound to is a MODEL, and the screen needs both at once:
|
|
102
|
+
// the ink has to stay with the role, the text has to name the model.
|
|
103
|
+
//
|
|
104
|
+
// COLOUR FOLLOWS THE SLOT, deliberately. `mate1` is the stable identity — it is what the
|
|
105
|
+
// agent table's Tier column says, what the workflow bar's legend names, and what does not
|
|
106
|
+
// change when someone rebinds the slot to a different model next week. Giving each external
|
|
107
|
+
// model its own hue would mean the bar's colours moved every time a binding changed, and
|
|
108
|
+
// there is no palette that can hold a catalogue of hundreds anyway.
|
|
109
|
+
//
|
|
110
|
+
// So every drawing function takes `model` (the routing value — decides the ink) and an
|
|
111
|
+
// optional `label` (what is written). They are the same string everywhere except a mate,
|
|
112
|
+
// which is precisely why they were one parameter until a slot could be bound.
|
|
113
|
+
|
|
114
|
+
/** The text for a routing value, and the identity that colours it. */
|
|
115
|
+
export interface ModelCell {
|
|
116
|
+
/** The routing value: an alias, `inherit`, or a slot. Decides ink. */
|
|
117
|
+
model: string;
|
|
118
|
+
/** What is drawn: a bound id for a mate, otherwise the same as `model`. */
|
|
119
|
+
label: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Resolve one routing value into the pair the renderers need. */
|
|
123
|
+
export function modelCell(config: ModelsConfig, model: string): ModelCell {
|
|
124
|
+
return { model, label: modelLabel(config, model) };
|
|
125
|
+
}
|
|
126
|
+
|
|
85
127
|
/** Columns a model BADGE occupies: the name plus one space of padding each side. */
|
|
86
128
|
export function modelBadgeWidth(model: string): number {
|
|
87
129
|
return model.length + 2;
|
|
@@ -100,13 +142,19 @@ export function modelBadgeWidth(model: string): number {
|
|
|
100
142
|
*/
|
|
101
143
|
export function ModelText({
|
|
102
144
|
model,
|
|
145
|
+
label,
|
|
103
146
|
selected = false,
|
|
104
147
|
}: {
|
|
148
|
+
/** The routing value — decides the INK. For a mate this is the slot. */
|
|
105
149
|
model: string;
|
|
150
|
+
/** What to write. Defaults to `model`; a mate passes its bound id. */
|
|
151
|
+
label?: string;
|
|
106
152
|
selected?: boolean;
|
|
107
153
|
}): React.ReactNode {
|
|
108
154
|
return (
|
|
109
|
-
<span fg={selected ? theme.selection.fg : modelFg(model)}>
|
|
155
|
+
<span fg={selected ? theme.selection.fg : modelFg(model)}>
|
|
156
|
+
{label ?? model}
|
|
157
|
+
</span>
|
|
110
158
|
);
|
|
111
159
|
}
|
|
112
160
|
|
|
@@ -121,14 +169,19 @@ export function ModelText({
|
|
|
121
169
|
*/
|
|
122
170
|
export function ModelBadge({
|
|
123
171
|
model,
|
|
172
|
+
label,
|
|
124
173
|
selected = false,
|
|
125
174
|
}: {
|
|
175
|
+
/** The routing value — decides the INK. For a mate this is the slot. */
|
|
126
176
|
model: string;
|
|
177
|
+
/** What to write. Defaults to `model`; a mate passes its bound id. */
|
|
178
|
+
label?: string;
|
|
127
179
|
selected?: boolean;
|
|
128
180
|
}): React.ReactNode {
|
|
129
|
-
|
|
181
|
+
const text = label ?? model;
|
|
182
|
+
if (selected) return <ModelText model={model} label={text} selected />;
|
|
130
183
|
const ink = modelBadge(baseAlias(model));
|
|
131
|
-
return <span bg={ink.bg} fg={ink.fg}>{` ${
|
|
184
|
+
return <span bg={ink.bg} fg={ink.fg}>{` ${text} `}</span>;
|
|
132
185
|
}
|
|
133
186
|
|
|
134
187
|
/** Pad a model COLUMN — emitted after the name, so columns line up. */
|
|
@@ -213,8 +266,6 @@ export function EffortGlyph({
|
|
|
213
266
|
selected = false,
|
|
214
267
|
}: {
|
|
215
268
|
effort: Effort | undefined;
|
|
216
|
-
/** Accepted and ignored — kept so call sites read the same as the model columns. */
|
|
217
|
-
model?: string;
|
|
218
269
|
selected?: boolean;
|
|
219
270
|
}): React.ReactNode {
|
|
220
271
|
const level = effortLevel(effort);
|
|
@@ -225,6 +276,9 @@ export function EffortGlyph({
|
|
|
225
276
|
</span>
|
|
226
277
|
);
|
|
227
278
|
}
|
|
279
|
+
// Every level is on the effort ramp, a mate's included. An external model's effort is as
|
|
280
|
+
// in force as a Claude subagent's — claudish applies it per run — so a second visual
|
|
281
|
+
// grade for it would say the two mean different things when they do not.
|
|
228
282
|
const on = selected
|
|
229
283
|
? theme.selection.fg
|
|
230
284
|
: (effortInk(effort as string) as UiColor);
|
|
@@ -237,8 +291,35 @@ export function EffortGlyph({
|
|
|
237
291
|
);
|
|
238
292
|
}
|
|
239
293
|
|
|
240
|
-
/**
|
|
241
|
-
|
|
294
|
+
/**
|
|
295
|
+
* What the effort column says for a mate carrying no effort of its own.
|
|
296
|
+
*
|
|
297
|
+
* Not "no effort": every model in claudish's catalogue has a preset, and a slot that names
|
|
298
|
+
* none runs at its model's. `--pro-on-ultracode` in `claudish --help` is the flag that opts
|
|
299
|
+
* into that preset explicitly, which is what makes it a real default rather than an absence.
|
|
300
|
+
*/
|
|
301
|
+
export const MATE_EFFORT_NOTE = "model preset";
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The word after the glyph. Absent effort says what absent MEANS.
|
|
305
|
+
*
|
|
306
|
+
* `model` is optional and matters for one case: an absent effort means something different
|
|
307
|
+
* for a mate. A Claude subagent with none inherits the session's; an external model has no
|
|
308
|
+
* session to inherit from, and falls to its own catalogue preset instead.
|
|
309
|
+
*
|
|
310
|
+
* A mate's effort IS in force, and is written plainly. It reaches the model through claudish
|
|
311
|
+
* rather than through `settings.modelSettings` — that map is keyed by models Claude Code
|
|
312
|
+
* runs, so `buildSettingsPatch` correctly drops a mate's — and claudish applies it per run,
|
|
313
|
+
* subject to a per-model clamp (`--effort-override` is the flag that skips the clamp). A
|
|
314
|
+
* clamp is a bound on the value, not a reason to doubt it, so the cell reads like any other.
|
|
315
|
+
*
|
|
316
|
+
* Taking the model here rather than special-casing it at the two call sites is what keeps
|
|
317
|
+
* `fitAgentTable`'s width maths agreeing with what `AgentTableRow` draws. A column measured
|
|
318
|
+
* from a different string than the one rendered drifts by a cell as the data changes, which
|
|
319
|
+
* is the defect `modelWidth` exists to prevent on the other column.
|
|
320
|
+
*/
|
|
321
|
+
export function effortWord(effort: Effort | undefined, model?: string): string {
|
|
322
|
+
if (isMate(model)) return effort ?? MATE_EFFORT_NOTE;
|
|
242
323
|
return effort ?? "inherits session";
|
|
243
324
|
}
|
|
244
325
|
|
|
@@ -303,10 +384,16 @@ export function agentDistribution(config: ModelsConfig): AgentSegment[] {
|
|
|
303
384
|
* Empty tiers are dropped here, unlike in `agentDistribution`: a workflow that touches two
|
|
304
385
|
* agents genuinely has two segments, and padding it to four would suggest it reaches tiers
|
|
305
386
|
* it never does.
|
|
387
|
+
*
|
|
388
|
+
* `externalSteps` is a property of the WORKFLOW, not of the config. `WORKFLOWS[].external`
|
|
389
|
+
* declares the phases a command runs on an outside model, read out of the command file that
|
|
390
|
+
* runs them, so the run is drawn under every preset: changing the tier preset does not change
|
|
391
|
+
* whether `/dev:dev` reviews its plan on an external model.
|
|
306
392
|
*/
|
|
307
393
|
export function workflowDistribution(
|
|
308
394
|
config: ModelsConfig,
|
|
309
395
|
agents: string[],
|
|
396
|
+
externalSteps = 0,
|
|
310
397
|
): AgentSegment[] {
|
|
311
398
|
const counts = new Map<string, number>();
|
|
312
399
|
for (const agent of agents) {
|
|
@@ -319,6 +406,13 @@ export function workflowDistribution(
|
|
|
319
406
|
: assignment.model;
|
|
320
407
|
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
321
408
|
}
|
|
409
|
+
// One run under the shared family tone: the bar answers "how much of this leaves Claude
|
|
410
|
+
// Code", which is one number however many slots serve it. Added to whatever `config.agents`
|
|
411
|
+
// already routes to a slot, so a project that hand-binds an agent and runs a workflow with
|
|
412
|
+
// external phases counts both.
|
|
413
|
+
if (externalSteps > 0) {
|
|
414
|
+
counts.set(MATES_SEGMENT, (counts.get(MATES_SEGMENT) ?? 0) + externalSteps);
|
|
415
|
+
}
|
|
322
416
|
|
|
323
417
|
const rank = (key: string) => {
|
|
324
418
|
const index = (GRADES as readonly string[]).indexOf(key);
|
|
@@ -368,7 +462,11 @@ export function modelsInUse(configs: ModelsConfig[]): string[] {
|
|
|
368
462
|
if (typeof assignment !== "string") seen.add(baseAlias(assignment.model));
|
|
369
463
|
}
|
|
370
464
|
}
|
|
371
|
-
|
|
465
|
+
// Aliases, then mates, then `inherit`. The list reads as "what claudeup routes", then
|
|
466
|
+
// "what it hands to claudish", then "what it leaves alone" — decreasing involvement, which
|
|
467
|
+
// is the only ordering that stays stable as slots are added. `inherit` keeps the tail it
|
|
468
|
+
// already had: it is the absence of a decision and belongs after every decision.
|
|
469
|
+
const order = [...MODEL_ALIASES, ...MATES, "inherit"] as readonly string[];
|
|
372
470
|
const rank = (model: string) => {
|
|
373
471
|
const index = order.indexOf(model);
|
|
374
472
|
return index === -1 ? order.length : index;
|
|
@@ -380,10 +478,19 @@ export function modelsInUse(configs: ModelsConfig[]): string[] {
|
|
|
380
478
|
|
|
381
479
|
export interface AgentRow {
|
|
382
480
|
name: string;
|
|
383
|
-
/** The assignment as written: a grade, `inherit`, or a pinned model. */
|
|
481
|
+
/** The assignment as written: a grade, `inherit`, or a pinned model. The Tier cell. */
|
|
384
482
|
key: string;
|
|
385
|
-
/** What that assignment resolves to — the row's
|
|
483
|
+
/** What that assignment resolves to — the row's COLOUR comes from this. */
|
|
386
484
|
model: string;
|
|
485
|
+
/**
|
|
486
|
+
* What the Model cell WRITES: a mate's bound id, or `unset` when the slot is unbound.
|
|
487
|
+
* Identical to `model` for everything else.
|
|
488
|
+
*
|
|
489
|
+
* Separate from `model` because the Model column used to print the routing value and so
|
|
490
|
+
* printed a slot name — a row read `mate1 mate1 —`, the same word in the Tier and Model
|
|
491
|
+
* columns, with neither of them naming a model.
|
|
492
|
+
*/
|
|
493
|
+
label: string;
|
|
387
494
|
effort: Effort | undefined;
|
|
388
495
|
/** The bar fill this model paints, from the theme. */
|
|
389
496
|
fill: UiColor;
|
|
@@ -409,17 +516,32 @@ export function agentRows(config: ModelsConfig): AgentRow[] {
|
|
|
409
516
|
const gradeIndex = (GRADES as readonly string[]).indexOf(key);
|
|
410
517
|
const grade = gradeIndex === -1 ? undefined : GRADES[gradeIndex];
|
|
411
518
|
const model = keyModel(config, key);
|
|
519
|
+
// An assignment's own effort, else the grade's. A MATE adds a third source: when
|
|
520
|
+
// neither names one, the slot's own declared effort from the `mates` block stands in,
|
|
521
|
+
// so `{"mate1": {"effort": "high"}}` reaches every agent on that slot without being
|
|
522
|
+
// repeated per agent. An agent-level effort still wins — it is the more specific
|
|
523
|
+
// statement, and the same precedence an override already has over a grade.
|
|
524
|
+
const written =
|
|
525
|
+
typeof assignment === "string"
|
|
526
|
+
? grade
|
|
527
|
+
? config.grades[grade].effort
|
|
528
|
+
: undefined
|
|
529
|
+
: assignment.effort;
|
|
530
|
+
const effort =
|
|
531
|
+
written ?? (isMate(model) ? mateEffort(config, model) : undefined);
|
|
412
532
|
rows.push({
|
|
413
533
|
name,
|
|
414
534
|
key,
|
|
415
535
|
model,
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
? grade
|
|
419
|
-
? config.grades[grade].effort
|
|
420
|
-
: undefined
|
|
421
|
-
: assignment.effort,
|
|
536
|
+
label: modelLabel(config, model),
|
|
537
|
+
effort,
|
|
422
538
|
fill: modelBarFill(model),
|
|
539
|
+
// A MATE DOES NOT SET THIS. `inherit` blanks both the model and the effort cell to
|
|
540
|
+
// `—`, which is right for "nothing was decided" and wrong for a mate: the slot name
|
|
541
|
+
// IS the answer, and blanking it would hide real routing behind the same dash that
|
|
542
|
+
// means the opposite. `baseAlias("mate1")` is `mate1`, so the existing test already
|
|
543
|
+
// gives the right answer — stated here because it is right by luck of the string
|
|
544
|
+
// rather than by a rule anyone wrote down.
|
|
423
545
|
inherit: baseAlias(model) === "inherit",
|
|
424
546
|
});
|
|
425
547
|
}
|
|
@@ -465,6 +587,19 @@ function effortColumn(detail: EffortDetail, word: number): number {
|
|
|
465
587
|
return EFFORT_GLYPH_CELLS + 1 + (detail === "short" ? 1 : word);
|
|
466
588
|
}
|
|
467
589
|
|
|
590
|
+
/** The fewest cells the agent-name column is ever squeezed to. */
|
|
591
|
+
const NAME_FLOOR = 6;
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The fewest cells the MODEL column is ever squeezed to.
|
|
595
|
+
*
|
|
596
|
+
* Enough for a prefix plus the `…` that says it is one. Catalogue ids are front-loaded with
|
|
597
|
+
* the family — `grok-4.6`, `glm-5.3`, `kimi-k3` — so eight cells still distinguishes the
|
|
598
|
+
* bindings a project is realistically running side by side, while `gemini…` and `gemini-…`
|
|
599
|
+
* would not.
|
|
600
|
+
*/
|
|
601
|
+
const MODEL_FLOOR = 8;
|
|
602
|
+
|
|
468
603
|
/**
|
|
469
604
|
* Column widths for the agent table, from the data rather than from constants.
|
|
470
605
|
*
|
|
@@ -490,13 +625,23 @@ export function fitAgentTable(
|
|
|
490
625
|
rows.map((row) => row.key.length),
|
|
491
626
|
HEADERS.grade.length,
|
|
492
627
|
);
|
|
628
|
+
// Measured from `row.label` — the string the row DRAWS — not from `row.model`.
|
|
629
|
+
//
|
|
630
|
+
// The column was sized for `kangaroo` (8) on the reasoning that a slot name was the widest
|
|
631
|
+
// thing it would ever hold. Binding a slot broke that by a wide margin: real catalogue ids
|
|
632
|
+
// run to `gemini-omni-1.1-flash` (21) and `deepseek-v4.1-flash` (19), so a column measured
|
|
633
|
+
// from the slot would under-count by sixteen cells and every row would overrun its
|
|
634
|
+
// neighbour. This is the same defect `modelWidth`'s comment warns about, at scale.
|
|
493
635
|
const modelNeeded = longest(
|
|
494
|
-
rows.map((row) => (row.inherit ? NONE_CELL.length : modelWidth(row.
|
|
636
|
+
rows.map((row) => (row.inherit ? NONE_CELL.length : modelWidth(row.label))),
|
|
495
637
|
HEADERS.model.length,
|
|
496
638
|
);
|
|
639
|
+
// `row.model` is passed so a mate's `via claudish` (or `high (declared)`) is measured,
|
|
640
|
+
// not `inherits session`. Measuring the wrong string here is how the effort column ends up
|
|
641
|
+
// a cell short of what the row draws.
|
|
497
642
|
const wordNeeded = longest(
|
|
498
643
|
rows.map((row) =>
|
|
499
|
-
row.inherit ? NONE_CELL.length : effortWord(row.effort).length,
|
|
644
|
+
row.inherit ? NONE_CELL.length : effortWord(row.effort, row.model).length,
|
|
500
645
|
),
|
|
501
646
|
1,
|
|
502
647
|
);
|
|
@@ -508,11 +653,16 @@ export function fitAgentTable(
|
|
|
508
653
|
{ grade: 0, effort: "none" },
|
|
509
654
|
];
|
|
510
655
|
|
|
511
|
-
const measure = (
|
|
656
|
+
const measure = (
|
|
657
|
+
grade: number,
|
|
658
|
+
effort: EffortDetail,
|
|
659
|
+
name: number,
|
|
660
|
+
model: number = modelNeeded,
|
|
661
|
+
) =>
|
|
512
662
|
name +
|
|
513
663
|
1 +
|
|
514
664
|
(grade > 0 ? grade + 1 : 0) +
|
|
515
|
-
|
|
665
|
+
model +
|
|
516
666
|
1 +
|
|
517
667
|
effortColumn(effort, wordNeeded);
|
|
518
668
|
|
|
@@ -532,13 +682,33 @@ export function fitAgentTable(
|
|
|
532
682
|
// Nothing fit even stripped down: give the name whatever is left, and
|
|
533
683
|
// truncate into it rather than wrapping.
|
|
534
684
|
const fixed = measure(0, "none", 0);
|
|
535
|
-
const name = Math.max(
|
|
685
|
+
const name = Math.max(NAME_FLOOR, width - fixed);
|
|
686
|
+
const total = measure(0, "none", name);
|
|
687
|
+
if (total <= width) {
|
|
688
|
+
return { name, grade: 0, model: modelNeeded, effort: "none", total };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// THE LAST RUNG, AND IT IS NEW.
|
|
692
|
+
//
|
|
693
|
+
// The model column used to be the one column that never gave way: every value it could
|
|
694
|
+
// hold was an alias (≤6) or a slot (≤8), so surrendering the name column always got the
|
|
695
|
+
// table under the width. A bound id is up to 21 cells, and at that size the old floor
|
|
696
|
+
// stopped being reachable — at 30 columns the fixed part alone is 27, the name floor adds
|
|
697
|
+
// 7, and the table overran by four with no rung left to take them from.
|
|
698
|
+
//
|
|
699
|
+
// So the model column truncates too, with the `…` `clipCell` puts there. It keeps
|
|
700
|
+
// `MODEL_FLOOR` cells, which is enough to tell two bound ids apart in practice; below that
|
|
701
|
+
// the table is not readable at any setting and the name floor is the last thing standing.
|
|
702
|
+
const model = Math.max(
|
|
703
|
+
MODEL_FLOOR,
|
|
704
|
+
width - (NAME_FLOOR + 1 + 1 + effortColumn("none", wordNeeded)),
|
|
705
|
+
);
|
|
536
706
|
return {
|
|
537
|
-
name,
|
|
707
|
+
name: NAME_FLOOR,
|
|
538
708
|
grade: 0,
|
|
539
|
-
model
|
|
709
|
+
model,
|
|
540
710
|
effort: "none",
|
|
541
|
-
total: measure(0, "none",
|
|
711
|
+
total: measure(0, "none", NAME_FLOOR, model),
|
|
542
712
|
};
|
|
543
713
|
}
|
|
544
714
|
|
|
@@ -558,37 +728,61 @@ export function agentTableHeaders(
|
|
|
558
728
|
return cells;
|
|
559
729
|
}
|
|
560
730
|
|
|
731
|
+
/**
|
|
732
|
+
* Truncate to `width`, with an ellipsis marking that something was cut.
|
|
733
|
+
*
|
|
734
|
+
* NEVER a silent clip. `gemini-omni-1.1-flash` cut to `gemini-omni-1.1-flas` is a model id
|
|
735
|
+
* that looks complete and is not — a reader would take it for the whole name and go looking
|
|
736
|
+
* for it in the catalogue. The `…` costs one cell and makes the cut visible.
|
|
737
|
+
*
|
|
738
|
+
* Split out of `padCell` because the model column needs the truncation WITHOUT the padding:
|
|
739
|
+
* it is drawn as a coloured `ModelText` followed by its own pad span, so padding here would
|
|
740
|
+
* paint the gap in the model's ink instead of the border's.
|
|
741
|
+
*/
|
|
742
|
+
export function clipCell(text: string, width: number): string {
|
|
743
|
+
if (width <= 0) return "";
|
|
744
|
+
return text.length > width
|
|
745
|
+
? `${text.slice(0, Math.max(0, width - 1))}…`
|
|
746
|
+
: text;
|
|
747
|
+
}
|
|
748
|
+
|
|
561
749
|
/** Truncate to `width`, then pad to it. A cell is exactly its column, always. */
|
|
562
750
|
export function padCell(text: string, width: number): string {
|
|
563
|
-
|
|
564
|
-
text.length > width ? `${text.slice(0, Math.max(0, width - 1))}…` : text;
|
|
565
|
-
return clipped.padEnd(width);
|
|
751
|
+
return clipCell(text, width).padEnd(width);
|
|
566
752
|
}
|
|
567
753
|
|
|
568
754
|
/** The effort word as the layout wants it: the word, its initial, or nothing. */
|
|
569
755
|
export function effortLabel(
|
|
570
756
|
effort: Effort | undefined,
|
|
571
757
|
detail: EffortDetail,
|
|
758
|
+
model?: string,
|
|
572
759
|
): string {
|
|
573
760
|
if (detail === "none") return "";
|
|
574
|
-
const word = effortWord(effort);
|
|
761
|
+
const word = effortWord(effort, model);
|
|
762
|
+
// A mate's fallback note does not abbreviate. `short` keeps a real effort's initial
|
|
763
|
+
// because the four dots beside it still carry the level, so one letter disambiguates
|
|
764
|
+
// rather than being the whole message — but "model preset" clipped to `m` is not a
|
|
765
|
+
// shorter way of saying anything, it is a character with no meaning in a column of them.
|
|
766
|
+
if (word === MATE_EFFORT_NOTE) return detail === "short" ? "" : word;
|
|
575
767
|
return detail === "short" ? word.slice(0, 1) : word;
|
|
576
768
|
}
|
|
577
769
|
|
|
578
770
|
// ─── The preset list rows ─────────────────────────────────────────────────────
|
|
579
771
|
|
|
580
|
-
/**
|
|
772
|
+
/**
|
|
773
|
+
* The trailing markers a preset row may carry. Their leading space is theirs.
|
|
774
|
+
*
|
|
775
|
+
* A custom row carries NONE. `Custom` is already the whole claim — the row exists only
|
|
776
|
+
* because the settings differ from every built-in — so a marker beside it repeats the label
|
|
777
|
+
* in different words.
|
|
778
|
+
*/
|
|
581
779
|
export const PRESET_MARKERS = {
|
|
582
780
|
default: " (default)",
|
|
583
|
-
custom: " (yours)",
|
|
584
781
|
} as const;
|
|
585
782
|
|
|
586
|
-
/** Columns a row's markers occupy — 0
|
|
587
|
-
export function presetMarkerWidth(isDefault: boolean
|
|
588
|
-
return
|
|
589
|
-
(isDefault ? PRESET_MARKERS.default.length : 0) +
|
|
590
|
-
(custom ? PRESET_MARKERS.custom.length : 0)
|
|
591
|
-
);
|
|
783
|
+
/** Columns a row's markers occupy — 0 or the default marker. */
|
|
784
|
+
export function presetMarkerWidth(isDefault: boolean): number {
|
|
785
|
+
return isDefault ? PRESET_MARKERS.default.length : 0;
|
|
592
786
|
}
|
|
593
787
|
|
|
594
788
|
/** What a preset row shows beside its name, widest form first. */
|
|
@@ -614,8 +808,12 @@ export interface PresetRowLayout {
|
|
|
614
808
|
/** One row's inputs — a preset reduced to the fields the column maths needs. */
|
|
615
809
|
export interface PresetRowInput {
|
|
616
810
|
label: string;
|
|
811
|
+
/** The main model. Never a mate — the validator refuses one on `main.model`. */
|
|
617
812
|
main: string;
|
|
813
|
+
/** The routing value for `smart` — decides its INK. May be a slot. */
|
|
618
814
|
smart: string;
|
|
815
|
+
/** What the `smart` column WRITES: a bound id for a mate, else the same as `smart`. */
|
|
816
|
+
smartLabel: string;
|
|
619
817
|
/** Columns this row's trailing markers need. */
|
|
620
818
|
markers: number;
|
|
621
819
|
}
|
|
@@ -623,6 +821,20 @@ export interface PresetRowInput {
|
|
|
623
821
|
/** The longest a preset name may be before it truncates into an ellipsis. */
|
|
624
822
|
export const PRESET_NAME_MAX = 20;
|
|
625
823
|
|
|
824
|
+
/**
|
|
825
|
+
* The longest a MODEL may be in a list row before it truncates.
|
|
826
|
+
*
|
|
827
|
+
* The list panel is the narrow one — it shares the screen with the detail pane — and a
|
|
828
|
+
* 21-cell `gemini-omni-1.1-flash` here does not merely widen the row, it knocks the whole
|
|
829
|
+
* list down the degradation ladder to `main`, so every row loses its second model to one
|
|
830
|
+
* row's long binding. Truncating the one long value keeps the field for all of them, and the
|
|
831
|
+
* detail pane three columns to the right shows it in full.
|
|
832
|
+
*
|
|
833
|
+
* 14 is chosen against real ids: `deepseek-v4.1…`, `gemini-3.8-fla…`, and `grok-4.6` /
|
|
834
|
+
* `kimi-k3` / `glm-5.3` untouched.
|
|
835
|
+
*/
|
|
836
|
+
export const PRESET_MODEL_MAX = 14;
|
|
837
|
+
|
|
626
838
|
/** `[●] ` — the active marker and the space after it, on every row. */
|
|
627
839
|
const PRESET_GUTTER = 4;
|
|
628
840
|
/** The indent `SelectableRow` adds. */
|
|
@@ -677,9 +889,14 @@ export function fitPresetRows(
|
|
|
677
889
|
rows.map((row) => modelWidth(row.main)),
|
|
678
890
|
1,
|
|
679
891
|
);
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
892
|
+
// Measured from the LABEL and capped. `smart` is the one column here that can hold a
|
|
893
|
+
// bound id, so it is the one that had to learn a ceiling — see `PRESET_MODEL_MAX`.
|
|
894
|
+
const smart = Math.min(
|
|
895
|
+
PRESET_MODEL_MAX,
|
|
896
|
+
longest(
|
|
897
|
+
rows.map((row) => modelWidth(row.smartLabel)),
|
|
898
|
+
1,
|
|
899
|
+
),
|
|
683
900
|
);
|
|
684
901
|
const markers = longest(
|
|
685
902
|
rows.map((row) => row.markers),
|
|
@@ -722,132 +939,464 @@ export function fitPresetRows(
|
|
|
722
939
|
return { name, main: 0, smart: 0, detail: "none", total: base + markers };
|
|
723
940
|
}
|
|
724
941
|
|
|
725
|
-
// ─── The
|
|
942
|
+
// ─── The workflow bar ─────────────────────────────────────────────────────────
|
|
726
943
|
|
|
727
944
|
/**
|
|
728
|
-
*
|
|
945
|
+
* The bar is laid out by YOGA, not by arithmetic in this file.
|
|
946
|
+
*
|
|
947
|
+
* What used to be here — `spendRuns` and `splitCells` — divided N cells among K counts by
|
|
948
|
+
* largest remainder, floored every share at one cell, and then paid whatever was left over
|
|
949
|
+
* in track so the row still came out exactly its allotted width. Every one of those steps
|
|
950
|
+
* is a thing a flex engine already does, and doing it twice is how a row ends up a cell
|
|
951
|
+
* short of the box it sits in.
|
|
729
952
|
*
|
|
730
|
-
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
953
|
+
* So a segment is a `<box>` with `flexGrow` set to its share of the workflow's agents, and
|
|
954
|
+
* the row filling its width exactly is a property of the layout rather than a correction
|
|
955
|
+
* this module applies. MEASURED with the real renderer at 20/26/32/40/64/80 columns: the
|
|
956
|
+
* captured spans sum to the row width at every rung, with no shortfall to pay.
|
|
957
|
+
*
|
|
958
|
+
* ## Three findings that shape the code below, all measured against OpenTUI 0.1.107
|
|
959
|
+
*
|
|
960
|
+
* 1. `flexBasis: 0` + `flexGrow: n` alone lets a small share resolve to ZERO cells and
|
|
961
|
+
* vanish: shares 40/1/1/1/1 at 20 columns drew three segments, not five. `minWidth: 1`
|
|
962
|
+
* is what keeps every segment on screen, and it is the layout-native spelling of the
|
|
963
|
+
* min-one-cell rule `splitCells` used to carry. It costs a little proportionality at
|
|
964
|
+
* narrow widths — Yoga takes the floor out of the free space first, so 9/3/3/2 at 20
|
|
965
|
+
* columns resolves 9,4,4,3 rather than 11,3,4,2 — which is a fair trade at a width where
|
|
966
|
+
* one cell is a twentieth of the bar.
|
|
967
|
+
*
|
|
968
|
+
* 2. A segment's CONTENT never perturbs the distribution. A 200-character fill and an empty
|
|
969
|
+
* box resolve to identical widths at every rung, which is what makes it safe to measure a
|
|
970
|
+
* segment and then draw into it: the measurement cannot invalidate itself.
|
|
971
|
+
*
|
|
972
|
+
* 3. `flexDirection: "row"` on a box that also clips (`overflow: "hidden"`) loses ONE CELL
|
|
973
|
+
* off the right of its text child — 9,4,4,3 drew `000000000111 222 33 `. The same tree in
|
|
974
|
+
* the default column direction is gapless at every rung. So a segment box is NEVER a row;
|
|
975
|
+
* the row direction lives on the container above it.
|
|
733
976
|
*/
|
|
734
|
-
export function splitCells(values: number[], cells: number): number[] {
|
|
735
|
-
const total = values.reduce((sum, value) => sum + value, 0);
|
|
736
|
-
const out = values.map(() => 0);
|
|
737
|
-
if (total <= 0 || cells <= 0) return out;
|
|
738
977
|
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
978
|
+
/** The unlit cell between two neighbouring segments. */
|
|
979
|
+
export const BAR_DIVIDER = "░";
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* The weave a MATE segment is drawn with, so it is not colour alone that says this work
|
|
983
|
+
* leaves Claude Code.
|
|
984
|
+
*
|
|
985
|
+
* `▓` is 75% ink, which is the whole reason it is this glyph and not `▒` or `░`. Drawn in
|
|
986
|
+
* the mate's `bar` tone over its `bg` tone, three quarters of every cell is the same fill
|
|
987
|
+
* weight the four Claude models carry, so a mate reads as a peer with a texture rather than
|
|
988
|
+
* as something dimmed, disabled or errored — the failure mode a lighter glyph walks straight
|
|
989
|
+
* into. `░` is spoken for anyway: it is the unlit track, and reusing it would say "nothing
|
|
990
|
+
* runs here" about a segment that is running someone else's model.
|
|
991
|
+
*/
|
|
992
|
+
export const MATE_WEFT = "▓";
|
|
749
993
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
994
|
+
/**
|
|
995
|
+
* The id, the routing sentinel and the WORD for the one run every mate-routed agent in a
|
|
996
|
+
* workflow collapses into.
|
|
997
|
+
*
|
|
998
|
+
* Not a routing value and never written to a config — `models-core` knows nothing about it.
|
|
999
|
+
* It is a bar-only identity, which is why it lives here beside the drawing code: the bar
|
|
1000
|
+
* answers "how much of this workflow leaves Claude Code", and that is one number however
|
|
1001
|
+
* many slots it is spread across.
|
|
1002
|
+
*
|
|
1003
|
+
* A workflow that routes two agents to two different slots used to draw two narrow textured
|
|
1004
|
+
* runs labelled `mate1` and `mate2`. At the widths this panel gets, neither label fitted, so
|
|
1005
|
+
* the distinction cost two segments and bought nothing — while the number the bar exists to
|
|
1006
|
+
* show was split in half.
|
|
1007
|
+
*
|
|
1008
|
+
* The word is `mates` even when exactly ONE slot is in play. A lone `mate1` there would say
|
|
1009
|
+
* the label names a slot, and the next config over — same label, two slots — would prove it
|
|
1010
|
+
* does not. The detail pane names the slots; the key under the chart names them with their
|
|
1011
|
+
* bindings. This is the only place that deliberately does not.
|
|
1012
|
+
*/
|
|
1013
|
+
export const MATES_SEGMENT = "mates";
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Is this bar segment external work — a slot, or the collapsed run standing for several?
|
|
1017
|
+
*
|
|
1018
|
+
* The one predicate the weave, the label-fit rule and the ground colour all read, so a
|
|
1019
|
+
* collapsed segment cannot end up woven-but-uncoloured or coloured-but-plain.
|
|
1020
|
+
*/
|
|
1021
|
+
export function isMateSegment(model: string): boolean {
|
|
1022
|
+
return model === MATES_SEGMENT || isMate(baseAlias(model));
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* A fill long enough to outrun any segment, since the box clips it to size.
|
|
1027
|
+
*
|
|
1028
|
+
* The alternative is to repeat the glyph to a MEASURED width, which is a frame behind the
|
|
1029
|
+
* layout and would leave a mate segment plain on the frame it first appears. Clipping is
|
|
1030
|
+
* exact (finding 3 above), so an over-long run is simply the fill, with no arithmetic and
|
|
1031
|
+
* nothing to be stale about. 240 clears any pane a terminal can offer this panel.
|
|
1032
|
+
*/
|
|
1033
|
+
const WEFT_RUN = 240;
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Columns an inline label needs: the name, plus one padding cell each side.
|
|
1037
|
+
*
|
|
1038
|
+
* The same measure `modelBadgeWidth` uses, and deliberately so — an inline label IS the
|
|
1039
|
+
* chip, inset into the bar, so it costs what a chip costs.
|
|
1040
|
+
*/
|
|
1041
|
+
export function inlineLabelWidth(label: string): number {
|
|
1042
|
+
return modelBadgeWidth(label);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* What a segment writes inside itself.
|
|
1047
|
+
*
|
|
1048
|
+
* A mate writes its SLOT — `mate1` — and never the id it is bound to. The slot is the stable
|
|
1049
|
+
* identity the rest of this screen is keyed on, it is what the legend names, and a bound id
|
|
1050
|
+
* runs to 21 characters (`gemini-omni-1.1-flash`), which no segment at a real pane width
|
|
1051
|
+
* could hold. `baseAlias` gives the slot for a mate and the plain alias for everything else,
|
|
1052
|
+
* so one call covers both.
|
|
1053
|
+
*/
|
|
1054
|
+
export function segmentLabel(model: string): string {
|
|
1055
|
+
return baseAlias(model);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Does the label fit inside the segment, given the width the layout resolved?
|
|
1060
|
+
*
|
|
1061
|
+
* `undefined` means the layout has not reported yet — the first frame after mount — and is
|
|
1062
|
+
* a NO. There is no third answer here on purpose: a label either sits inside its segment
|
|
1063
|
+
* whole or it does not appear at all, and the legend under the bar carries it instead.
|
|
1064
|
+
* Clipping `sonnet` to `son…` inside a coloured block reads as a model id that does not
|
|
1065
|
+
* exist, which is worse than the segment being unlabelled.
|
|
1066
|
+
*
|
|
1067
|
+
* A MATE ASKS FOR TWO CELLS MORE than its label needs, and that is the rule that keeps the
|
|
1068
|
+
* weave honest. At exactly `inlineLabelWidth` the chip fills the segment edge to edge and
|
|
1069
|
+
* not one cell of `▓` survives — so the one segment on the row that is supposed to be
|
|
1070
|
+
* distinguishable without colour would, at that single width, be distinguishable only by
|
|
1071
|
+
* colour. Two spare cells is one weave cell each side, which is the least that still reads
|
|
1072
|
+
* as a texture rather than as a stray character.
|
|
1073
|
+
*/
|
|
1074
|
+
export function inlineLabelFits(
|
|
1075
|
+
model: string,
|
|
1076
|
+
cells: number | undefined,
|
|
1077
|
+
): boolean {
|
|
1078
|
+
const weave = isMateSegment(model) ? 2 : 0;
|
|
1079
|
+
return (
|
|
1080
|
+
cells !== undefined &&
|
|
1081
|
+
inlineLabelWidth(segmentLabel(model)) + weave <= cells
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** One run of the bar: what it routes to, and how much of the workflow it is. */
|
|
1086
|
+
export interface BarSegmentSpec {
|
|
1087
|
+
/** Identity within the row — the distribution key, so two tiers on one model stay two. */
|
|
1088
|
+
id: string;
|
|
1089
|
+
/** The routing value: an alias, `inherit`, or a slot. Decides ink, weave and label. */
|
|
1090
|
+
model: string;
|
|
1091
|
+
/** Agents this segment stands for. Becomes its `flexGrow`. */
|
|
1092
|
+
share: number;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* A tier distribution as the runs a bar actually draws: every mate folded into ONE.
|
|
1097
|
+
*
|
|
1098
|
+
* The fold is a SUM, not a pick, so the proportion is untouched — two agents on two slots
|
|
1099
|
+
* occupy exactly the cells the two separate runs occupied, less the divider that used to sit
|
|
1100
|
+
* between them. Shares stay counts and the layout stays Yoga's; nothing here divides cells
|
|
1101
|
+
* (see the note above `BAR_DIVIDER` for why that matters).
|
|
1102
|
+
*
|
|
1103
|
+
* Collapsing keys off the MODEL, never the key: a grade bound to `mate1` reaches the bar as
|
|
1104
|
+
* `{key: "cheap", model: "mate1"}`, and folding by key would leave that one outside the run
|
|
1105
|
+
* it belongs in.
|
|
1106
|
+
*
|
|
1107
|
+
* The collapsed run takes the position of the FIRST mate in the order, so adding a second
|
|
1108
|
+
* slot to a config does not move the bar's other segments around.
|
|
1109
|
+
*/
|
|
1110
|
+
export function barSegments(segments: AgentSegment[]): BarSegmentSpec[] {
|
|
1111
|
+
const runs: BarSegmentSpec[] = [];
|
|
1112
|
+
let mates: BarSegmentSpec | undefined;
|
|
1113
|
+
for (const segment of segments) {
|
|
1114
|
+
// `isMateSegment`, not `isMate`: two kinds of segment belong in this run — a slot a
|
|
1115
|
+
// config bound to an agent, and the workflow's own external phases, which arrive
|
|
1116
|
+
// already carrying the family name. Matching only the slots leaves the second kind
|
|
1117
|
+
// outside and draws two `mates` runs side by side.
|
|
1118
|
+
if (isMateSegment(segment.model)) {
|
|
1119
|
+
if (!mates) {
|
|
1120
|
+
mates = { id: MATES_SEGMENT, model: MATES_SEGMENT, share: 0 };
|
|
1121
|
+
runs.push(mates);
|
|
759
1122
|
}
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
1123
|
+
mates.share += segment.count;
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
runs.push({ id: segment.key, model: segment.model, share: segment.count });
|
|
764
1127
|
}
|
|
1128
|
+
return runs;
|
|
1129
|
+
}
|
|
765
1130
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1131
|
+
/** Callback a segment uses to report the width the layout gave it. */
|
|
1132
|
+
type Measure = (id: string, cells: number) => void;
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* One segment: a coloured box that Yoga sizes, with its label drawn inside when it fits.
|
|
1136
|
+
*
|
|
1137
|
+
* The fill is the box's `backgroundColor` rather than a run of `█`, which is the same
|
|
1138
|
+
* picture with none of the counting — a `█` in a colour and a cell painted that colour are
|
|
1139
|
+
* indistinguishable in a terminal, and only one of them needs to know how wide the box is.
|
|
1140
|
+
*
|
|
1141
|
+
* ## The label is written ON the fill, not in a chip inset into it
|
|
1142
|
+
*
|
|
1143
|
+
* It used to be the model's CHIP — `bg` under `fg`, inset — which put a second, visibly
|
|
1144
|
+
* different rectangle inside a block that is supposed to read as one thing. Reported as
|
|
1145
|
+
* "do not add additional background colour to model segments".
|
|
1146
|
+
*
|
|
1147
|
+
* The reason the chip was there in the first place still stands and is why deleting it was
|
|
1148
|
+
* not the fix on its own: MEASURED across both pages, a model's `fg` laid on its own `bar`
|
|
1149
|
+
* runs 1.74–2.99:1 on the dark page, which is a smudge rather than a word. So the palette
|
|
1150
|
+
* grew a fourth ink — `label`, chosen against each model's own `bar` and nothing else,
|
|
1151
|
+
* 4.80–4.93:1 on every model and both pages — and the segment writes the name in that,
|
|
1152
|
+
* directly on its own fill. No second background, and no unreadable text.
|
|
1153
|
+
*
|
|
1154
|
+
* A MATE is the one case that still sets a background, and it sets it to `bar` — the tone
|
|
1155
|
+
* its own weave is painted in. A mate's box ground is `bg` with `▓` (75% ink) of `bar` over
|
|
1156
|
+
* it, so the segment READS as `bar`; leaving the label cells at the raw `bg` would punch a
|
|
1157
|
+
* darker patch where the weave stops, which is the same defect in the other direction.
|
|
1158
|
+
* Painting those few cells `bar` makes the whole segment one tone, and it means one rule
|
|
1159
|
+
* covers every model: the label sits on `bar`, so it is measured against `bar`.
|
|
1160
|
+
*/
|
|
1161
|
+
function BarSegment({
|
|
1162
|
+
segment,
|
|
1163
|
+
cells,
|
|
1164
|
+
onMeasure,
|
|
1165
|
+
}: {
|
|
1166
|
+
segment: BarSegmentSpec;
|
|
1167
|
+
/** Columns the layout resolved for this box, or `undefined` before the first report. */
|
|
1168
|
+
cells: number | undefined;
|
|
1169
|
+
onMeasure: Measure;
|
|
1170
|
+
}): React.ReactNode {
|
|
1171
|
+
const identity = baseAlias(segment.model);
|
|
1172
|
+
const ink = modelBadge(identity);
|
|
1173
|
+
const mate = isMateSegment(identity);
|
|
1174
|
+
const label = segmentLabel(segment.model);
|
|
1175
|
+
const fits = inlineLabelFits(segment.model, cells);
|
|
1176
|
+
// EVERY segment has ONE ground, and it is `bar` — mates included. A mate's texture is a
|
|
1177
|
+
// pale MARK painted on that ground, never a second surface behind it: two tones inside
|
|
1178
|
+
// one run read as two blocks, a solid patch of colour with a patterned margin beside it.
|
|
1179
|
+
//
|
|
1180
|
+
// One ground is also what keeps the contrast numbers honest. Every model's `label` ink is
|
|
1181
|
+
// measured against its `bar`, and `bar` is what the label sits on in every segment.
|
|
1182
|
+
const ground = ink.bar;
|
|
1183
|
+
const pad = fits && cells !== undefined ? cells - inlineLabelWidth(label) : 0;
|
|
1184
|
+
const left = Math.floor(pad / 2);
|
|
1185
|
+
const weft = mate ? MATE_WEFT : " ";
|
|
1186
|
+
|
|
1187
|
+
return (
|
|
1188
|
+
<box
|
|
1189
|
+
flexGrow={segment.share}
|
|
1190
|
+
flexBasis={0}
|
|
1191
|
+
minWidth={1}
|
|
1192
|
+
overflow="hidden"
|
|
1193
|
+
backgroundColor={ground}
|
|
1194
|
+
onSizeChange={function (this: BoxRenderable) {
|
|
1195
|
+
onMeasure(segment.id, this.width);
|
|
1196
|
+
}}
|
|
1197
|
+
>
|
|
1198
|
+
{fits ? (
|
|
1199
|
+
// The weave is drawn in `bg`, the model's PALEST tone, over the `bar` ground.
|
|
1200
|
+
// A Claude segment's weft is a space, so its ink never shows.
|
|
1201
|
+
<text fg={(mate ? ink.bg : ink.bar) as UiColor}>
|
|
1202
|
+
<span>{weft.repeat(left)}</span>
|
|
1203
|
+
{/* No `bg` on the label, for any model: the box is already `bar`, and naming
|
|
1204
|
+
a background here could only repaint it as a second tone. */}
|
|
1205
|
+
<span fg={ink.label}>{` ${label} `}</span>
|
|
1206
|
+
<span>{weft.repeat(pad - left)}</span>
|
|
1207
|
+
</text>
|
|
1208
|
+
) : mate ? (
|
|
1209
|
+
<text fg={ink.bg as UiColor}>{MATE_WEFT.repeat(WEFT_RUN)}</text>
|
|
1210
|
+
) : null}
|
|
1211
|
+
</box>
|
|
1212
|
+
);
|
|
777
1213
|
}
|
|
778
1214
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
1215
|
+
// ─── The colour key under the bars ────────────────────────────────────────────
|
|
1216
|
+
|
|
1217
|
+
/** One chip in the key: the ink it is painted in, and the words written on it. */
|
|
1218
|
+
export interface KeyChip {
|
|
1219
|
+
/**
|
|
1220
|
+
* Decides the INK, and it is not always the model.
|
|
1221
|
+
*
|
|
1222
|
+
* Every slot resolves to `MATES_SEGMENT`, because the key describes the CHART and the
|
|
1223
|
+
* chart paints all external work in one tone. Chipping `mate1` in ochre here would put a
|
|
1224
|
+
* colour in the legend that appears at no width in the graphic above it — the exact
|
|
1225
|
+
* defect `ModelKey`'s own note records, in a new place.
|
|
1226
|
+
*/
|
|
1227
|
+
ink: string;
|
|
1228
|
+
/** What the chip writes: `opus`, or `mate1 grok-4.6`, or `mate1 unset`. */
|
|
1229
|
+
text: string;
|
|
784
1230
|
}
|
|
785
1231
|
|
|
786
1232
|
/**
|
|
787
|
-
* The
|
|
1233
|
+
* The key's chips, fitted to the row it has.
|
|
1234
|
+
*
|
|
1235
|
+
* A slot is written WITH ITS BINDING, which is the detail the bar gave up when it collapsed
|
|
1236
|
+
* its mates into one run. The bar answers how much work leaves Claude Code; this answers
|
|
1237
|
+
* where it goes, in the one place on this screen with the width for a 21-character
|
|
1238
|
+
* catalogue id.
|
|
788
1239
|
*
|
|
789
|
-
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
* exists to explain — how much model the main thread gets versus the work it hands out —
|
|
794
|
-
* was the one thing it did not draw.
|
|
1240
|
+
* An unbound slot writes `unset` — `modelLabel`'s word, the same one the tier table's Model
|
|
1241
|
+
* column already uses for that state, rather than a second word for one thing. What it must
|
|
1242
|
+
* never write is the slot name twice: `mate1 mate1` names no model and is what binding a
|
|
1243
|
+
* slot exists to remove.
|
|
795
1244
|
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
1245
|
+
* ## Four rungs, and it never wraps
|
|
1246
|
+
*
|
|
1247
|
+
* Wrapping is not available: this block is budgeted at `SUMMARY_COST.key` = 2 rows in a
|
|
1248
|
+
* fixed-height panel, and an over-tall block there OVERPRINTS rather than scrolling or
|
|
1249
|
+
* clipping (see `planModelsSummary`). A second row would land on whatever is drawn below it.
|
|
1250
|
+
*
|
|
1251
|
+
* bound every chip with its slot's model `mate1 grok-4.6 mate2 kimi-k3`
|
|
1252
|
+
* slots every chip, bindings dropped `mate1 mate2`
|
|
1253
|
+
* folded the slots as the bar's own one word `mates`
|
|
1254
|
+
* truncated chips dropped from the right, folded
|
|
1255
|
+
*
|
|
1256
|
+
* The first three rungs are ALL-OR-NOTHING for the whole row, the way `fitPresetRows` is for
|
|
1257
|
+
* the whole list: one long binding would otherwise show its model while its neighbour showed
|
|
1258
|
+
* a bare slot, which reads as two formats rather than as one terse row. Keeping every colour
|
|
1259
|
+
* in the key beats keeping one binding — a legend missing an entry cannot decode the chart.
|
|
1260
|
+
*
|
|
1261
|
+
* The `folded` rung is why truncation is the LAST resort rather than the second. Dropping
|
|
1262
|
+
* chips from the right takes them in `modelsInUse` order, which puts the mates near the end
|
|
1263
|
+
* — so a two-slot config at 40 columns kept `mate1` and silently dropped `mate2`, naming one
|
|
1264
|
+
* slot as though it were the only external one. Folding says exactly what the bar says, in
|
|
1265
|
+
* the tone the bar paints, and costs one chip instead of two.
|
|
799
1266
|
*/
|
|
800
|
-
export function
|
|
1267
|
+
export function fitModelKey(
|
|
801
1268
|
config: ModelsConfig,
|
|
802
|
-
segments: AgentSegment[],
|
|
803
1269
|
width: number,
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
1270
|
+
labelCells: number,
|
|
1271
|
+
/**
|
|
1272
|
+
* Does a WORKFLOW put a mates run on the chart, independently of this config?
|
|
1273
|
+
*
|
|
1274
|
+
* The key describes the chart, and the chart draws mates for two reasons: a config that
|
|
1275
|
+
* binds a slot to an agent, and a workflow whose own phases run outside Claude Code. Only
|
|
1276
|
+
* the first is visible in `config`, so without this a built-in preset — which binds no
|
|
1277
|
+
* slot — drew a textured run with no chip under it to say what the texture meant.
|
|
1278
|
+
*/
|
|
1279
|
+
externalWorkflows = false,
|
|
1280
|
+
): KeyChip[] {
|
|
1281
|
+
const models = modelsInUse([config]);
|
|
1282
|
+
const form = (rung: "bound" | "slots" | "folded"): KeyChip[] => {
|
|
1283
|
+
const chips: KeyChip[] = [];
|
|
1284
|
+
let folded = false;
|
|
1285
|
+
let sawMate = false;
|
|
1286
|
+
for (const model of models) {
|
|
1287
|
+
if (!isMate(model)) {
|
|
1288
|
+
chips.push({ ink: model, text: model });
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
sawMate = true;
|
|
1292
|
+
// Folded exactly as `barSegments` folds the bar: one entry, at the position of
|
|
1293
|
+
// the first slot, standing for all of them.
|
|
1294
|
+
if (rung === "folded") {
|
|
1295
|
+
if (folded) continue;
|
|
1296
|
+
folded = true;
|
|
1297
|
+
chips.push({ ink: MATES_SEGMENT, text: MATES_SEGMENT });
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
chips.push({
|
|
1301
|
+
ink: MATES_SEGMENT,
|
|
1302
|
+
text:
|
|
1303
|
+
rung === "bound" ? `${model} ${modelLabel(config, model)}` : model,
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
// The workflow's own external phases, when the config named no slot of its own. One
|
|
1307
|
+
// unnamed chip, because there is no slot to name — the run stands for whichever model
|
|
1308
|
+
// claudish reaches for, and the config has not said which.
|
|
1309
|
+
//
|
|
1310
|
+
// Placed BEFORE `inherit`, never appended. Chips are dropped from the right when the
|
|
1311
|
+
// row will not fit, and last place is first to go — which lost the one chip that
|
|
1312
|
+
// explains a texture the reader cannot otherwise decode, while keeping the chip for a
|
|
1313
|
+
// flat grey that means "nothing was chosen". `inherit` is the one that can go.
|
|
1314
|
+
if (!sawMate && externalWorkflows) {
|
|
1315
|
+
const chip = { ink: MATES_SEGMENT, text: MATES_SEGMENT };
|
|
1316
|
+
const inherit = chips.findIndex((c) => c.ink === "inherit");
|
|
1317
|
+
if (inherit === -1) chips.push(chip);
|
|
1318
|
+
else chips.splice(inherit, 0, chip);
|
|
1319
|
+
}
|
|
1320
|
+
return chips;
|
|
1321
|
+
};
|
|
1322
|
+
// A chip costs its own two padding cells (`modelBadgeWidth`) plus one space separating it
|
|
1323
|
+
// from the chip before it — measured the way the row is drawn, so it cannot overflow by
|
|
1324
|
+
// exactly the padding the chips paint.
|
|
1325
|
+
const cost = (chips: KeyChip[]) =>
|
|
1326
|
+
chips.reduce(
|
|
1327
|
+
(sum, chip, index) => sum + modelBadgeWidth(chip.text) + (index ? 1 : 0),
|
|
1328
|
+
labelCells,
|
|
1329
|
+
);
|
|
1330
|
+
|
|
1331
|
+
for (const rung of ["bound", "slots", "folded"] as const) {
|
|
1332
|
+
const chips = form(rung);
|
|
1333
|
+
if (cost(chips) <= width) return chips;
|
|
1334
|
+
}
|
|
821
1335
|
|
|
822
|
-
const
|
|
823
|
-
|
|
824
|
-
const
|
|
825
|
-
|
|
826
|
-
|
|
1336
|
+
const shown: KeyChip[] = [];
|
|
1337
|
+
let used = labelCells;
|
|
1338
|
+
for (const chip of form("folded")) {
|
|
1339
|
+
const next = modelBadgeWidth(chip.text) + (shown.length > 0 ? 1 : 0);
|
|
1340
|
+
if (used + next > width) break;
|
|
1341
|
+
shown.push(chip);
|
|
1342
|
+
used += next;
|
|
1343
|
+
}
|
|
1344
|
+
return shown;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** The unlit cell that keeps two neighbouring segments from reading as one. */
|
|
1348
|
+
function BarDivider(): React.ReactNode {
|
|
1349
|
+
return (
|
|
1350
|
+
<box width={1} flexShrink={0}>
|
|
1351
|
+
<text fg={trackFill() as UiColor}>{BAR_DIVIDER}</text>
|
|
1352
|
+
</box>
|
|
827
1353
|
);
|
|
1354
|
+
}
|
|
828
1355
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
1356
|
+
/**
|
|
1357
|
+
* A whole bar: every segment side by side, filling whatever the row leaves it.
|
|
1358
|
+
*
|
|
1359
|
+
* Holds the measurements because they are one reading of one row — a map keyed by segment
|
|
1360
|
+
* id, written only when a width actually changes, so a label appearing cannot start a loop.
|
|
1361
|
+
* It converges in one extra frame and then sits still; finding 2 above is what guarantees
|
|
1362
|
+
* that, since the label it draws cannot move the box it is drawn in.
|
|
1363
|
+
*
|
|
1364
|
+
* The one-cell divider is kept from the bar this replaces, for the reason it was added:
|
|
1365
|
+
* MEASURED on a dark screenshot, two adjacent runs resolving to the same model merged into a
|
|
1366
|
+
* single rectangle and three segments read as two.
|
|
1367
|
+
*/
|
|
1368
|
+
export function BarRow({
|
|
1369
|
+
segments,
|
|
1370
|
+
}: {
|
|
1371
|
+
segments: BarSegmentSpec[];
|
|
1372
|
+
}): React.ReactNode {
|
|
1373
|
+
const [measured, setMeasured] = useState<Record<string, number>>({});
|
|
1374
|
+
const record: Measure = (id, cells) =>
|
|
1375
|
+
setMeasured((previous) =>
|
|
1376
|
+
previous[id] === cells ? previous : { ...previous, [id]: cells },
|
|
1377
|
+
);
|
|
845
1378
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
1379
|
+
const drawn = segments.filter((segment) => segment.share > 0);
|
|
1380
|
+
return (
|
|
1381
|
+
<box flexDirection="row" flexGrow={1} minWidth={0}>
|
|
1382
|
+
{drawn.length === 0 ? (
|
|
1383
|
+
<box flexGrow={1} minWidth={0} overflow="hidden">
|
|
1384
|
+
<text fg={trackFill() as UiColor}>
|
|
1385
|
+
{BAR_DIVIDER.repeat(WEFT_RUN)}
|
|
1386
|
+
</text>
|
|
1387
|
+
</box>
|
|
1388
|
+
) : (
|
|
1389
|
+
drawn.map((segment, index) => (
|
|
1390
|
+
<Fragment key={segment.id}>
|
|
1391
|
+
{index > 0 ? <BarDivider /> : null}
|
|
1392
|
+
<BarSegment
|
|
1393
|
+
segment={segment}
|
|
1394
|
+
cells={measured[segment.id]}
|
|
1395
|
+
onMeasure={record}
|
|
1396
|
+
/>
|
|
1397
|
+
</Fragment>
|
|
1398
|
+
))
|
|
1399
|
+
)}
|
|
1400
|
+
</box>
|
|
1401
|
+
);
|
|
853
1402
|
}
|