claudeup 6.3.2 → 6.4.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.
Files changed (58) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/cli-live.test.ts +9 -2
  3. package/src/__tests__/footer-hints.test.ts +40 -0
  4. package/src/__tests__/gitignore-prerun.test.ts +6 -13
  5. package/src/__tests__/hook-import-policy.test.ts +90 -0
  6. package/src/__tests__/hook-process.test.ts +256 -0
  7. package/src/__tests__/hook-registration.test.ts +224 -0
  8. package/src/__tests__/manifest.test.ts +134 -0
  9. package/src/__tests__/model-visuals.test.tsx +789 -0
  10. package/src/__tests__/models-adapter.test.ts +317 -0
  11. package/src/__tests__/models-cli.test.ts +173 -0
  12. package/src/__tests__/models-core.test.ts +640 -0
  13. package/src/__tests__/models-manager.test.ts +497 -0
  14. package/src/__tests__/models-screen-state.test.ts +259 -0
  15. package/src/__tests__/profile-materializer.test.ts +46 -0
  16. package/src/__tests__/resolver.test.ts +36 -0
  17. package/src/__tests__/settings-file.test.ts +179 -0
  18. package/src/__tests__/symlink-manager.test.ts +65 -1
  19. package/src/__tests__/tabbar-layout.test.ts +40 -2
  20. package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
  21. package/src/cli/doctor.ts +90 -0
  22. package/src/cli/hook.ts +129 -0
  23. package/src/cli/models.ts +214 -0
  24. package/src/cli/router.ts +12 -0
  25. package/src/data/gitignore-defaults.ts +4 -0
  26. package/src/data/models-presets.ts +281 -0
  27. package/src/data/predefined-profiles.ts +9 -0
  28. package/src/data/settings-catalog.ts +11 -4
  29. package/src/main.tsx +51 -82
  30. package/src/services/hook-registration.ts +218 -0
  31. package/src/services/manifest.ts +84 -0
  32. package/src/services/models-core.ts +628 -0
  33. package/src/services/models-manager.ts +606 -0
  34. package/src/services/profile-materializer.ts +17 -0
  35. package/src/services/resolver.ts +11 -0
  36. package/src/services/settings-file.ts +69 -0
  37. package/src/services/styles-manager.ts +23 -45
  38. package/src/services/symlink-manager.ts +57 -11
  39. package/src/tui.tsx +112 -0
  40. package/src/types/bun.d.ts +21 -0
  41. package/src/types/index.ts +14 -0
  42. package/src/ui/App.tsx +15 -3
  43. package/src/ui/adapters/modelsAdapter.ts +170 -0
  44. package/src/ui/components/TabBar.tsx +9 -4
  45. package/src/ui/components/layout/FooterHints.tsx +20 -3
  46. package/src/ui/components/layout/ScreenLayout.tsx +87 -7
  47. package/src/ui/components/primitives/MetaText.tsx +27 -1
  48. package/src/ui/renderers/modelRenderers.tsx +1004 -0
  49. package/src/ui/renderers/modelVisuals.tsx +853 -0
  50. package/src/ui/renderers/skillRenderers.tsx +13 -3
  51. package/src/ui/renderers/styleRenderers.tsx +7 -3
  52. package/src/ui/screens/ModelsScreen.tsx +478 -0
  53. package/src/ui/screens/StylesScreen.tsx +8 -13
  54. package/src/ui/screens/index.ts +1 -0
  55. package/src/ui/state/reducer.ts +94 -0
  56. package/src/ui/state/types.ts +65 -2
  57. package/src/ui/theme-mode.ts +116 -0
  58. package/src/ui/theme.ts +26 -0
