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