claudeup 6.7.1 → 6.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,11 +1,21 @@
1
1
  import type React from "react";
2
2
  import { WORKFLOWS } from "../../data/models-presets.js";
3
+ import {
4
+ type CatalogModel,
5
+ stalenessNote,
6
+ unknownBindings,
7
+ } from "../../services/mate-catalog.js";
3
8
  import {
4
9
  type Effort,
5
10
  GRADES,
11
+ MATES,
6
12
  type ModelsConfig,
7
13
  type ModelsState,
8
14
  type ModelsStatus,
15
+ boundModel,
16
+ mateEffort,
17
+ matesInUse,
18
+ modelLabel,
9
19
  } from "../../services/models-core.js";
10
20
  import type {
11
21
  ModelsBrowserItem,
@@ -24,6 +34,7 @@ import { type UiColor, theme } from "../theme.js";
24
34
  import {
25
35
  type AgentRow,
26
36
  type AgentTableLayout,
37
+ BarRow,
27
38
  EffortGlyph,
28
39
  ModelBadge,
29
40
  ModelText,
@@ -34,18 +45,17 @@ import {
34
45
  agentDistribution,
35
46
  agentRows,
36
47
  agentTableHeaders,
48
+ barSegments,
49
+ clipCell,
37
50
  effortLabel,
38
51
  effortWord,
39
52
  fitAgentTable,
53
+ fitModelKey,
40
54
  fitPresetRows,
41
- modelBadgeWidth,
42
- modelBarFill,
43
55
  modelPad,
44
56
  modelWidth,
45
- modelsInUse,
46
57
  padCell,
47
58
  presetMarkerWidth,
48
- spendRuns,
49
59
  workflowDistribution,
50
60
  } from "./modelVisuals.js";
51
61
  import { wrapText } from "./styleRenderers.js";
@@ -64,7 +74,6 @@ import { wrapText } from "./styleRenderers.js";
64
74
  const STATE_HEADLINE: Record<ModelsState, string> = {
65
75
  off: "no .claude/models.json, so every subagent inherits the session model",
66
76
  on: "every Agent call is routed through this config",
67
- stale: "settings no longer match the config, so part of it is not in force",
68
77
  invalid: "the config does not validate, and nothing is routed",
69
78
  unhooked: "the config is here, but no hook runs it, so nothing is routed",
70
79
  };
@@ -72,7 +81,6 @@ const STATE_HEADLINE: Record<ModelsState, string> = {
72
81
  const STATE_TONE: Record<ModelsState, UiColor> = {
73
82
  off: theme.colors.muted,
74
83
  on: theme.colors.success,
75
- stale: theme.colors.warning,
76
84
  invalid: theme.colors.danger,
77
85
  unhooked: theme.colors.danger,
78
86
  };
@@ -82,7 +90,6 @@ const STATE_CATEGORY: Record<ModelsState, "gray" | "green" | "yellow" | "red"> =
82
90
  {
83
91
  off: "gray",
84
92
  on: "green",
85
- stale: "yellow",
86
93
  invalid: "red",
87
94
  unhooked: "red",
88
95
  };
@@ -101,7 +108,6 @@ const MAX_DRIFT_LINES = 6;
101
108
  const STATE_FIX: Record<ModelsState, string> = {
102
109
  off: "Pick a preset below and press a to start routing.",
103
110
  on: "",
104
- stale: "Press a on the active preset to write the settings again.",
105
111
  invalid:
106
112
  "Fix .claude/models.json by hand, or press a on a preset to overwrite it.",
107
113
  unhooked: "Press a on a preset — applying one registers the hook.",
@@ -254,11 +260,29 @@ const SPEC_LABEL_WIDTH = 10;
254
260
  /**
255
261
  * The widest model column in a config, so every name in the table ends at the
256
262
  * same column and the effort glyphs beside them form a straight edge.
263
+ *
264
+ * `withMates` is not a detail: measuring without the mate rows while DRAWING them leaves
265
+ * `modelPad` returning an empty string for those rows and the whole effort column jumps left
266
+ * on them. The flag is the same boolean that decides whether the rows are drawn at all,
267
+ * passed in rather than re-derived, because the two answers have to be the same answer.
268
+ *
269
+ * Measured from what each row WRITES, via `modelLabel`. It used to measure `MATES` themselves
270
+ * on the reasoning that `kangaroo` (8) was the widest thing the column could hold. A bound
271
+ * slot writes its catalogue id instead — up to 21 cells — so measuring the slot names now
272
+ * under-counts by more than the column is wide. An unbound slot writes `unset` (5).
257
273
  */
258
- function specModelColumn(config: ModelsConfig): number {
274
+ export function specModelColumn(
275
+ config: ModelsConfig,
276
+ withMates: boolean,
277
+ ): number {
259
278
  return Math.max(
260
279
  modelWidth(config.main.model),
261
- ...GRADES.map((grade) => modelWidth(config.grades[grade].model)),
280
+ ...GRADES.map((grade) =>
281
+ modelWidth(modelLabel(config, config.grades[grade].model)),
282
+ ),
283
+ ...(withMates
284
+ ? MATES.map((mate) => modelWidth(modelLabel(config, mate)))
285
+ : []),
262
286
  );
263
287
  }
264
288
 
@@ -273,32 +297,47 @@ function specModelColumn(config: ModelsConfig): number {
273
297
  function SpecRow({
274
298
  label,
275
299
  model,
300
+ modelText,
276
301
  effort,
277
302
  modelColumn,
278
303
  highlight,
279
304
  }: {
280
305
  label: string;
306
+ /** The routing value — decides the chip's INK. For a mate this is the slot. */
281
307
  model: string;
308
+ /** What the chip WRITES. Defaults to `model`; a bound mate passes its catalogue id. */
309
+ modelText?: string;
282
310
  effort: Effort | undefined;
283
311
  modelColumn: number;
284
312
  /** The grade an unassigned agent falls to — worth reading first. */
285
313
  highlight?: boolean;
286
314
  }): React.ReactNode {
315
+ const text = modelText ?? model;
316
+ // The word takes the effort ramp whenever there is a level to name, a mate's included:
317
+ // claudish applies an external model's effort per run, so it is as live as a Claude
318
+ // subagent's. Only the absent case is muted, and it names a fallback rather than a level.
319
+ const live = effort !== undefined;
287
320
  return (
288
321
  <text fg={theme.colors.text}>
289
322
  <span fg={highlight ? theme.colors.text : theme.colors.muted}>
290
323
  {`${label} `.padEnd(SPEC_LABEL_WIDTH)}
291
324
  </span>
292
325
  {/* A CHIP here, text in the agent table below. Four of them read as a key;
293
- seventeen read as a solid column, which is what this screen was reported for. */}
294
- <ModelBadge model={model} />
295
- <span
296
- fg={theme.colors.border}
297
- >{`${modelPad(model, modelColumn)} `}</span>
298
- <EffortGlyph effort={effort} model={model} />
326
+ seventeen read as a solid column, which is what this screen was reported for.
327
+ The chip's ink is the SLOT's and its text is the bound model: the slot is the
328
+ stable identity, the binding is what changes. */}
329
+ <ModelBadge model={model} label={text} />
330
+ <span fg={theme.colors.border}>{`${modelPad(text, modelColumn)} `}</span>
331
+ <EffortGlyph effort={effort} />
332
+ {/* `model` reaches `effortWord` so a mate row says `via claudish` (unbound effort)
333
+ or `high (declared)` where a Claude model would say `inherits session`. Nothing
334
+ of the session reaches an external model, so the second sentence would simply
335
+ be untrue on those rows. */}
299
336
  <span
300
- fg={effort ? (effortInk(effort) as UiColor) : theme.colors.muted}
301
- >{` ${effortWord(effort)}`}</span>
337
+ fg={
338
+ live ? (effortInk(effort as string) as UiColor) : theme.colors.muted
339
+ }
340
+ >{` ${effortWord(effort, model)}`}</span>
302
341
  </text>
303
342
  );
304
343
  }
@@ -360,7 +399,18 @@ function AgentTableRow({
360
399
  row: AgentRow;
361
400
  layout: AgentTableLayout;
362
401
  }): React.ReactNode {
363
- const label = effortLabel(row.effort, layout.effort);
402
+ // `row.model` goes in so a mate row reads `— via claudish` rather than the
403
+ // `— inherits session` a Claude model with no effort gets. The two say opposite things:
404
+ // one inherits the session's effort, the other never sees the session at all.
405
+ const label = effortLabel(row.effort, layout.effort, row.model);
406
+ // Truncated HERE as well as fitted in `fitAgentTable`, because the two do different jobs:
407
+ // the fit decides how wide the column is, this decides what fits in it. The model cell is
408
+ // drawn as coloured text plus a separate pad span, so it cannot use `padCell` — and
409
+ // without a clip a 21-cell binding would simply overrun the column the fit just sized.
410
+ const modelText = clipCell(row.label, layout.model);
411
+ // Muted only when there is no level to name. Same rule as `SpecRow`, so the tier table
412
+ // and the agent table cannot end up grading the same effort differently.
413
+ const live = row.effort !== undefined;
364
414
  return (
365
415
  <text fg={theme.colors.text}>
366
416
  <span fg={row.inherit ? theme.colors.dim : theme.colors.text}>
@@ -374,25 +424,35 @@ function AgentTableRow({
374
424
  {row.inherit ? (
375
425
  <span fg={theme.colors.dim}>{padCell(NONE_CELL, layout.model)}</span>
376
426
  ) : (
377
- <ModelText model={row.model} />
427
+ <ModelText model={row.model} label={modelText} />
378
428
  )}
379
429
  {row.inherit ? null : (
380
430
  <span fg={theme.colors.border}>
381
- {modelPad(row.model, layout.model)}
431
+ {modelPad(modelText, layout.model)}
382
432
  </span>
383
433
  )}
384
434
  <span fg={theme.colors.border}> </span>
385
435
  {row.inherit ? (
386
436
  <span fg={theme.colors.dim}>{NONE_CELL}</span>
387
437
  ) : (
388
- <EffortGlyph effort={row.effort} model={row.model} />
438
+ <EffortGlyph effort={row.effort} />
389
439
  )}
390
440
  {/* The word takes the SAME ink as the dots beside it. Dim grey made the pair read
391
441
  as two things — a coloured meter and an unrelated caption — when it is one
392
- reading with its value spelt out. */}
442
+ reading with its value spelt out.
443
+
444
+ With NO effort there are no lit dots to match, so the word takes the muted ink
445
+ the dash already uses — which is what `SpecRow` does in the tier table above.
446
+ It used to fall back to `effortInk("low")`, painting `inherits session` (and
447
+ now `via claudish`) in the colour that means the lowest effort level: a value
448
+ where an absence belongs. */}
393
449
  {!row.inherit && label ? (
394
450
  <span
395
- fg={effortInk(row.effort ?? "low") as UiColor}
451
+ fg={
452
+ live
453
+ ? (effortInk(row.effort as string) as UiColor)
454
+ : theme.colors.muted
455
+ }
396
456
  >{` ${label}`}</span>
397
457
  ) : null}
398
458
  </text>
@@ -400,7 +460,47 @@ function AgentTableRow({
400
460
  }
401
461
 
402
462
  /**
403
- * How the routed agents are spread, as one bar and a legend.
463
+ * What labels the second row of a workflow's pair.
464
+ *
465
+ * NOT indented. It used to be `" subagents"`, two cells in from the workflow name above it,
466
+ * on the reasoning that an indent says "these belong to that". Reported off a real screen:
467
+ * "on left dev and subagents wrongly aligned" — with six labels stacked down the panel, two
468
+ * different starting columns read as raggedness long before they read as hierarchy.
469
+ *
470
+ * The grouping is now said by the blank line BETWEEN workflows instead, which is a stronger
471
+ * signal and costs the labels nothing.
472
+ */
473
+ export const SUBAGENT_ROW_LABEL = "subagents";
474
+
475
+ /** Rows one workflow costs: its main thread, then everything it dispatches. */
476
+ export const WORKFLOW_ROWS = 2;
477
+
478
+ /** The blank row before every workflow except the first. See `AgentSpread`. */
479
+ export const WORKFLOW_GAP_ROWS = 1;
480
+
481
+ /**
482
+ * The label gutter, shared by both rows of every pair, and LEFT-aligned within it.
483
+ *
484
+ * Left rather than right: right-aligning would square the labels up against the bars and
485
+ * leave the outer edge ragged, and the outer edge is the one the eye runs down when it is
486
+ * scanning six labels for the workflow it wants. One straight left edge, one straight bar
487
+ * edge — the ragged part lands in the middle, where nothing is being compared.
488
+ *
489
+ * Derived from the longest label actually DRAWN, never a constant: `investigate` (11) is the
490
+ * longest today and `subagents` (9) is in the running, and a hardcoded 11 would silently
491
+ * clip the first workflow anyone adds with a longer name. The `+ 1` is the gap before the
492
+ * bar starts.
493
+ *
494
+ * Exported because a test measuring the bar has to start counting at the same column the
495
+ * renderer stops writing at, and a second copy of this expression is a test that keeps
496
+ * passing while the picture drifts.
497
+ */
498
+ export const SPREAD_LABEL_CELLS =
499
+ Math.max(SUBAGENT_ROW_LABEL.length, ...WORKFLOWS.map((w) => w.name.length)) +
500
+ 1;
501
+
502
+ /**
503
+ * How the routed agents are spread, two rows per workflow.
404
504
  *
405
505
  * A category distribution, so a stacked bar rather than the list of counts it
406
506
  * would otherwise be — and now the ONLY graphic on the screen, which is what
@@ -408,6 +508,29 @@ function AgentTableRow({
408
508
  * PRESET LIST, not in the detail pane, because a distribution is most useful
409
509
  * exactly where you are choosing between presets. It follows the cursor, so
410
510
  * moving between presets redraws it.
511
+ *
512
+ * ## Why the halves are gone
513
+ *
514
+ * One bar per workflow used to be split down the middle: `main` on the left, everything it
515
+ * dispatches crammed into the right. The shape was honest about the trade — the orchestrator
516
+ * against the work it hands out — and dishonest about the second half, which had to hold
517
+ * four or five segments in half a pane. At the widths this panel actually gets, the third
518
+ * and fourth subagent tiers were a cell or two each and nothing could be labelled.
519
+ *
520
+ * So the trade is now a PAIR OF ROWS, each across the full width. The main thread's row is
521
+ * one segment and reads instantly; the subagent row gets twice the cells it had, which is
522
+ * what makes an inline label possible at all. The caption that used to name the two halves
523
+ * is gone with them — `SUBAGENT_ROW_LABEL` says which row is which, on the row itself,
524
+ * where a caption under a chart never could.
525
+ *
526
+ * ## The blank line between workflows is what makes the pairs pairs
527
+ *
528
+ * Six adjacent rows is one block of six, not three blocks of two: `dev`'s subagent row and
529
+ * `debug`'s main row touched, so the pairing had to be worked out from the labels rather
530
+ * than seen. A blank row goes BETWEEN groups and never inside one — the two rows of a
531
+ * workflow stay adjacent precisely because their adjacency is the thing that says they
532
+ * belong together. None after the last group either: a trailing gap is a row spent
533
+ * separating the chart from nothing.
411
534
  */
412
535
  export function AgentSpread({
413
536
  config,
@@ -416,57 +539,77 @@ export function AgentSpread({
416
539
  config: ModelsConfig;
417
540
  width: number;
418
541
  }): React.ReactNode {
419
- // One bar per WORKFLOW, not one bar for every agent that exists.
542
+ // One pair of bars per WORKFLOW, not one bar for every agent that exists.
420
543
  //
421
544
  // Nobody runs all seventeen agents; they run `/dev:dev`, `/dev:debug` or
422
545
  // `/dev:investigate`, and each reaches for a different handful. A single lump-sum bar
423
546
  // answered a question nobody asks and hid the one they do: what does THIS command cost
424
547
  // under THIS preset.
425
- const label = Math.max(...WORKFLOWS.map((w) => w.name.length)) + 1;
426
- const cells = Math.max(8, width - label);
427
- // Where the right half starts, so the two words sit under the halves they name. Mirrors
428
- // the split in `spendRuns`, which would drift if either recomputed it differently.
429
- const leftCells = Math.floor((cells - 1) / 2);
548
+ const label = SPREAD_LABEL_CELLS;
430
549
 
431
550
  return (
432
- <box flexDirection="column" marginTop={1}>
433
- <text fg={theme.colors.muted}>workflow</text>
434
- {WORKFLOWS.map((workflow) => {
435
- const segments = workflowDistribution(config, workflow.agents);
436
- const runs = spendRuns(config, segments, cells);
437
- return (
438
- <text key={workflow.name} fg={theme.colors.text}>
439
- <span fg={theme.colors.muted}>{padCell(workflow.name, label)}</span>
440
- {runs.map((run) =>
441
- run.kind === "track" ? (
442
- <span key={run.id} fg={trackFill()}>
443
- {"░".repeat(run.cells)}
444
- </span>
445
- ) : (
446
- <span key={run.id} fg={run.colour}>
447
- {"█".repeat(run.cells)}
448
- </span>
449
- ),
450
- )}
451
- </text>
452
- );
453
- })}
551
+ <box flexDirection="column" marginTop={1} width={width}>
454
552
  {/*
455
- * TWO WORDS, one per half not a key.
553
+ * The header is the WORD `workflow` and nothing else.
554
+ *
555
+ * It used to carry `▓ external model, via claudish` right-aligned beside it, and
556
+ * that sentence was on screen three times at once: here, in the detail pane's own
557
+ * paragraph about mates, and — in colour — as the key's chips under the chart.
558
+ * Reported directly: "why do we have on top of diagram 'external models via
559
+ * claudish' if we already have this described in the right and in the bottom".
456
560
  *
457
- * This line used to read `main opus · smart fable 5 · default sonnet 2 · cheap
458
- * fable 8`, which restated the tier table sitting three columns to the right,
459
- * wrapped to two lines at a normal pane width, and buried the one thing the bar
460
- * is for: this half is the thread, that half is everything it dispatches. The
461
- * colours already say which model a run is; the table says which model a tier is.
561
+ * The weave stays; it is now explained by the `mates` chips in the key below,
562
+ * which name the slots AND their bound models rather than restating the category.
563
+ *
564
+ * It cost no row the note was right-aligned INSIDE this height-1 header — so
565
+ * `SUMMARY_COST.spread` and `planModelsSummary` are untouched by its removal.
462
566
  */}
463
- <text fg={theme.colors.muted}>
464
- <span>{" ".repeat(label)}</span>
465
- <span fg={modelBarFill(config.main.model)}>
466
- {padCell("main", leftCells + 1)}
467
- </span>
468
- <span>subagents</span>
469
- </text>
567
+ <box flexDirection="row" width={width} height={1}>
568
+ <text fg={theme.colors.muted}>workflow</text>
569
+ </box>
570
+ {WORKFLOWS.map((workflow, index) => (
571
+ <box
572
+ key={workflow.name}
573
+ flexDirection="column"
574
+ width={width}
575
+ // The separator, and it belongs to the group BELOW it rather than above:
576
+ // hung off the first group instead, the last one would carry a trailing
577
+ // blank row that separates the chart from nothing.
578
+ marginTop={index > 0 ? WORKFLOW_GAP_ROWS : 0}
579
+ >
580
+ <box flexDirection="row" width={width} height={1}>
581
+ <text fg={theme.colors.muted} flexShrink={0}>
582
+ {padCell(workflow.name, label)}
583
+ </text>
584
+ {/* The main thread is one segment across the full width — a share of one,
585
+ because a row with a single segment has nothing to be a share OF. */}
586
+ <BarRow
587
+ segments={[{ id: "main", model: config.main.model, share: 1 }]}
588
+ />
589
+ </box>
590
+ <box flexDirection="row" width={width} height={1}>
591
+ <text fg={theme.colors.muted} flexShrink={0}>
592
+ {padCell(SUBAGENT_ROW_LABEL, label)}
593
+ </text>
594
+ {/* `barSegments` is what folds every mate-routed agent into ONE run —
595
+ the bar answers how much of this workflow leaves Claude Code, and
596
+ that is one number however many slots it is spread across. The
597
+ slots come back, with their bindings, in the key below. */}
598
+ {/* The external phases come from the WORKFLOW, not the config, so the run
599
+ is drawn under every preset — a workflow calls out because its
600
+ command says so, not because of the routing applied to it. */}
601
+ <BarRow
602
+ segments={barSegments(
603
+ workflowDistribution(
604
+ config,
605
+ workflow.agents,
606
+ workflow.external.length,
607
+ ),
608
+ )}
609
+ />
610
+ </box>
611
+ </box>
612
+ ))}
470
613
  </box>
471
614
  );
472
615
  }
@@ -485,9 +628,16 @@ function renderStatusRow(
485
628
  item: ModelsStatusItem,
486
629
  isSelected: boolean,
487
630
  ): React.ReactNode {
631
+ // NEITHER the file's `preset` string NOR a drift count.
632
+ //
633
+ // The string records what was last applied, so a config edited afterwards shows a
634
+ // built-in's name while not being that built-in — which is why the row for a diverged
635
+ // config is called `Custom` and reads nothing from the file.
636
+ //
637
+ // The drift count was a count of MESSAGES, not of differences, and one of those messages
638
+ // is "settings carry none of the config". `1 drift` therefore read as one small thing
639
+ // while meaning nothing is in force — a number that misleads is worse than no number.
488
640
  const parts = [
489
- item.preset,
490
- item.driftCount > 0 ? `${item.driftCount} drift` : null,
491
641
  item.warningCount > 0
492
642
  ? `${item.warningCount} warning${item.warningCount === 1 ? "" : "s"}`
493
643
  : null,
@@ -535,8 +685,11 @@ function presetRowInput(item: ModelsPresetItem): PresetRowInput {
535
685
  return {
536
686
  label: item.label,
537
687
  main: item.config.main.model,
688
+ // Both halves of a mate's identity: the slot colours the text, the bound id IS the
689
+ // text. Passing only the slot is what printed a role where a model belonged.
538
690
  smart: item.config.grades.smart.model,
539
- markers: presetMarkerWidth(item.isDefault, item.custom),
691
+ smartLabel: modelLabel(item.config, item.config.grades.smart.model),
692
+ markers: presetMarkerWidth(item.isDefault),
540
693
  };
541
694
  }
542
695
 
@@ -576,6 +729,10 @@ function summarySpans(
576
729
  const rule = selected ? theme.selection.dim : theme.colors.border;
577
730
  const main = config.main.model;
578
731
  const smart = config.grades.smart.model;
732
+ // Clipped to the column `fitPresetRows` capped, with the `…` that marks the cut. The cap
733
+ // is what stops one long binding dragging the whole list down a rung; the clip is what
734
+ // makes that cap honest rather than an overrun.
735
+ const smartText = clipCell(modelLabel(config, smart), layout.smart);
579
736
 
580
737
  const parts: React.ReactNode[] = [
581
738
  <span key="gap" fg={rule}>
@@ -610,10 +767,17 @@ function summarySpans(
610
767
  </span>,
611
768
  );
612
769
  }
613
- parts.push(<ModelText key="smart" model={smart} selected={selected} />);
770
+ parts.push(
771
+ <ModelText
772
+ key="smart"
773
+ model={smart}
774
+ label={smartText}
775
+ selected={selected}
776
+ />,
777
+ );
614
778
  parts.push(
615
779
  <span key="smart-pad" fg={rule}>
616
- {modelPad(smart, layout.smart)}
780
+ {modelPad(smartText, layout.smart)}
617
781
  </span>,
618
782
  );
619
783
  return parts;
@@ -651,25 +815,36 @@ function renderPresetRow(
651
815
  {item.isDefault ? (
652
816
  <MetaText text={PRESET_MARKERS.default} selected={isSelected} />
653
817
  ) : null}
654
- {item.custom ? (
655
- <MetaText
656
- text={PRESET_MARKERS.custom}
657
- tone="warning"
658
- selected={isSelected}
659
- />
660
- ) : null}
661
818
  </SelectableRow>
662
819
  );
663
820
  }
664
821
 
665
822
  function renderPresetDetail(
666
823
  item: ModelsPresetItem,
667
- width?: number,
824
+ width: number | undefined,
825
+ /** Is `multimodel@magus` installed — i.e. is there anything to serve a mate? */
826
+ matesAvailable: boolean,
827
+ /** The live catalogue, or [] when it could not be read. ADVISORY ONLY. */
828
+ catalog: CatalogModel[],
668
829
  ): React.ReactNode {
669
830
  const { config } = item;
670
- const modelColumn = specModelColumn(config);
831
+ const modelColumn = specModelColumn(config, matesAvailable);
671
832
  const barWidth = Math.max(8, width ?? 48);
672
833
 
834
+ // Slots that carry an effort. Named rather than counted, so the caveat below says WHICH
835
+ // rows it is about — with three slots on screen, "one of these is not applied" would
836
+ // leave the reader checking all three.
837
+ const declaredEfforts = MATES.filter(
838
+ (mate) => mateEffort(config, mate) !== undefined,
839
+ );
840
+ // Only the bindings this config actually ROUTES to. A slot bound in the `mates` block but
841
+ // assigned to no agent is dead weight, and warning that a model nothing uses may have
842
+ // been retired is noise about a decision with no consequences.
843
+ const bound = matesInUse(config)
844
+ .map((mate) => boundModel(config, mate))
845
+ .filter((id): id is string => id !== null);
846
+ const staleNote = stalenessNote(unknownBindings(bound, catalog));
847
+
673
848
  return (
674
849
  <box flexDirection="column">
675
850
  <text fg={theme.colors.accent}>
@@ -713,12 +888,76 @@ function renderPresetDetail(
713
888
  key={grade}
714
889
  label={grade}
715
890
  model={config.grades[grade].model}
891
+ // A grade routed to a bound mate writes the model, not the slot.
892
+ modelText={modelLabel(config, config.grades[grade].model)}
716
893
  effort={config.grades[grade].effort}
717
894
  modelColumn={modelColumn}
718
895
  highlight={grade === config.fallback}
719
896
  />
720
897
  ))}
898
+ {/*
899
+ * The mate slots, drawn only when `multimodel@magus` is installed.
900
+ *
901
+ * The tier column is the SLOT and the model column is what it is BOUND
902
+ * to. Both columns used to carry the same word — the row read
903
+ * `mate1 mate1 —` — because a slot was all a config could name. A slot
904
+ * is a role; the `mates` block says which model plays it, and that model
905
+ * is what belongs under `model`.
906
+ *
907
+ * An unbound slot writes `unset`, not a blank and not its own name: the
908
+ * author decided to route here and has not yet said where, which is a
909
+ * different state from the `—` that means nothing was decided at all.
910
+ *
911
+ * Without the plugin there is nothing to serve these calls, so drawing
912
+ * three rows of routing that cannot happen would be an advertisement
913
+ * rather than a status.
914
+ */}
915
+ {matesAvailable
916
+ ? MATES.map((mate) => (
917
+ <SpecRow
918
+ key={mate}
919
+ label={mate}
920
+ model={mate}
921
+ modelText={modelLabel(config, mate)}
922
+ effort={mateEffort(config, mate)}
923
+ modelColumn={modelColumn}
924
+ />
925
+ ))
926
+ : null}
721
927
  </box>
928
+ {matesAvailable ? (
929
+ <box flexDirection="column" marginTop={1}>
930
+ <Wrapped
931
+ width={width}
932
+ text="A mate is an external model reached through claudish. Bind one in the `mates` block of .claude/models.json, then assign the slot to an agent — claudeup leaves those calls alone, so claudish decides how they run."
933
+ />
934
+ {/* Where a mate's effort comes from, in the order it is resolved. The
935
+ two rules are one sentence because they are one lookup: the slot
936
+ answers if it can, the preset answers for the rest. */}
937
+ <box marginTop={1}>
938
+ <Wrapped
939
+ width={width}
940
+ tone={theme.colors.muted}
941
+ text="A mate runs at the effort its slot names, or the preset's if it names none. claudish applies it per run, clamped to what the model supports."
942
+ />
943
+ </box>
944
+ {/*
945
+ * The staleness advisory. Absent when the catalogue could not be read
946
+ * — `unknownBindings` returns nothing on an empty catalogue, so a
947
+ * machine with no claudish sees the pane exactly as before rather
948
+ * than every binding flagged as retired.
949
+ */}
950
+ {staleNote ? (
951
+ <box marginTop={1}>
952
+ <Wrapped
953
+ width={width}
954
+ tone={theme.colors.warning}
955
+ text={staleNote}
956
+ />
957
+ </box>
958
+ ) : null}
959
+ </box>
960
+ ) : null}
722
961
  </DetailSection>
723
962
 
724
963
  <DetailSection title="Agents">
@@ -769,39 +1008,66 @@ export function shortConfigPath(
769
1008
  * These were filled chips. A chip here would be the only chip left on the
770
1009
  * screen and would teach a vocabulary nothing else uses — every model
771
1010
  * everywhere else is now its name in its colour, so the key is too.
1011
+ *
1012
+ * ## It describes the BAR, and only the bar
1013
+ *
1014
+ * This used to be built from `presets.map((preset) => preset.config)` — every selectable row
1015
+ * in the list — while `AgentSpread` above it drew the SELECTED config alone. The two
1016
+ * disagreed exactly when it mattered: a project config binding `dev:qa-engineer` to
1017
+ * `mate1` put `mate1` in the key, the cursor sat on a built-in preset that has no mate, and
1018
+ * the bar could not contain that colour at any width. Reported as "the mate is missing from
1019
+ * the bar", which it was not — the key was naming a colour from a config nobody was looking
1020
+ * at, and sent a reader hunting for a segment that was never going to be there.
1021
+ *
1022
+ * `modelsInUse` still takes an array, because it answers a genuine question about a set of
1023
+ * configs and other callers may want it. `fitModelKey` passes exactly one: the config the
1024
+ * graphic above is drawing.
1025
+ *
1026
+ * ## The mates are named here, and painted in ONE tone
1027
+ *
1028
+ * The bar collapses every mate-routed agent into a single `mates` run, so the slots have to
1029
+ * be recoverable somewhere — and this row is the only part of the screen with room for a
1030
+ * 21-character catalogue id. Each slot in use gets a chip reading `mate1 grok-4.6`.
1031
+ *
1032
+ * All of them wear the FAMILY ink, not their own. The key decodes the chart, the chart has
1033
+ * one mate colour, and a legend showing ochre for `mate1` would name a tone that appears at
1034
+ * no width in the graphic above it — which is this comment's own bug, one level down.
772
1035
  */
773
1036
  function ModelKey({
774
- presets,
1037
+ config,
775
1038
  width,
776
1039
  }: {
777
- presets: ModelsPresetItem[];
1040
+ config: ModelsConfig;
778
1041
  width: number;
779
1042
  }): React.ReactNode {
780
1043
  const label = "models ";
781
- const models = modelsInUse(presets.map((preset) => preset.config));
782
- const shown: string[] = [];
783
- let used = label.length;
784
- for (const model of models) {
785
- // A badge costs its name plus its own two padding cells, plus one separating
786
- // space. Measured with `modelBadgeWidth` rather than `modelWidth` so the row
787
- // cannot overflow by exactly the padding the chips draw.
788
- const cost = modelBadgeWidth(model) + (shown.length > 0 ? 1 : 0);
789
- if (used + cost > width) break;
790
- shown.push(model);
791
- used += cost;
792
- }
793
- if (shown.length === 0) return null;
1044
+ // A SLOT IS WRITTEN WITH ITS BOUND MODEL here — `mate1 grok-4.6` and that is the other
1045
+ // half of collapsing the bar's mates into one run. The bar gave up which slot; this is
1046
+ // where it comes back, in the one place on the screen wide enough for a catalogue id.
1047
+ // The fitting, the three rungs and the reason it must not wrap live in `fitModelKey`.
1048
+ // `WORKFLOWS` decides half of it: a preset that binds no slot still draws a mates run for
1049
+ // every workflow with external phases, and a run with no chip under it is a texture the
1050
+ // reader cannot decode.
1051
+ const chips = fitModelKey(
1052
+ config,
1053
+ width,
1054
+ label.length,
1055
+ WORKFLOWS.some((workflow) => workflow.external.length > 0),
1056
+ );
1057
+ if (chips.length === 0) return null;
794
1058
 
795
1059
  const parts: React.ReactNode[] = [
796
1060
  <span key="label" fg={theme.colors.muted}>
797
1061
  {label}
798
1062
  </span>,
799
1063
  ];
800
- shown.forEach((model, index) => {
1064
+ chips.forEach((chip, index) => {
801
1065
  // No `·` between chips: a fill already separates them, and a separator
802
1066
  // between two blocks reads as a third thing.
803
- if (index > 0) parts.push(<span key={`gap:${model}`}> </span>);
804
- parts.push(<ModelBadge key={model} model={model} />);
1067
+ if (index > 0) parts.push(<span key={`gap:${chip.text}`}> </span>);
1068
+ parts.push(
1069
+ <ModelBadge key={chip.text} model={chip.ink} label={chip.text} />,
1070
+ );
805
1071
  });
806
1072
  return <text fg={theme.colors.text}>{parts}</text>;
807
1073
  }
@@ -814,8 +1080,31 @@ export interface SummaryPlan {
814
1080
  rows: number;
815
1081
  }
816
1082
 
817
- /** Rows each block costs, its own leading blank line included. */
818
- const SUMMARY_COST = { rule: 1, spread: 4, key: 2, facts: 3 } as const;
1083
+ /**
1084
+ * Rows each block costs, its own leading blank line included.
1085
+ *
1086
+ * The spread's cost is DERIVED, not a number typed here. It was `4` while the block drew a
1087
+ * header, one bar per workflow and a caption — six rows including its margin — so the plan
1088
+ * was already two rows short of what it drew, and every workflow added would have widened
1089
+ * the gap silently. It is now the margin, the header, two rows per workflow, and one blank
1090
+ * row between each pair of workflows — all counted from `WORKFLOWS` so the two cannot drift
1091
+ * apart again.
1092
+ *
1093
+ * The separators make the chart two rows taller, and that is not free: at eleven rows — what
1094
+ * an 80×24 terminal leaves under the list — the chart no longer fits and the panel draws
1095
+ * nothing at all. That is the honest reading of the budget rather than a rounding to be
1096
+ * softened: an over-tall block in a fixed-height box overprints, it does not clip.
1097
+ */
1098
+ const SUMMARY_COST = {
1099
+ rule: 1,
1100
+ spread:
1101
+ 1 +
1102
+ 1 +
1103
+ WORKFLOWS.length * WORKFLOW_ROWS +
1104
+ Math.max(0, WORKFLOWS.length - 1) * WORKFLOW_GAP_ROWS,
1105
+ key: 2,
1106
+ facts: 3,
1107
+ } as const;
819
1108
 
820
1109
  /**
821
1110
  * Decide what the panel has room for, most valuable first.
@@ -884,16 +1173,19 @@ export function planModelsSummary(
884
1173
  * being made.
885
1174
  *
886
1175
  * `available` is rows, and it is not advisory — see `planModelsSummary`.
1176
+ *
1177
+ * It no longer takes the item LIST. Everything under the rule now describes one config — the
1178
+ * one the cursor is on and the chart is drawing — and the only thing the full list was used
1179
+ * for was a colour key built from every preset at once, which is the drift `ModelKey`
1180
+ * documents.
887
1181
  */
888
1182
  export function renderModelsSummary({
889
- items,
890
1183
  selected,
891
1184
  status,
892
1185
  configPath,
893
1186
  width,
894
1187
  available,
895
1188
  }: {
896
- items: ModelsBrowserItem[];
897
1189
  selected: ModelsBrowserItem | undefined;
898
1190
  status: ModelsStatus | null;
899
1191
  configPath: string | null;
@@ -903,9 +1195,6 @@ export function renderModelsSummary({
903
1195
  }): React.ReactNode {
904
1196
  if (!status) return null;
905
1197
  const preset = selected?.kind === "preset" ? selected : undefined;
906
- const presets = items.filter(
907
- (item): item is ModelsPresetItem => item.kind === "preset",
908
- );
909
1198
  const plan = planModelsSummary(available, Boolean(configPath));
910
1199
  if (plan.rows === 0) return null;
911
1200
 
@@ -919,9 +1208,11 @@ export function renderModelsSummary({
919
1208
  {plan.spread && preset ? (
920
1209
  <AgentSpread config={preset.config} width={inner} />
921
1210
  ) : null}
922
- {plan.key ? (
1211
+ {/* Gated on `preset` for the same reason the chart is: the key describes the
1212
+ graphic above it, and with nothing selected there is no graphic to describe. */}
1213
+ {plan.key && preset ? (
923
1214
  <box marginTop={1}>
924
- <ModelKey presets={presets} width={inner} />
1215
+ <ModelKey config={preset.config} width={inner} />
925
1216
  </box>
926
1217
  ) : null}
927
1218
  {/*
@@ -966,11 +1257,25 @@ export function renderModelRow(
966
1257
  * `status` is passed alongside the item rather than copied onto it: the drift
967
1258
  * lines belong to the project, not to whichever preset the cursor is on, and
968
1259
  * duplicating them per row is how the two would end up disagreeing.
1260
+ *
1261
+ * `matesAvailable` arrives the same way and for the same reason — it is a fact about the
1262
+ * MACHINE (is `multimodel@magus` installed), not about the preset under the cursor. It is
1263
+ * passed in rather than looked up here because this module is a renderer: answering it needs
1264
+ * the filesystem and the network, which is the adapter layer's job. It defaults to false so
1265
+ * a caller that has not asked yet draws the screen as it always was, rather than flickering
1266
+ * three rows in on the first frame.
1267
+ *
1268
+ * `catalog` arrives the same way again, and defaults to EMPTY for a stronger reason than
1269
+ * convenience: empty is what a failed lookup yields, and the advisory built from it returns
1270
+ * nothing on an empty catalogue. So a caller that never asks, a machine with no claudish, and
1271
+ * a claudish that timed out all render identically — the bound ids as written, unannotated.
969
1272
  */
970
1273
  export function renderModelDetail(
971
1274
  item: ModelsBrowserItem | undefined,
972
1275
  status: ModelsStatus | null,
973
1276
  width?: number,
1277
+ matesAvailable = false,
1278
+ catalog: CatalogModel[] = [],
974
1279
  ): React.ReactNode {
975
1280
  if (!item) {
976
1281
  return (
@@ -998,7 +1303,7 @@ export function renderModelDetail(
998
1303
  <StateBlock status={status} width={width} />
999
1304
  </box>
1000
1305
  ) : null}
1001
- {renderPresetDetail(item, width)}
1306
+ {renderPresetDetail(item, width, matesAvailable, catalog)}
1002
1307
  </box>
1003
1308
  );
1004
1309
  }