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,1402 @@
|
|
|
1
|
+
import type { BoxRenderable } from "@opentui/core";
|
|
2
|
+
import type React from "react";
|
|
3
|
+
import { Fragment, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
type AgentModel,
|
|
6
|
+
EFFORTS,
|
|
7
|
+
type Effort,
|
|
8
|
+
GRADES,
|
|
9
|
+
MATES,
|
|
10
|
+
MODEL_ALIASES,
|
|
11
|
+
type ModelsConfig,
|
|
12
|
+
baseAlias,
|
|
13
|
+
isMate,
|
|
14
|
+
mateEffort,
|
|
15
|
+
modelLabel,
|
|
16
|
+
} from "../../services/models-core.js";
|
|
17
|
+
import { effortInk, modelBadge, trackFill } from "../theme-mode.js";
|
|
18
|
+
import { type UiColor, theme } from "../theme.js";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The Models screen's visual vocabulary: which colour a model owns, how effort
|
|
22
|
+
* is drawn, and how the agent table becomes one bar.
|
|
23
|
+
*
|
|
24
|
+
* Everything here is shared by the list rows and the detail pane on purpose.
|
|
25
|
+
* The two used to describe the same routing in two unrelated shapes — a row
|
|
26
|
+
* said "opus lead", the detail said "main opus (medium)" — and nothing stopped
|
|
27
|
+
* them drifting apart. One module, one mapping, one set of glyphs.
|
|
28
|
+
*
|
|
29
|
+
* ## Where a fill is allowed, and where it is not
|
|
30
|
+
*
|
|
31
|
+
* MEASURED on the real TUI: when every model was a chip AND every effort a filled meter,
|
|
32
|
+
* seventeen agent rows stacked those into two solid vertical colour columns. A screen where
|
|
33
|
+
* everything is saturated has no signal in it — the eye cannot pick a row out of a slab.
|
|
34
|
+
*
|
|
35
|
+
* So a fill is spent where there are FEW of them and it reads as a swatch: the four-row tier
|
|
36
|
+
* table, the models key, the distribution bar, the selected row, and a problem that needs
|
|
37
|
+
* action. The agent table gets none — a model is its own ink there (`ModelText`), and effort
|
|
38
|
+
* is four dots of text.
|
|
39
|
+
*
|
|
40
|
+
* An earlier pass concluded the opposite, that colour could not carry "which model" at all,
|
|
41
|
+
* because the `theme.category` tones it was drawing from measure within 0.02 of each other:
|
|
42
|
+
* `fable` 5.04:1 on cream / 3.16 on dark, `opus` 5.02 / 3.18, with `haiku` and `inherit` the
|
|
43
|
+
* identical hex. That was true of THOSE colours. It is not a law about colour — the palette
|
|
44
|
+
* now lives in `theme-mode.ts` as `MODEL_BADGE`, is chosen per page, and separates the four
|
|
45
|
+
* models by hue and by fill rather than by luminance alone.
|
|
46
|
+
*
|
|
47
|
+
* ## Two colour channels, and why they do not collide
|
|
48
|
+
*
|
|
49
|
+
* Model identity is one channel; effort is the other. They stay legible together because
|
|
50
|
+
* they differ in KIND, not only in hue: a model is a fill or a name's ink, effort is always
|
|
51
|
+
* four small glyphs of text, and effort's ramp skips green and red, which already mean "on"
|
|
52
|
+
* and "failed" on this screen.
|
|
53
|
+
*
|
|
54
|
+
* Nothing in this file holds a colour literal. Every value comes from `theme.ts` or
|
|
55
|
+
* `theme-mode.ts`, so the palette is a theme decision and this file is a consumer of one.
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
export type CategoryTone = keyof typeof theme.category;
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The ink a model's NAME is written in.
|
|
62
|
+
*
|
|
63
|
+
* Comes from the theme (`MODEL_BADGE` in `theme-mode.ts`), never from a hex literal here:
|
|
64
|
+
* colour is a theme decision, a renderer is a consumer of one, and the same palette has to
|
|
65
|
+
* serve this screen, its badges and its chart without three copies drifting apart.
|
|
66
|
+
*
|
|
67
|
+
* `inherit` is not a model but the absence of a routing decision, and the palette gives it
|
|
68
|
+
* grey on both pages so it recedes instead of reading as another choice.
|
|
69
|
+
*
|
|
70
|
+
* A mate is not one of Claude Code's models either, but it IS a decision — the author sent
|
|
71
|
+
* that agent to claudish on purpose — so it takes an ink of its own from the warm family
|
|
72
|
+
* rather than the grey that means "nobody chose".
|
|
73
|
+
*/
|
|
74
|
+
export function modelFg(model: string): UiColor {
|
|
75
|
+
return modelBadge(baseAlias(model)).fg as UiColor;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The distribution bar's fill for a model — a softer step than the badge ink. */
|
|
79
|
+
export function modelBarFill(model: string): UiColor {
|
|
80
|
+
return modelBadge(baseAlias(model)).bar as UiColor;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Columns a model name occupies as plain text. Its own length.
|
|
85
|
+
*
|
|
86
|
+
* Kept as a function rather than inlining `.length` at four call sites: the column maths in
|
|
87
|
+
* `fitAgentTable`, `fitPresetRows` and `specModelColumn` all have to agree with what
|
|
88
|
+
* `ModelText` actually draws, and one of them disagreeing is a column that drifts by a cell
|
|
89
|
+
* as the data changes.
|
|
90
|
+
*
|
|
91
|
+
* Takes the string that is DRAWN, which for a mate is its bound id and not the slot. Pass it
|
|
92
|
+
* `modelLabel(config, model)`, never the routing value — measuring `mate1` (5) while drawing
|
|
93
|
+
* `gemini-omni-1.1-flash` (21) is a sixteen-cell overrun, not a one-cell drift.
|
|
94
|
+
*/
|
|
95
|
+
export function modelWidth(model: string): number {
|
|
96
|
+
return model.length;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ─── Identity vs label: two things a "model" means on this screen ─────────────
|
|
100
|
+
//
|
|
101
|
+
// A slot is a ROLE and the id it is bound to is a MODEL, and the screen needs both at once:
|
|
102
|
+
// the ink has to stay with the role, the text has to name the model.
|
|
103
|
+
//
|
|
104
|
+
// COLOUR FOLLOWS THE SLOT, deliberately. `mate1` is the stable identity — it is what the
|
|
105
|
+
// agent table's Tier column says, what the workflow bar's legend names, and what does not
|
|
106
|
+
// change when someone rebinds the slot to a different model next week. Giving each external
|
|
107
|
+
// model its own hue would mean the bar's colours moved every time a binding changed, and
|
|
108
|
+
// there is no palette that can hold a catalogue of hundreds anyway.
|
|
109
|
+
//
|
|
110
|
+
// So every drawing function takes `model` (the routing value — decides the ink) and an
|
|
111
|
+
// optional `label` (what is written). They are the same string everywhere except a mate,
|
|
112
|
+
// which is precisely why they were one parameter until a slot could be bound.
|
|
113
|
+
|
|
114
|
+
/** The text for a routing value, and the identity that colours it. */
|
|
115
|
+
export interface ModelCell {
|
|
116
|
+
/** The routing value: an alias, `inherit`, or a slot. Decides ink. */
|
|
117
|
+
model: string;
|
|
118
|
+
/** What is drawn: a bound id for a mate, otherwise the same as `model`. */
|
|
119
|
+
label: string;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Resolve one routing value into the pair the renderers need. */
|
|
123
|
+
export function modelCell(config: ModelsConfig, model: string): ModelCell {
|
|
124
|
+
return { model, label: modelLabel(config, model) };
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Columns a model BADGE occupies: the name plus one space of padding each side. */
|
|
128
|
+
export function modelBadgeWidth(model: string): number {
|
|
129
|
+
return model.length + 2;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* A model name, in the model's own ink.
|
|
134
|
+
*
|
|
135
|
+
* Returns a `<span>`, so it MUST be placed inside a `<text>`. A `<span>` with no `<text>`
|
|
136
|
+
* parent replaces the whole UI with an error page while the process carries on exiting 0,
|
|
137
|
+
* which no test and no typecheck notices.
|
|
138
|
+
*
|
|
139
|
+
* `selected` surrenders the hue: on the selection fill every page-level tone measures under
|
|
140
|
+
* 1.2:1 (see `theme.ts`), so a selected row has exactly two inks and neither is this one.
|
|
141
|
+
* The WORD still says which model it is, which is why losing the tone there costs nothing.
|
|
142
|
+
*/
|
|
143
|
+
export function ModelText({
|
|
144
|
+
model,
|
|
145
|
+
label,
|
|
146
|
+
selected = false,
|
|
147
|
+
}: {
|
|
148
|
+
/** The routing value — decides the INK. For a mate this is the slot. */
|
|
149
|
+
model: string;
|
|
150
|
+
/** What to write. Defaults to `model`; a mate passes its bound id. */
|
|
151
|
+
label?: string;
|
|
152
|
+
selected?: boolean;
|
|
153
|
+
}): React.ReactNode {
|
|
154
|
+
return (
|
|
155
|
+
<span fg={selected ? theme.selection.fg : modelFg(model)}>
|
|
156
|
+
{label ?? model}
|
|
157
|
+
</span>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* A model as a filled CHIP — the one place a fill earns its place on this screen.
|
|
163
|
+
*
|
|
164
|
+
* Used in the tier table and the models key, where there are four of them and a chip reads
|
|
165
|
+
* as a swatch. Deliberately NOT used in the agent table: seventeen stacked chips is the
|
|
166
|
+
* solid colour column this redesign exists to remove, so an agent row gets `ModelText`.
|
|
167
|
+
*
|
|
168
|
+
* On a selected row it falls back to text, for the contrast reason in `ModelText`.
|
|
169
|
+
*/
|
|
170
|
+
export function ModelBadge({
|
|
171
|
+
model,
|
|
172
|
+
label,
|
|
173
|
+
selected = false,
|
|
174
|
+
}: {
|
|
175
|
+
/** The routing value — decides the INK. For a mate this is the slot. */
|
|
176
|
+
model: string;
|
|
177
|
+
/** What to write. Defaults to `model`; a mate passes its bound id. */
|
|
178
|
+
label?: string;
|
|
179
|
+
selected?: boolean;
|
|
180
|
+
}): React.ReactNode {
|
|
181
|
+
const text = label ?? model;
|
|
182
|
+
if (selected) return <ModelText model={model} label={text} selected />;
|
|
183
|
+
const ink = modelBadge(baseAlias(model));
|
|
184
|
+
return <span bg={ink.bg} fg={ink.fg}>{` ${text} `}</span>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Pad a model COLUMN — emitted after the name, so columns line up. */
|
|
188
|
+
export function modelPad(model: string, column: number): string {
|
|
189
|
+
return " ".repeat(Math.max(0, column - modelWidth(model)));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Pad a BADGE column, which is two cells wider than the name. */
|
|
193
|
+
export function modelBadgePad(model: string, column: number): string {
|
|
194
|
+
return " ".repeat(Math.max(0, column - modelBadgeWidth(model)));
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Effort: a bounded ordinal, so a ramp glyph ───────────────────────────────
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Four dots, filled to the level: `••··` is two of four.
|
|
201
|
+
*
|
|
202
|
+
* Three shapes were built and shown before this one. A four-cell `████`/`░░░░` meter read as
|
|
203
|
+
* a second painted block beside the model's chip, and seventeen rows of that is the solid
|
|
204
|
+
* column this screen was reported for. A single height-coded glyph (`▁▃▅▇`) was quiet enough
|
|
205
|
+
* but did not read AS an indicator at all — "i dont see what is the new inidato". A fraction
|
|
206
|
+
* (`2/4 medium`) read as arithmetic where a picture belonged.
|
|
207
|
+
*
|
|
208
|
+
* Dots work because the ON dot and the OFF dot differ in WEIGHT, not in area: `•` and `·`
|
|
209
|
+
* are plain punctuation present in every monospace font, so the row reads as a fill without
|
|
210
|
+
* painting a block.
|
|
211
|
+
*
|
|
212
|
+
* The unlit dots are DRAWN, not omitted. Dots that simply stop read as a count ("two"); dots
|
|
213
|
+
* on a track read as a level ("two of four"), and the level is the point.
|
|
214
|
+
*/
|
|
215
|
+
export const EFFORT_ON = "•";
|
|
216
|
+
export const EFFORT_OFF = "·";
|
|
217
|
+
|
|
218
|
+
/** Columns the indicator occupies — one per level, always four. */
|
|
219
|
+
export const EFFORT_GLYPH_CELLS = 4;
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Effort not set: a dash, deliberately NOT zero filled dots.
|
|
223
|
+
*
|
|
224
|
+
* `low` and `absent` are different answers — one is a decision, the other is "inherits
|
|
225
|
+
* whatever the session is on" — so they may not share a shape. An empty track would read as
|
|
226
|
+
* the bottom of the same scale.
|
|
227
|
+
*/
|
|
228
|
+
export const NO_EFFORT_GLYPH = "—";
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* 0 for "no effort set", else 1..4 for low/medium/high/xhigh.
|
|
232
|
+
*
|
|
233
|
+
* 0 is a real answer, not a failure: a grade with no effort inherits the
|
|
234
|
+
* session's, and drawing the dash says that without a sentence.
|
|
235
|
+
*/
|
|
236
|
+
export function effortLevel(effort: Effort | undefined): number {
|
|
237
|
+
if (!effort) return 0;
|
|
238
|
+
const index = (EFFORTS as readonly string[]).indexOf(effort);
|
|
239
|
+
return index === -1 ? 0 : index + 1;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The four-cell indicator as plain text, for width maths and tests. */
|
|
243
|
+
export function effortGlyph(effort: Effort | undefined): string {
|
|
244
|
+
const level = effortLevel(effort);
|
|
245
|
+
if (level === 0) return NO_EFFORT_GLYPH.padEnd(EFFORT_GLYPH_CELLS);
|
|
246
|
+
return (
|
|
247
|
+
EFFORT_ON.repeat(level) + EFFORT_OFF.repeat(EFFORT_GLYPH_CELLS - level)
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* The effort indicator, as spans for ONE `<text>`.
|
|
253
|
+
*
|
|
254
|
+
* Coloured BY EFFORT, from the theme's ramp — which is a reversal, and deliberate. The
|
|
255
|
+
* previous rule was that colour may only mean one thing, so the glyph borrowed the model's
|
|
256
|
+
* hue and let height carry the level. That holds only while the model's hue is the ONLY
|
|
257
|
+
* colour on the row; once the model is a chip or its own ink, borrowing it again puts the
|
|
258
|
+
* same hue in two adjacent columns meaning two different things.
|
|
259
|
+
*
|
|
260
|
+
* These two channels never collide: effort's ramp is a single hue family per step, effort is
|
|
261
|
+
* TEXT and a model chip is a FILL, and the ramp skips green and red, which already mean "on"
|
|
262
|
+
* and "failed" elsewhere on this screen.
|
|
263
|
+
*/
|
|
264
|
+
export function EffortGlyph({
|
|
265
|
+
effort,
|
|
266
|
+
selected = false,
|
|
267
|
+
}: {
|
|
268
|
+
effort: Effort | undefined;
|
|
269
|
+
selected?: boolean;
|
|
270
|
+
}): React.ReactNode {
|
|
271
|
+
const level = effortLevel(effort);
|
|
272
|
+
if (level === 0) {
|
|
273
|
+
return (
|
|
274
|
+
<span fg={selected ? theme.selection.dim : theme.colors.muted}>
|
|
275
|
+
{NO_EFFORT_GLYPH.padEnd(EFFORT_GLYPH_CELLS)}
|
|
276
|
+
</span>
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
// Every level is on the effort ramp, a mate's included. An external model's effort is as
|
|
280
|
+
// in force as a Claude subagent's — claudish applies it per run — so a second visual
|
|
281
|
+
// grade for it would say the two mean different things when they do not.
|
|
282
|
+
const on = selected
|
|
283
|
+
? theme.selection.fg
|
|
284
|
+
: (effortInk(effort as string) as UiColor);
|
|
285
|
+
const off = selected ? theme.selection.dim : (trackFill() as UiColor);
|
|
286
|
+
return (
|
|
287
|
+
<>
|
|
288
|
+
<span fg={on}>{EFFORT_ON.repeat(level)}</span>
|
|
289
|
+
<span fg={off}>{EFFORT_OFF.repeat(EFFORT_GLYPH_CELLS - level)}</span>
|
|
290
|
+
</>
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* What the effort column says for a mate carrying no effort of its own.
|
|
296
|
+
*
|
|
297
|
+
* Not "no effort": every model in claudish's catalogue has a preset, and a slot that names
|
|
298
|
+
* none runs at its model's. `--pro-on-ultracode` in `claudish --help` is the flag that opts
|
|
299
|
+
* into that preset explicitly, which is what makes it a real default rather than an absence.
|
|
300
|
+
*/
|
|
301
|
+
export const MATE_EFFORT_NOTE = "model preset";
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* The word after the glyph. Absent effort says what absent MEANS.
|
|
305
|
+
*
|
|
306
|
+
* `model` is optional and matters for one case: an absent effort means something different
|
|
307
|
+
* for a mate. A Claude subagent with none inherits the session's; an external model has no
|
|
308
|
+
* session to inherit from, and falls to its own catalogue preset instead.
|
|
309
|
+
*
|
|
310
|
+
* A mate's effort IS in force, and is written plainly. It reaches the model through claudish
|
|
311
|
+
* rather than through `settings.modelSettings` — that map is keyed by models Claude Code
|
|
312
|
+
* runs, so `buildSettingsPatch` correctly drops a mate's — and claudish applies it per run,
|
|
313
|
+
* subject to a per-model clamp (`--effort-override` is the flag that skips the clamp). A
|
|
314
|
+
* clamp is a bound on the value, not a reason to doubt it, so the cell reads like any other.
|
|
315
|
+
*
|
|
316
|
+
* Taking the model here rather than special-casing it at the two call sites is what keeps
|
|
317
|
+
* `fitAgentTable`'s width maths agreeing with what `AgentTableRow` draws. A column measured
|
|
318
|
+
* from a different string than the one rendered drifts by a cell as the data changes, which
|
|
319
|
+
* is the defect `modelWidth` exists to prevent on the other column.
|
|
320
|
+
*/
|
|
321
|
+
export function effortWord(effort: Effort | undefined, model?: string): string {
|
|
322
|
+
if (isMate(model)) return effort ?? MATE_EFFORT_NOTE;
|
|
323
|
+
return effort ?? "inherits session";
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── The agent table, as one distribution ─────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
export interface AgentSegment {
|
|
329
|
+
/** A grade name, `inherit`, or a model an agent was pinned to outright. */
|
|
330
|
+
key: string;
|
|
331
|
+
count: number;
|
|
332
|
+
/** The model this key routes to — what decides the segment's colour. */
|
|
333
|
+
model: string;
|
|
334
|
+
/** The bar fill this model paints, from the theme. */
|
|
335
|
+
fill: UiColor;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Every agent the config names, counted by what it routes to.
|
|
340
|
+
*
|
|
341
|
+
* The whole table, `default` grade included — the bar answers "how is the work
|
|
342
|
+
* spread", which an omitted majority answers wrongly. An earlier version left
|
|
343
|
+
* the default-grade agents out and drew a bar saying the routing was mostly
|
|
344
|
+
* `critical`, when critical was the exception.
|
|
345
|
+
*
|
|
346
|
+
* Two keys that resolve to the same model get the same colour, and that is
|
|
347
|
+
* correct rather than a collision: the reader is being told those agents run on
|
|
348
|
+
* the same model. The bar puts a track cell between them so they still read as
|
|
349
|
+
* two runs, and the legend names the keys.
|
|
350
|
+
*/
|
|
351
|
+
export function agentDistribution(config: ModelsConfig): AgentSegment[] {
|
|
352
|
+
// EVERY tier starts at zero, so the chart is the same four rows whichever preset the
|
|
353
|
+
// cursor is on. Emitting only the tiers that happen to have agents made the chart change
|
|
354
|
+
// height and row order as you moved down the list, which reads as the data changing
|
|
355
|
+
// shape rather than as one tier being empty — and "nothing runs at `default` here" is
|
|
356
|
+
// itself worth seeing.
|
|
357
|
+
const counts = new Map<string, number>(GRADES.map((grade) => [grade, 0]));
|
|
358
|
+
for (const assignment of Object.values(config.agents ?? {})) {
|
|
359
|
+
const key = typeof assignment === "string" ? assignment : assignment.model;
|
|
360
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const rank = (key: string) => {
|
|
364
|
+
const index = (GRADES as readonly string[]).indexOf(key);
|
|
365
|
+
return index === -1 ? GRADES.length : index;
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
return [...counts.entries()]
|
|
369
|
+
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
|
370
|
+
.map(([key, count]) => {
|
|
371
|
+
const model = resolveKeyModel(config, key);
|
|
372
|
+
return { key, count, model, fill: modelBarFill(model) };
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* The tier spread for ONE workflow's agents.
|
|
378
|
+
*
|
|
379
|
+
* Same shape as `agentDistribution`, over a named subset rather than everything in the
|
|
380
|
+
* config. An agent the workflow dispatches that the config never mentions still counts — it
|
|
381
|
+
* falls to `fallback`, exactly as it will at runtime, and leaving it out would draw a bar
|
|
382
|
+
* for four agents when five get spawned.
|
|
383
|
+
*
|
|
384
|
+
* Empty tiers are dropped here, unlike in `agentDistribution`: a workflow that touches two
|
|
385
|
+
* agents genuinely has two segments, and padding it to four would suggest it reaches tiers
|
|
386
|
+
* it never does.
|
|
387
|
+
*
|
|
388
|
+
* `externalSteps` is a property of the WORKFLOW, not of the config. `WORKFLOWS[].external`
|
|
389
|
+
* declares the phases a command runs on an outside model, read out of the command file that
|
|
390
|
+
* runs them, so the run is drawn under every preset: changing the tier preset does not change
|
|
391
|
+
* whether `/dev:dev` reviews its plan on an external model.
|
|
392
|
+
*/
|
|
393
|
+
export function workflowDistribution(
|
|
394
|
+
config: ModelsConfig,
|
|
395
|
+
agents: string[],
|
|
396
|
+
externalSteps = 0,
|
|
397
|
+
): AgentSegment[] {
|
|
398
|
+
const counts = new Map<string, number>();
|
|
399
|
+
for (const agent of agents) {
|
|
400
|
+
const assignment = config.agents?.[agent];
|
|
401
|
+
const key =
|
|
402
|
+
assignment === undefined
|
|
403
|
+
? config.fallback
|
|
404
|
+
: typeof assignment === "string"
|
|
405
|
+
? assignment
|
|
406
|
+
: assignment.model;
|
|
407
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
408
|
+
}
|
|
409
|
+
// One run under the shared family tone: the bar answers "how much of this leaves Claude
|
|
410
|
+
// Code", which is one number however many slots serve it. Added to whatever `config.agents`
|
|
411
|
+
// already routes to a slot, so a project that hand-binds an agent and runs a workflow with
|
|
412
|
+
// external phases counts both.
|
|
413
|
+
if (externalSteps > 0) {
|
|
414
|
+
counts.set(MATES_SEGMENT, (counts.get(MATES_SEGMENT) ?? 0) + externalSteps);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const rank = (key: string) => {
|
|
418
|
+
const index = (GRADES as readonly string[]).indexOf(key);
|
|
419
|
+
return index === -1 ? GRADES.length : index;
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
return [...counts.entries()]
|
|
423
|
+
.sort(([a], [b]) => rank(a) - rank(b) || a.localeCompare(b))
|
|
424
|
+
.map(([key, count]) => {
|
|
425
|
+
const model = keyModel(config, key);
|
|
426
|
+
return { key, count, model, fill: modelBarFill(model) };
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* What a grade name, `inherit`, or a pinned model actually runs on.
|
|
432
|
+
*
|
|
433
|
+
* This is the whole reason an agent's colour is not its own: colour here means
|
|
434
|
+
* "which model", so every agent at one grade takes that grade's model colour
|
|
435
|
+
* and they read as one tier. Colouring agents individually produced six hues
|
|
436
|
+
* carrying no information — with red, which means danger everywhere else in
|
|
437
|
+
* this app, decorating an agent name.
|
|
438
|
+
*/
|
|
439
|
+
export function keyModel(config: ModelsConfig, key: string): string {
|
|
440
|
+
const index = (GRADES as readonly string[]).indexOf(key);
|
|
441
|
+
if (index === -1) return key;
|
|
442
|
+
const grade = GRADES[index];
|
|
443
|
+
return grade ? config.grades[grade].model : key;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Alias kept for the local call site below. */
|
|
447
|
+
const resolveKeyModel = keyModel;
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Every distinct model these configs name, in alias order.
|
|
451
|
+
*
|
|
452
|
+
* Feeds the key under the bar. The bar is coloured by MODEL and labelled by
|
|
453
|
+
* GRADE, so without this the colours have no key at all — a reader can see that
|
|
454
|
+
* two runs differ and not what they differ INTO.
|
|
455
|
+
*/
|
|
456
|
+
export function modelsInUse(configs: ModelsConfig[]): string[] {
|
|
457
|
+
const seen = new Set<string>();
|
|
458
|
+
for (const config of configs) {
|
|
459
|
+
seen.add(baseAlias(config.main.model));
|
|
460
|
+
for (const grade of GRADES) seen.add(baseAlias(config.grades[grade].model));
|
|
461
|
+
for (const assignment of Object.values(config.agents ?? {})) {
|
|
462
|
+
if (typeof assignment !== "string") seen.add(baseAlias(assignment.model));
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
// Aliases, then mates, then `inherit`. The list reads as "what magus routes", then
|
|
466
|
+
// "what it hands to claudish", then "what it leaves alone" — decreasing involvement, which
|
|
467
|
+
// is the only ordering that stays stable as slots are added. `inherit` keeps the tail it
|
|
468
|
+
// already had: it is the absence of a decision and belongs after every decision.
|
|
469
|
+
const order = [...MODEL_ALIASES, ...MATES, "inherit"] as readonly string[];
|
|
470
|
+
const rank = (model: string) => {
|
|
471
|
+
const index = order.indexOf(model);
|
|
472
|
+
return index === -1 ? order.length : index;
|
|
473
|
+
};
|
|
474
|
+
return [...seen].sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// ─── The agent table ──────────────────────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
export interface AgentRow {
|
|
480
|
+
name: string;
|
|
481
|
+
/** The assignment as written: a grade, `inherit`, or a pinned model. The Tier cell. */
|
|
482
|
+
key: string;
|
|
483
|
+
/** What that assignment resolves to — the row's COLOUR comes from this. */
|
|
484
|
+
model: string;
|
|
485
|
+
/**
|
|
486
|
+
* What the Model cell WRITES: a mate's bound id, or `unset` when the slot is unbound.
|
|
487
|
+
* Identical to `model` for everything else.
|
|
488
|
+
*
|
|
489
|
+
* Separate from `model` because the Model column used to print the routing value and so
|
|
490
|
+
* printed a slot name — a row read `mate1 mate1 —`, the same word in the Tier and Model
|
|
491
|
+
* columns, with neither of them naming a model.
|
|
492
|
+
*/
|
|
493
|
+
label: string;
|
|
494
|
+
effort: Effort | undefined;
|
|
495
|
+
/** The bar fill this model paints, from the theme. */
|
|
496
|
+
fill: UiColor;
|
|
497
|
+
/** Nothing is set for this agent, so its model and effort cells read `—`. */
|
|
498
|
+
inherit: boolean;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/**
|
|
502
|
+
* EVERY agent the config names, one row each, in reading order.
|
|
503
|
+
*
|
|
504
|
+
* No sampling and no "+N more". A reader comes to this table to find their own
|
|
505
|
+
* agent and read its answer off the row, which a truncated list cannot answer —
|
|
506
|
+
* and the detail pane scrolls, so length is not the constraint it looks like.
|
|
507
|
+
*
|
|
508
|
+
* Sorted by grade in ROUTING order (critical, standard, fast, then `inherit`
|
|
509
|
+
* and any pinned model), then alphabetically inside each. The strongest tier is
|
|
510
|
+
* at the top and a name is where the eye expects it.
|
|
511
|
+
*/
|
|
512
|
+
export function agentRows(config: ModelsConfig): AgentRow[] {
|
|
513
|
+
const rows: AgentRow[] = [];
|
|
514
|
+
for (const [name, assignment] of Object.entries(config.agents ?? {})) {
|
|
515
|
+
const key = typeof assignment === "string" ? assignment : assignment.model;
|
|
516
|
+
const gradeIndex = (GRADES as readonly string[]).indexOf(key);
|
|
517
|
+
const grade = gradeIndex === -1 ? undefined : GRADES[gradeIndex];
|
|
518
|
+
const model = keyModel(config, key);
|
|
519
|
+
// An assignment's own effort, else the grade's. A MATE adds a third source: when
|
|
520
|
+
// neither names one, the slot's own declared effort from the `mates` block stands in,
|
|
521
|
+
// so `{"mate1": {"effort": "high"}}` reaches every agent on that slot without being
|
|
522
|
+
// repeated per agent. An agent-level effort still wins — it is the more specific
|
|
523
|
+
// statement, and the same precedence an override already has over a grade.
|
|
524
|
+
const written =
|
|
525
|
+
typeof assignment === "string"
|
|
526
|
+
? grade
|
|
527
|
+
? config.grades[grade].effort
|
|
528
|
+
: undefined
|
|
529
|
+
: assignment.effort;
|
|
530
|
+
const effort =
|
|
531
|
+
written ?? (isMate(model) ? mateEffort(config, model) : undefined);
|
|
532
|
+
rows.push({
|
|
533
|
+
name,
|
|
534
|
+
key,
|
|
535
|
+
model,
|
|
536
|
+
label: modelLabel(config, model),
|
|
537
|
+
effort,
|
|
538
|
+
fill: modelBarFill(model),
|
|
539
|
+
// A MATE DOES NOT SET THIS. `inherit` blanks both the model and the effort cell to
|
|
540
|
+
// `—`, which is right for "nothing was decided" and wrong for a mate: the slot name
|
|
541
|
+
// IS the answer, and blanking it would hide real routing behind the same dash that
|
|
542
|
+
// means the opposite. `baseAlias("mate1")` is `mate1`, so the existing test already
|
|
543
|
+
// gives the right answer — stated here because it is right by luck of the string
|
|
544
|
+
// rather than by a rule anyone wrote down.
|
|
545
|
+
inherit: baseAlias(model) === "inherit",
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const rank = (key: string) => {
|
|
550
|
+
const index = (GRADES as readonly string[]).indexOf(key);
|
|
551
|
+
return index === -1 ? GRADES.length : index;
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
return rows.sort(
|
|
555
|
+
(a, b) =>
|
|
556
|
+
rank(a.key) - rank(b.key) ||
|
|
557
|
+
a.key.localeCompare(b.key) ||
|
|
558
|
+
a.name.localeCompare(b.name),
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** What the effort cell shows when a column has to give way. */
|
|
563
|
+
export type EffortDetail = "full" | "short" | "none";
|
|
564
|
+
|
|
565
|
+
export interface AgentTableLayout {
|
|
566
|
+
name: number;
|
|
567
|
+
/** 0 when the grade column was dropped to make the table fit. */
|
|
568
|
+
grade: number;
|
|
569
|
+
model: number;
|
|
570
|
+
effort: EffortDetail;
|
|
571
|
+
/** Columns the whole table occupies, gaps included. */
|
|
572
|
+
total: number;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** The cell an inherit row shows where a model or an effort would go. */
|
|
576
|
+
export const NONE_CELL = "—";
|
|
577
|
+
|
|
578
|
+
const HEADERS = {
|
|
579
|
+
name: "Agent",
|
|
580
|
+
grade: "Tier",
|
|
581
|
+
model: "Model",
|
|
582
|
+
effort: "Effort",
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
function effortColumn(detail: EffortDetail, word: number): number {
|
|
586
|
+
if (detail === "none") return EFFORT_GLYPH_CELLS;
|
|
587
|
+
return EFFORT_GLYPH_CELLS + 1 + (detail === "short" ? 1 : word);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** The fewest cells the agent-name column is ever squeezed to. */
|
|
591
|
+
const NAME_FLOOR = 6;
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The fewest cells the MODEL column is ever squeezed to.
|
|
595
|
+
*
|
|
596
|
+
* Enough for a prefix plus the `…` that says it is one. Catalogue ids are front-loaded with
|
|
597
|
+
* the family — `grok-4.6`, `glm-5.3`, `kimi-k3` — so eight cells still distinguishes the
|
|
598
|
+
* bindings a project is realistically running side by side, while `gemini…` and `gemini-…`
|
|
599
|
+
* would not.
|
|
600
|
+
*/
|
|
601
|
+
const MODEL_FLOOR = 8;
|
|
602
|
+
|
|
603
|
+
/**
|
|
604
|
+
* Column widths for the agent table, from the data rather than from constants.
|
|
605
|
+
*
|
|
606
|
+
* Degrades in a fixed order, because some columns are recoverable and others
|
|
607
|
+
* are not: the grade goes first (the model name beside it already says which
|
|
608
|
+
* tier this is, in colour), then the effort word shrinks to its initial (the
|
|
609
|
+
* glyph still carries the level), and only then does the name column truncate.
|
|
610
|
+
* NOTHING wraps — a wrapped cell turns a table back into the paragraph this
|
|
611
|
+
* replaced.
|
|
612
|
+
*/
|
|
613
|
+
export function fitAgentTable(
|
|
614
|
+
rows: AgentRow[],
|
|
615
|
+
width: number,
|
|
616
|
+
): AgentTableLayout {
|
|
617
|
+
const longest = (values: number[], floor: number) =>
|
|
618
|
+
values.reduce((max, value) => Math.max(max, value), floor);
|
|
619
|
+
|
|
620
|
+
const nameNeeded = longest(
|
|
621
|
+
rows.map((row) => row.name.length),
|
|
622
|
+
HEADERS.name.length,
|
|
623
|
+
);
|
|
624
|
+
const gradeNeeded = longest(
|
|
625
|
+
rows.map((row) => row.key.length),
|
|
626
|
+
HEADERS.grade.length,
|
|
627
|
+
);
|
|
628
|
+
// Measured from `row.label` — the string the row DRAWS — not from `row.model`.
|
|
629
|
+
//
|
|
630
|
+
// The column was sized for `kangaroo` (8) on the reasoning that a slot name was the widest
|
|
631
|
+
// thing it would ever hold. Binding a slot broke that by a wide margin: real catalogue ids
|
|
632
|
+
// run to `gemini-omni-1.1-flash` (21) and `deepseek-v4.1-flash` (19), so a column measured
|
|
633
|
+
// from the slot would under-count by sixteen cells and every row would overrun its
|
|
634
|
+
// neighbour. This is the same defect `modelWidth`'s comment warns about, at scale.
|
|
635
|
+
const modelNeeded = longest(
|
|
636
|
+
rows.map((row) => (row.inherit ? NONE_CELL.length : modelWidth(row.label))),
|
|
637
|
+
HEADERS.model.length,
|
|
638
|
+
);
|
|
639
|
+
// `row.model` is passed so a mate's `via claudish` (or `high (declared)`) is measured,
|
|
640
|
+
// not `inherits session`. Measuring the wrong string here is how the effort column ends up
|
|
641
|
+
// a cell short of what the row draws.
|
|
642
|
+
const wordNeeded = longest(
|
|
643
|
+
rows.map((row) =>
|
|
644
|
+
row.inherit ? NONE_CELL.length : effortWord(row.effort, row.model).length,
|
|
645
|
+
),
|
|
646
|
+
1,
|
|
647
|
+
);
|
|
648
|
+
|
|
649
|
+
const candidates: Array<{ grade: number; effort: EffortDetail }> = [
|
|
650
|
+
{ grade: gradeNeeded, effort: "full" },
|
|
651
|
+
{ grade: 0, effort: "full" },
|
|
652
|
+
{ grade: 0, effort: "short" },
|
|
653
|
+
{ grade: 0, effort: "none" },
|
|
654
|
+
];
|
|
655
|
+
|
|
656
|
+
const measure = (
|
|
657
|
+
grade: number,
|
|
658
|
+
effort: EffortDetail,
|
|
659
|
+
name: number,
|
|
660
|
+
model: number = modelNeeded,
|
|
661
|
+
) =>
|
|
662
|
+
name +
|
|
663
|
+
1 +
|
|
664
|
+
(grade > 0 ? grade + 1 : 0) +
|
|
665
|
+
model +
|
|
666
|
+
1 +
|
|
667
|
+
effortColumn(effort, wordNeeded);
|
|
668
|
+
|
|
669
|
+
for (const candidate of candidates) {
|
|
670
|
+
const total = measure(candidate.grade, candidate.effort, nameNeeded);
|
|
671
|
+
if (total <= width) {
|
|
672
|
+
return {
|
|
673
|
+
name: nameNeeded,
|
|
674
|
+
grade: candidate.grade,
|
|
675
|
+
model: modelNeeded,
|
|
676
|
+
effort: candidate.effort,
|
|
677
|
+
total,
|
|
678
|
+
};
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
// Nothing fit even stripped down: give the name whatever is left, and
|
|
683
|
+
// truncate into it rather than wrapping.
|
|
684
|
+
const fixed = measure(0, "none", 0);
|
|
685
|
+
const name = Math.max(NAME_FLOOR, width - fixed);
|
|
686
|
+
const total = measure(0, "none", name);
|
|
687
|
+
if (total <= width) {
|
|
688
|
+
return { name, grade: 0, model: modelNeeded, effort: "none", total };
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// THE LAST RUNG, AND IT IS NEW.
|
|
692
|
+
//
|
|
693
|
+
// The model column used to be the one column that never gave way: every value it could
|
|
694
|
+
// hold was an alias (≤6) or a slot (≤8), so surrendering the name column always got the
|
|
695
|
+
// table under the width. A bound id is up to 21 cells, and at that size the old floor
|
|
696
|
+
// stopped being reachable — at 30 columns the fixed part alone is 27, the name floor adds
|
|
697
|
+
// 7, and the table overran by four with no rung left to take them from.
|
|
698
|
+
//
|
|
699
|
+
// So the model column truncates too, with the `…` `clipCell` puts there. It keeps
|
|
700
|
+
// `MODEL_FLOOR` cells, which is enough to tell two bound ids apart in practice; below that
|
|
701
|
+
// the table is not readable at any setting and the name floor is the last thing standing.
|
|
702
|
+
const model = Math.max(
|
|
703
|
+
MODEL_FLOOR,
|
|
704
|
+
width - (NAME_FLOOR + 1 + 1 + effortColumn("none", wordNeeded)),
|
|
705
|
+
);
|
|
706
|
+
return {
|
|
707
|
+
name: NAME_FLOOR,
|
|
708
|
+
grade: 0,
|
|
709
|
+
model,
|
|
710
|
+
effort: "none",
|
|
711
|
+
total: measure(0, "none", NAME_FLOOR, model),
|
|
712
|
+
};
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
/** The header cells, already padded to the layout's columns. */
|
|
716
|
+
export function agentTableHeaders(
|
|
717
|
+
layout: AgentTableLayout,
|
|
718
|
+
): Array<{ key: string; text: string }> {
|
|
719
|
+
const cells = [{ key: "name", text: padCell(HEADERS.name, layout.name) }];
|
|
720
|
+
if (layout.grade > 0) {
|
|
721
|
+
cells.push({ key: "grade", text: padCell(HEADERS.grade, layout.grade) });
|
|
722
|
+
}
|
|
723
|
+
cells.push({ key: "model", text: padCell(HEADERS.model, layout.model) });
|
|
724
|
+
cells.push({
|
|
725
|
+
key: "effort",
|
|
726
|
+
text: padCell(HEADERS.effort, effortColumn(layout.effort, 6)),
|
|
727
|
+
});
|
|
728
|
+
return cells;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* Truncate to `width`, with an ellipsis marking that something was cut.
|
|
733
|
+
*
|
|
734
|
+
* NEVER a silent clip. `gemini-omni-1.1-flash` cut to `gemini-omni-1.1-flas` is a model id
|
|
735
|
+
* that looks complete and is not — a reader would take it for the whole name and go looking
|
|
736
|
+
* for it in the catalogue. The `…` costs one cell and makes the cut visible.
|
|
737
|
+
*
|
|
738
|
+
* Split out of `padCell` because the model column needs the truncation WITHOUT the padding:
|
|
739
|
+
* it is drawn as a coloured `ModelText` followed by its own pad span, so padding here would
|
|
740
|
+
* paint the gap in the model's ink instead of the border's.
|
|
741
|
+
*/
|
|
742
|
+
export function clipCell(text: string, width: number): string {
|
|
743
|
+
if (width <= 0) return "";
|
|
744
|
+
return text.length > width
|
|
745
|
+
? `${text.slice(0, Math.max(0, width - 1))}…`
|
|
746
|
+
: text;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
/** Truncate to `width`, then pad to it. A cell is exactly its column, always. */
|
|
750
|
+
export function padCell(text: string, width: number): string {
|
|
751
|
+
return clipCell(text, width).padEnd(width);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
/** The effort word as the layout wants it: the word, its initial, or nothing. */
|
|
755
|
+
export function effortLabel(
|
|
756
|
+
effort: Effort | undefined,
|
|
757
|
+
detail: EffortDetail,
|
|
758
|
+
model?: string,
|
|
759
|
+
): string {
|
|
760
|
+
if (detail === "none") return "";
|
|
761
|
+
const word = effortWord(effort, model);
|
|
762
|
+
// A mate's fallback note does not abbreviate. `short` keeps a real effort's initial
|
|
763
|
+
// because the four dots beside it still carry the level, so one letter disambiguates
|
|
764
|
+
// rather than being the whole message — but "model preset" clipped to `m` is not a
|
|
765
|
+
// shorter way of saying anything, it is a character with no meaning in a column of them.
|
|
766
|
+
if (word === MATE_EFFORT_NOTE) return detail === "short" ? "" : word;
|
|
767
|
+
return detail === "short" ? word.slice(0, 1) : word;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
// ─── The preset list rows ─────────────────────────────────────────────────────
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* The trailing markers a preset row may carry. Their leading space is theirs.
|
|
774
|
+
*
|
|
775
|
+
* A custom row carries NONE. `Custom` is already the whole claim — the row exists only
|
|
776
|
+
* because the settings differ from every built-in — so a marker beside it repeats the label
|
|
777
|
+
* in different words.
|
|
778
|
+
*/
|
|
779
|
+
export const PRESET_MARKERS = {
|
|
780
|
+
default: " (default)",
|
|
781
|
+
} as const;
|
|
782
|
+
|
|
783
|
+
/** Columns a row's markers occupy — 0 or the default marker. */
|
|
784
|
+
export function presetMarkerWidth(isDefault: boolean): number {
|
|
785
|
+
return isDefault ? PRESET_MARKERS.default.length : 0;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/** What a preset row shows beside its name, widest form first. */
|
|
789
|
+
export type PresetSummaryDetail =
|
|
790
|
+
| "labelled"
|
|
791
|
+
| "smart"
|
|
792
|
+
| "compact"
|
|
793
|
+
| "main"
|
|
794
|
+
| "none";
|
|
795
|
+
|
|
796
|
+
export interface PresetRowLayout {
|
|
797
|
+
/** The name column, identical on every row. */
|
|
798
|
+
name: number;
|
|
799
|
+
/** The `main` model column. 0 when no summary is drawn at all. */
|
|
800
|
+
main: number;
|
|
801
|
+
/** The `critical` model column. 0 when that field was dropped. */
|
|
802
|
+
smart: number;
|
|
803
|
+
detail: PresetSummaryDetail;
|
|
804
|
+
/** Columns the widest row occupies, markers included. */
|
|
805
|
+
total: number;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/** One row's inputs — a preset reduced to the fields the column maths needs. */
|
|
809
|
+
export interface PresetRowInput {
|
|
810
|
+
label: string;
|
|
811
|
+
/** The main model. Never a mate — the validator refuses one on `main.model`. */
|
|
812
|
+
main: string;
|
|
813
|
+
/** The routing value for `smart` — decides its INK. May be a slot. */
|
|
814
|
+
smart: string;
|
|
815
|
+
/** What the `smart` column WRITES: a bound id for a mate, else the same as `smart`. */
|
|
816
|
+
smartLabel: string;
|
|
817
|
+
/** Columns this row's trailing markers need. */
|
|
818
|
+
markers: number;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/** The longest a preset name may be before it truncates into an ellipsis. */
|
|
822
|
+
export const PRESET_NAME_MAX = 20;
|
|
823
|
+
|
|
824
|
+
/**
|
|
825
|
+
* The longest a MODEL may be in a list row before it truncates.
|
|
826
|
+
*
|
|
827
|
+
* The list panel is the narrow one — it shares the screen with the detail pane — and a
|
|
828
|
+
* 21-cell `gemini-omni-1.1-flash` here does not merely widen the row, it knocks the whole
|
|
829
|
+
* list down the degradation ladder to `main`, so every row loses its second model to one
|
|
830
|
+
* row's long binding. Truncating the one long value keeps the field for all of them, and the
|
|
831
|
+
* detail pane three columns to the right shows it in full.
|
|
832
|
+
*
|
|
833
|
+
* 14 is chosen against real ids: `deepseek-v4.1…`, `gemini-3.8-fla…`, and `grok-4.6` /
|
|
834
|
+
* `kimi-k3` / `glm-5.3` untouched.
|
|
835
|
+
*/
|
|
836
|
+
export const PRESET_MODEL_MAX = 14;
|
|
837
|
+
|
|
838
|
+
/** `[●] ` — the active marker and the space after it, on every row. */
|
|
839
|
+
const PRESET_GUTTER = 4;
|
|
840
|
+
/** The indent `SelectableRow` adds. */
|
|
841
|
+
const PRESET_INDENT = 1;
|
|
842
|
+
/** Between the name column and the summary. */
|
|
843
|
+
const PRESET_NAME_GAP = 2;
|
|
844
|
+
/** Between the two models. */
|
|
845
|
+
const PRESET_SEP = " · ";
|
|
846
|
+
|
|
847
|
+
const MAIN_LABEL = "main ";
|
|
848
|
+
const SMART_LABEL = "smart ";
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* ONE layout for the WHOLE list, not one per row.
|
|
852
|
+
*
|
|
853
|
+
* The bug this exists for, reported off a 120×30 screenshot: each row decided
|
|
854
|
+
* its own form from its own width, so the selected row dropped its `main` /
|
|
855
|
+
* `critical` labels while the other three kept theirs. The same field was
|
|
856
|
+
* labelled on some rows and not on others, and nothing lined up down the
|
|
857
|
+
* column. A list that is uniformly terse beats a list in two formats.
|
|
858
|
+
*
|
|
859
|
+
* So the fit is measured against the WIDEST row and applied to every row. If
|
|
860
|
+
* the widest row cannot carry the labels, none of them do.
|
|
861
|
+
*
|
|
862
|
+
* Degrades in value order, five rungs:
|
|
863
|
+
*
|
|
864
|
+
* labelled `main opus · critical fable`
|
|
865
|
+
* critical `opus · critical fable` — `critical` is the grade this whole
|
|
866
|
+
* screen is about, so its label outlives `main`'s; the leading
|
|
867
|
+
* column is positionally main and the detail pane says so.
|
|
868
|
+
* compact `opus · fable`
|
|
869
|
+
* main `opus` — at a narrow panel one model beats
|
|
870
|
+
* none: it is what the orchestrator runs, and a bare second model
|
|
871
|
+
* with no label beside it says nothing a reader can use.
|
|
872
|
+
* none the name alone.
|
|
873
|
+
*/
|
|
874
|
+
export function fitPresetRows(
|
|
875
|
+
rows: PresetRowInput[],
|
|
876
|
+
width: number,
|
|
877
|
+
): PresetRowLayout {
|
|
878
|
+
const longest = (values: number[], floor: number) =>
|
|
879
|
+
values.reduce((max, value) => Math.max(max, value), floor);
|
|
880
|
+
|
|
881
|
+
const name = Math.min(
|
|
882
|
+
PRESET_NAME_MAX,
|
|
883
|
+
longest(
|
|
884
|
+
rows.map((row) => row.label.length),
|
|
885
|
+
4,
|
|
886
|
+
),
|
|
887
|
+
);
|
|
888
|
+
const main = longest(
|
|
889
|
+
rows.map((row) => modelWidth(row.main)),
|
|
890
|
+
1,
|
|
891
|
+
);
|
|
892
|
+
// Measured from the LABEL and capped. `smart` is the one column here that can hold a
|
|
893
|
+
// bound id, so it is the one that had to learn a ceiling — see `PRESET_MODEL_MAX`.
|
|
894
|
+
const smart = Math.min(
|
|
895
|
+
PRESET_MODEL_MAX,
|
|
896
|
+
longest(
|
|
897
|
+
rows.map((row) => modelWidth(row.smartLabel)),
|
|
898
|
+
1,
|
|
899
|
+
),
|
|
900
|
+
);
|
|
901
|
+
const markers = longest(
|
|
902
|
+
rows.map((row) => row.markers),
|
|
903
|
+
0,
|
|
904
|
+
);
|
|
905
|
+
|
|
906
|
+
const base = PRESET_INDENT + PRESET_GUTTER + name;
|
|
907
|
+
const summary = (detail: PresetSummaryDetail) => {
|
|
908
|
+
if (detail === "none") return 0;
|
|
909
|
+
if (detail === "main") return PRESET_NAME_GAP + main;
|
|
910
|
+
const pair = main + PRESET_SEP.length + smart;
|
|
911
|
+
if (detail === "compact") return PRESET_NAME_GAP + pair;
|
|
912
|
+
if (detail === "smart") {
|
|
913
|
+
return PRESET_NAME_GAP + pair + SMART_LABEL.length;
|
|
914
|
+
}
|
|
915
|
+
return PRESET_NAME_GAP + pair + SMART_LABEL.length + MAIN_LABEL.length;
|
|
916
|
+
};
|
|
917
|
+
|
|
918
|
+
const order: PresetSummaryDetail[] = [
|
|
919
|
+
"labelled",
|
|
920
|
+
"smart",
|
|
921
|
+
"compact",
|
|
922
|
+
"main",
|
|
923
|
+
"none",
|
|
924
|
+
];
|
|
925
|
+
for (const detail of order) {
|
|
926
|
+
const total = base + summary(detail) + markers;
|
|
927
|
+
if (total <= width || detail === "none") {
|
|
928
|
+
return {
|
|
929
|
+
name,
|
|
930
|
+
main: detail === "none" ? 0 : main,
|
|
931
|
+
smart: detail === "none" || detail === "main" ? 0 : smart,
|
|
932
|
+
detail,
|
|
933
|
+
total,
|
|
934
|
+
};
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
// Unreachable: `none` always returns above. Kept so the function is total
|
|
938
|
+
// without the compiler having to prove the loop covers every case.
|
|
939
|
+
return { name, main: 0, smart: 0, detail: "none", total: base + markers };
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// ─── The workflow bar ─────────────────────────────────────────────────────────
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* The bar is laid out by YOGA, not by arithmetic in this file.
|
|
946
|
+
*
|
|
947
|
+
* What used to be here — `spendRuns` and `splitCells` — divided N cells among K counts by
|
|
948
|
+
* largest remainder, floored every share at one cell, and then paid whatever was left over
|
|
949
|
+
* in track so the row still came out exactly its allotted width. Every one of those steps
|
|
950
|
+
* is a thing a flex engine already does, and doing it twice is how a row ends up a cell
|
|
951
|
+
* short of the box it sits in.
|
|
952
|
+
*
|
|
953
|
+
* So a segment is a `<box>` with `flexGrow` set to its share of the workflow's agents, and
|
|
954
|
+
* the row filling its width exactly is a property of the layout rather than a correction
|
|
955
|
+
* this module applies. MEASURED with the real renderer at 20/26/32/40/64/80 columns: the
|
|
956
|
+
* captured spans sum to the row width at every rung, with no shortfall to pay.
|
|
957
|
+
*
|
|
958
|
+
* ## Three findings that shape the code below, all measured against OpenTUI 0.1.107
|
|
959
|
+
*
|
|
960
|
+
* 1. `flexBasis: 0` + `flexGrow: n` alone lets a small share resolve to ZERO cells and
|
|
961
|
+
* vanish: shares 40/1/1/1/1 at 20 columns drew three segments, not five. `minWidth: 1`
|
|
962
|
+
* is what keeps every segment on screen, and it is the layout-native spelling of the
|
|
963
|
+
* min-one-cell rule `splitCells` used to carry. It costs a little proportionality at
|
|
964
|
+
* narrow widths — Yoga takes the floor out of the free space first, so 9/3/3/2 at 20
|
|
965
|
+
* columns resolves 9,4,4,3 rather than 11,3,4,2 — which is a fair trade at a width where
|
|
966
|
+
* one cell is a twentieth of the bar.
|
|
967
|
+
*
|
|
968
|
+
* 2. A segment's CONTENT never perturbs the distribution. A 200-character fill and an empty
|
|
969
|
+
* box resolve to identical widths at every rung, which is what makes it safe to measure a
|
|
970
|
+
* segment and then draw into it: the measurement cannot invalidate itself.
|
|
971
|
+
*
|
|
972
|
+
* 3. `flexDirection: "row"` on a box that also clips (`overflow: "hidden"`) loses ONE CELL
|
|
973
|
+
* off the right of its text child — 9,4,4,3 drew `000000000111 222 33 `. The same tree in
|
|
974
|
+
* the default column direction is gapless at every rung. So a segment box is NEVER a row;
|
|
975
|
+
* the row direction lives on the container above it.
|
|
976
|
+
*/
|
|
977
|
+
|
|
978
|
+
/** The unlit cell between two neighbouring segments. */
|
|
979
|
+
export const BAR_DIVIDER = "░";
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* The weave a MATE segment is drawn with, so it is not colour alone that says this work
|
|
983
|
+
* leaves Claude Code.
|
|
984
|
+
*
|
|
985
|
+
* `▓` is 75% ink, which is the whole reason it is this glyph and not `▒` or `░`. Drawn in
|
|
986
|
+
* the mate's `bar` tone over its `bg` tone, three quarters of every cell is the same fill
|
|
987
|
+
* weight the four Claude models carry, so a mate reads as a peer with a texture rather than
|
|
988
|
+
* as something dimmed, disabled or errored — the failure mode a lighter glyph walks straight
|
|
989
|
+
* into. `░` is spoken for anyway: it is the unlit track, and reusing it would say "nothing
|
|
990
|
+
* runs here" about a segment that is running someone else's model.
|
|
991
|
+
*/
|
|
992
|
+
export const MATE_WEFT = "▓";
|
|
993
|
+
|
|
994
|
+
/**
|
|
995
|
+
* The id, the routing sentinel and the WORD for the one run every mate-routed agent in a
|
|
996
|
+
* workflow collapses into.
|
|
997
|
+
*
|
|
998
|
+
* Not a routing value and never written to a config — `models-core` knows nothing about it.
|
|
999
|
+
* It is a bar-only identity, which is why it lives here beside the drawing code: the bar
|
|
1000
|
+
* answers "how much of this workflow leaves Claude Code", and that is one number however
|
|
1001
|
+
* many slots it is spread across.
|
|
1002
|
+
*
|
|
1003
|
+
* A workflow that routes two agents to two different slots used to draw two narrow textured
|
|
1004
|
+
* runs labelled `mate1` and `mate2`. At the widths this panel gets, neither label fitted, so
|
|
1005
|
+
* the distinction cost two segments and bought nothing — while the number the bar exists to
|
|
1006
|
+
* show was split in half.
|
|
1007
|
+
*
|
|
1008
|
+
* The word is `mates` even when exactly ONE slot is in play. A lone `mate1` there would say
|
|
1009
|
+
* the label names a slot, and the next config over — same label, two slots — would prove it
|
|
1010
|
+
* does not. The detail pane names the slots; the key under the chart names them with their
|
|
1011
|
+
* bindings. This is the only place that deliberately does not.
|
|
1012
|
+
*/
|
|
1013
|
+
export const MATES_SEGMENT = "mates";
|
|
1014
|
+
|
|
1015
|
+
/**
|
|
1016
|
+
* Is this bar segment external work — a slot, or the collapsed run standing for several?
|
|
1017
|
+
*
|
|
1018
|
+
* The one predicate the weave, the label-fit rule and the ground colour all read, so a
|
|
1019
|
+
* collapsed segment cannot end up woven-but-uncoloured or coloured-but-plain.
|
|
1020
|
+
*/
|
|
1021
|
+
export function isMateSegment(model: string): boolean {
|
|
1022
|
+
return model === MATES_SEGMENT || isMate(baseAlias(model));
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* A fill long enough to outrun any segment, since the box clips it to size.
|
|
1027
|
+
*
|
|
1028
|
+
* The alternative is to repeat the glyph to a MEASURED width, which is a frame behind the
|
|
1029
|
+
* layout and would leave a mate segment plain on the frame it first appears. Clipping is
|
|
1030
|
+
* exact (finding 3 above), so an over-long run is simply the fill, with no arithmetic and
|
|
1031
|
+
* nothing to be stale about. 240 clears any pane a terminal can offer this panel.
|
|
1032
|
+
*/
|
|
1033
|
+
const WEFT_RUN = 240;
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Columns an inline label needs: the name, plus one padding cell each side.
|
|
1037
|
+
*
|
|
1038
|
+
* The same measure `modelBadgeWidth` uses, and deliberately so — an inline label IS the
|
|
1039
|
+
* chip, inset into the bar, so it costs what a chip costs.
|
|
1040
|
+
*/
|
|
1041
|
+
export function inlineLabelWidth(label: string): number {
|
|
1042
|
+
return modelBadgeWidth(label);
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
/**
|
|
1046
|
+
* What a segment writes inside itself.
|
|
1047
|
+
*
|
|
1048
|
+
* A mate writes its SLOT — `mate1` — and never the id it is bound to. The slot is the stable
|
|
1049
|
+
* identity the rest of this screen is keyed on, it is what the legend names, and a bound id
|
|
1050
|
+
* runs to 21 characters (`gemini-omni-1.1-flash`), which no segment at a real pane width
|
|
1051
|
+
* could hold. `baseAlias` gives the slot for a mate and the plain alias for everything else,
|
|
1052
|
+
* so one call covers both.
|
|
1053
|
+
*/
|
|
1054
|
+
export function segmentLabel(model: string): string {
|
|
1055
|
+
return baseAlias(model);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* Does the label fit inside the segment, given the width the layout resolved?
|
|
1060
|
+
*
|
|
1061
|
+
* `undefined` means the layout has not reported yet — the first frame after mount — and is
|
|
1062
|
+
* a NO. There is no third answer here on purpose: a label either sits inside its segment
|
|
1063
|
+
* whole or it does not appear at all, and the legend under the bar carries it instead.
|
|
1064
|
+
* Clipping `sonnet` to `son…` inside a coloured block reads as a model id that does not
|
|
1065
|
+
* exist, which is worse than the segment being unlabelled.
|
|
1066
|
+
*
|
|
1067
|
+
* A MATE ASKS FOR TWO CELLS MORE than its label needs, and that is the rule that keeps the
|
|
1068
|
+
* weave honest. At exactly `inlineLabelWidth` the chip fills the segment edge to edge and
|
|
1069
|
+
* not one cell of `▓` survives — so the one segment on the row that is supposed to be
|
|
1070
|
+
* distinguishable without colour would, at that single width, be distinguishable only by
|
|
1071
|
+
* colour. Two spare cells is one weave cell each side, which is the least that still reads
|
|
1072
|
+
* as a texture rather than as a stray character.
|
|
1073
|
+
*/
|
|
1074
|
+
export function inlineLabelFits(
|
|
1075
|
+
model: string,
|
|
1076
|
+
cells: number | undefined,
|
|
1077
|
+
): boolean {
|
|
1078
|
+
const weave = isMateSegment(model) ? 2 : 0;
|
|
1079
|
+
return (
|
|
1080
|
+
cells !== undefined &&
|
|
1081
|
+
inlineLabelWidth(segmentLabel(model)) + weave <= cells
|
|
1082
|
+
);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** One run of the bar: what it routes to, and how much of the workflow it is. */
|
|
1086
|
+
export interface BarSegmentSpec {
|
|
1087
|
+
/** Identity within the row — the distribution key, so two tiers on one model stay two. */
|
|
1088
|
+
id: string;
|
|
1089
|
+
/** The routing value: an alias, `inherit`, or a slot. Decides ink, weave and label. */
|
|
1090
|
+
model: string;
|
|
1091
|
+
/** Agents this segment stands for. Becomes its `flexGrow`. */
|
|
1092
|
+
share: number;
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* A tier distribution as the runs a bar actually draws: every mate folded into ONE.
|
|
1097
|
+
*
|
|
1098
|
+
* The fold is a SUM, not a pick, so the proportion is untouched — two agents on two slots
|
|
1099
|
+
* occupy exactly the cells the two separate runs occupied, less the divider that used to sit
|
|
1100
|
+
* between them. Shares stay counts and the layout stays Yoga's; nothing here divides cells
|
|
1101
|
+
* (see the note above `BAR_DIVIDER` for why that matters).
|
|
1102
|
+
*
|
|
1103
|
+
* Collapsing keys off the MODEL, never the key: a grade bound to `mate1` reaches the bar as
|
|
1104
|
+
* `{key: "cheap", model: "mate1"}`, and folding by key would leave that one outside the run
|
|
1105
|
+
* it belongs in.
|
|
1106
|
+
*
|
|
1107
|
+
* The collapsed run takes the position of the FIRST mate in the order, so adding a second
|
|
1108
|
+
* slot to a config does not move the bar's other segments around.
|
|
1109
|
+
*/
|
|
1110
|
+
export function barSegments(segments: AgentSegment[]): BarSegmentSpec[] {
|
|
1111
|
+
const runs: BarSegmentSpec[] = [];
|
|
1112
|
+
let mates: BarSegmentSpec | undefined;
|
|
1113
|
+
for (const segment of segments) {
|
|
1114
|
+
// `isMateSegment`, not `isMate`: two kinds of segment belong in this run — a slot a
|
|
1115
|
+
// config bound to an agent, and the workflow's own external phases, which arrive
|
|
1116
|
+
// already carrying the family name. Matching only the slots leaves the second kind
|
|
1117
|
+
// outside and draws two `mates` runs side by side.
|
|
1118
|
+
if (isMateSegment(segment.model)) {
|
|
1119
|
+
if (!mates) {
|
|
1120
|
+
mates = { id: MATES_SEGMENT, model: MATES_SEGMENT, share: 0 };
|
|
1121
|
+
runs.push(mates);
|
|
1122
|
+
}
|
|
1123
|
+
mates.share += segment.count;
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
runs.push({ id: segment.key, model: segment.model, share: segment.count });
|
|
1127
|
+
}
|
|
1128
|
+
return runs;
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/** Callback a segment uses to report the width the layout gave it. */
|
|
1132
|
+
type Measure = (id: string, cells: number) => void;
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* One segment: a coloured box that Yoga sizes, with its label drawn inside when it fits.
|
|
1136
|
+
*
|
|
1137
|
+
* The fill is the box's `backgroundColor` rather than a run of `█`, which is the same
|
|
1138
|
+
* picture with none of the counting — a `█` in a colour and a cell painted that colour are
|
|
1139
|
+
* indistinguishable in a terminal, and only one of them needs to know how wide the box is.
|
|
1140
|
+
*
|
|
1141
|
+
* ## The label is written ON the fill, not in a chip inset into it
|
|
1142
|
+
*
|
|
1143
|
+
* It used to be the model's CHIP — `bg` under `fg`, inset — which put a second, visibly
|
|
1144
|
+
* different rectangle inside a block that is supposed to read as one thing. Reported as
|
|
1145
|
+
* "do not add additional background colour to model segments".
|
|
1146
|
+
*
|
|
1147
|
+
* The reason the chip was there in the first place still stands and is why deleting it was
|
|
1148
|
+
* not the fix on its own: MEASURED across both pages, a model's `fg` laid on its own `bar`
|
|
1149
|
+
* runs 1.74–2.99:1 on the dark page, which is a smudge rather than a word. So the palette
|
|
1150
|
+
* grew a fourth ink — `label`, chosen against each model's own `bar` and nothing else,
|
|
1151
|
+
* 4.80–4.93:1 on every model and both pages — and the segment writes the name in that,
|
|
1152
|
+
* directly on its own fill. No second background, and no unreadable text.
|
|
1153
|
+
*
|
|
1154
|
+
* A MATE is the one case that still sets a background, and it sets it to `bar` — the tone
|
|
1155
|
+
* its own weave is painted in. A mate's box ground is `bg` with `▓` (75% ink) of `bar` over
|
|
1156
|
+
* it, so the segment READS as `bar`; leaving the label cells at the raw `bg` would punch a
|
|
1157
|
+
* darker patch where the weave stops, which is the same defect in the other direction.
|
|
1158
|
+
* Painting those few cells `bar` makes the whole segment one tone, and it means one rule
|
|
1159
|
+
* covers every model: the label sits on `bar`, so it is measured against `bar`.
|
|
1160
|
+
*/
|
|
1161
|
+
function BarSegment({
|
|
1162
|
+
segment,
|
|
1163
|
+
cells,
|
|
1164
|
+
onMeasure,
|
|
1165
|
+
}: {
|
|
1166
|
+
segment: BarSegmentSpec;
|
|
1167
|
+
/** Columns the layout resolved for this box, or `undefined` before the first report. */
|
|
1168
|
+
cells: number | undefined;
|
|
1169
|
+
onMeasure: Measure;
|
|
1170
|
+
}): React.ReactNode {
|
|
1171
|
+
const identity = baseAlias(segment.model);
|
|
1172
|
+
const ink = modelBadge(identity);
|
|
1173
|
+
const mate = isMateSegment(identity);
|
|
1174
|
+
const label = segmentLabel(segment.model);
|
|
1175
|
+
const fits = inlineLabelFits(segment.model, cells);
|
|
1176
|
+
// EVERY segment has ONE ground, and it is `bar` — mates included. A mate's texture is a
|
|
1177
|
+
// pale MARK painted on that ground, never a second surface behind it: two tones inside
|
|
1178
|
+
// one run read as two blocks, a solid patch of colour with a patterned margin beside it.
|
|
1179
|
+
//
|
|
1180
|
+
// One ground is also what keeps the contrast numbers honest. Every model's `label` ink is
|
|
1181
|
+
// measured against its `bar`, and `bar` is what the label sits on in every segment.
|
|
1182
|
+
const ground = ink.bar;
|
|
1183
|
+
const pad = fits && cells !== undefined ? cells - inlineLabelWidth(label) : 0;
|
|
1184
|
+
const left = Math.floor(pad / 2);
|
|
1185
|
+
const weft = mate ? MATE_WEFT : " ";
|
|
1186
|
+
|
|
1187
|
+
return (
|
|
1188
|
+
<box
|
|
1189
|
+
flexGrow={segment.share}
|
|
1190
|
+
flexBasis={0}
|
|
1191
|
+
minWidth={1}
|
|
1192
|
+
overflow="hidden"
|
|
1193
|
+
backgroundColor={ground}
|
|
1194
|
+
onSizeChange={function (this: BoxRenderable) {
|
|
1195
|
+
onMeasure(segment.id, this.width);
|
|
1196
|
+
}}
|
|
1197
|
+
>
|
|
1198
|
+
{fits ? (
|
|
1199
|
+
// The weave is drawn in `bg`, the model's PALEST tone, over the `bar` ground.
|
|
1200
|
+
// A Claude segment's weft is a space, so its ink never shows.
|
|
1201
|
+
<text fg={(mate ? ink.bg : ink.bar) as UiColor}>
|
|
1202
|
+
<span>{weft.repeat(left)}</span>
|
|
1203
|
+
{/* No `bg` on the label, for any model: the box is already `bar`, and naming
|
|
1204
|
+
a background here could only repaint it as a second tone. */}
|
|
1205
|
+
<span fg={ink.label}>{` ${label} `}</span>
|
|
1206
|
+
<span>{weft.repeat(pad - left)}</span>
|
|
1207
|
+
</text>
|
|
1208
|
+
) : mate ? (
|
|
1209
|
+
<text fg={ink.bg as UiColor}>{MATE_WEFT.repeat(WEFT_RUN)}</text>
|
|
1210
|
+
) : null}
|
|
1211
|
+
</box>
|
|
1212
|
+
);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// ─── The colour key under the bars ────────────────────────────────────────────
|
|
1216
|
+
|
|
1217
|
+
/** One chip in the key: the ink it is painted in, and the words written on it. */
|
|
1218
|
+
export interface KeyChip {
|
|
1219
|
+
/**
|
|
1220
|
+
* Decides the INK, and it is not always the model.
|
|
1221
|
+
*
|
|
1222
|
+
* Every slot resolves to `MATES_SEGMENT`, because the key describes the CHART and the
|
|
1223
|
+
* chart paints all external work in one tone. Chipping `mate1` in ochre here would put a
|
|
1224
|
+
* colour in the legend that appears at no width in the graphic above it — the exact
|
|
1225
|
+
* defect `ModelKey`'s own note records, in a new place.
|
|
1226
|
+
*/
|
|
1227
|
+
ink: string;
|
|
1228
|
+
/** What the chip writes: `opus`, or `mate1 grok-4.6`, or `mate1 unset`. */
|
|
1229
|
+
text: string;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* The key's chips, fitted to the row it has.
|
|
1234
|
+
*
|
|
1235
|
+
* A slot is written WITH ITS BINDING, which is the detail the bar gave up when it collapsed
|
|
1236
|
+
* its mates into one run. The bar answers how much work leaves Claude Code; this answers
|
|
1237
|
+
* where it goes, in the one place on this screen with the width for a 21-character
|
|
1238
|
+
* catalogue id.
|
|
1239
|
+
*
|
|
1240
|
+
* An unbound slot writes `unset` — `modelLabel`'s word, the same one the tier table's Model
|
|
1241
|
+
* column already uses for that state, rather than a second word for one thing. What it must
|
|
1242
|
+
* never write is the slot name twice: `mate1 mate1` names no model and is what binding a
|
|
1243
|
+
* slot exists to remove.
|
|
1244
|
+
*
|
|
1245
|
+
* ## Four rungs, and it never wraps
|
|
1246
|
+
*
|
|
1247
|
+
* Wrapping is not available: this block is budgeted at `SUMMARY_COST.key` = 2 rows in a
|
|
1248
|
+
* fixed-height panel, and an over-tall block there OVERPRINTS rather than scrolling or
|
|
1249
|
+
* clipping (see `planModelsSummary`). A second row would land on whatever is drawn below it.
|
|
1250
|
+
*
|
|
1251
|
+
* bound every chip with its slot's model `mate1 grok-4.6 mate2 kimi-k3`
|
|
1252
|
+
* slots every chip, bindings dropped `mate1 mate2`
|
|
1253
|
+
* folded the slots as the bar's own one word `mates`
|
|
1254
|
+
* truncated chips dropped from the right, folded
|
|
1255
|
+
*
|
|
1256
|
+
* The first three rungs are ALL-OR-NOTHING for the whole row, the way `fitPresetRows` is for
|
|
1257
|
+
* the whole list: one long binding would otherwise show its model while its neighbour showed
|
|
1258
|
+
* a bare slot, which reads as two formats rather than as one terse row. Keeping every colour
|
|
1259
|
+
* in the key beats keeping one binding — a legend missing an entry cannot decode the chart.
|
|
1260
|
+
*
|
|
1261
|
+
* The `folded` rung is why truncation is the LAST resort rather than the second. Dropping
|
|
1262
|
+
* chips from the right takes them in `modelsInUse` order, which puts the mates near the end
|
|
1263
|
+
* — so a two-slot config at 40 columns kept `mate1` and silently dropped `mate2`, naming one
|
|
1264
|
+
* slot as though it were the only external one. Folding says exactly what the bar says, in
|
|
1265
|
+
* the tone the bar paints, and costs one chip instead of two.
|
|
1266
|
+
*/
|
|
1267
|
+
export function fitModelKey(
|
|
1268
|
+
config: ModelsConfig,
|
|
1269
|
+
width: number,
|
|
1270
|
+
labelCells: number,
|
|
1271
|
+
/**
|
|
1272
|
+
* Does a WORKFLOW put a mates run on the chart, independently of this config?
|
|
1273
|
+
*
|
|
1274
|
+
* The key describes the chart, and the chart draws mates for two reasons: a config that
|
|
1275
|
+
* binds a slot to an agent, and a workflow whose own phases run outside Claude Code. Only
|
|
1276
|
+
* the first is visible in `config`, so without this a built-in preset — which binds no
|
|
1277
|
+
* slot — drew a textured run with no chip under it to say what the texture meant.
|
|
1278
|
+
*/
|
|
1279
|
+
externalWorkflows = false,
|
|
1280
|
+
): KeyChip[] {
|
|
1281
|
+
const models = modelsInUse([config]);
|
|
1282
|
+
const form = (rung: "bound" | "slots" | "folded"): KeyChip[] => {
|
|
1283
|
+
const chips: KeyChip[] = [];
|
|
1284
|
+
let folded = false;
|
|
1285
|
+
let sawMate = false;
|
|
1286
|
+
for (const model of models) {
|
|
1287
|
+
if (!isMate(model)) {
|
|
1288
|
+
chips.push({ ink: model, text: model });
|
|
1289
|
+
continue;
|
|
1290
|
+
}
|
|
1291
|
+
sawMate = true;
|
|
1292
|
+
// Folded exactly as `barSegments` folds the bar: one entry, at the position of
|
|
1293
|
+
// the first slot, standing for all of them.
|
|
1294
|
+
if (rung === "folded") {
|
|
1295
|
+
if (folded) continue;
|
|
1296
|
+
folded = true;
|
|
1297
|
+
chips.push({ ink: MATES_SEGMENT, text: MATES_SEGMENT });
|
|
1298
|
+
continue;
|
|
1299
|
+
}
|
|
1300
|
+
chips.push({
|
|
1301
|
+
ink: MATES_SEGMENT,
|
|
1302
|
+
text:
|
|
1303
|
+
rung === "bound" ? `${model} ${modelLabel(config, model)}` : model,
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1306
|
+
// The workflow's own external phases, when the config named no slot of its own. One
|
|
1307
|
+
// unnamed chip, because there is no slot to name — the run stands for whichever model
|
|
1308
|
+
// claudish reaches for, and the config has not said which.
|
|
1309
|
+
//
|
|
1310
|
+
// Placed BEFORE `inherit`, never appended. Chips are dropped from the right when the
|
|
1311
|
+
// row will not fit, and last place is first to go — which lost the one chip that
|
|
1312
|
+
// explains a texture the reader cannot otherwise decode, while keeping the chip for a
|
|
1313
|
+
// flat grey that means "nothing was chosen". `inherit` is the one that can go.
|
|
1314
|
+
if (!sawMate && externalWorkflows) {
|
|
1315
|
+
const chip = { ink: MATES_SEGMENT, text: MATES_SEGMENT };
|
|
1316
|
+
const inherit = chips.findIndex((c) => c.ink === "inherit");
|
|
1317
|
+
if (inherit === -1) chips.push(chip);
|
|
1318
|
+
else chips.splice(inherit, 0, chip);
|
|
1319
|
+
}
|
|
1320
|
+
return chips;
|
|
1321
|
+
};
|
|
1322
|
+
// A chip costs its own two padding cells (`modelBadgeWidth`) plus one space separating it
|
|
1323
|
+
// from the chip before it — measured the way the row is drawn, so it cannot overflow by
|
|
1324
|
+
// exactly the padding the chips paint.
|
|
1325
|
+
const cost = (chips: KeyChip[]) =>
|
|
1326
|
+
chips.reduce(
|
|
1327
|
+
(sum, chip, index) => sum + modelBadgeWidth(chip.text) + (index ? 1 : 0),
|
|
1328
|
+
labelCells,
|
|
1329
|
+
);
|
|
1330
|
+
|
|
1331
|
+
for (const rung of ["bound", "slots", "folded"] as const) {
|
|
1332
|
+
const chips = form(rung);
|
|
1333
|
+
if (cost(chips) <= width) return chips;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
const shown: KeyChip[] = [];
|
|
1337
|
+
let used = labelCells;
|
|
1338
|
+
for (const chip of form("folded")) {
|
|
1339
|
+
const next = modelBadgeWidth(chip.text) + (shown.length > 0 ? 1 : 0);
|
|
1340
|
+
if (used + next > width) break;
|
|
1341
|
+
shown.push(chip);
|
|
1342
|
+
used += next;
|
|
1343
|
+
}
|
|
1344
|
+
return shown;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** The unlit cell that keeps two neighbouring segments from reading as one. */
|
|
1348
|
+
function BarDivider(): React.ReactNode {
|
|
1349
|
+
return (
|
|
1350
|
+
<box width={1} flexShrink={0}>
|
|
1351
|
+
<text fg={trackFill() as UiColor}>{BAR_DIVIDER}</text>
|
|
1352
|
+
</box>
|
|
1353
|
+
);
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/**
|
|
1357
|
+
* A whole bar: every segment side by side, filling whatever the row leaves it.
|
|
1358
|
+
*
|
|
1359
|
+
* Holds the measurements because they are one reading of one row — a map keyed by segment
|
|
1360
|
+
* id, written only when a width actually changes, so a label appearing cannot start a loop.
|
|
1361
|
+
* It converges in one extra frame and then sits still; finding 2 above is what guarantees
|
|
1362
|
+
* that, since the label it draws cannot move the box it is drawn in.
|
|
1363
|
+
*
|
|
1364
|
+
* The one-cell divider is kept from the bar this replaces, for the reason it was added:
|
|
1365
|
+
* MEASURED on a dark screenshot, two adjacent runs resolving to the same model merged into a
|
|
1366
|
+
* single rectangle and three segments read as two.
|
|
1367
|
+
*/
|
|
1368
|
+
export function BarRow({
|
|
1369
|
+
segments,
|
|
1370
|
+
}: {
|
|
1371
|
+
segments: BarSegmentSpec[];
|
|
1372
|
+
}): React.ReactNode {
|
|
1373
|
+
const [measured, setMeasured] = useState<Record<string, number>>({});
|
|
1374
|
+
const record: Measure = (id, cells) =>
|
|
1375
|
+
setMeasured((previous) =>
|
|
1376
|
+
previous[id] === cells ? previous : { ...previous, [id]: cells },
|
|
1377
|
+
);
|
|
1378
|
+
|
|
1379
|
+
const drawn = segments.filter((segment) => segment.share > 0);
|
|
1380
|
+
return (
|
|
1381
|
+
<box flexDirection="row" flexGrow={1} minWidth={0}>
|
|
1382
|
+
{drawn.length === 0 ? (
|
|
1383
|
+
<box flexGrow={1} minWidth={0} overflow="hidden">
|
|
1384
|
+
<text fg={trackFill() as UiColor}>
|
|
1385
|
+
{BAR_DIVIDER.repeat(WEFT_RUN)}
|
|
1386
|
+
</text>
|
|
1387
|
+
</box>
|
|
1388
|
+
) : (
|
|
1389
|
+
drawn.map((segment, index) => (
|
|
1390
|
+
<Fragment key={segment.id}>
|
|
1391
|
+
{index > 0 ? <BarDivider /> : null}
|
|
1392
|
+
<BarSegment
|
|
1393
|
+
segment={segment}
|
|
1394
|
+
cells={measured[segment.id]}
|
|
1395
|
+
onMeasure={record}
|
|
1396
|
+
/>
|
|
1397
|
+
</Fragment>
|
|
1398
|
+
))
|
|
1399
|
+
)}
|
|
1400
|
+
</box>
|
|
1401
|
+
);
|
|
1402
|
+
}
|