magus-cli 0.0.0-placeholder → 7.0.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/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +445 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- 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__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +335 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/moved-marketplace.test.ts +86 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1373 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +520 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +207 -0
- package/src/cli/update-view.ts +526 -0
- package/src/cli/update.ts +613 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1589 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +587 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +253 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +878 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +344 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +863 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +538 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1464 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +352 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +144 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,1309 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { WORKFLOWS } from "../../data/models-presets.js";
|
|
3
|
+
import {
|
|
4
|
+
type CatalogModel,
|
|
5
|
+
stalenessNote,
|
|
6
|
+
unknownBindings,
|
|
7
|
+
} from "../../services/mate-catalog.js";
|
|
8
|
+
import {
|
|
9
|
+
type Effort,
|
|
10
|
+
GRADES,
|
|
11
|
+
MATES,
|
|
12
|
+
type ModelsConfig,
|
|
13
|
+
type ModelsState,
|
|
14
|
+
type ModelsStatus,
|
|
15
|
+
boundModel,
|
|
16
|
+
mateEffort,
|
|
17
|
+
matesInUse,
|
|
18
|
+
modelLabel,
|
|
19
|
+
} from "../../services/models-core.js";
|
|
20
|
+
import type {
|
|
21
|
+
ModelsBrowserItem,
|
|
22
|
+
ModelsPresetItem,
|
|
23
|
+
ModelsStatusItem,
|
|
24
|
+
} from "../adapters/modelsAdapter.js";
|
|
25
|
+
import {
|
|
26
|
+
DetailSection,
|
|
27
|
+
KeyValueLine,
|
|
28
|
+
ListCategoryRow,
|
|
29
|
+
MetaText,
|
|
30
|
+
SelectableRow,
|
|
31
|
+
} from "../components/primitives/index.js";
|
|
32
|
+
import { effortInk, trackFill } from "../theme-mode.js";
|
|
33
|
+
import { type UiColor, theme } from "../theme.js";
|
|
34
|
+
import {
|
|
35
|
+
type AgentRow,
|
|
36
|
+
type AgentTableLayout,
|
|
37
|
+
BarRow,
|
|
38
|
+
EffortGlyph,
|
|
39
|
+
ModelBadge,
|
|
40
|
+
ModelText,
|
|
41
|
+
NONE_CELL,
|
|
42
|
+
PRESET_MARKERS,
|
|
43
|
+
type PresetRowInput,
|
|
44
|
+
type PresetRowLayout,
|
|
45
|
+
agentDistribution,
|
|
46
|
+
agentRows,
|
|
47
|
+
agentTableHeaders,
|
|
48
|
+
barSegments,
|
|
49
|
+
clipCell,
|
|
50
|
+
effortLabel,
|
|
51
|
+
effortWord,
|
|
52
|
+
fitAgentTable,
|
|
53
|
+
fitModelKey,
|
|
54
|
+
fitPresetRows,
|
|
55
|
+
modelPad,
|
|
56
|
+
modelWidth,
|
|
57
|
+
padCell,
|
|
58
|
+
presetMarkerWidth,
|
|
59
|
+
workflowDistribution,
|
|
60
|
+
} from "./modelVisuals.js";
|
|
61
|
+
import { wrapText } from "./styleRenderers.js";
|
|
62
|
+
|
|
63
|
+
// ─── State vocabulary ─────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* One sentence per state, and each says what is HAPPENING, not what the field
|
|
67
|
+
* is called. "unhooked" on its own is a word nobody can act on; "the config is
|
|
68
|
+
* here, but no hook runs it" names the missing piece.
|
|
69
|
+
*
|
|
70
|
+
* The state WORD is not repeated here — it is drawn as a badge beside the
|
|
71
|
+
* sentence, which is the shape a discrete status wants and which also keeps an
|
|
72
|
+
* ambiguous-width glyph out of a line whose width has to be exact.
|
|
73
|
+
*/
|
|
74
|
+
const STATE_HEADLINE: Record<ModelsState, string> = {
|
|
75
|
+
off: "no .claude/models.json, so every subagent inherits the session model",
|
|
76
|
+
on: "every Agent call is routed through this config",
|
|
77
|
+
invalid: "the config does not validate, and nothing is routed",
|
|
78
|
+
unhooked: "the config is here, but no hook runs it, so nothing is routed",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const STATE_TONE: Record<ModelsState, UiColor> = {
|
|
82
|
+
off: theme.colors.muted,
|
|
83
|
+
on: theme.colors.success,
|
|
84
|
+
invalid: theme.colors.danger,
|
|
85
|
+
unhooked: theme.colors.danger,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Badge tone for the header row. `unhooked` is red with `invalid`: both route nothing. */
|
|
89
|
+
const STATE_CATEGORY: Record<ModelsState, "gray" | "green" | "yellow" | "red"> =
|
|
90
|
+
{
|
|
91
|
+
off: "gray",
|
|
92
|
+
on: "green",
|
|
93
|
+
invalid: "red",
|
|
94
|
+
unhooked: "red",
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* How many validation errors to draw before summarising the rest.
|
|
99
|
+
*
|
|
100
|
+
* MEASURED on a real config written before the tier rename: 23 errors, each wrapping to two
|
|
101
|
+
* lines, which filled the whole detail panel and pushed the one useful line — what to press
|
|
102
|
+
* — off the bottom of the screen. Every error after the first few says the same thing about
|
|
103
|
+
* a different key, so the list stops being information and becomes a wall.
|
|
104
|
+
*/
|
|
105
|
+
const MAX_DRIFT_LINES = 6;
|
|
106
|
+
|
|
107
|
+
/** What the user should press next, per state. Empty when there is nothing to fix. */
|
|
108
|
+
const STATE_FIX: Record<ModelsState, string> = {
|
|
109
|
+
off: "Pick a preset below and press a to start routing.",
|
|
110
|
+
on: "",
|
|
111
|
+
invalid:
|
|
112
|
+
"Fix .claude/models.json by hand, or press a on a preset to overwrite it.",
|
|
113
|
+
unhooked: "Press a on a preset — applying one registers the hook.",
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A badge as a `<span>`, for a row that also carries text.
|
|
120
|
+
*
|
|
121
|
+
* The ONLY filled block this screen still paints outside the selection and the
|
|
122
|
+
* distribution bar — a discrete status is exactly what a fill is for, and there
|
|
123
|
+
* is one of them on screen rather than seventeen.
|
|
124
|
+
*
|
|
125
|
+
* Only ever called from inside a `<text>`. A `<span>` with no `<text>` parent
|
|
126
|
+
* swaps the entire UI for an error page and still exits 0, so this is a rule
|
|
127
|
+
* the type system cannot enforce and a test cannot see.
|
|
128
|
+
*/
|
|
129
|
+
function Chip({
|
|
130
|
+
label,
|
|
131
|
+
tone,
|
|
132
|
+
}: {
|
|
133
|
+
label: string;
|
|
134
|
+
tone: keyof typeof theme.category;
|
|
135
|
+
}): React.ReactNode {
|
|
136
|
+
const colors = theme.category[tone];
|
|
137
|
+
return (
|
|
138
|
+
<span bg={colors.bg} fg={colors.fg}>
|
|
139
|
+
<strong>{` ${label} `}</strong>
|
|
140
|
+
</span>
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A paragraph, pre-wrapped to the panel.
|
|
146
|
+
*
|
|
147
|
+
* OpenTUI folds a long line rather than dropping it, but it folds at the box's
|
|
148
|
+
* width with no regard for words — so pre-wrapping is what keeps a sentence
|
|
149
|
+
* breaking between words instead of mid-token.
|
|
150
|
+
*/
|
|
151
|
+
function Wrapped({
|
|
152
|
+
text,
|
|
153
|
+
width,
|
|
154
|
+
tone,
|
|
155
|
+
}: {
|
|
156
|
+
text: string;
|
|
157
|
+
width?: number;
|
|
158
|
+
/** `UiColor`, not `string`: the terminal's own foreground is an RGBA. */
|
|
159
|
+
tone?: UiColor;
|
|
160
|
+
}): React.ReactNode {
|
|
161
|
+
return (
|
|
162
|
+
<box flexDirection="column">
|
|
163
|
+
{wrapText(text, width ?? 48).map((line, index) => (
|
|
164
|
+
<text
|
|
165
|
+
key={`${index}:${line.slice(0, 12)}`}
|
|
166
|
+
fg={tone ?? theme.colors.muted}
|
|
167
|
+
>
|
|
168
|
+
{line || " "}
|
|
169
|
+
</text>
|
|
170
|
+
))}
|
|
171
|
+
</box>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* The state block: what is routed, what drifted, and what to press.
|
|
177
|
+
*
|
|
178
|
+
* Rendered above the preset on EVERY row, not only on the header. The question
|
|
179
|
+
* a reader has while looking at a preset is "is this the one that is running",
|
|
180
|
+
* and answering it one row away means switching rows to find out.
|
|
181
|
+
*/
|
|
182
|
+
function StateBlock({
|
|
183
|
+
status,
|
|
184
|
+
width,
|
|
185
|
+
}: {
|
|
186
|
+
status: ModelsStatus | null;
|
|
187
|
+
width?: number;
|
|
188
|
+
}): React.ReactNode {
|
|
189
|
+
if (!status) {
|
|
190
|
+
return <text fg={theme.colors.muted}>Reading the routing…</text>;
|
|
191
|
+
}
|
|
192
|
+
const fix = STATE_FIX[status.state];
|
|
193
|
+
const isInvalid = status.state === "invalid";
|
|
194
|
+
return (
|
|
195
|
+
<box flexDirection="column">
|
|
196
|
+
<text fg={theme.colors.text}>
|
|
197
|
+
<Chip label={status.state} tone={STATE_CATEGORY[status.state]} />
|
|
198
|
+
</text>
|
|
199
|
+
<Wrapped
|
|
200
|
+
text={STATE_HEADLINE[status.state]}
|
|
201
|
+
width={width}
|
|
202
|
+
tone={STATE_TONE[status.state]}
|
|
203
|
+
/>
|
|
204
|
+
{status.drift.length > 0 ? (
|
|
205
|
+
<box flexDirection="column" marginTop={1}>
|
|
206
|
+
<text fg={theme.colors.text}>
|
|
207
|
+
<Chip
|
|
208
|
+
label={`${isInvalid ? "errors" : "drift"} ${status.drift.length}`}
|
|
209
|
+
tone={isInvalid ? "red" : "yellow"}
|
|
210
|
+
/>
|
|
211
|
+
</text>
|
|
212
|
+
{status.drift.slice(0, MAX_DRIFT_LINES).map((line) => (
|
|
213
|
+
<Wrapped
|
|
214
|
+
key={line}
|
|
215
|
+
text={line}
|
|
216
|
+
width={width}
|
|
217
|
+
tone={isInvalid ? theme.colors.danger : theme.colors.warning}
|
|
218
|
+
/>
|
|
219
|
+
))}
|
|
220
|
+
{status.drift.length > MAX_DRIFT_LINES ? (
|
|
221
|
+
// `Wrapped`, not `MetaText`: MetaText returns a bare <span>, and a <span>
|
|
222
|
+
// with no <text> parent swaps the whole UI for an error page while still
|
|
223
|
+
// exiting 0. Typecheck and every unit test pass; only running it shows it.
|
|
224
|
+
<Wrapped
|
|
225
|
+
text={`…and ${status.drift.length - MAX_DRIFT_LINES} more — run magus models status`}
|
|
226
|
+
width={width}
|
|
227
|
+
/>
|
|
228
|
+
) : null}
|
|
229
|
+
</box>
|
|
230
|
+
) : null}
|
|
231
|
+
{status.warnings.length > 0 ? (
|
|
232
|
+
<box flexDirection="column" marginTop={1}>
|
|
233
|
+
<text fg={theme.colors.text}>
|
|
234
|
+
<Chip label={`warnings ${status.warnings.length}`} tone="yellow" />
|
|
235
|
+
</text>
|
|
236
|
+
{status.warnings.map((line) => (
|
|
237
|
+
<Wrapped
|
|
238
|
+
key={line}
|
|
239
|
+
text={line}
|
|
240
|
+
width={width}
|
|
241
|
+
tone={theme.colors.warning}
|
|
242
|
+
/>
|
|
243
|
+
))}
|
|
244
|
+
</box>
|
|
245
|
+
) : null}
|
|
246
|
+
{fix ? (
|
|
247
|
+
<box marginTop={1}>
|
|
248
|
+
<Wrapped text={fix} width={width} />
|
|
249
|
+
</box>
|
|
250
|
+
) : null}
|
|
251
|
+
</box>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ─── The grade table ──────────────────────────────────────────────────────────
|
|
256
|
+
|
|
257
|
+
/** Gutter for the row label, matching `KeyValueLine` so the columns line up. */
|
|
258
|
+
const SPEC_LABEL_WIDTH = 10;
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The widest model column in a config, so every name in the table ends at the
|
|
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).
|
|
273
|
+
*/
|
|
274
|
+
export function specModelColumn(
|
|
275
|
+
config: ModelsConfig,
|
|
276
|
+
withMates: boolean,
|
|
277
|
+
): number {
|
|
278
|
+
return Math.max(
|
|
279
|
+
modelWidth(config.main.model),
|
|
280
|
+
...GRADES.map((grade) =>
|
|
281
|
+
modelWidth(modelLabel(config, config.grades[grade].model)),
|
|
282
|
+
),
|
|
283
|
+
...(withMates
|
|
284
|
+
? MATES.map((mate) => modelWidth(modelLabel(config, mate)))
|
|
285
|
+
: []),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* One line of the grade table: `critical fable ▇ xhigh`.
|
|
291
|
+
*
|
|
292
|
+
* Effort is a bounded ordinal — four named levels, ordered — so it gets a ramp
|
|
293
|
+
* glyph and the word, rather than being parenthesised after the model name
|
|
294
|
+
* where its ordering is invisible. The glyph takes the MODEL's hue, so shape
|
|
295
|
+
* carries the level and colour still carries only one thing.
|
|
296
|
+
*/
|
|
297
|
+
function SpecRow({
|
|
298
|
+
label,
|
|
299
|
+
model,
|
|
300
|
+
modelText,
|
|
301
|
+
effort,
|
|
302
|
+
modelColumn,
|
|
303
|
+
highlight,
|
|
304
|
+
}: {
|
|
305
|
+
label: string;
|
|
306
|
+
/** The routing value — decides the chip's INK. For a mate this is the slot. */
|
|
307
|
+
model: string;
|
|
308
|
+
/** What the chip WRITES. Defaults to `model`; a bound mate passes its catalogue id. */
|
|
309
|
+
modelText?: string;
|
|
310
|
+
effort: Effort | undefined;
|
|
311
|
+
modelColumn: number;
|
|
312
|
+
/** The grade an unassigned agent falls to — worth reading first. */
|
|
313
|
+
highlight?: boolean;
|
|
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;
|
|
320
|
+
return (
|
|
321
|
+
<text fg={theme.colors.text}>
|
|
322
|
+
<span fg={highlight ? theme.colors.text : theme.colors.muted}>
|
|
323
|
+
{`${label} `.padEnd(SPEC_LABEL_WIDTH)}
|
|
324
|
+
</span>
|
|
325
|
+
{/* A CHIP here, text in the agent table below. Four of them read as a key;
|
|
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. */}
|
|
336
|
+
<span
|
|
337
|
+
fg={
|
|
338
|
+
live ? (effortInk(effort as string) as UiColor) : theme.colors.muted
|
|
339
|
+
}
|
|
340
|
+
>{` ${effortWord(effort, model)}`}</span>
|
|
341
|
+
</text>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ─── Agent table ──────────────────────────────────────────────────────────────
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Every agent, one row, four aligned columns — and NOT ONE FILLED BLOCK.
|
|
349
|
+
*
|
|
350
|
+
* This replaced a paragraph of grouped names with a "+4 more" on the end. The
|
|
351
|
+
* question a reader brings is "what does MY agent get", and a sampled list
|
|
352
|
+
* cannot answer it; the detail pane scrolls (`^U/^D`, `PgUp/PgDn`), so the only
|
|
353
|
+
* real constraint is width, and `fitAgentTable` spends that in a fixed order
|
|
354
|
+
* rather than wrapping a cell.
|
|
355
|
+
*
|
|
356
|
+
* The model was a filled chip and the effort a filled four-cell meter. MEASURED
|
|
357
|
+
* on both a light and a dark terminal, seventeen such rows stacked into two
|
|
358
|
+
* solid vertical colour columns — two painted bars the height of the table,
|
|
359
|
+
* which read as slabs rather than as data. Coloured TEXT plus one ramp glyph
|
|
360
|
+
* says the same thing and leaves a table you can scan a column of.
|
|
361
|
+
*
|
|
362
|
+
* Colour stays on the model: the name and the glyph carry it, the agent name
|
|
363
|
+
* does not. A name is an identifier — colouring it made six agent names six
|
|
364
|
+
* different hues, with red (which means danger everywhere else here) landing on
|
|
365
|
+
* `dev:debugger` for no reason at all.
|
|
366
|
+
*/
|
|
367
|
+
function AgentTable({
|
|
368
|
+
config,
|
|
369
|
+
width,
|
|
370
|
+
}: {
|
|
371
|
+
config: ModelsConfig;
|
|
372
|
+
width: number;
|
|
373
|
+
}): React.ReactNode {
|
|
374
|
+
const rows = agentRows(config);
|
|
375
|
+
if (rows.length === 0) return null;
|
|
376
|
+
const layout = fitAgentTable(rows, width);
|
|
377
|
+
const headers = agentTableHeaders(layout);
|
|
378
|
+
|
|
379
|
+
return (
|
|
380
|
+
<box flexDirection="column">
|
|
381
|
+
<text fg={theme.colors.muted}>
|
|
382
|
+
{headers.map((cell, index) => (
|
|
383
|
+
<span key={cell.key} fg={theme.colors.muted}>
|
|
384
|
+
{index > 0 ? ` ${cell.text}` : cell.text}
|
|
385
|
+
</span>
|
|
386
|
+
))}
|
|
387
|
+
</text>
|
|
388
|
+
{rows.map((row) => (
|
|
389
|
+
<AgentTableRow key={row.name} row={row} layout={layout} />
|
|
390
|
+
))}
|
|
391
|
+
</box>
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function AgentTableRow({
|
|
396
|
+
row,
|
|
397
|
+
layout,
|
|
398
|
+
}: {
|
|
399
|
+
row: AgentRow;
|
|
400
|
+
layout: AgentTableLayout;
|
|
401
|
+
}): React.ReactNode {
|
|
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;
|
|
414
|
+
return (
|
|
415
|
+
<text fg={theme.colors.text}>
|
|
416
|
+
<span fg={row.inherit ? theme.colors.dim : theme.colors.text}>
|
|
417
|
+
{padCell(row.name, layout.name)}
|
|
418
|
+
</span>
|
|
419
|
+
<span fg={theme.colors.border}> </span>
|
|
420
|
+
{layout.grade > 0 ? (
|
|
421
|
+
<span fg={theme.colors.muted}>{padCell(row.key, layout.grade)}</span>
|
|
422
|
+
) : null}
|
|
423
|
+
{layout.grade > 0 ? <span fg={theme.colors.border}> </span> : null}
|
|
424
|
+
{row.inherit ? (
|
|
425
|
+
<span fg={theme.colors.dim}>{padCell(NONE_CELL, layout.model)}</span>
|
|
426
|
+
) : (
|
|
427
|
+
<ModelText model={row.model} label={modelText} />
|
|
428
|
+
)}
|
|
429
|
+
{row.inherit ? null : (
|
|
430
|
+
<span fg={theme.colors.border}>
|
|
431
|
+
{modelPad(modelText, layout.model)}
|
|
432
|
+
</span>
|
|
433
|
+
)}
|
|
434
|
+
<span fg={theme.colors.border}> </span>
|
|
435
|
+
{row.inherit ? (
|
|
436
|
+
<span fg={theme.colors.dim}>{NONE_CELL}</span>
|
|
437
|
+
) : (
|
|
438
|
+
<EffortGlyph effort={row.effort} />
|
|
439
|
+
)}
|
|
440
|
+
{/* The word takes the SAME ink as the dots beside it. Dim grey made the pair read
|
|
441
|
+
as two things — a coloured meter and an unrelated caption — when it is one
|
|
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. */}
|
|
449
|
+
{!row.inherit && label ? (
|
|
450
|
+
<span
|
|
451
|
+
fg={
|
|
452
|
+
live
|
|
453
|
+
? (effortInk(row.effort as string) as UiColor)
|
|
454
|
+
: theme.colors.muted
|
|
455
|
+
}
|
|
456
|
+
>{` ${label}`}</span>
|
|
457
|
+
) : null}
|
|
458
|
+
</text>
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
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.
|
|
504
|
+
*
|
|
505
|
+
* A category distribution, so a stacked bar rather than the list of counts it
|
|
506
|
+
* would otherwise be — and now the ONLY graphic on the screen, which is what
|
|
507
|
+
* lets it be saturated without competing with anything. It lives UNDER THE
|
|
508
|
+
* PRESET LIST, not in the detail pane, because a distribution is most useful
|
|
509
|
+
* exactly where you are choosing between presets. It follows the cursor, so
|
|
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.
|
|
534
|
+
*/
|
|
535
|
+
export function AgentSpread({
|
|
536
|
+
config,
|
|
537
|
+
width,
|
|
538
|
+
}: {
|
|
539
|
+
config: ModelsConfig;
|
|
540
|
+
width: number;
|
|
541
|
+
}): React.ReactNode {
|
|
542
|
+
// One pair of bars per WORKFLOW, not one bar for every agent that exists.
|
|
543
|
+
//
|
|
544
|
+
// Nobody runs all seventeen agents; they run `/dev:dev`, `/dev:debug` or
|
|
545
|
+
// `/dev:investigate`, and each reaches for a different handful. A single lump-sum bar
|
|
546
|
+
// answered a question nobody asks and hid the one they do: what does THIS command cost
|
|
547
|
+
// under THIS preset.
|
|
548
|
+
const label = SPREAD_LABEL_CELLS;
|
|
549
|
+
|
|
550
|
+
return (
|
|
551
|
+
<box flexDirection="column" marginTop={1} width={width}>
|
|
552
|
+
{/*
|
|
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".
|
|
560
|
+
*
|
|
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.
|
|
566
|
+
*/}
|
|
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
|
+
))}
|
|
613
|
+
</box>
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
// ─── Status renderer ──────────────────────────────────────────────────────────
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Deliberately NOT an `ItemRenderer`.
|
|
621
|
+
*
|
|
622
|
+
* Its detail needs the drift lines and warnings, which the item does not carry —
|
|
623
|
+
* they belong to the project rather than to a row, and copying them onto the
|
|
624
|
+
* item is how the two would end up disagreeing. So the row reads the item and
|
|
625
|
+
* the detail reads the status, and neither pretends to be the other.
|
|
626
|
+
*/
|
|
627
|
+
function renderStatusRow(
|
|
628
|
+
item: ModelsStatusItem,
|
|
629
|
+
isSelected: boolean,
|
|
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.
|
|
640
|
+
const parts = [
|
|
641
|
+
item.warningCount > 0
|
|
642
|
+
? `${item.warningCount} warning${item.warningCount === 1 ? "" : "s"}`
|
|
643
|
+
: null,
|
|
644
|
+
].filter((part): part is string => part !== null);
|
|
645
|
+
|
|
646
|
+
return (
|
|
647
|
+
<ListCategoryRow
|
|
648
|
+
selected={isSelected}
|
|
649
|
+
title={`Model tiers: ${item.state}`}
|
|
650
|
+
badge={parts.length > 0 ? parts.join(" · ") : undefined}
|
|
651
|
+
tone={STATE_CATEGORY[item.state]}
|
|
652
|
+
// A section label, not a collapsible group — the cursor skips it, so a
|
|
653
|
+
// ▶ would advertise an interaction that is not there.
|
|
654
|
+
expandable={false}
|
|
655
|
+
/>
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function renderStatusDetail(
|
|
660
|
+
status: ModelsStatus | null,
|
|
661
|
+
width?: number,
|
|
662
|
+
): React.ReactNode {
|
|
663
|
+
return (
|
|
664
|
+
<box flexDirection="column">
|
|
665
|
+
<text fg={theme.colors.accent}>
|
|
666
|
+
<strong>Model tiers</strong>
|
|
667
|
+
</text>
|
|
668
|
+
<box marginTop={1}>
|
|
669
|
+
<StateBlock status={status} width={width} />
|
|
670
|
+
</box>
|
|
671
|
+
<box marginTop={1}>
|
|
672
|
+
<Wrapped
|
|
673
|
+
width={width}
|
|
674
|
+
text="Tiers say how much model you are spending: smart is the strongest, and deliberately not the orchestrator's. Move down to a preset to see what runs where."
|
|
675
|
+
/>
|
|
676
|
+
</box>
|
|
677
|
+
</box>
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// ─── Preset rows: ONE layout for the whole list ───────────────────────────────
|
|
682
|
+
|
|
683
|
+
/** A preset row reduced to what the column maths needs. */
|
|
684
|
+
function presetRowInput(item: ModelsPresetItem): PresetRowInput {
|
|
685
|
+
return {
|
|
686
|
+
label: item.label,
|
|
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.
|
|
690
|
+
smart: item.config.grades.smart.model,
|
|
691
|
+
smartLabel: modelLabel(item.config, item.config.grades.smart.model),
|
|
692
|
+
markers: presetMarkerWidth(item.isDefault),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* The list's shared column layout, measured ONCE from every row.
|
|
698
|
+
*
|
|
699
|
+
* The screen computes this and hands the same object to every row. Rows used to
|
|
700
|
+
* fit themselves individually, and on a 120×30 terminal that meant the selected
|
|
701
|
+
* row dropped its `main` / `critical` labels while its three neighbours kept
|
|
702
|
+
* theirs — the same field labelled on some rows and not others, with nothing
|
|
703
|
+
* lining up down the column.
|
|
704
|
+
*/
|
|
705
|
+
export function fitPresetList(
|
|
706
|
+
items: ModelsBrowserItem[],
|
|
707
|
+
width: number,
|
|
708
|
+
): PresetRowLayout {
|
|
709
|
+
const rows = items
|
|
710
|
+
.filter((item): item is ModelsPresetItem => item.kind === "preset")
|
|
711
|
+
.map(presetRowInput);
|
|
712
|
+
return fitPresetRows(rows, width);
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* The two models a row summarises, as spans for ONE `<text>`.
|
|
717
|
+
*
|
|
718
|
+
* Coloured text rather than two filled chips: a chip is a painted block, and
|
|
719
|
+
* two per row down a four-row list is two painted columns. The word already
|
|
720
|
+
* names the model; the hue only has to tie it to the bar below.
|
|
721
|
+
*/
|
|
722
|
+
function summarySpans(
|
|
723
|
+
config: ModelsConfig,
|
|
724
|
+
layout: PresetRowLayout,
|
|
725
|
+
selected: boolean,
|
|
726
|
+
): React.ReactNode[] {
|
|
727
|
+
if (layout.detail === "none") return [];
|
|
728
|
+
const label = selected ? theme.selection.dim : theme.colors.muted;
|
|
729
|
+
const rule = selected ? theme.selection.dim : theme.colors.border;
|
|
730
|
+
const main = config.main.model;
|
|
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);
|
|
736
|
+
|
|
737
|
+
const parts: React.ReactNode[] = [
|
|
738
|
+
<span key="gap" fg={rule}>
|
|
739
|
+
{" "}
|
|
740
|
+
</span>,
|
|
741
|
+
];
|
|
742
|
+
if (layout.detail === "labelled") {
|
|
743
|
+
parts.push(
|
|
744
|
+
<span key="main-label" fg={label}>
|
|
745
|
+
{"main "}
|
|
746
|
+
</span>,
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
parts.push(<ModelText key="main" model={main} selected={selected} />);
|
|
750
|
+
parts.push(
|
|
751
|
+
<span key="main-pad" fg={rule}>
|
|
752
|
+
{modelPad(main, layout.main)}
|
|
753
|
+
</span>,
|
|
754
|
+
);
|
|
755
|
+
// The narrowest rung that still says anything: one model, no separator to
|
|
756
|
+
// dangle after it.
|
|
757
|
+
if (layout.detail === "main") return parts;
|
|
758
|
+
parts.push(
|
|
759
|
+
<span key="sep" fg={rule}>
|
|
760
|
+
{" · "}
|
|
761
|
+
</span>,
|
|
762
|
+
);
|
|
763
|
+
if (layout.detail === "labelled" || layout.detail === "smart") {
|
|
764
|
+
parts.push(
|
|
765
|
+
<span key="critical-label" fg={label}>
|
|
766
|
+
{"smart "}
|
|
767
|
+
</span>,
|
|
768
|
+
);
|
|
769
|
+
}
|
|
770
|
+
parts.push(
|
|
771
|
+
<ModelText
|
|
772
|
+
key="smart"
|
|
773
|
+
model={smart}
|
|
774
|
+
label={smartText}
|
|
775
|
+
selected={selected}
|
|
776
|
+
/>,
|
|
777
|
+
);
|
|
778
|
+
parts.push(
|
|
779
|
+
<span key="smart-pad" fg={rule}>
|
|
780
|
+
{modelPad(smartText, layout.smart)}
|
|
781
|
+
</span>,
|
|
782
|
+
);
|
|
783
|
+
return parts;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ─── Preset renderer ──────────────────────────────────────────────────────────
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* One preset row, drawn against the layout the whole list shares.
|
|
790
|
+
*
|
|
791
|
+
* Deliberately NOT an `ItemRenderer.renderRow`: that signature takes a width
|
|
792
|
+
* and nothing else, so a renderer wearing it can only fit itself — which is
|
|
793
|
+
* precisely the defect this row was reported for. The layout is an argument.
|
|
794
|
+
*/
|
|
795
|
+
function renderPresetRow(
|
|
796
|
+
item: ModelsPresetItem,
|
|
797
|
+
isSelected: boolean,
|
|
798
|
+
layout: PresetRowLayout,
|
|
799
|
+
): React.ReactNode {
|
|
800
|
+
// A NAME and its markers, nothing else.
|
|
801
|
+
//
|
|
802
|
+
// The row used to carry `[●]` plus `opus · smart fable` — a checkbox and a two-model
|
|
803
|
+
// summary of what the detail pane is already showing in full, three lines to the right.
|
|
804
|
+
// Repeating it made the list the same width as the detail and twice as busy, and the
|
|
805
|
+
// checkbox competed with the cursor for "which one is this". The cursor marks where you
|
|
806
|
+
// are; `●` marks what is running; the detail says what it does.
|
|
807
|
+
return (
|
|
808
|
+
<SelectableRow selected={isSelected} indent={1}>
|
|
809
|
+
<span
|
|
810
|
+
fg={isSelected ? theme.selection.fg : theme.colors.success}
|
|
811
|
+
>{`${item.active ? "●" : " "} `}</span>
|
|
812
|
+
<span fg={isSelected ? theme.selection.fg : theme.colors.text}>
|
|
813
|
+
{padCell(item.label, layout.name)}
|
|
814
|
+
</span>
|
|
815
|
+
{item.isDefault ? (
|
|
816
|
+
<MetaText text={PRESET_MARKERS.default} selected={isSelected} />
|
|
817
|
+
) : null}
|
|
818
|
+
</SelectableRow>
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
function renderPresetDetail(
|
|
823
|
+
item: ModelsPresetItem,
|
|
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[],
|
|
829
|
+
): React.ReactNode {
|
|
830
|
+
const { config } = item;
|
|
831
|
+
const modelColumn = specModelColumn(config, matesAvailable);
|
|
832
|
+
const barWidth = Math.max(8, width ?? 48);
|
|
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
|
+
|
|
848
|
+
return (
|
|
849
|
+
<box flexDirection="column">
|
|
850
|
+
<text fg={theme.colors.accent}>
|
|
851
|
+
<strong>{item.label}</strong>
|
|
852
|
+
</text>
|
|
853
|
+
|
|
854
|
+
{/*
|
|
855
|
+
* `Active` / `Default` / `Source` used to be three key-value lines here, and they
|
|
856
|
+
* are gone. Every one of them is already answered a few characters to the left:
|
|
857
|
+
* the active preset carries `●` in the list, the default carries `(default)`, and
|
|
858
|
+
* a hand-edited config carries its own marker. Restating them as sentences pushed
|
|
859
|
+
* the tier table — the thing you opened this pane to read — below the fold.
|
|
860
|
+
*
|
|
861
|
+
* `item.custom` keeps its warning, because THAT one is not on the row: it changes
|
|
862
|
+
* what applying does.
|
|
863
|
+
*/}
|
|
864
|
+
{item.custom ? (
|
|
865
|
+
<box marginTop={1}>
|
|
866
|
+
<Wrapped
|
|
867
|
+
width={width}
|
|
868
|
+
tone={theme.colors.warning}
|
|
869
|
+
text="This project's own config, not a built-in — applying re-writes the settings it implies."
|
|
870
|
+
/>
|
|
871
|
+
</box>
|
|
872
|
+
) : null}
|
|
873
|
+
|
|
874
|
+
<DetailSection title="Models">
|
|
875
|
+
<box flexDirection="column">
|
|
876
|
+
<text fg={theme.colors.muted}>
|
|
877
|
+
{`${padCell("tier", SPEC_LABEL_WIDTH)}${padCell("model", modelColumn + 4)}effort`}
|
|
878
|
+
</text>
|
|
879
|
+
<SpecRow
|
|
880
|
+
label="main"
|
|
881
|
+
model={config.main.model}
|
|
882
|
+
effort={config.main.effort}
|
|
883
|
+
modelColumn={modelColumn}
|
|
884
|
+
highlight
|
|
885
|
+
/>
|
|
886
|
+
{GRADES.map((grade) => (
|
|
887
|
+
<SpecRow
|
|
888
|
+
key={grade}
|
|
889
|
+
label={grade}
|
|
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)}
|
|
893
|
+
effort={config.grades[grade].effort}
|
|
894
|
+
modelColumn={modelColumn}
|
|
895
|
+
highlight={grade === config.fallback}
|
|
896
|
+
/>
|
|
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}
|
|
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 — magus 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}
|
|
961
|
+
</DetailSection>
|
|
962
|
+
|
|
963
|
+
<DetailSection title="Agents">
|
|
964
|
+
<box flexDirection="column">
|
|
965
|
+
<AgentTable config={config} width={barWidth} />
|
|
966
|
+
<box marginTop={1}>
|
|
967
|
+
<Wrapped
|
|
968
|
+
width={width}
|
|
969
|
+
text={`Anything unlisted runs at ${config.fallback}, including agents magus has never seen.`}
|
|
970
|
+
/>
|
|
971
|
+
</box>
|
|
972
|
+
</box>
|
|
973
|
+
</DetailSection>
|
|
974
|
+
</box>
|
|
975
|
+
);
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// ─── The list panel's own footer ──────────────────────────────────────────────
|
|
979
|
+
|
|
980
|
+
/**
|
|
981
|
+
* A path shortened from the LEFT, keeping the tail.
|
|
982
|
+
*
|
|
983
|
+
* `.claude/models.json` is the part that identifies the file; the machine's home
|
|
984
|
+
* directory in front of it is the part nobody reads. Truncating from the right
|
|
985
|
+
* would keep exactly the wrong half.
|
|
986
|
+
*/
|
|
987
|
+
export function shortConfigPath(
|
|
988
|
+
configPath: string,
|
|
989
|
+
projectPath: string,
|
|
990
|
+
max: number,
|
|
991
|
+
): string {
|
|
992
|
+
const base = projectPath.endsWith("/") ? projectPath : `${projectPath}/`;
|
|
993
|
+
const relative = configPath.startsWith(base)
|
|
994
|
+
? configPath.slice(base.length)
|
|
995
|
+
: configPath;
|
|
996
|
+
if (relative.length <= max) return relative;
|
|
997
|
+
return `…${relative.slice(relative.length - Math.max(1, max - 1))}`;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* The key to every colour on this screen: one model name, in its own hue.
|
|
1002
|
+
*
|
|
1003
|
+
* The bar is coloured by MODEL and labelled by GRADE, so without this row the
|
|
1004
|
+
* hues have no key — a reader can see that two runs differ and not what they
|
|
1005
|
+
* differ into. Names are dropped from the right when the panel is too narrow,
|
|
1006
|
+
* because a key that overflows its panel is worse than a shorter key.
|
|
1007
|
+
*
|
|
1008
|
+
* These were filled chips. A chip here would be the only chip left on the
|
|
1009
|
+
* screen and would teach a vocabulary nothing else uses — every model
|
|
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.
|
|
1035
|
+
*/
|
|
1036
|
+
function ModelKey({
|
|
1037
|
+
config,
|
|
1038
|
+
width,
|
|
1039
|
+
}: {
|
|
1040
|
+
config: ModelsConfig;
|
|
1041
|
+
width: number;
|
|
1042
|
+
}): React.ReactNode {
|
|
1043
|
+
const label = "models ";
|
|
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;
|
|
1058
|
+
|
|
1059
|
+
const parts: React.ReactNode[] = [
|
|
1060
|
+
<span key="label" fg={theme.colors.muted}>
|
|
1061
|
+
{label}
|
|
1062
|
+
</span>,
|
|
1063
|
+
];
|
|
1064
|
+
chips.forEach((chip, index) => {
|
|
1065
|
+
// No `·` between chips: a fill already separates them, and a separator
|
|
1066
|
+
// between two blocks reads as a third thing.
|
|
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
|
+
);
|
|
1071
|
+
});
|
|
1072
|
+
return <text fg={theme.colors.text}>{parts}</text>;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
/** Which blocks of the list-panel summary fit, and how many rows they cost. */
|
|
1076
|
+
export interface SummaryPlan {
|
|
1077
|
+
spread: boolean;
|
|
1078
|
+
key: boolean;
|
|
1079
|
+
facts: boolean;
|
|
1080
|
+
rows: number;
|
|
1081
|
+
}
|
|
1082
|
+
|
|
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;
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* Decide what the panel has room for, most valuable first.
|
|
1111
|
+
*
|
|
1112
|
+
* NOT cosmetic trimming. MEASURED at 80×24 before this existed: the blocks
|
|
1113
|
+
* asked for about 17 rows in the 12 the list had left, and Yoga answered by
|
|
1114
|
+
* SHRINKING them — two preset names composited into one, `off` printed on top
|
|
1115
|
+
* of `—`, and two blocks vanished. An over-tall panel does not scroll and does
|
|
1116
|
+
* not clip, it overprints.
|
|
1117
|
+
*
|
|
1118
|
+
* Drop order is by value: the distribution and the project's own facts are what
|
|
1119
|
+
* a short terminal keeps, the colour key is what a tall one adds.
|
|
1120
|
+
*
|
|
1121
|
+
* There used to be a fourth block here — one bar per preset, stacked, so the
|
|
1122
|
+
* presets could be compared. MEASURED on a dark 120×30 terminal it rendered as
|
|
1123
|
+
* a purple bar, then ONE teal rectangle spanning the two presets that share a
|
|
1124
|
+
* colour, then grey: three blocks where four comparisons were meant, because
|
|
1125
|
+
* adjacent same-coloured bars had nothing between them. It was noise, and the
|
|
1126
|
+
* rows it cost are rows the list now keeps.
|
|
1127
|
+
*/
|
|
1128
|
+
export function planModelsSummary(
|
|
1129
|
+
available: number,
|
|
1130
|
+
hasConfigPath: boolean,
|
|
1131
|
+
): SummaryPlan {
|
|
1132
|
+
const plan: SummaryPlan = {
|
|
1133
|
+
spread: false,
|
|
1134
|
+
key: false,
|
|
1135
|
+
facts: false,
|
|
1136
|
+
rows: 0,
|
|
1137
|
+
};
|
|
1138
|
+
// The leading blank line plus the rule: the block cannot exist for less.
|
|
1139
|
+
const base = 1 + SUMMARY_COST.rule;
|
|
1140
|
+
if (available < base) return plan;
|
|
1141
|
+
plan.rows = base;
|
|
1142
|
+
|
|
1143
|
+
const factsCost = SUMMARY_COST.facts + (hasConfigPath ? 1 : 0);
|
|
1144
|
+
|
|
1145
|
+
// A strict cascade, not a greedy fill: once something does not fit, nothing
|
|
1146
|
+
// below it is considered either. Otherwise a cheap block slips in past an
|
|
1147
|
+
// expensive one and the panel shows the colour key for a bar it did not draw.
|
|
1148
|
+
let stopped = false;
|
|
1149
|
+
const take = (cost: number, enabled: boolean) => {
|
|
1150
|
+
if (stopped) return false;
|
|
1151
|
+
if (!enabled || cost === 0) return false;
|
|
1152
|
+
if (plan.rows + cost > available) {
|
|
1153
|
+
stopped = true;
|
|
1154
|
+
return false;
|
|
1155
|
+
}
|
|
1156
|
+
plan.rows += cost;
|
|
1157
|
+
return true;
|
|
1158
|
+
};
|
|
1159
|
+
|
|
1160
|
+
plan.spread = take(SUMMARY_COST.spread, true);
|
|
1161
|
+
plan.facts = take(factsCost, true);
|
|
1162
|
+
plan.key = take(SUMMARY_COST.key, true);
|
|
1163
|
+
return plan;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* What sits under the preset list: the selected preset's spread, the colour key,
|
|
1168
|
+
* then the project's routing facts.
|
|
1169
|
+
*
|
|
1170
|
+
* The panel held four rows of text and roughly thirty-five unpainted ones at
|
|
1171
|
+
* 145×45, and the screen's one graphic was buried at the bottom of a detail
|
|
1172
|
+
* pane nobody had scrolled to. This is that widget, moved to where the choice is
|
|
1173
|
+
* being made.
|
|
1174
|
+
*
|
|
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.
|
|
1181
|
+
*/
|
|
1182
|
+
export function renderModelsSummary({
|
|
1183
|
+
selected,
|
|
1184
|
+
status,
|
|
1185
|
+
configPath,
|
|
1186
|
+
width,
|
|
1187
|
+
available,
|
|
1188
|
+
}: {
|
|
1189
|
+
selected: ModelsBrowserItem | undefined;
|
|
1190
|
+
status: ModelsStatus | null;
|
|
1191
|
+
configPath: string | null;
|
|
1192
|
+
width: number;
|
|
1193
|
+
/** Rows left under the list. Blocks are dropped until the block fits. */
|
|
1194
|
+
available: number;
|
|
1195
|
+
}): React.ReactNode {
|
|
1196
|
+
if (!status) return null;
|
|
1197
|
+
const preset = selected?.kind === "preset" ? selected : undefined;
|
|
1198
|
+
const plan = planModelsSummary(available, Boolean(configPath));
|
|
1199
|
+
if (plan.rows === 0) return null;
|
|
1200
|
+
|
|
1201
|
+
// One column of indent, so this block's left edge lines up with the rows
|
|
1202
|
+
// above it — every list row carries `indent={1}`.
|
|
1203
|
+
const inner = Math.max(8, width - 1);
|
|
1204
|
+
|
|
1205
|
+
return (
|
|
1206
|
+
<box flexDirection="column" marginTop={1} paddingLeft={1} flexShrink={0}>
|
|
1207
|
+
<text fg={theme.colors.border}>{"─".repeat(inner)}</text>
|
|
1208
|
+
{plan.spread && preset ? (
|
|
1209
|
+
<AgentSpread config={preset.config} width={inner} />
|
|
1210
|
+
) : null}
|
|
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 ? (
|
|
1214
|
+
<box marginTop={1}>
|
|
1215
|
+
<ModelKey config={preset.config} width={inner} />
|
|
1216
|
+
</box>
|
|
1217
|
+
) : null}
|
|
1218
|
+
{/*
|
|
1219
|
+
* The `state / preset / config` block that used to sit here is GONE.
|
|
1220
|
+
*
|
|
1221
|
+
* All three were already on screen: the state is a chip in the panel header AND in
|
|
1222
|
+
* the title bar, the preset is the row the cursor is on, and the path is a detail
|
|
1223
|
+
* nobody acts on from a list. Restating them under the chart made the column read
|
|
1224
|
+
* as a form to fill in rather than a summary of what is running.
|
|
1225
|
+
*
|
|
1226
|
+
* `plan.facts` still exists in the layout plan: it is the rung that decides whether
|
|
1227
|
+
* the chart and the key fit at all, and deleting it would change the fitting maths
|
|
1228
|
+
* rather than only what is drawn.
|
|
1229
|
+
*/}
|
|
1230
|
+
</box>
|
|
1231
|
+
);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// ─── Dispatch ─────────────────────────────────────────────────────────────────
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* A row, drawn against the layout the WHOLE list shares.
|
|
1238
|
+
*
|
|
1239
|
+
* `layout` is not optional and is not derived here on purpose: deriving it
|
|
1240
|
+
* per-row is exactly how four presets ended up in two different formats.
|
|
1241
|
+
*/
|
|
1242
|
+
export function renderModelRow(
|
|
1243
|
+
item: ModelsBrowserItem,
|
|
1244
|
+
_index: number,
|
|
1245
|
+
isSelected: boolean,
|
|
1246
|
+
layout: PresetRowLayout,
|
|
1247
|
+
): React.ReactNode {
|
|
1248
|
+
if (item.kind === "status") {
|
|
1249
|
+
return renderStatusRow(item, isSelected);
|
|
1250
|
+
}
|
|
1251
|
+
return renderPresetRow(item, isSelected, layout);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
/**
|
|
1255
|
+
* The detail pane.
|
|
1256
|
+
*
|
|
1257
|
+
* `status` is passed alongside the item rather than copied onto it: the drift
|
|
1258
|
+
* lines belong to the project, not to whichever preset the cursor is on, and
|
|
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.
|
|
1272
|
+
*/
|
|
1273
|
+
export function renderModelDetail(
|
|
1274
|
+
item: ModelsBrowserItem | undefined,
|
|
1275
|
+
status: ModelsStatus | null,
|
|
1276
|
+
width?: number,
|
|
1277
|
+
matesAvailable = false,
|
|
1278
|
+
catalog: CatalogModel[] = [],
|
|
1279
|
+
): React.ReactNode {
|
|
1280
|
+
if (!item) {
|
|
1281
|
+
return (
|
|
1282
|
+
<box flexDirection="column">
|
|
1283
|
+
<text fg={theme.colors.muted}>No preset selected.</text>
|
|
1284
|
+
</box>
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
if (item.kind === "status") {
|
|
1288
|
+
return renderStatusDetail(status, width);
|
|
1289
|
+
}
|
|
1290
|
+
// The state block only when there is something to DO about it.
|
|
1291
|
+
//
|
|
1292
|
+
// `on` and `off` are already stated twice — in the title bar and on the list's own
|
|
1293
|
+
// header row — so repeating "on / every Agent call is routed through this config"
|
|
1294
|
+
// above every preset spent four rows restating the obvious and pushed the tier table
|
|
1295
|
+
// down. `stale`, `invalid` and `unhooked` stay, because each one carries lines the
|
|
1296
|
+
// reader has seen nowhere else and a fix to press.
|
|
1297
|
+
const needsAttention =
|
|
1298
|
+
status !== null && status.state !== "on" && status.state !== "off";
|
|
1299
|
+
return (
|
|
1300
|
+
<box flexDirection="column">
|
|
1301
|
+
{needsAttention ? (
|
|
1302
|
+
<box marginBottom={1}>
|
|
1303
|
+
<StateBlock status={status} width={width} />
|
|
1304
|
+
</box>
|
|
1305
|
+
) : null}
|
|
1306
|
+
{renderPresetDetail(item, width, matesAvailable, catalog)}
|
|
1307
|
+
</box>
|
|
1308
|
+
);
|
|
1309
|
+
}
|