magus-cli 0.0.0-placeholder → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +725 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +524 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +248 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +340 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/open-file.test.ts +59 -0
  96. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  97. package/src/__tests__/plugin-checkout.test.ts +66 -0
  98. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  99. package/src/__tests__/plugin-contents.test.ts +371 -0
  100. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  101. package/src/__tests__/plugin-releases.test.ts +98 -0
  102. package/src/__tests__/plugin-requires.test.ts +187 -0
  103. package/src/__tests__/plugin-row-text.test.ts +345 -0
  104. package/src/__tests__/plugin-setup.test.ts +1016 -0
  105. package/src/__tests__/profile-adopt.test.ts +177 -0
  106. package/src/__tests__/profile-command.test.ts +110 -0
  107. package/src/__tests__/profile-materializer.test.ts +238 -0
  108. package/src/__tests__/profile-sync.test.ts +154 -0
  109. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  110. package/src/__tests__/resolve-executable.test.ts +42 -0
  111. package/src/__tests__/resolver.test.ts +351 -0
  112. package/src/__tests__/scope-action.test.ts +134 -0
  113. package/src/__tests__/scope-squares.test.tsx +164 -0
  114. package/src/__tests__/selection-scope.test.ts +100 -0
  115. package/src/__tests__/settings-file.test.ts +179 -0
  116. package/src/__tests__/shell-script-callers.test.ts +92 -0
  117. package/src/__tests__/skill-install-files.test.ts +136 -0
  118. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  119. package/src/__tests__/style-wrap.test.ts +220 -0
  120. package/src/__tests__/styles-manager.test.ts +1098 -0
  121. package/src/__tests__/styles-origins.test.ts +414 -0
  122. package/src/__tests__/styles-screen-state.test.ts +457 -0
  123. package/src/__tests__/styles-status-line.test.ts +72 -0
  124. package/src/__tests__/styles-sync.test.ts +452 -0
  125. package/src/__tests__/symlink-manager.test.ts +180 -0
  126. package/src/__tests__/tabbar-layout.test.ts +100 -0
  127. package/src/__tests__/terminology-filler.test.ts +214 -0
  128. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  129. package/src/__tests__/theme-env.test.ts +310 -0
  130. package/src/__tests__/theme-resolve.test.ts +380 -0
  131. package/src/__tests__/tool-probe.test.ts +1400 -0
  132. package/src/__tests__/toolchain.test.ts +232 -0
  133. package/src/__tests__/ui-version-writers.test.ts +88 -0
  134. package/src/__tests__/update-apply.test.ts +637 -0
  135. package/src/__tests__/update-plan.test.ts +1396 -0
  136. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  137. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  138. package/src/__tests__/version-check-refresh.test.ts +45 -0
  139. package/src/__tests__/version-snapshot.test.ts +212 -0
  140. package/src/__tests__/version-unknown-state.test.ts +403 -0
  141. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  142. package/src/cli/ansi.ts +528 -0
  143. package/src/cli/bootstrap.ts +134 -0
  144. package/src/cli/doctor.ts +218 -0
  145. package/src/cli/hook.ts +129 -0
  146. package/src/cli/install.ts +382 -0
  147. package/src/cli/live.ts +374 -0
  148. package/src/cli/models.ts +297 -0
  149. package/src/cli/profile.ts +197 -0
  150. package/src/cli/prompt.ts +32 -0
  151. package/src/cli/router.ts +264 -0
  152. package/src/cli/update-view.ts +717 -0
  153. package/src/cli/update.ts +600 -0
  154. package/src/cli/upgrade.ts +97 -0
  155. package/src/data/alias-flags.ts +301 -0
  156. package/src/data/cli-tools.ts +146 -0
  157. package/src/data/community-styles.ts +531 -0
  158. package/src/data/gitignore-defaults.ts +33 -0
  159. package/src/data/gitignore-reasons.ts +93 -0
  160. package/src/data/gitignore-templates.ts +25 -0
  161. package/src/data/marketplaces.ts +278 -0
  162. package/src/data/mcp-servers.ts +51 -0
  163. package/src/data/models-presets.ts +329 -0
  164. package/src/data/predefined-profiles.ts +252 -0
  165. package/src/data/settings-catalog.ts +714 -0
  166. package/src/data/skill-repos.ts +91 -0
  167. package/src/data/styles/asd-ste100.md +38 -0
  168. package/src/data/styles/calibrated.md +24 -0
  169. package/src/data/styles/direct.md +23 -0
  170. package/src/data/styles/evidence-first.md +22 -0
  171. package/src/data/styles/explanatory.md +23 -0
  172. package/src/data/styles/index.ts +57 -0
  173. package/src/data/styles/no-slop.md +32 -0
  174. package/src/data/styles/plain-language.md +23 -0
  175. package/src/data/styles/structured.md +30 -0
  176. package/src/data/styles/terminology.md +28 -0
  177. package/src/data/styles/terse.md +20 -0
  178. package/src/main.tsx +110 -0
  179. package/src/markdown.d.ts +13 -0
  180. package/src/opentui.d.ts +203 -0
  181. package/src/services/active-closure.ts +117 -0
  182. package/src/services/alias-settings.ts +60 -0
  183. package/src/services/alias-shell-writer.ts +1244 -0
  184. package/src/services/alias-store.ts +172 -0
  185. package/src/services/binary-signing.ts +78 -0
  186. package/src/services/catalog-cache-store.ts +316 -0
  187. package/src/services/claude-cli.ts +304 -0
  188. package/src/services/claude-runner.ts +31 -0
  189. package/src/services/claude-settings.ts +1564 -0
  190. package/src/services/cli-tool-commands.ts +133 -0
  191. package/src/services/community-fetcher.ts +90 -0
  192. package/src/services/community-styles.ts +1194 -0
  193. package/src/services/content-drift.ts +208 -0
  194. package/src/services/conventions-manager.ts +945 -0
  195. package/src/services/doctor-bins.ts +63 -0
  196. package/src/services/doctor.ts +570 -0
  197. package/src/services/dotenv.ts +181 -0
  198. package/src/services/git-worktree.ts +129 -0
  199. package/src/services/github-budget.ts +274 -0
  200. package/src/services/gitignore-check.ts +63 -0
  201. package/src/services/gitignore-detector.ts +167 -0
  202. package/src/services/gitignore-fixer.ts +280 -0
  203. package/src/services/gitignore-resolver.ts +208 -0
  204. package/src/services/gitignore-service.ts +167 -0
  205. package/src/services/hook-registration.ts +218 -0
  206. package/src/services/install-plan.ts +107 -0
  207. package/src/services/local-marketplace.ts +657 -0
  208. package/src/services/manifest.ts +250 -0
  209. package/src/services/marketplace-catalog-git.ts +173 -0
  210. package/src/services/marketplace-catalog.ts +129 -0
  211. package/src/services/marketplace-fetcher.ts +400 -0
  212. package/src/services/marketplace-refresh.ts +245 -0
  213. package/src/services/marketplace-sync.ts +145 -0
  214. package/src/services/mate-availability.ts +133 -0
  215. package/src/services/mate-catalog.ts +265 -0
  216. package/src/services/mcp-registry.ts +171 -0
  217. package/src/services/models-core.ts +969 -0
  218. package/src/services/models-manager.ts +606 -0
  219. package/src/services/plugin-checkout.ts +186 -0
  220. package/src/services/plugin-manager.ts +847 -0
  221. package/src/services/plugin-mcp-config.ts +242 -0
  222. package/src/services/plugin-releases.ts +47 -0
  223. package/src/services/plugin-requires.ts +227 -0
  224. package/src/services/plugin-setup.ts +965 -0
  225. package/src/services/profile-adopt.ts +206 -0
  226. package/src/services/profile-materializer.ts +133 -0
  227. package/src/services/profile-sync.ts +158 -0
  228. package/src/services/profiles.ts +225 -0
  229. package/src/services/resolver.ts +377 -0
  230. package/src/services/settings-file.ts +69 -0
  231. package/src/services/settings-manager.ts +307 -0
  232. package/src/services/skills-manager.ts +1111 -0
  233. package/src/services/skillsmp-client.ts +193 -0
  234. package/src/services/styles-manager.ts +1294 -0
  235. package/src/services/symlink-manager.ts +189 -0
  236. package/src/services/terminology-filler.ts +266 -0
  237. package/src/services/tool-probe.ts +1637 -0
  238. package/src/services/toolchain.ts +431 -0
  239. package/src/services/update-engine.ts +691 -0
  240. package/src/services/update-plan.ts +820 -0
  241. package/src/services/version-check.ts +127 -0
  242. package/src/services/version-snapshot.ts +189 -0
  243. package/src/tui.tsx +112 -0
  244. package/src/types/bun.d.ts +27 -0
  245. package/src/types/gitignore.ts +68 -0
  246. package/src/types/index.ts +447 -0
  247. package/src/ui/App.tsx +535 -0
  248. package/src/ui/adapters/catalogNotice.ts +122 -0
  249. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  250. package/src/ui/adapters/modelsAdapter.ts +213 -0
  251. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  252. package/src/ui/adapters/settingsAdapter.ts +168 -0
  253. package/src/ui/adapters/skillsAdapter.ts +229 -0
  254. package/src/ui/adapters/stylesAdapter.ts +403 -0
  255. package/src/ui/components/CategoryHeader.tsx +64 -0
  256. package/src/ui/components/EmptyFilterState.tsx +32 -0
  257. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  258. package/src/ui/components/ScopeIndicator.tsx +63 -0
  259. package/src/ui/components/ScrollableList.tsx +95 -0
  260. package/src/ui/components/SearchInput.tsx +57 -0
  261. package/src/ui/components/StyledText.tsx +75 -0
  262. package/src/ui/components/TabBar.tsx +101 -0
  263. package/src/ui/components/layout/FooterHints.tsx +78 -0
  264. package/src/ui/components/layout/Panel.tsx +63 -0
  265. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  266. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  267. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  268. package/src/ui/components/layout/index.ts +9 -0
  269. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  270. package/src/ui/components/modals/InputModal.tsx +75 -0
  271. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  272. package/src/ui/components/modals/MessageModal.tsx +66 -0
  273. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  274. package/src/ui/components/modals/SelectModal.tsx +90 -0
  275. package/src/ui/components/modals/index.ts +6 -0
  276. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  277. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  278. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  279. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  280. package/src/ui/components/primitives/MetaText.tsx +40 -0
  281. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  282. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  283. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  284. package/src/ui/components/primitives/index.ts +9 -0
  285. package/src/ui/hooks/index.ts +2 -0
  286. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  287. package/src/ui/hooks/useKeyboard.ts +24 -0
  288. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  289. package/src/ui/registry.ts +33 -0
  290. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  291. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  292. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  293. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  294. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  295. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  296. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  297. package/src/ui/renderers/pluginRowText.ts +70 -0
  298. package/src/ui/renderers/pluginStatusText.ts +56 -0
  299. package/src/ui/renderers/profileRenderers.tsx +406 -0
  300. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  301. package/src/ui/renderers/skillRenderers.tsx +479 -0
  302. package/src/ui/renderers/styleRenderers.tsx +813 -0
  303. package/src/ui/screens/AliasScreen.tsx +1558 -0
  304. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  305. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  306. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  307. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  308. package/src/ui/screens/McpScreen.tsx +256 -0
  309. package/src/ui/screens/ModelsScreen.tsx +542 -0
  310. package/src/ui/screens/PluginsScreen.tsx +1423 -0
  311. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  312. package/src/ui/screens/SkillsScreen.tsx +853 -0
  313. package/src/ui/screens/StylesScreen.tsx +1064 -0
  314. package/src/ui/screens/index.ts +11 -0
  315. package/src/ui/state/AppContext.tsx +233 -0
  316. package/src/ui/state/DimensionsContext.tsx +154 -0
  317. package/src/ui/state/reducer.ts +810 -0
  318. package/src/ui/state/types.ts +503 -0
  319. package/src/ui/theme-mode.ts +440 -0
  320. package/src/ui/theme-resolve.ts +147 -0
  321. package/src/ui/theme.ts +175 -0
  322. package/src/utils/clipboard.ts +58 -0
  323. package/src/utils/command-utils.ts +149 -0
  324. package/src/utils/config-dir.ts +45 -0
  325. package/src/utils/file-locking.ts +146 -0
  326. package/src/utils/fuzzy-search.ts +138 -0
  327. package/src/utils/go-module.ts +36 -0
  328. package/src/utils/open-file.ts +84 -0
  329. package/src/utils/run.ts +67 -0
  330. 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
+ }