claudeup 6.7.1 → 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__/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/data/models-presets.ts +62 -2
- 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
|
@@ -54,9 +54,65 @@ export type Effort = (typeof EFFORTS)[number];
|
|
|
54
54
|
export const GRADES = ["smart", "normal", "cheap"] as const;
|
|
55
55
|
export type Grade = (typeof GRADES)[number];
|
|
56
56
|
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
/**
|
|
58
|
+
* Subagents whose model is a third-party external one, reached through claudish rather than
|
|
59
|
+
* through Claude Code's own aliases.
|
|
60
|
+
*
|
|
61
|
+
* Named for what they are to this tool: someone else's model, helping out. `kangaroo` is
|
|
62
|
+
* `mate3` — it is a joke, and it is load-bearing only in the sense that nobody mistakes it
|
|
63
|
+
* for a Claude alias.
|
|
64
|
+
*
|
|
65
|
+
* These are NOT routable. `MODEL_ALIASES` is the entire set the Agent tool accepts, and
|
|
66
|
+
* AMR-1 measured that anything else refuses the spawn (5/5). A mate therefore behaves like
|
|
67
|
+
* `inherit` at the hook: claudeup declines to rewrite the call, and whatever claudish or the
|
|
68
|
+
* agent's own frontmatter decides is what runs.
|
|
69
|
+
*/
|
|
70
|
+
export const MATES = ["mate1", "mate2", "kangaroo"] as const;
|
|
71
|
+
export type Mate = (typeof MATES)[number];
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* An agent's model: an alias, `inherit` to leave the call alone, or a mate.
|
|
75
|
+
*
|
|
76
|
+
* A mate is the second opt-out, and it says something `inherit` does not: `inherit` means the
|
|
77
|
+
* author declined to decide, a mate means the author sent this agent somewhere claudeup has
|
|
78
|
+
* no say over. Both produce the same hook behaviour and different words on screen.
|
|
79
|
+
*/
|
|
80
|
+
export type AgentModel = ModelAlias | "inherit" | Mate;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What a slot is BOUND to: a real external model, and optionally an effort for it.
|
|
84
|
+
*
|
|
85
|
+
* A slot is a ROLE — "the first external helper" — and a role is not a model. Until this
|
|
86
|
+
* existed the screen had nothing else to print in its Model column and printed the slot name
|
|
87
|
+
* there, so a row read `mate1 mate1 —`: the same word twice, neither of them naming a model.
|
|
88
|
+
*
|
|
89
|
+
* `model` is a BARE CATALOG ID (`grok-4.6`), never an `openrouterId` (`moonshotai/kimi-k3`)
|
|
90
|
+
* and never an Access route (`kc@kimi-k3`). Both of those forms PIN THE PROVIDER, which
|
|
91
|
+
* bypasses claudish's subscription-aware routing — the committed file should record the
|
|
92
|
+
* model's IDENTITY and leave the route to the thing that owns routing. The validator rejects
|
|
93
|
+
* `/` and `@` for exactly that reason, and says so.
|
|
94
|
+
*/
|
|
95
|
+
export interface MateBinding {
|
|
96
|
+
/** A bare catalog id. No `@`, no `/` — see above. */
|
|
97
|
+
model: string;
|
|
98
|
+
/**
|
|
99
|
+
* DECLARED, not applied. claudeup writes it and nothing in claudeup reads it back:
|
|
100
|
+
* effort reaches a Claude subagent through `settings.modelSettings`, which is keyed by
|
|
101
|
+
* models Claude Code runs, and an external model has no key there. It is here for
|
|
102
|
+
* claudish to read. Every place it is drawn says so — see `effortWord`.
|
|
103
|
+
*/
|
|
104
|
+
effort?: Effort;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The bindings, by slot. A slot may be absent: unbound is legal, and renders as unset. */
|
|
108
|
+
export type MatesConfig = Partial<Record<Mate, MateBinding>>;
|
|
109
|
+
/**
|
|
110
|
+
* The main thread may also ask for a long-context variant.
|
|
111
|
+
*
|
|
112
|
+
* Deliberately NOT widened to `Mate`. claudeup writes `main.model` straight into
|
|
113
|
+
* `settings.model`, which has to name a model Claude Code itself runs — a mate names one it
|
|
114
|
+
* has never heard of. The validator rejects it and says so; see `validateModelsConfig`.
|
|
115
|
+
*/
|
|
60
116
|
export type MainModel = ModelAlias | `${ModelAlias}[1m]`;
|
|
61
117
|
|
|
62
118
|
export interface GradeSpec {
|
|
@@ -82,6 +138,24 @@ export interface ModelsConfig {
|
|
|
82
138
|
agents: Record<string, AgentAssignment>;
|
|
83
139
|
/** Grade for an agent with no assignment. */
|
|
84
140
|
fallback: Grade;
|
|
141
|
+
/**
|
|
142
|
+
* Which external model each slot names. Optional in full: every config written before
|
|
143
|
+
* this existed stays valid, and an unbound slot renders as unset rather than erroring.
|
|
144
|
+
*/
|
|
145
|
+
mates?: MatesConfig;
|
|
146
|
+
/**
|
|
147
|
+
* The effort every slot runs at unless it names its own.
|
|
148
|
+
*
|
|
149
|
+
* A PRESET DECISION, like the grades above it. A preset cannot bind a slot to a model —
|
|
150
|
+
* it ships in this repo and a catalogue id would go stale — but it can say how hard the
|
|
151
|
+
* external work should try, and that is the half of the answer that does not depend on
|
|
152
|
+
* which model serves it.
|
|
153
|
+
*
|
|
154
|
+
* A slot's own `mates[slot].effort` wins where it is set, so a project can raise or lower
|
|
155
|
+
* one slot without leaving the preset. `mateEffort()` is the single place that resolution
|
|
156
|
+
* happens.
|
|
157
|
+
*/
|
|
158
|
+
mateEffort?: Effort;
|
|
85
159
|
}
|
|
86
160
|
|
|
87
161
|
export interface ValidationError {
|
|
@@ -106,6 +180,84 @@ export function isGrade(v: unknown): v is Grade {
|
|
|
106
180
|
return typeof v === "string" && (GRADES as readonly string[]).includes(v);
|
|
107
181
|
}
|
|
108
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Is this one of the three external-model slots?
|
|
185
|
+
*
|
|
186
|
+
* Exact match only, and no prefix rule: `mate1` is a slot, `mate10` is a typo, and near-
|
|
187
|
+
* matching one into the other would route an agent somewhere its author never named.
|
|
188
|
+
*/
|
|
189
|
+
export function isMate(v: unknown): v is Mate {
|
|
190
|
+
return typeof v === "string" && (MATES as readonly string[]).includes(v);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The values that own no `modelSettings` KEY: `inherit`, and every mate.
|
|
195
|
+
*
|
|
196
|
+
* Effort reaches a subagent through `modelSettings`, which is keyed BY MODEL (AMR-1 rule 5).
|
|
197
|
+
* `inherit` names none by construction; a mate names one Claude Code has never heard of, so
|
|
198
|
+
* a key under it could not select anything.
|
|
199
|
+
*
|
|
200
|
+
* This is now used by exactly ONE rule — the one-model-one-effort conflict pass — and it
|
|
201
|
+
* deliberately no longer gates VALIDATION. Those two questions looked identical and are not:
|
|
202
|
+
*
|
|
203
|
+
* - "may this carry an effort at all?" `inherit` no, a mate YES. A mate's effort is a
|
|
204
|
+
* per-call value for claudish, not a settings key, so it is a legitimate thing to write.
|
|
205
|
+
* - "can this collide with another effort over a shared key?" neither, because neither
|
|
206
|
+
* has a key to collide over. Two mates bound to the SAME external model may still carry
|
|
207
|
+
* different efforts, and that is not a conflict for the same reason.
|
|
208
|
+
*
|
|
209
|
+
* Collapsing them back into one predicate is what made an effort on a mate a validation
|
|
210
|
+
* error in the first place.
|
|
211
|
+
*/
|
|
212
|
+
function namesNoKeyableModel(model: unknown): boolean {
|
|
213
|
+
return model === "inherit" || isMate(model);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Why a bare catalog id, and not the two forms that also identify a model.
|
|
218
|
+
*
|
|
219
|
+
* Both rejected forms name a ROUTE rather than an identity, and a committed file that pins a
|
|
220
|
+
* route makes every reader of it use that route — including a reader whose subscription
|
|
221
|
+
* would have served the same model directly. Routing is claudish's to decide; this file
|
|
222
|
+
* records WHICH MODEL, and nothing about how to reach it.
|
|
223
|
+
*/
|
|
224
|
+
const ROUTE_PINNED =
|
|
225
|
+
"pins the provider and bypasses claudish's subscription-aware routing — store the model's identity, not a route to it";
|
|
226
|
+
|
|
227
|
+
/** Shape-check one `mates` entry. Returns the errors, which may be empty. */
|
|
228
|
+
function validateMateBinding(slot: string, value: unknown): ValidationError[] {
|
|
229
|
+
const out: ValidationError[] = [];
|
|
230
|
+
const at = `mates.${slot}`;
|
|
231
|
+
if (!isRec(value)) {
|
|
232
|
+
return [{ path: at, message: "must be an object with a `model`" }];
|
|
233
|
+
}
|
|
234
|
+
const model = value.model;
|
|
235
|
+
if (typeof model !== "string" || model.length === 0) {
|
|
236
|
+
out.push({
|
|
237
|
+
path: `${at}.model`,
|
|
238
|
+
message:
|
|
239
|
+
"must be a non-empty string naming a model in claudish's catalogue",
|
|
240
|
+
});
|
|
241
|
+
} else if (model.includes("/")) {
|
|
242
|
+
out.push({
|
|
243
|
+
path: `${at}.model`,
|
|
244
|
+
message: `${JSON.stringify(model)} looks like an openrouterId. A provider-prefixed id ${ROUTE_PINNED}. Use the bare catalogue id — \`${model.slice(model.lastIndexOf("/") + 1)}\``,
|
|
245
|
+
});
|
|
246
|
+
} else if (model.includes("@")) {
|
|
247
|
+
out.push({
|
|
248
|
+
path: `${at}.model`,
|
|
249
|
+
message: `${JSON.stringify(model)} looks like an Access route. A \`provider@model\` selector ${ROUTE_PINNED}. Use the bare catalogue id — \`${model.slice(model.indexOf("@") + 1)}\``,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
if (value.effort !== undefined && !isEffort(value.effort)) {
|
|
253
|
+
out.push({
|
|
254
|
+
path: `${at}.effort`,
|
|
255
|
+
message: `must be one of ${EFFORTS.join(", ")}`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
return out;
|
|
259
|
+
}
|
|
260
|
+
|
|
109
261
|
/**
|
|
110
262
|
* Is this a value the Agent tool will accept as `model`?
|
|
111
263
|
*
|
|
@@ -114,6 +266,10 @@ export function isGrade(v: unknown): v is Grade {
|
|
|
114
266
|
* purpose — at write time, at install time, and again in the hook — because the cost of a bad
|
|
115
267
|
* value is not a bad model, it is every subagent spawn failing for everyone who pulled the
|
|
116
268
|
* config.
|
|
269
|
+
*
|
|
270
|
+
* Mates are excluded DELIBERATELY, and widening this to admit them would be the one change
|
|
271
|
+
* that turns the whole feature into an outage: a mate is a name claudish understands and the
|
|
272
|
+
* Agent tool does not, so injecting one is exactly the `bogus-model` case measured above.
|
|
117
273
|
*/
|
|
118
274
|
export function isRoutableModel(v: unknown): v is ModelAlias {
|
|
119
275
|
return isModelAlias(v);
|
|
@@ -131,6 +287,13 @@ export function baseAlias(model: string): string {
|
|
|
131
287
|
* Hand-rolled rather than schema-library-driven, matching `manifest.ts validateManifest`:
|
|
132
288
|
* claudeup ships no validation dependency, and the errors here are meant to be read by a
|
|
133
289
|
* person fixing a committed file.
|
|
290
|
+
*
|
|
291
|
+
* A MATE IS VALID WHEREVER AN AGENT MODEL IS, whether or not the multimodel plugin is
|
|
292
|
+
* installed on this machine. Validity is a property of the file, not of the machine reading
|
|
293
|
+
* it: a `models.json` is committed and shared, so gating `mate1` on a local install would
|
|
294
|
+
* make the same file valid on one teammate's laptop and invalid on the next one's — and the
|
|
295
|
+
* failure would land on whoever had not installed the plugin yet, over a routing decision
|
|
296
|
+
* they did not make. Whether the slots are DRAWN is a UI question, answered in the renderers.
|
|
134
297
|
*/
|
|
135
298
|
export function validateModelsConfig(raw: unknown): ValidationError[] {
|
|
136
299
|
const errors: ValidationError[] = [];
|
|
@@ -160,6 +323,15 @@ export function validateModelsConfig(raw: unknown): ValidationError[] {
|
|
|
160
323
|
"main.model",
|
|
161
324
|
"`inherit` is for agents only — the main thread needs a real model",
|
|
162
325
|
);
|
|
326
|
+
} else if (isMate(m)) {
|
|
327
|
+
// The main thread is the one seat a mate can never take. Its model is written to
|
|
328
|
+
// `settings.model`, which Claude Code reads to start the session itself — a name
|
|
329
|
+
// only claudish understands would leave the session with no model at all, and
|
|
330
|
+
// unlike a subagent there is nothing left to fall back to.
|
|
331
|
+
err(
|
|
332
|
+
"main.model",
|
|
333
|
+
`\`${m}\` is for subagents only — claudeup writes main.model into settings.model, so it has to name a model Claude Code runs (${MODEL_ALIASES.join(", ")}). A mate names an external model reached through claudish, which the main thread cannot use`,
|
|
334
|
+
);
|
|
163
335
|
} else if (!isModelAlias(baseAlias(m))) {
|
|
164
336
|
err(
|
|
165
337
|
"main.model",
|
|
@@ -186,16 +358,23 @@ export function validateModelsConfig(raw: unknown): ValidationError[] {
|
|
|
186
358
|
err(`grades.${g}`, "must be an object with a `model`");
|
|
187
359
|
continue;
|
|
188
360
|
}
|
|
189
|
-
if (
|
|
361
|
+
if (
|
|
362
|
+
spec.model !== "inherit" &&
|
|
363
|
+
!isMate(spec.model) &&
|
|
364
|
+
!isModelAlias(spec.model)
|
|
365
|
+
) {
|
|
190
366
|
err(
|
|
191
367
|
`grades.${g}.model`,
|
|
192
|
-
`must be one of ${MODEL_ALIASES.join(", ")} or "
|
|
368
|
+
`must be one of ${MODEL_ALIASES.join(", ")}, "inherit", or a mate (${MATES.join(", ")}), got ${JSON.stringify(spec.model)}`,
|
|
193
369
|
);
|
|
194
370
|
}
|
|
371
|
+
// `inherit` ONLY. An effort on a MATE is valid — declared for claudish rather
|
|
372
|
+
// than written to modelSettings — so `namesNoKeyableModel` is deliberately not
|
|
373
|
+
// used here; its comment explains why the two questions are not the same one.
|
|
195
374
|
if (spec.model === "inherit" && spec.effort !== undefined) {
|
|
196
375
|
err(
|
|
197
376
|
`grades.${g}.effort`,
|
|
198
|
-
"cannot set effort on `inherit` — effort is applied per model, and inherit names no model",
|
|
377
|
+
"cannot set effort on `inherit` — effort is applied per model through modelSettings, and inherit names no model",
|
|
199
378
|
);
|
|
200
379
|
}
|
|
201
380
|
if (spec.effort !== undefined && !isEffort(spec.effort)) {
|
|
@@ -242,21 +421,54 @@ export function validateModelsConfig(raw: unknown): ValidationError[] {
|
|
|
242
421
|
);
|
|
243
422
|
continue;
|
|
244
423
|
}
|
|
245
|
-
if (
|
|
424
|
+
if (
|
|
425
|
+
value.model !== "inherit" &&
|
|
426
|
+
!isMate(value.model) &&
|
|
427
|
+
!isModelAlias(value.model)
|
|
428
|
+
) {
|
|
246
429
|
err(
|
|
247
430
|
`agents.${name}.model`,
|
|
248
|
-
`must be one of ${MODEL_ALIASES.join(", ")} or "
|
|
431
|
+
`must be one of ${MODEL_ALIASES.join(", ")}, "inherit", or a mate (${MATES.join(", ")}), got ${JSON.stringify(value.model)}`,
|
|
249
432
|
);
|
|
250
433
|
}
|
|
251
434
|
if (value.effort !== undefined && !isEffort(value.effort)) {
|
|
252
435
|
err(`agents.${name}.effort`, `must be one of ${EFFORTS.join(", ")}`);
|
|
253
436
|
}
|
|
437
|
+
// `inherit` ONLY — see the identical rule on grades above.
|
|
254
438
|
if (value.model === "inherit" && value.effort !== undefined) {
|
|
255
439
|
err(
|
|
256
440
|
`agents.${name}.effort`,
|
|
257
|
-
"cannot set effort on `inherit` — effort is applied per model",
|
|
441
|
+
"cannot set effort on `inherit` — effort is applied per model, and inherit names no model",
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// ── mates ───────────────────────────────────────────────────────────────────────────
|
|
448
|
+
//
|
|
449
|
+
// VALIDATION IS OFFLINE AND CONSULTS NO CATALOGUE. Shape only — that `grok-4.6` is a
|
|
450
|
+
// plausible bare id, never that it exists.
|
|
451
|
+
//
|
|
452
|
+
// A `models.json` is committed and shared, so its validity has to be a property of the
|
|
453
|
+
// FILE. A validator that reached the network would make the same file valid on a
|
|
454
|
+
// connected laptop and invalid on a plane, and the failure would land on whoever happened
|
|
455
|
+
// to open it offline, over a routing decision they did not make. Whether a bound id is
|
|
456
|
+
// still in the live catalogue is a real question, and it is answered separately, as an
|
|
457
|
+
// ADVISORY note — `services/mate-catalog.ts`, which never blocks anything.
|
|
458
|
+
if (raw.mates !== undefined && !isRec(raw.mates)) {
|
|
459
|
+
err("mates", `must be an object keyed by ${MATES.join(", ")}`);
|
|
460
|
+
} else if (isRec(raw.mates)) {
|
|
461
|
+
for (const [slot, value] of Object.entries(raw.mates)) {
|
|
462
|
+
if (!isMate(slot)) {
|
|
463
|
+
// No near-matching: `mate10` is a typo, not `mate1`, and quietly binding one
|
|
464
|
+
// to the other would send an agent to a model its author never named.
|
|
465
|
+
err(
|
|
466
|
+
`mates.${slot}`,
|
|
467
|
+
`unknown slot — expected one of ${MATES.join(", ")}`,
|
|
258
468
|
);
|
|
469
|
+
continue;
|
|
259
470
|
}
|
|
471
|
+
for (const e of validateMateBinding(slot, value)) errors.push(e);
|
|
260
472
|
}
|
|
261
473
|
}
|
|
262
474
|
|
|
@@ -269,7 +481,17 @@ export function validateModelsConfig(raw: unknown): ValidationError[] {
|
|
|
269
481
|
const cfg = raw as unknown as ModelsConfig;
|
|
270
482
|
const claims = new Map<string, { effort: Effort; at: string }>();
|
|
271
483
|
const claim = (model: string, effort: Effort | undefined, at: string) => {
|
|
272
|
-
|
|
484
|
+
// Mates are skipped with `inherit`, and by the same predicate: neither owns a
|
|
485
|
+
// `modelSettings` key, so neither can collide with anything over one. Two seats
|
|
486
|
+
// on `mate1` are not a conflict — they are two agents sent to claudish, which is
|
|
487
|
+
// the normal way to use a slot.
|
|
488
|
+
//
|
|
489
|
+
// This exemption now carries real weight. Mates DO carry effort (the rule was
|
|
490
|
+
// reversed), so without it two agents on `mate1` at different efforts would be
|
|
491
|
+
// reported as a conflict — and it is not one. Effort on a mate is a per-call
|
|
492
|
+
// value handed to claudish, not a settings key, so two mates bound to the very
|
|
493
|
+
// same external model may still legitimately declare different efforts.
|
|
494
|
+
if (effort === undefined || namesNoKeyableModel(model)) return;
|
|
273
495
|
const key = baseAlias(model);
|
|
274
496
|
const prior = claims.get(key);
|
|
275
497
|
if (prior && prior.effort !== effort) {
|
|
@@ -293,6 +515,103 @@ export function validateModelsConfig(raw: unknown): ValidationError[] {
|
|
|
293
515
|
return errors;
|
|
294
516
|
}
|
|
295
517
|
|
|
518
|
+
// ─── Reading a binding ────────────────────────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* What the Model column shows for an unbound slot.
|
|
522
|
+
*
|
|
523
|
+
* NOT the slot name repeated, and not a blank. The slot name in a Model column is what this
|
|
524
|
+
* whole change exists to remove — it said `mate1 mate1 —` and neither word named a model. A
|
|
525
|
+
* blank would read as the `—` that means "nothing was decided", which is the opposite: the
|
|
526
|
+
* author decided to route here and has not yet said where.
|
|
527
|
+
*/
|
|
528
|
+
export const UNBOUND_MATE = "unset";
|
|
529
|
+
|
|
530
|
+
/** The catalogue id a slot is bound to, or null when it is unbound. */
|
|
531
|
+
export function boundModel(
|
|
532
|
+
config: Pick<ModelsConfig, "mates">,
|
|
533
|
+
slot: Mate,
|
|
534
|
+
): string | null {
|
|
535
|
+
return config.mates?.[slot]?.model ?? null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* The effort a slot runs at: its own if it names one, otherwise the preset's.
|
|
540
|
+
*
|
|
541
|
+
* Two rungs and no third. The slot is the specific answer and wins; the preset is the
|
|
542
|
+
* general one and covers every slot that did not bother. Resolved here rather than at each
|
|
543
|
+
* call site, so the tier table, the agent table and the CLI cannot disagree about what a
|
|
544
|
+
* slot with no effort of its own is actually running at.
|
|
545
|
+
*/
|
|
546
|
+
export function mateEffort(
|
|
547
|
+
config: Pick<ModelsConfig, "mates" | "mateEffort">,
|
|
548
|
+
slot: Mate,
|
|
549
|
+
): Effort | undefined {
|
|
550
|
+
return config.mates?.[slot]?.effort ?? config.mateEffort;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* The text that names a routing value: a bound slot resolves to its model, everything else
|
|
555
|
+
* is already a model name.
|
|
556
|
+
*
|
|
557
|
+
* One function, because the Model column, its width maths and the CLI all have to print the
|
|
558
|
+
* SAME string. The renderers measure a column from this and then draw from it; the defect
|
|
559
|
+
* `modelWidth` exists to prevent — a column measured from a different string than the one
|
|
560
|
+
* rendered — is exactly what two copies of this rule would reintroduce.
|
|
561
|
+
*/
|
|
562
|
+
export function modelLabel(
|
|
563
|
+
config: Pick<ModelsConfig, "mates">,
|
|
564
|
+
model: string,
|
|
565
|
+
): string {
|
|
566
|
+
if (!isMate(model)) return model;
|
|
567
|
+
return boundModel(config, model) ?? UNBOUND_MATE;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* A config as a canonical string, for comparing two of them.
|
|
572
|
+
*
|
|
573
|
+
* Key order is normalised because the two sides come from different places: a built-in preset
|
|
574
|
+
* is a TypeScript literal and a project's config is parsed from a file, so a plain
|
|
575
|
+
* `JSON.stringify` comparison would call two identical configs different whenever the file
|
|
576
|
+
* happened to list `fallback` before `agents`.
|
|
577
|
+
*/
|
|
578
|
+
function canonical(value: unknown): string {
|
|
579
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
580
|
+
if (value && typeof value === "object") {
|
|
581
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
582
|
+
// `undefined` members are ABSENT, not present-and-empty: `{effort: undefined}` and
|
|
583
|
+
// `{}` describe the same routing, and a JSON round-trip turns the first into the
|
|
584
|
+
// second — so a literal compared against a parsed file would differ over nothing.
|
|
585
|
+
.filter(([, v]) => v !== undefined)
|
|
586
|
+
.sort(([a], [b]) => a.localeCompare(b));
|
|
587
|
+
return `{${entries.map(([k, v]) => `${k}:${canonical(v)}`).join(",")}}`;
|
|
588
|
+
}
|
|
589
|
+
return JSON.stringify(value) ?? "null";
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Do these two configs describe the same routing?
|
|
594
|
+
*
|
|
595
|
+
* Exists because "is the project running a built-in" cannot be answered by NAME. `claudeup
|
|
596
|
+
* models use opus-lead` writes `preset: "opus-lead"`, and every hand edit afterwards — a
|
|
597
|
+
* `mates` block, a rebound agent — leaves that name untouched. A name check therefore reports
|
|
598
|
+
* a diverged config as a built-in, which is how the one config on the machine that can name a
|
|
599
|
+
* mate became invisible to the TUI.
|
|
600
|
+
*/
|
|
601
|
+
export function sameRouting(a: ModelsConfig, b: ModelsConfig): boolean {
|
|
602
|
+
return canonical(a) === canonical(b);
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Every slot a config actually uses — assigned to a grade or to an agent. */
|
|
606
|
+
export function matesInUse(config: ModelsConfig): Mate[] {
|
|
607
|
+
const seen = new Set<string>();
|
|
608
|
+
for (const grade of GRADES) seen.add(config.grades[grade].model);
|
|
609
|
+
for (const assignment of Object.values(config.agents ?? {})) {
|
|
610
|
+
if (typeof assignment !== "string") seen.add(assignment.model);
|
|
611
|
+
}
|
|
612
|
+
return MATES.filter((mate) => seen.has(mate));
|
|
613
|
+
}
|
|
614
|
+
|
|
296
615
|
export interface Routing {
|
|
297
616
|
model: AgentModel;
|
|
298
617
|
effort?: Effort;
|
|
@@ -347,6 +666,7 @@ export type AgentHookDecision =
|
|
|
347
666
|
| "invalid-config"
|
|
348
667
|
| "explicit-model"
|
|
349
668
|
| "inherit"
|
|
669
|
+
| "mate"
|
|
350
670
|
| "unroutable-model";
|
|
351
671
|
}
|
|
352
672
|
| {
|
|
@@ -399,6 +719,15 @@ export function evaluateAgentHook(
|
|
|
399
719
|
if (routing.model === "inherit")
|
|
400
720
|
return { kind: "passthrough", reason: "inherit" };
|
|
401
721
|
|
|
722
|
+
// A mate's model is claudish's to choose, not ours. Same passthrough as `inherit`, kept as
|
|
723
|
+
// its own reason so the hook log distinguishes "the author opted out" from "the author sent
|
|
724
|
+
// this one outside".
|
|
725
|
+
//
|
|
726
|
+
// BEFORE the guard below, not after: a mate would fail `isRoutableModel` too, and reporting
|
|
727
|
+
// it as `unroutable-model` would read as a broken config — the one state the log exists to
|
|
728
|
+
// make obvious — when it is the config working exactly as written.
|
|
729
|
+
if (isMate(routing.model)) return { kind: "passthrough", reason: "mate" };
|
|
730
|
+
|
|
402
731
|
// Belt and braces over the writer's validation. A hand-edited profile or an older claudeup
|
|
403
732
|
// can put an unroutable value here, and MEASURED (AMR-1 `bogus-model`) that does not
|
|
404
733
|
// degrade — it refuses the spawn outright. Passing through costs the routing; injecting
|
|
@@ -450,6 +779,16 @@ export function buildSettingsPatch(
|
|
|
450
779
|
const add = (model: string, effort: Effort | undefined) => {
|
|
451
780
|
if (effort === undefined || model === "inherit") return;
|
|
452
781
|
const alias = baseAlias(model);
|
|
782
|
+
// The `isModelAlias` gate is what keeps a MATE out of `modelSettings`, and it is the
|
|
783
|
+
// only thing that does. It is now LOAD-BEARING rather than belt-and-braces: an effort
|
|
784
|
+
// on a mate used to be a validation error, so nothing could reach here carrying one,
|
|
785
|
+
// and today it is valid and ordinary. `modelSettings` is keyed by models Claude Code
|
|
786
|
+
// RUNS — a `mate1` key selects nothing, and a `grok-4.6` key selects nothing either.
|
|
787
|
+
//
|
|
788
|
+
// A mate's effort is written to the `mates` block and read by claudish. It never
|
|
789
|
+
// reaches settings, from any path: `add()` is the only writer and this line is the
|
|
790
|
+
// only gate, so the test that pins it asserts on the whole map (no slot, no external
|
|
791
|
+
// id, no `undefined` key) rather than on this branch.
|
|
453
792
|
if (!isModelAlias(alias)) return;
|
|
454
793
|
modelSettings[alias] = { effortLevel: effort };
|
|
455
794
|
const full = resolveFullId(alias);
|
|
@@ -466,7 +805,14 @@ export function buildSettingsPatch(
|
|
|
466
805
|
return { set, unset: [] };
|
|
467
806
|
}
|
|
468
807
|
|
|
469
|
-
|
|
808
|
+
/**
|
|
809
|
+
* The four states routing can be in. Each is a fact the reader can act on.
|
|
810
|
+
*
|
|
811
|
+
* There is no state for "the settings differ from the config". Differing is what a CUSTOM
|
|
812
|
+
* config is, and the list says so with a `Custom` row rather than the header saying it with
|
|
813
|
+
* a warning.
|
|
814
|
+
*/
|
|
815
|
+
export type ModelsState = "off" | "on" | "invalid" | "unhooked";
|
|
470
816
|
|
|
471
817
|
export interface ModelsStatus {
|
|
472
818
|
state: ModelsState;
|
|
@@ -525,27 +871,22 @@ export function computeModelsStatus(args: {
|
|
|
525
871
|
};
|
|
526
872
|
}
|
|
527
873
|
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
//
|
|
535
|
-
//
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
const drift = allMissing
|
|
542
|
-
? ["settings carry none of the config — none of it is in force"]
|
|
543
|
-
: differing.map(([key, want]) => describeDrift(key, settings[key], want));
|
|
544
|
-
|
|
874
|
+
// A config whose settings differ from a built-in's is CUSTOM, and custom is not a fault.
|
|
875
|
+
//
|
|
876
|
+
// There is no comparison here any more, and no state for the result of one. A project may
|
|
877
|
+
// legitimately run models and efforts no shipped preset names; saying so as a state called
|
|
878
|
+
// `stale`, with a count of the differences, described a thing you have as a thing that is
|
|
879
|
+
// wrong with you — and the count was of MESSAGES rather than of differences, so `1 drift`
|
|
880
|
+
// could mean every setting differed.
|
|
881
|
+
//
|
|
882
|
+
// The list carries the fact instead: a config matching no built-in gets its own `Custom`
|
|
883
|
+
// row holding those settings, with its own tier table and chart. `drift` survives for the
|
|
884
|
+
// two states that ARE faults — a config that does not validate, and one with no hook to
|
|
885
|
+
// run it — where the lines name something the reader has to fix.
|
|
545
886
|
return {
|
|
546
|
-
state:
|
|
887
|
+
state: "on",
|
|
547
888
|
preset: config.preset,
|
|
548
|
-
drift,
|
|
889
|
+
drift: [],
|
|
549
890
|
warnings,
|
|
550
891
|
};
|
|
551
892
|
}
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
type ModelsConfig,
|
|
9
9
|
type ModelsState,
|
|
10
10
|
type ModelsStatus,
|
|
11
|
+
sameRouting,
|
|
11
12
|
} from "../../services/models-core.js";
|
|
12
13
|
|
|
13
14
|
// ─── Item types ───────────────────────────────────────────────────────────────
|
|
@@ -20,6 +21,14 @@ import {
|
|
|
20
21
|
* warnings behind the counts live on the `ModelsStatus` the detail renderer is
|
|
21
22
|
* handed, so they are not stored twice and cannot disagree.
|
|
22
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* What the row for a diverged config is called.
|
|
26
|
+
*
|
|
27
|
+
* A fixed word, never the file's `preset` string: that string names whatever was last
|
|
28
|
+
* applied, and a config edited afterwards would wear a built-in's name while not being it.
|
|
29
|
+
*/
|
|
30
|
+
export const CUSTOM_LABEL = "Custom";
|
|
31
|
+
|
|
23
32
|
export interface ModelsStatusItem {
|
|
24
33
|
id: string;
|
|
25
34
|
kind: "status";
|
|
@@ -73,11 +82,16 @@ function matches(config: ModelsConfig, lowerQuery: string): boolean {
|
|
|
73
82
|
// Both names are searchable: the row SHOWS "Opus with Fable help", and a user who knows
|
|
74
83
|
// the command types "fable-advisor". Matching only one of them makes the other look like
|
|
75
84
|
// a missing preset.
|
|
85
|
+
//
|
|
86
|
+
// Bound models are searchable too: a project that routes `smart` to `mate1` → `grok-4.6`
|
|
87
|
+
// is findable by typing `grok`, which is the name its author thinks in. The slot names
|
|
88
|
+
// come along with the grade models above, so both halves of the binding match.
|
|
76
89
|
const haystack = [
|
|
77
90
|
config.preset,
|
|
78
91
|
presetLabel(config.preset),
|
|
79
92
|
config.main.model,
|
|
80
93
|
...GRADES.map((grade) => config.grades[grade].model),
|
|
94
|
+
...Object.values(config.mates ?? {}).map((binding) => binding.model),
|
|
81
95
|
]
|
|
82
96
|
.join(" ")
|
|
83
97
|
.toLowerCase();
|
|
@@ -105,7 +119,29 @@ export function buildModelsItems({
|
|
|
105
119
|
|
|
106
120
|
const lowerQuery = query.trim().toLowerCase();
|
|
107
121
|
const activeName = config?.preset ?? null;
|
|
108
|
-
|
|
122
|
+
|
|
123
|
+
// DOES THE PROJECT'S OWN CONFIG SAY SOMETHING NO BUILT-IN DOES?
|
|
124
|
+
//
|
|
125
|
+
// The test used to be "does it name a preset the built-ins do not", and that missed the
|
|
126
|
+
// ordinary case entirely. `claudeup models use opus-lead` writes `preset: "opus-lead"`,
|
|
127
|
+
// and hand-editing the file afterwards — adding a `mates` block, rebinding an agent —
|
|
128
|
+
// leaves that name untouched. So the config carrying the mate assignments matched a
|
|
129
|
+
// built-in by NAME, got no row of its own, and the list rendered the BUILT-IN's contents
|
|
130
|
+
// under the built-in's label.
|
|
131
|
+
//
|
|
132
|
+
// The consequence was not cosmetic: the only config on the machine that can name a mate
|
|
133
|
+
// was unreachable on screen, so neither the agent table nor the workflow bar could ever
|
|
134
|
+
// show one, no matter what the file said. `claudeup models list` printed it correctly the
|
|
135
|
+
// whole time, which is what made the gap look like a rendering bug rather than a missing
|
|
136
|
+
// row.
|
|
137
|
+
//
|
|
138
|
+
// Compared by CONTENT now, so a project that merely applied a preset still gets one row
|
|
139
|
+
// (its file and the built-in agree) and a project that changed anything gets two.
|
|
140
|
+
const builtInNamed = presets.find((preset) => preset.preset === activeName);
|
|
141
|
+
const projectOwn =
|
|
142
|
+
config && (!builtInNamed || !sameRouting(config, builtInNamed))
|
|
143
|
+
? config
|
|
144
|
+
: null;
|
|
109
145
|
|
|
110
146
|
const rows: ModelsPresetItem[] = presets
|
|
111
147
|
.filter((preset) => matches(preset, lowerQuery))
|
|
@@ -114,26 +150,33 @@ export function buildModelsItems({
|
|
|
114
150
|
kind: "preset" as const,
|
|
115
151
|
label: presetLabel(preset.preset),
|
|
116
152
|
config: preset,
|
|
117
|
-
|
|
153
|
+
// `●` marks WHAT IS RUNNING, and when the project has diverged the built-in of
|
|
154
|
+
// that name is not it. Leaving the mark on both rows would say two different
|
|
155
|
+
// routings are simultaneously in force.
|
|
156
|
+
active: preset.preset === activeName && projectOwn === null,
|
|
118
157
|
isDefault: preset.preset === DEFAULT_PRESET,
|
|
119
158
|
custom: false,
|
|
120
159
|
}));
|
|
121
160
|
|
|
122
|
-
// A project
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
161
|
+
// A project whose settings differ from every built-in gets ONE row, called `Custom`.
|
|
162
|
+
//
|
|
163
|
+
// Divergence is a THING YOU HAVE, not a fault to warn about. A project can legitimately
|
|
164
|
+
// run models or efforts no preset ships, and the list's job is to show that as one more
|
|
165
|
+
// option carrying those settings — selectable, inspectable, with its own tier table and
|
|
166
|
+
// chart — rather than as a badge saying the config and the settings disagree.
|
|
167
|
+
//
|
|
168
|
+
// It is called `Custom` and never the string in the file's `preset` field. That string is
|
|
169
|
+
// whatever was last applied — `models use opus-lead` writes `opus-lead` and hand-editing
|
|
170
|
+
// afterwards leaves it — so showing it puts a built-in's name on a row that is not that
|
|
171
|
+
// built-in.
|
|
172
|
+
if (projectOwn && matches(projectOwn, lowerQuery)) {
|
|
132
173
|
rows.push({
|
|
133
|
-
|
|
174
|
+
// `project:` rather than `preset:`, because the name it carries may be a built-in's
|
|
175
|
+
// and two rows may not share an id.
|
|
176
|
+
id: `project:${projectOwn.preset}`,
|
|
134
177
|
kind: "preset",
|
|
135
|
-
label:
|
|
136
|
-
config,
|
|
178
|
+
label: CUSTOM_LABEL,
|
|
179
|
+
config: projectOwn,
|
|
137
180
|
active: true,
|
|
138
181
|
isDefault: false,
|
|
139
182
|
custom: true,
|
|
@@ -275,7 +275,12 @@ export function ScreenLayout({
|
|
|
275
275
|
{/* Detail panel — scrollable. The thumb is themed so the reader can
|
|
276
276
|
see there is more below the fold; the track keeps the library
|
|
277
277
|
default so it stays chrome, not signal. */}
|
|
278
|
-
|
|
278
|
+
{/* `overflow: hidden`, like the list panel opposite. Without it a line wider
|
|
279
|
+
than the panel is drawn OUTSIDE it, over the terminal's right edge, and the
|
|
280
|
+
reader loses characters off the end of a sentence with nothing to say they
|
|
281
|
+
were lost. Clipping at the panel is the guarantee; wrapping to
|
|
282
|
+
`detailPanelWidth` is what keeps there being nothing to clip. */}
|
|
283
|
+
<box width="50%" height={panelHeight} paddingLeft={1} overflow="hidden">
|
|
279
284
|
<scrollbox
|
|
280
285
|
ref={detailScrollRef}
|
|
281
286
|
height={panelHeight}
|