claudeup 6.3.2 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/__tests__/catalog-notice.test.ts +3 -3
- package/src/__tests__/cli-live.test.ts +9 -2
- package/src/__tests__/cli-update-view.test.ts +2 -2
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/gap-fill-versions.test.ts +24 -24
- package/src/__tests__/gitignore-prerun.test.ts +6 -13
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/manifest.test.ts +134 -0
- package/src/__tests__/marketplace-badge.test.ts +1 -1
- package/src/__tests__/marketplaces.test.ts +0 -1
- package/src/__tests__/model-visuals.test.tsx +793 -0
- package/src/__tests__/models-adapter.test.ts +317 -0
- package/src/__tests__/models-cli.test.ts +173 -0
- package/src/__tests__/models-core.test.ts +640 -0
- package/src/__tests__/models-manager.test.ts +497 -0
- package/src/__tests__/models-screen-state.test.ts +259 -0
- package/src/__tests__/moved-marketplace.test.ts +7 -8
- package/src/__tests__/plugin-contents.test.ts +1 -1
- package/src/__tests__/profile-adopt.test.ts +1 -1
- package/src/__tests__/profile-materializer.test.ts +48 -2
- package/src/__tests__/resolver.test.ts +43 -6
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/symlink-manager.test.ts +65 -1
- package/src/__tests__/tabbar-layout.test.ts +40 -2
- package/src/__tests__/theme-adaptive-colors.test.ts +48 -1
- package/src/__tests__/version-snapshot.test.ts +4 -4
- package/src/cli/doctor.ts +90 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/models.ts +214 -0
- package/src/cli/router.ts +12 -0
- package/src/data/gitignore-defaults.ts +4 -1
- package/src/data/gitignore-reasons.ts +0 -4
- package/src/data/marketplaces.ts +1 -15
- package/src/data/models-presets.ts +270 -0
- package/src/data/predefined-profiles.ts +12 -21
- package/src/data/settings-catalog.ts +11 -4
- package/src/main.tsx +51 -82
- package/src/services/hook-registration.ts +218 -0
- package/src/services/manifest.ts +84 -0
- package/src/services/models-core.ts +628 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-manager.ts +2 -3
- package/src/services/profile-materializer.ts +17 -0
- package/src/services/resolver.ts +13 -2
- package/src/services/settings-file.ts +69 -0
- package/src/services/styles-manager.ts +23 -45
- package/src/services/symlink-manager.ts +57 -11
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +21 -0
- package/src/types/index.ts +14 -0
- package/src/ui/App.tsx +15 -3
- package/src/ui/adapters/modelsAdapter.ts +170 -0
- package/src/ui/adapters/pluginsAdapter.ts +1 -1
- package/src/ui/components/TabBar.tsx +9 -4
- package/src/ui/components/layout/FooterHints.tsx +20 -3
- package/src/ui/components/layout/ScreenLayout.tsx +87 -7
- package/src/ui/components/primitives/MetaText.tsx +27 -1
- package/src/ui/renderers/modelRenderers.tsx +1004 -0
- package/src/ui/renderers/modelVisuals.tsx +853 -0
- package/src/ui/renderers/skillRenderers.tsx +13 -3
- package/src/ui/renderers/styleRenderers.tsx +7 -3
- package/src/ui/screens/ModelsScreen.tsx +478 -0
- package/src/ui/screens/PluginsScreen.tsx +1 -1
- package/src/ui/screens/StylesScreen.tsx +8 -13
- package/src/ui/screens/index.ts +1 -0
- package/src/ui/state/reducer.ts +94 -0
- package/src/ui/state/types.ts +65 -2
- package/src/ui/theme-mode.ts +116 -0
- 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
|
+
}
|