@@ -0,0 +1,853 @@
1
+ import type React from "react";
2
+ import {
3
+ type AgentModel,
4
+ EFFORTS,
5
+ type Effort,
6
+ GRADES,
7
+ MODEL_ALIASES,
8
+ type ModelsConfig,
9
+ baseAlias,
10
+ } from "../../services/models-core.js";
11
+ import { effortInk, modelBadge, trackFill } from "../theme-mode.js";
12
+ import { type UiColor, theme } from "../theme.js";
13
+
14
+ /**
15
+ * The Models screen's visual vocabulary: which colour a model owns, how effort
16
+ * is drawn, and how the agent table becomes one bar.
17
+ *
18
+ * Everything here is shared by the list rows and the detail pane on purpose.
19
+ * The two used to describe the same routing in two unrelated shapes — a row
20
+ * said "opus lead", the detail said "main opus (medium)" — and nothing stopped
21
+ * them drifting apart. One module, one mapping, one set of glyphs.
22
+ *
23
+ * ## Where a fill is allowed, and where it is not
24
+ *
25
+ * MEASURED on the real TUI: when every model was a chip AND every effort a filled meter,
26
+ * seventeen agent rows stacked those into two solid vertical colour columns. A screen where
27
+ * everything is saturated has no signal in it — the eye cannot pick a row out of a slab.
28
+ *
29
+ * So a fill is spent where there are FEW of them and it reads as a swatch: the four-row tier
30
+ * table, the models key, the distribution bar, the selected row, and a problem that needs
31
+ * action. The agent table gets none — a model is its own ink there (`ModelText`), and effort
32
+ * is four dots of text.
33
+ *
34
+ * An earlier pass concluded the opposite, that colour could not carry "which model" at all,
35
+ * because the `theme.category` tones it was drawing from measure within 0.02 of each other:
36
+ * `fable` 5.04:1 on cream / 3.16 on dark, `opus` 5.02 / 3.18, with `haiku` and `inherit` the
37
+ * identical hex. That was true of THOSE colours. It is not a law about colour — the palette
38
+ * now lives in `theme-mode.ts` as `MODEL_BADGE`, is chosen per page, and separates the four
39
+ * models by hue and by fill rather than by luminance alone.
40
+ *
41
+ * ## Two colour channels, and why they do not collide
42
+ *
43
+ * Model identity is one channel; effort is the other. They stay legible together because
44
+ * they differ in KIND, not only in hue: a model is a fill or a name's ink, effort is always
45
+ * four small glyphs of text, and effort's ramp skips green and red, which already mean "on"
46
+ * and "failed" on this screen.
47
+ *
48
+ * Nothing in this file holds a colour literal. Every value comes from `theme.ts` or
49
+ * `theme-mode.ts`, so the palette is a theme decision and this file is a consumer of one.
50
+ */
51
+
52
+ export type CategoryTone = keyof typeof theme.category;
53
+
54
+ /**
55
+ * The ink a model's NAME is written in.
56
+ *
57
+ * Comes from the theme (`MODEL_BADGE` in `theme-mode.ts`), never from a hex literal here:
58
+ * colour is a theme decision, a renderer is a consumer of one, and the same palette has to
59
+ * serve this screen, its badges and its chart without three copies drifting apart.
60
+ *
61
+ * `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.
63
+ */
64
+ export function modelFg(model: string): UiColor {
65
+ return modelBadge(baseAlias(model)).fg as UiColor;
66
+ }
67
+
68
+ /** The distribution bar's fill for a model — a softer step than the badge ink. */
69
+ export function modelBarFill(model: string): UiColor {
70
+ return modelBadge(baseAlias(model)).bar as UiColor;
71
+ }
72
+
73
+ /**
74
+ * Columns a model name occupies as plain text. Its own length.
75
+ *
76
+ * Kept as a function rather than inlining `.length` at four call sites: the column maths in
77
+ * `fitAgentTable`, `fitPresetRows` and `specModelColumn` all have to agree with what
78
+ * `ModelText` actually draws, and one of them disagreeing is a column that drifts by a cell
79
+ * as the data changes.
80
+ */
81
+ export function modelWidth(model: string): number {
82
+ return model.length;
83
+ }
84
+
85
+ /** Columns a model BADGE occupies: the name plus one space of padding each side. */
86
+ export function modelBadgeWidth(model: string): number {
87
+ return model.length + 2;
88
+ }
89
+
90
+ /**
91
+ * A model name, in the model's own ink.
92
+ *
93
+ * Returns a `<span>`, so it MUST be placed inside a `<text>`. A `<span>` with no `<text>`
94
+ * parent replaces the whole UI with an error page while the process carries on exiting 0,
95
+ * which no test and no typecheck notices.
96
+ *
97
+ * `selected` surrenders the hue: on the selection fill every page-level tone measures under
98
+ * 1.2:1 (see `theme.ts`), so a selected row has exactly two inks and neither is this one.
99
+ * The WORD still says which model it is, which is why losing the tone there costs nothing.
100
+ */
101
+ export function ModelText({
102
+ model,
103
+ selected = false,
104
+ }: {
105
+ model: string;
106
+ selected?: boolean;
107
+ }): React.ReactNode {
108
+ return (
109
+ <span fg={selected ? theme.selection.fg : modelFg(model)}>{model}</span>
110
+ );
111
+ }
112
+
113
+ /**
114
+ * A model as a filled CHIP — the one place a fill earns its place on this screen.
115
+ *
116
+ * Used in the tier table and the models key, where there are four of them and a chip reads
117
+ * as a swatch. Deliberately NOT used in the agent table: seventeen stacked chips is the
118
+ * solid colour column this redesign exists to remove, so an agent row gets `ModelText`.
119
+ *
120
+ * On a selected row it falls back to text, for the contrast reason in `ModelText`.
121
+ */
122
+ export function ModelBadge({
123
+ model,
124
+ selected = false,
125
+ }: {
126
+ model: string;
127
+ selected?: boolean;
128
+ }): React.ReactNode {
129
+ if (selected) return <ModelText model={model} selected />;
130
+ const ink = modelBadge(baseAlias(model));
131
+ return <span bg={ink.bg} fg={ink.fg}>{` ${model} `}</span>;
132
+ }
133
+
134
+ /** Pad a model COLUMN — emitted after the name, so columns line up. */
135
+ export function modelPad(model: string, column: number): string {
136
+ return " ".repeat(Math.max(0, column - modelWidth(model)));
137
+ }
138
+
139
+ /** Pad a BADGE column, which is two cells wider than the name. */
140
+ export function modelBadgePad(model: string, column: number): string {
141
+ return " ".repeat(Math.max(0, column - modelBadgeWidth(model)));
142
+ }
143
+
144
+ // ─── Effort: a bounded ordinal, so a ramp glyph ───────────────────────────────
145
+
146
+ /**
147
+ * Four dots, filled to the level: `••··` is two of four.
148
+ *
149
+ * Three shapes were built and shown before this one. A four-cell `████`/`░░░░` meter read as
150
+ * a second painted block beside the model's chip, and seventeen rows of that is the solid
151
+ * column this screen was reported for. A single height-coded glyph (`▁▃▅▇`) was quiet enough
152
+ * but did not read AS an indicator at all — "i dont see what is the new inidato". A fraction
153
+ * (`2/4 medium`) read as arithmetic where a picture belonged.
154
+ *
155
+ * Dots work because the ON dot and the OFF dot differ in WEIGHT, not in area: `•` and `·`
156
+ * are plain punctuation present in every monospace font, so the row reads as a fill without
157
+ * painting a block.
158
+ *
159
+ * The unlit dots are DRAWN, not omitted. Dots that simply stop read as a count ("two"); dots
160
+ * on a track read as a level ("two of four"), and the level is the point.
161
+ */
162
+ export const EFFORT_ON = "•";
163
+ export const EFFORT_OFF = "·";
164
+
165
+ /** Columns the indicator occupies — one per level, always four. */
166
+ export const EFFORT_GLYPH_CELLS = 4;
167
+
168
+ /**
169
+ * Effort not set: a dash, deliberately NOT zero filled dots.
170
+ *
171
+ * `low` and `absent` are different answers — one is a decision, the other is "inherits
172
+ * whatever the session is on" — so they may not share a shape. An empty track would read as
173
+ * the bottom of the same scale.
174
+ */
175
+ export const NO_EFFORT_GLYPH = "—";
176
+
177
+ /**
178
+ * 0 for "no effort set", else 1..4 for low/medium/high/xhigh.
179
+ *
180
+ * 0 is a real answer, not a failure: a grade with no effort inherits the
181
+ * session's, and drawing the dash says that without a sentence.
182
+ */
183
+ export function effortLevel(effort: Effort | undefined): number {
184
+ if (!effort) return 0;
185
+ const index = (EFFORTS as readonly string[]).indexOf(effort);
186
+ return index === -1 ? 0 : index + 1;
187
+ }
188
+
189
+ /** The four-cell indicator as plain text, for width maths and tests. */
190
+ export function effortGlyph(effort: Effort | undefined): string {
191
+ const level = effortLevel(effort);
192
+ if (level === 0) return NO_EFFORT_GLYPH.padEnd(EFFORT_GLYPH_CELLS);
193
+ return (
194
+ EFFORT_ON.repeat(level) + EFFORT_OFF.repeat(EFFORT_GLYPH_CELLS - level)
195
+ );
196
+ }
197
+
198
+ /**
199
+ * The effort indicator, as spans for ONE `<text>`.
200
+ *
201
+ * Coloured BY EFFORT, from the theme's ramp — which is a reversal, and deliberate. The
202
+ * previous rule was that colour may only mean one thing, so the glyph borrowed the model's
203
+ * hue and let height carry the level. That holds only while the model's hue is the ONLY
204
+ * colour on the row; once the model is a chip or its own ink, borrowing it again puts the
205
+ * same hue in two adjacent columns meaning two different things.
206
+ *
207
+ * These two channels never collide: effort's ramp is a single hue family per step, effort is
208
+ * TEXT and a model chip is a FILL, and the ramp skips green and red, which already mean "on"
209
+ * and "failed" elsewhere on this screen.
210
+ */
211
+ export function EffortGlyph({
212
+ effort,
213
+ selected = false,
214
+ }: {
215
+ effort: Effort | undefined;
216
+ /** Accepted and ignored — kept so call sites read the same as the model columns. */
217
+ model?: string;
218
+ selected?: boolean;
219
+ }): React.ReactNode {
220
+ const level = effortLevel(effort);
221
+ if (level === 0) {
222
+ return (
223
+ <span fg={selected ? theme.selection.dim : theme.colors.muted}>
224
+ {NO_EFFORT_GLYPH.padEnd(EFFORT_GLYPH_CELLS)}
225
+ </span>
226
+ );
227
+ }
228
+ const on = selected
229
+ ? theme.selection.fg
230
+ : (effortInk(effort as string) as UiColor);
231
+ const off = selected ? theme.selection.dim : (trackFill() as UiColor);
232
+ return (
233
+ <>
234
+ <span fg={on}>{EFFORT_ON.repeat(level)}</span>
235
+ <span fg={off}>{EFFORT_OFF.repeat(EFFORT_GLYPH_CELLS - level)}</span>
236
+ </>
237
+ );
238
+ }
239
+
240
+ /** The word after the glyph. Absent effort says what absent MEANS. */
241
+ export function effortWord(effort: Effort | undefined): string {
242
+ return effort ?? "inherits session";
243
+ }
244
+
245
+ // ─── The agent table, as one distribution ─────────────────────────────────────
246
+
247
+ export interface AgentSegment {
248
+ /** A grade name, `inherit`, or a model an agent was pinned to outright. */
249
+ key: string;
250
+ count: number;
251
+ /** The model this key routes to — what decides the segment's colour. */
252
+ model: string;
253
+ /** The bar fill this model paints, from the theme. */
254
+ fill: UiColor;
255
+ }
256
+
257
+ /**
258
+ * Every agent the config names, counted by what it routes to.
259
+ *
260
+ * The whole table, `default` grade included — the bar answers "how is the work
261
+ * spread", which an omitted majority answers wrongly. An earlier version left
262
+ * the default-grade agents out and drew a bar saying the routing was mostly
263
+ * `critical`, when critical was the exception.
264
+ *
265
+ * Two keys that resolve to the same model get the same colour, and that is
266
+ * correct rather than a collision: the reader is being told those agents run on
267
+ * the same model. The bar puts a track cell between them so they still read as
268
+ * two runs, and the legend names the keys.
269
+ */
270
+ export function agentDistribution(config: ModelsConfig): AgentSegment[] {
271
+ // EVERY tier starts at zero, so the chart is the same four rows whichever preset the
272
+ // cursor is on. Emitting only the tiers that happen to have agents made the chart change
273
+ // height and row order as you moved down the list, which reads as the data changing
274
+ // shape rather than as one tier being empty — and "nothing runs at `default` here" is
275
+ // itself worth seeing.
276
+ const counts = new Map<string, number>(GRADES.map((grade) => [grade, 0]));
277
+ for (const assignment of Object.values(config.agents ?? {})) {
278
+ const key = typeof assignment === "string" ? assignment : assignment.model;
279
+ counts.set(key, (counts.get(key) ?? 0) + 1);
280
+ }
281
+
282
+ const rank = (key: string) => {
283
+ const index = (GRADES as readonly string[]).indexOf(key);
284
+ return index === -1 ? GRADES.length : index;
285
+ };
286
+
287
+ return [...counts.entries()]
288
+ .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
289
+ .map(([key, count]) => {
290
+ const model = resolveKeyModel(config, key);
291
+ return { key, count, model, fill: modelBarFill(model) };
292
+ });
293
+ }
294
+
295
+ /**
296
+ * The tier spread for ONE workflow's agents.
297
+ *
298
+ * Same shape as `agentDistribution`, over a named subset rather than everything in the
299
+ * config. An agent the workflow dispatches that the config never mentions still counts — it
300
+ * falls to `fallback`, exactly as it will at runtime, and leaving it out would draw a bar
301
+ * for four agents when five get spawned.
302
+ *
303
+ * Empty tiers are dropped here, unlike in `agentDistribution`: a workflow that touches two
304
+ * agents genuinely has two segments, and padding it to four would suggest it reaches tiers
305
+ * it never does.
306
+ */
307
+ export function workflowDistribution(
308
+ config: ModelsConfig,
309
+ agents: string[],
310
+ ): AgentSegment[] {
311
+ const counts = new Map<string, number>();
312
+ for (const agent of agents) {
313
+ const assignment = config.agents?.[agent];
314
+ const key =
315
+ assignment === undefined
316
+ ? config.fallback
317
+ : typeof assignment === "string"
318
+ ? assignment
319
+ : assignment.model;
320
+ counts.set(key, (counts.get(key) ?? 0) + 1);
321
+ }
322
+
323
+ const rank = (key: string) => {
324
+ const index = (GRADES as readonly string[]).indexOf(key);
325
+ return index === -1 ? GRADES.length : index;
326
+ };
327
+
328
+ return [...counts.entries()]
329
+ .sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
330
+ .map(([key, count]) => {
331
+ const model = keyModel(config, key);
332
+ return { key, count, model, fill: modelBarFill(model) };
333
+ });
334
+ }
335
+
336
+ /**
337
+ * What a grade name, `inherit`, or a pinned model actually runs on.
338
+ *
339
+ * This is the whole reason an agent's colour is not its own: colour here means
340
+ * "which model", so every agent at one grade takes that grade's model colour
341
+ * and they read as one tier. Colouring agents individually produced six hues
342
+ * carrying no information — with red, which means danger everywhere else in
343
+ * this app, decorating an agent name.
344
+ */
345
+ export function keyModel(config: ModelsConfig, key: string): string {
346
+ const index = (GRADES as readonly string[]).indexOf(key);
347
+ if (index === -1) return key;
348
+ const grade = GRADES[index];
349
+ return grade ? config.grades[grade].model : key;
350
+ }
351
+
352
+ /** Alias kept for the local call site below. */
353
+ const resolveKeyModel = keyModel;
354
+
355
+ /**
356
+ * Every distinct model these configs name, in alias order.
357
+ *
358
+ * Feeds the key under the bar. The bar is coloured by MODEL and labelled by
359
+ * GRADE, so without this the colours have no key at all — a reader can see that
360
+ * two runs differ and not what they differ INTO.
361
+ */
362
+ export function modelsInUse(configs: ModelsConfig[]): string[] {
363
+ const seen = new Set<string>();
364
+ for (const config of configs) {
365
+ seen.add(baseAlias(config.main.model));
366
+ for (const grade of GRADES) seen.add(baseAlias(config.grades[grade].model));
367
+ for (const assignment of Object.values(config.agents ?? {})) {
368
+ if (typeof assignment !== "string") seen.add(baseAlias(assignment.model));
369
+ }
370
+ }
371
+ const order = [...MODEL_ALIASES, "inherit"] as readonly string[];
372
+ const rank = (model: string) => {
373
+ const index = order.indexOf(model);
374
+ return index === -1 ? order.length : index;
375
+ };
376
+ return [...seen].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
377
+ }
378
+
379
+ // ─── The agent table ──────────────────────────────────────────────────────────
380
+
381
+ export interface AgentRow {
382
+ name: string;
383
+ /** The assignment as written: a grade, `inherit`, or a pinned model. */
384
+ key: string;
385
+ /** What that assignment resolves to — the row's colour comes from this. */
386
+ model: string;
387
+ effort: Effort | undefined;
388
+ /** The bar fill this model paints, from the theme. */
389
+ fill: UiColor;
390
+ /** Nothing is set for this agent, so its model and effort cells read `—`. */
391
+ inherit: boolean;
392
+ }
393
+
394
+ /**
395
+ * EVERY agent the config names, one row each, in reading order.
396
+ *
397
+ * No sampling and no "+N more". A reader comes to this table to find their own
398
+ * agent and read its answer off the row, which a truncated list cannot answer —
399
+ * and the detail pane scrolls, so length is not the constraint it looks like.
400
+ *
401
+ * Sorted by grade in ROUTING order (critical, standard, fast, then `inherit`
402
+ * and any pinned model), then alphabetically inside each. The strongest tier is
403
+ * at the top and a name is where the eye expects it.
404
+ */
405
+ export function agentRows(config: ModelsConfig): AgentRow[] {
406
+ const rows: AgentRow[] = [];
407
+ for (const [name, assignment] of Object.entries(config.agents ?? {})) {
408
+ const key = typeof assignment === "string" ? assignment : assignment.model;
409
+ const gradeIndex = (GRADES as readonly string[]).indexOf(key);
410
+ const grade = gradeIndex === -1 ? undefined : GRADES[gradeIndex];
411
+ const model = keyModel(config, key);
412
+ rows.push({
413
+ name,
414
+ key,
415
+ model,
416
+ effort:
417
+ typeof assignment === "string"
418
+ ? grade
419
+ ? config.grades[grade].effort
420
+ : undefined
421
+ : assignment.effort,
422
+ fill: modelBarFill(model),
423
+ inherit: baseAlias(model) === "inherit",
424
+ });
425
+ }
426
+
427
+ const rank = (key: string) => {
428
+ const index = (GRADES as readonly string[]).indexOf(key);
429
+ return index === -1 ? GRADES.length : index;
430
+ };
431
+
432
+ return rows.sort(
433
+ (a, b) =>
434
+ rank(a.key) - rank(b.key) ||
435
+ a.key.localeCompare(b.key) ||
436
+ a.name.localeCompare(b.name),
437
+ );
438
+ }
439
+
440
+ /** What the effort cell shows when a column has to give way. */
441
+ export type EffortDetail = "full" | "short" | "none";
442
+
443
+ export interface AgentTableLayout {
444
+ name: number;
445
+ /** 0 when the grade column was dropped to make the table fit. */
446
+ grade: number;
447
+ model: number;
448
+ effort: EffortDetail;
449
+ /** Columns the whole table occupies, gaps included. */
450
+ total: number;
451
+ }
452
+
453
+ /** The cell an inherit row shows where a model or an effort would go. */
454
+ export const NONE_CELL = "—";
455
+
456
+ const HEADERS = {
457
+ name: "Agent",
458
+ grade: "Tier",
459
+ model: "Model",
460
+ effort: "Effort",
461
+ };
462
+
463
+ function effortColumn(detail: EffortDetail, word: number): number {
464
+ if (detail === "none") return EFFORT_GLYPH_CELLS;
465
+ return EFFORT_GLYPH_CELLS + 1 + (detail === "short" ? 1 : word);
466
+ }
467
+
468
+ /**
469
+ * Column widths for the agent table, from the data rather than from constants.
470
+ *
471
+ * Degrades in a fixed order, because some columns are recoverable and others
472
+ * are not: the grade goes first (the model name beside it already says which
473
+ * tier this is, in colour), then the effort word shrinks to its initial (the
474
+ * glyph still carries the level), and only then does the name column truncate.
475
+ * NOTHING wraps — a wrapped cell turns a table back into the paragraph this
476
+ * replaced.
477
+ */
478
+ export function fitAgentTable(
479
+ rows: AgentRow[],
480
+ width: number,
481
+ ): AgentTableLayout {
482
+ const longest = (values: number[], floor: number) =>
483
+ values.reduce((max, value) => Math.max(max, value), floor);
484
+
485
+ const nameNeeded = longest(
486
+ rows.map((row) => row.name.length),
487
+ HEADERS.name.length,
488
+ );
489
+ const gradeNeeded = longest(
490
+ rows.map((row) => row.key.length),
491
+ HEADERS.grade.length,
492
+ );
493
+ const modelNeeded = longest(
494
+ rows.map((row) => (row.inherit ? NONE_CELL.length : modelWidth(row.model))),
495
+ HEADERS.model.length,
496
+ );
497
+ const wordNeeded = longest(
498
+ rows.map((row) =>
499
+ row.inherit ? NONE_CELL.length : effortWord(row.effort).length,
500
+ ),
501
+ 1,
502
+ );
503
+
504
+ const candidates: Array<{ grade: number; effort: EffortDetail }> = [
505
+ { grade: gradeNeeded, effort: "full" },
506
+ { grade: 0, effort: "full" },
507
+ { grade: 0, effort: "short" },
508
+ { grade: 0, effort: "none" },
509
+ ];
510
+
511
+ const measure = (grade: number, effort: EffortDetail, name: number) =>
512
+ name +
513
+ 1 +
514
+ (grade > 0 ? grade + 1 : 0) +
515
+ modelNeeded +
516
+ 1 +
517
+ effortColumn(effort, wordNeeded);
518
+
519
+ for (const candidate of candidates) {
520
+ const total = measure(candidate.grade, candidate.effort, nameNeeded);
521
+ if (total <= width) {
522
+ return {
523
+ name: nameNeeded,
524
+ grade: candidate.grade,
525
+ model: modelNeeded,
526
+ effort: candidate.effort,
527
+ total,
528
+ };
529
+ }
530
+ }
531
+
532
+ // Nothing fit even stripped down: give the name whatever is left, and
533
+ // truncate into it rather than wrapping.
534
+ const fixed = measure(0, "none", 0);
535
+ const name = Math.max(6, width - fixed);
536
+ return {
537
+ name,
538
+ grade: 0,
539
+ model: modelNeeded,
540
+ effort: "none",
541
+ total: measure(0, "none", name),
542
+ };
543
+ }
544
+
545
+ /** The header cells, already padded to the layout's columns. */
546
+ export function agentTableHeaders(
547
+ layout: AgentTableLayout,
548
+ ): Array<{ key: string; text: string }> {
549
+ const cells = [{ key: "name", text: padCell(HEADERS.name, layout.name) }];
550
+ if (layout.grade > 0) {
551
+ cells.push({ key: "grade", text: padCell(HEADERS.grade, layout.grade) });
552
+ }
553
+ cells.push({ key: "model", text: padCell(HEADERS.model, layout.model) });
554
+ cells.push({
555
+ key: "effort",
556
+ text: padCell(HEADERS.effort, effortColumn(layout.effort, 6)),
557
+ });
558
+ return cells;
559
+ }
560
+
561
+ /** Truncate to `width`, then pad to it. A cell is exactly its column, always. */
562
+ 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);
566
+ }
567
+
568
+ /** The effort word as the layout wants it: the word, its initial, or nothing. */
569
+ export function effortLabel(
570
+ effort: Effort | undefined,
571
+ detail: EffortDetail,
572
+ ): string {
573
+ if (detail === "none") return "";
574
+ const word = effortWord(effort);
575
+ return detail === "short" ? word.slice(0, 1) : word;
576
+ }
577
+
578
+ // ─── The preset list rows ─────────────────────────────────────────────────────
579
+
580
+ /** The trailing markers a preset row may carry. Their leading space is theirs. */
581
+ export const PRESET_MARKERS = {
582
+ default: " (default)",
583
+ custom: " (yours)",
584
+ } as const;
585
+
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
+ );
592
+ }
593
+
594
+ /** What a preset row shows beside its name, widest form first. */
595
+ export type PresetSummaryDetail =
596
+ | "labelled"
597
+ | "smart"
598
+ | "compact"
599
+ | "main"
600
+ | "none";
601
+
602
+ export interface PresetRowLayout {
603
+ /** The name column, identical on every row. */
604
+ name: number;
605
+ /** The `main` model column. 0 when no summary is drawn at all. */
606
+ main: number;
607
+ /** The `critical` model column. 0 when that field was dropped. */
608
+ smart: number;
609
+ detail: PresetSummaryDetail;
610
+ /** Columns the widest row occupies, markers included. */
611
+ total: number;
612
+ }
613
+
614
+ /** One row's inputs — a preset reduced to the fields the column maths needs. */
615
+ export interface PresetRowInput {
616
+ label: string;
617
+ main: string;
618
+ smart: string;
619
+ /** Columns this row's trailing markers need. */
620
+ markers: number;
621
+ }
622
+
623
+ /** The longest a preset name may be before it truncates into an ellipsis. */
624
+ export const PRESET_NAME_MAX = 20;
625
+
626
+ /** `[●] ` — the active marker and the space after it, on every row. */
627
+ const PRESET_GUTTER = 4;
628
+ /** The indent `SelectableRow` adds. */
629
+ const PRESET_INDENT = 1;
630
+ /** Between the name column and the summary. */
631
+ const PRESET_NAME_GAP = 2;
632
+ /** Between the two models. */
633
+ const PRESET_SEP = " · ";
634
+
635
+ const MAIN_LABEL = "main ";
636
+ const SMART_LABEL = "smart ";
637
+
638
+ /**
639
+ * ONE layout for the WHOLE list, not one per row.
640
+ *
641
+ * The bug this exists for, reported off a 120×30 screenshot: each row decided
642
+ * its own form from its own width, so the selected row dropped its `main` /
643
+ * `critical` labels while the other three kept theirs. The same field was
644
+ * labelled on some rows and not on others, and nothing lined up down the
645
+ * column. A list that is uniformly terse beats a list in two formats.
646
+ *
647
+ * So the fit is measured against the WIDEST row and applied to every row. If
648
+ * the widest row cannot carry the labels, none of them do.
649
+ *
650
+ * Degrades in value order, five rungs:
651
+ *
652
+ * labelled `main opus · critical fable`
653
+ * critical `opus · critical fable` — `critical` is the grade this whole
654
+ * screen is about, so its label outlives `main`'s; the leading
655
+ * column is positionally main and the detail pane says so.
656
+ * compact `opus · fable`
657
+ * main `opus` — at a narrow panel one model beats
658
+ * none: it is what the orchestrator runs, and a bare second model
659
+ * with no label beside it says nothing a reader can use.
660
+ * none the name alone.
661
+ */
662
+ export function fitPresetRows(
663
+ rows: PresetRowInput[],
664
+ width: number,
665
+ ): PresetRowLayout {
666
+ const longest = (values: number[], floor: number) =>
667
+ values.reduce((max, value) => Math.max(max, value), floor);
668
+
669
+ const name = Math.min(
670
+ PRESET_NAME_MAX,
671
+ longest(
672
+ rows.map((row) => row.label.length),
673
+ 4,
674
+ ),
675
+ );
676
+ const main = longest(
677
+ rows.map((row) => modelWidth(row.main)),
678
+ 1,
679
+ );
680
+ const smart = longest(
681
+ rows.map((row) => modelWidth(row.smart)),
682
+ 1,
683
+ );
684
+ const markers = longest(
685
+ rows.map((row) => row.markers),
686
+ 0,
687
+ );
688
+
689
+ const base = PRESET_INDENT + PRESET_GUTTER + name;
690
+ const summary = (detail: PresetSummaryDetail) => {
691
+ if (detail === "none") return 0;
692
+ if (detail === "main") return PRESET_NAME_GAP + main;
693
+ const pair = main + PRESET_SEP.length + smart;
694
+ if (detail === "compact") return PRESET_NAME_GAP + pair;
695
+ if (detail === "smart") {
696
+ return PRESET_NAME_GAP + pair + SMART_LABEL.length;
697
+ }
698
+ return PRESET_NAME_GAP + pair + SMART_LABEL.length + MAIN_LABEL.length;
699
+ };
700
+
701
+ const order: PresetSummaryDetail[] = [
702
+ "labelled",
703
+ "smart",
704
+ "compact",
705
+ "main",
706
+ "none",
707
+ ];
708
+ for (const detail of order) {
709
+ const total = base + summary(detail) + markers;
710
+ if (total <= width || detail === "none") {
711
+ return {
712
+ name,
713
+ main: detail === "none" ? 0 : main,
714
+ smart: detail === "none" || detail === "main" ? 0 : smart,
715
+ detail,
716
+ total,
717
+ };
718
+ }
719
+ }
720
+ // Unreachable: `none` always returns above. Kept so the function is total
721
+ // without the compiler having to prove the loop covers every case.
722
+ return { name, main: 0, smart: 0, detail: "none", total: base + markers };
723
+ }
724
+
725
+ // ─── The spend bar ────────────────────────────────────────────────────────────
726
+
727
+ /**
728
+ * Apportion `cells` columns across `values`, exactly.
729
+ *
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.
733
+ */
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
+
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
+ });
749
+
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;
759
+ }
760
+ });
761
+ if (victim === -1) break;
762
+ out[victim] = (out[victim] ?? 0) - 1;
763
+ used -= 1;
764
+ }
765
+
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;
777
+ }
778
+
779
+ export interface SpendRun {
780
+ kind: "fill" | "track";
781
+ cells: number;
782
+ id: string;
783
+ colour?: UiColor;
784
+ }
785
+
786
+ /**
787
+ * The spend bar's runs: ONE line, split in half.
788
+ *
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.
795
+ *
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.
799
+ */
800
+ export function spendRuns(
801
+ config: ModelsConfig,
802
+ segments: AgentSegment[],
803
+ 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
+ ];
821
+
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),
827
+ );
828
+
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
+ });
845
+
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;
853
+ }