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,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 a fifth choice.
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)}>{model}</span>
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
- if (selected) return <ModelText model={model} selected />;
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}>{` ${model} `}</span>;
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
- /** The word after the glyph. Absent effort says what absent MEANS. */
241
- export function effortWord(effort: Effort | undefined): string {
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
- const order = [...MODEL_ALIASES, "inherit"] as readonly string[];
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 colour comes from this. */
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
- effort:
417
- typeof assignment === "string"
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.model))),
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 = (grade: number, effort: EffortDetail, name: number) =>
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
- modelNeeded +
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(6, width - fixed);
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: modelNeeded,
709
+ model,
540
710
  effort: "none",
541
- total: measure(0, "none", name),
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
- const clipped =
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
- /** The trailing markers a preset row may carry. Their leading space is theirs. */
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, one of them, or both. */
587
- export function presetMarkerWidth(isDefault: boolean, custom: boolean): number {
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
- const smart = longest(
681
- rows.map((row) => modelWidth(row.smart)),
682
- 1,
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 spend bar ────────────────────────────────────────────────────────────
942
+ // ─── The workflow bar ─────────────────────────────────────────────────────────
726
943
 
727
944
  /**
728
- * Apportion `cells` columns across `values`, exactly.
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
- * Largest-remainder, with one extra rule: a value with any members gets at least one cell
731
- * while cells remain. A distribution where the smallest group rounds to zero draws a bar
732
- * saying that group does not exist, while the numbers beside it say it does.
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
- let used = 0;
740
- const remainders: { index: number; rest: number }[] = [];
741
- values.forEach((value, index) => {
742
- if (value <= 0) return;
743
- const exact = (value / total) * cells;
744
- const floor = Math.max(1, Math.floor(exact));
745
- out[index] = floor;
746
- used += floor;
747
- remainders.push({ index, rest: exact - Math.floor(exact) });
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
- // Over-allocated by the min-one rule: take cells back from the largest runs first, never
751
- // below one, so the smallest group keeps the cell the rule gave it.
752
- while (used > cells) {
753
- let victim = -1;
754
- let most = 1;
755
- out.forEach((value, index) => {
756
- if (value > most) {
757
- most = value;
758
- victim = index;
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
- if (victim === -1) break;
762
- out[victim] = (out[victim] ?? 0) - 1;
763
- used -= 1;
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
- // Under-allocated: hand the rest out by largest remainder.
767
- remainders.sort((a, b) => b.rest - a.rest);
768
- let i = 0;
769
- while (used < cells && remainders.length > 0) {
770
- const target = remainders[i % remainders.length];
771
- if (!target) break;
772
- out[target.index] = (out[target.index] ?? 0) + 1;
773
- used += 1;
774
- i += 1;
775
- }
776
- return out;
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
- export interface SpendRun {
780
- kind: "fill" | "track";
781
- cells: number;
782
- id: string;
783
- colour?: UiColor;
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 spend bar's runs: ONE line, split in half.
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
- * The left half is the MAIN model and the right half is everything it dispatches, because
790
- * that is the shape of the spend the orchestrator is running for most of a session, and
791
- * every subagent put together is the other side of the trade. Four separate tier bars
792
- * compared the tiers with each other and never showed that, so the one decision the screen
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
- * A one-cell track sits between neighbouring runs. MEASURED on a dark screenshot: two
797
- * adjacent runs resolving to the same model merged into a single rectangle, and three
798
- * segments read as two.
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 spendRuns(
1267
+ export function fitModelKey(
801
1268
  config: ModelsConfig,
802
- segments: AgentSegment[],
803
1269
  width: number,
804
- ): SpendRun[] {
805
- const cells = Math.max(4, width);
806
- // One cell of divider, then the two halves. The left is rounded down so the subagent
807
- // side never loses a cell to rounding — it is the half carrying several values.
808
- const divider = 1;
809
- const left = Math.floor((cells - divider) / 2);
810
- const right = cells - divider - left;
811
-
812
- const runs: SpendRun[] = [
813
- {
814
- kind: "fill",
815
- cells: left,
816
- id: "main",
817
- colour: modelBarFill(config.main.model),
818
- },
819
- { kind: "track", cells: divider, id: "divider" },
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 drawn = segments.filter((segment) => segment.count > 0);
823
- const gaps = Math.max(0, drawn.length - 1);
824
- const split = splitCells(
825
- drawn.map((segment) => segment.count),
826
- Math.max(0, right - gaps),
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
- let used = 0;
830
- drawn.forEach((segment, index) => {
831
- const run = split[index] ?? 0;
832
- if (run === 0) return;
833
- if (used > 0) {
834
- runs.push({ kind: "track", cells: 1, id: `gap:${segment.key}` });
835
- used += 1;
836
- }
837
- runs.push({
838
- kind: "fill",
839
- cells: run,
840
- id: segment.key,
841
- colour: segment.fill,
842
- });
843
- used += run;
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
- // Any shortfall is paid in track, so the row is EXACTLY `cells` wide however the
847
- // apportionment landed — a bar that ends somewhere arbitrary reads as a painted
848
- // rectangle rather than as a proportion.
849
- if (used < right) {
850
- runs.push({ kind: "track", cells: right - used, id: "rest" });
851
- }
852
- return runs;
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
  }