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.
Files changed (331) 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 +684 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +523 -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 +445 -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 +174 -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 +335 -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__/moved-marketplace.test.ts +86 -0
  96. package/src/__tests__/open-file.test.ts +59 -0
  97. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  98. package/src/__tests__/plugin-checkout.test.ts +66 -0
  99. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  100. package/src/__tests__/plugin-contents.test.ts +371 -0
  101. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  102. package/src/__tests__/plugin-releases.test.ts +98 -0
  103. package/src/__tests__/plugin-requires.test.ts +187 -0
  104. package/src/__tests__/plugin-row-text.test.ts +336 -0
  105. package/src/__tests__/plugin-setup.test.ts +1016 -0
  106. package/src/__tests__/profile-adopt.test.ts +177 -0
  107. package/src/__tests__/profile-command.test.ts +110 -0
  108. package/src/__tests__/profile-materializer.test.ts +238 -0
  109. package/src/__tests__/profile-sync.test.ts +154 -0
  110. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  111. package/src/__tests__/resolve-executable.test.ts +42 -0
  112. package/src/__tests__/resolver.test.ts +351 -0
  113. package/src/__tests__/scope-action.test.ts +134 -0
  114. package/src/__tests__/scope-squares.test.tsx +164 -0
  115. package/src/__tests__/selection-scope.test.ts +100 -0
  116. package/src/__tests__/settings-file.test.ts +179 -0
  117. package/src/__tests__/shell-script-callers.test.ts +92 -0
  118. package/src/__tests__/skill-install-files.test.ts +136 -0
  119. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  120. package/src/__tests__/style-wrap.test.ts +220 -0
  121. package/src/__tests__/styles-manager.test.ts +1098 -0
  122. package/src/__tests__/styles-origins.test.ts +414 -0
  123. package/src/__tests__/styles-screen-state.test.ts +457 -0
  124. package/src/__tests__/styles-status-line.test.ts +72 -0
  125. package/src/__tests__/styles-sync.test.ts +452 -0
  126. package/src/__tests__/symlink-manager.test.ts +180 -0
  127. package/src/__tests__/tabbar-layout.test.ts +100 -0
  128. package/src/__tests__/terminology-filler.test.ts +214 -0
  129. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  130. package/src/__tests__/theme-env.test.ts +281 -0
  131. package/src/__tests__/theme-resolve.test.ts +380 -0
  132. package/src/__tests__/tool-probe.test.ts +1400 -0
  133. package/src/__tests__/toolchain.test.ts +232 -0
  134. package/src/__tests__/ui-version-writers.test.ts +88 -0
  135. package/src/__tests__/update-apply.test.ts +637 -0
  136. package/src/__tests__/update-plan.test.ts +1373 -0
  137. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  138. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  139. package/src/__tests__/version-check-refresh.test.ts +45 -0
  140. package/src/__tests__/version-snapshot.test.ts +212 -0
  141. package/src/__tests__/version-unknown-state.test.ts +388 -0
  142. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  143. package/src/cli/ansi.ts +520 -0
  144. package/src/cli/bootstrap.ts +134 -0
  145. package/src/cli/doctor.ts +218 -0
  146. package/src/cli/hook.ts +129 -0
  147. package/src/cli/install.ts +382 -0
  148. package/src/cli/live.ts +374 -0
  149. package/src/cli/models.ts +297 -0
  150. package/src/cli/profile.ts +197 -0
  151. package/src/cli/prompt.ts +32 -0
  152. package/src/cli/router.ts +207 -0
  153. package/src/cli/update-view.ts +526 -0
  154. package/src/cli/update.ts +613 -0
  155. package/src/cli/upgrade.ts +97 -0
  156. package/src/data/alias-flags.ts +301 -0
  157. package/src/data/cli-tools.ts +146 -0
  158. package/src/data/community-styles.ts +531 -0
  159. package/src/data/gitignore-defaults.ts +33 -0
  160. package/src/data/gitignore-reasons.ts +93 -0
  161. package/src/data/gitignore-templates.ts +25 -0
  162. package/src/data/marketplaces.ts +278 -0
  163. package/src/data/mcp-servers.ts +51 -0
  164. package/src/data/models-presets.ts +329 -0
  165. package/src/data/predefined-profiles.ts +252 -0
  166. package/src/data/settings-catalog.ts +714 -0
  167. package/src/data/skill-repos.ts +91 -0
  168. package/src/data/styles/asd-ste100.md +38 -0
  169. package/src/data/styles/calibrated.md +24 -0
  170. package/src/data/styles/direct.md +23 -0
  171. package/src/data/styles/evidence-first.md +22 -0
  172. package/src/data/styles/explanatory.md +23 -0
  173. package/src/data/styles/index.ts +57 -0
  174. package/src/data/styles/no-slop.md +32 -0
  175. package/src/data/styles/plain-language.md +23 -0
  176. package/src/data/styles/structured.md +30 -0
  177. package/src/data/styles/terminology.md +28 -0
  178. package/src/data/styles/terse.md +20 -0
  179. package/src/main.tsx +110 -0
  180. package/src/markdown.d.ts +13 -0
  181. package/src/opentui.d.ts +203 -0
  182. package/src/services/active-closure.ts +117 -0
  183. package/src/services/alias-settings.ts +60 -0
  184. package/src/services/alias-shell-writer.ts +1244 -0
  185. package/src/services/alias-store.ts +172 -0
  186. package/src/services/binary-signing.ts +78 -0
  187. package/src/services/catalog-cache-store.ts +316 -0
  188. package/src/services/claude-cli.ts +304 -0
  189. package/src/services/claude-runner.ts +31 -0
  190. package/src/services/claude-settings.ts +1589 -0
  191. package/src/services/cli-tool-commands.ts +133 -0
  192. package/src/services/community-fetcher.ts +90 -0
  193. package/src/services/community-styles.ts +1194 -0
  194. package/src/services/content-drift.ts +208 -0
  195. package/src/services/conventions-manager.ts +945 -0
  196. package/src/services/doctor-bins.ts +63 -0
  197. package/src/services/doctor.ts +570 -0
  198. package/src/services/dotenv.ts +181 -0
  199. package/src/services/git-worktree.ts +129 -0
  200. package/src/services/github-budget.ts +274 -0
  201. package/src/services/gitignore-check.ts +63 -0
  202. package/src/services/gitignore-detector.ts +167 -0
  203. package/src/services/gitignore-fixer.ts +280 -0
  204. package/src/services/gitignore-resolver.ts +208 -0
  205. package/src/services/gitignore-service.ts +167 -0
  206. package/src/services/hook-registration.ts +218 -0
  207. package/src/services/install-plan.ts +107 -0
  208. package/src/services/local-marketplace.ts +587 -0
  209. package/src/services/manifest.ts +250 -0
  210. package/src/services/marketplace-catalog-git.ts +173 -0
  211. package/src/services/marketplace-catalog.ts +129 -0
  212. package/src/services/marketplace-fetcher.ts +400 -0
  213. package/src/services/marketplace-refresh.ts +253 -0
  214. package/src/services/marketplace-sync.ts +145 -0
  215. package/src/services/mate-availability.ts +133 -0
  216. package/src/services/mate-catalog.ts +265 -0
  217. package/src/services/mcp-registry.ts +171 -0
  218. package/src/services/models-core.ts +969 -0
  219. package/src/services/models-manager.ts +606 -0
  220. package/src/services/plugin-checkout.ts +186 -0
  221. package/src/services/plugin-manager.ts +878 -0
  222. package/src/services/plugin-mcp-config.ts +242 -0
  223. package/src/services/plugin-releases.ts +47 -0
  224. package/src/services/plugin-requires.ts +227 -0
  225. package/src/services/plugin-setup.ts +965 -0
  226. package/src/services/profile-adopt.ts +206 -0
  227. package/src/services/profile-materializer.ts +133 -0
  228. package/src/services/profile-sync.ts +158 -0
  229. package/src/services/profiles.ts +225 -0
  230. package/src/services/resolver.ts +344 -0
  231. package/src/services/settings-file.ts +69 -0
  232. package/src/services/settings-manager.ts +307 -0
  233. package/src/services/skills-manager.ts +1111 -0
  234. package/src/services/skillsmp-client.ts +193 -0
  235. package/src/services/styles-manager.ts +1294 -0
  236. package/src/services/symlink-manager.ts +189 -0
  237. package/src/services/terminology-filler.ts +266 -0
  238. package/src/services/tool-probe.ts +1637 -0
  239. package/src/services/toolchain.ts +431 -0
  240. package/src/services/update-engine.ts +691 -0
  241. package/src/services/update-plan.ts +863 -0
  242. package/src/services/version-check.ts +127 -0
  243. package/src/services/version-snapshot.ts +189 -0
  244. package/src/tui.tsx +112 -0
  245. package/src/types/bun.d.ts +27 -0
  246. package/src/types/gitignore.ts +68 -0
  247. package/src/types/index.ts +447 -0
  248. package/src/ui/App.tsx +538 -0
  249. package/src/ui/adapters/catalogNotice.ts +122 -0
  250. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  251. package/src/ui/adapters/modelsAdapter.ts +213 -0
  252. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  253. package/src/ui/adapters/settingsAdapter.ts +168 -0
  254. package/src/ui/adapters/skillsAdapter.ts +229 -0
  255. package/src/ui/adapters/stylesAdapter.ts +403 -0
  256. package/src/ui/components/CategoryHeader.tsx +64 -0
  257. package/src/ui/components/EmptyFilterState.tsx +32 -0
  258. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  259. package/src/ui/components/ScopeIndicator.tsx +63 -0
  260. package/src/ui/components/ScrollableList.tsx +95 -0
  261. package/src/ui/components/SearchInput.tsx +57 -0
  262. package/src/ui/components/StyledText.tsx +75 -0
  263. package/src/ui/components/TabBar.tsx +101 -0
  264. package/src/ui/components/layout/FooterHints.tsx +78 -0
  265. package/src/ui/components/layout/Panel.tsx +63 -0
  266. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  267. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  268. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  269. package/src/ui/components/layout/index.ts +9 -0
  270. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  271. package/src/ui/components/modals/InputModal.tsx +75 -0
  272. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  273. package/src/ui/components/modals/MessageModal.tsx +66 -0
  274. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  275. package/src/ui/components/modals/SelectModal.tsx +90 -0
  276. package/src/ui/components/modals/index.ts +6 -0
  277. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  278. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  279. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  280. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  281. package/src/ui/components/primitives/MetaText.tsx +40 -0
  282. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  283. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  284. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  285. package/src/ui/components/primitives/index.ts +9 -0
  286. package/src/ui/hooks/index.ts +2 -0
  287. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  288. package/src/ui/hooks/useKeyboard.ts +24 -0
  289. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  290. package/src/ui/registry.ts +33 -0
  291. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  292. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  293. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  294. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  295. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  296. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  297. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  298. package/src/ui/renderers/pluginRowText.ts +70 -0
  299. package/src/ui/renderers/pluginStatusText.ts +56 -0
  300. package/src/ui/renderers/profileRenderers.tsx +406 -0
  301. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  302. package/src/ui/renderers/skillRenderers.tsx +479 -0
  303. package/src/ui/renderers/styleRenderers.tsx +813 -0
  304. package/src/ui/screens/AliasScreen.tsx +1558 -0
  305. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  306. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  307. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  308. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  309. package/src/ui/screens/McpScreen.tsx +256 -0
  310. package/src/ui/screens/ModelsScreen.tsx +542 -0
  311. package/src/ui/screens/PluginsScreen.tsx +1464 -0
  312. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  313. package/src/ui/screens/SkillsScreen.tsx +853 -0
  314. package/src/ui/screens/StylesScreen.tsx +1064 -0
  315. package/src/ui/screens/index.ts +11 -0
  316. package/src/ui/state/AppContext.tsx +233 -0
  317. package/src/ui/state/DimensionsContext.tsx +154 -0
  318. package/src/ui/state/reducer.ts +810 -0
  319. package/src/ui/state/types.ts +503 -0
  320. package/src/ui/theme-mode.ts +352 -0
  321. package/src/ui/theme-resolve.ts +147 -0
  322. package/src/ui/theme.ts +175 -0
  323. package/src/utils/clipboard.ts +58 -0
  324. package/src/utils/command-utils.ts +144 -0
  325. package/src/utils/config-dir.ts +45 -0
  326. package/src/utils/file-locking.ts +146 -0
  327. package/src/utils/fuzzy-search.ts +138 -0
  328. package/src/utils/go-module.ts +36 -0
  329. package/src/utils/open-file.ts +84 -0
  330. package/src/utils/run.ts +67 -0
  331. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,352 @@
1
+ import type { ThemeMode } from "@opentui/core";
2
+
3
+ /**
4
+ * The one place magus needs to know whether the terminal is light or dark.
5
+ *
6
+ * Everything else in the palette avoids this question on purpose: accents are
7
+ * chosen to clear 3:1 against both backgrounds, and body text uses the terminal's
8
+ * own foreground. A *disabled row tint* is the exception, because "subtle" is
9
+ * defined relative to the page — a wash that sits gently on cream is a bright
10
+ * band on near-black, and there is no third colour that is quiet on both.
11
+ *
12
+ * ANSI cannot help here either: there is no alpha channel, so the tint cannot be
13
+ * expressed as "the page, 8% toward the foreground". OpenTUI's alpha blending
14
+ * lives only in its 3d module, not the text renderer.
15
+ *
16
+ * So we resolve, once at startup, through `theme-resolve.ts`: flag, then
17
+ * `MAGUS_THEME`, then `TERM_THEME`, then the bounded OSC probe, then
18
+ * `COLORFGBG`, then dark. **There is no unknown.** Resolution always yields a
19
+ * mode; when nothing answered it is dark, because that is the user's stated
20
+ * default, not because dark was detected.
21
+ */
22
+
23
+ let detected: ThemeMode = "dark";
24
+
25
+ /** Set once at startup from `resolveTheme()`. */
26
+ export function setThemeMode(mode: ThemeMode): void {
27
+ detected = mode;
28
+ }
29
+
30
+ export function getThemeMode(): ThemeMode {
31
+ return detected;
32
+ }
33
+
34
+ /** Test seam — return to the pre-resolution default between cases. */
35
+ export function resetThemeMode(): void {
36
+ detected = "dark";
37
+ }
38
+
39
+ /**
40
+ * Fill for an UNLIT segment of the three-square scope bar.
41
+ *
42
+ * The bar reads as three segments, each lit (in its scope colour) or unlit. Unlit
43
+ * used to be the same mid-grey as body de-emphasis, `#6B7280`, which is dark —
44
+ * so an unlit segment carried the same visual weight as a lit one and the bar
45
+ * read as three similar blobs. Reported directly: "non installed state is dark as
46
+ * well - confusing".
47
+ *
48
+ * An unlit segment should recede toward the page, and "toward the page" is the
49
+ * one thing a fixed colour cannot express: a pale fill that sits quietly on cream
50
+ * is a bright block on near-black. Hence the two values, chosen per resolved
51
+ * mode — this is the only place magus asks.
52
+ */
53
+ export const SCOPE_OFF_FILL = {
54
+ /** Just off a light page — a filled but quiet segment. */
55
+ light: "#D9D9CC",
56
+ /** Just off a dark page. */
57
+ dark: "#3A3A40",
58
+ } as const;
59
+
60
+ /** Fill colour for an unlit scope segment. Always a colour — never undefined,
61
+ * because the segment is a filled block whatever the terminal turns out to be. */
62
+ export function scopeOffFill(): string {
63
+ return SCOPE_OFF_FILL[detected];
64
+ }
65
+
66
+ /**
67
+ * Badge colours for the four plugin component kinds.
68
+ *
69
+ * These are chips, not accents: they sit on hundreds of rows at once, so the
70
+ * fill has to be a *wash* of the hue rather than the hue itself. That runs into
71
+ * the same wall `SCOPE_OFF_FILL` documents above — a wash is defined relative to
72
+ * the page, ANSI has no alpha, so the two ends are picked by hand.
73
+ *
74
+ * Hues are deliberately far apart (purple / teal / blue / amber) for the reason
75
+ * the scope squares are: one character of colour carries no readable hue shift,
76
+ * only a readable hue *difference*.
77
+ *
78
+ * The glyph colour is set per mode too. Reusing the vivid accent over a dark
79
+ * wash would drop it below the contrast the palette guarantees against the
80
+ * page — the accent's measured ratio is against `CONTRAST_REFERENCE`, not
81
+ * against another tint laid on top of it.
82
+ */
83
+ export const COMPONENT_BADGE = {
84
+ skill: {
85
+ light: { bg: "#E9DCF5", fg: "#6B21A8" },
86
+ dark: { bg: "#2E2140", fg: "#C4A5F0" },
87
+ },
88
+ command: {
89
+ light: { bg: "#D3E7EC", fg: "#0E5F73" },
90
+ dark: { bg: "#123037", fg: "#7DD3E0" },
91
+ },
92
+ agent: {
93
+ light: { bg: "#DCE4F7", fg: "#1D4ED8" },
94
+ dark: { bg: "#1B2740", fg: "#A5C0F5" },
95
+ },
96
+ mcp: {
97
+ light: { bg: "#F0E2CE", fg: "#92400E" },
98
+ dark: { bg: "#3A2A15", fg: "#E5B877" },
99
+ },
100
+ } as const;
101
+
102
+ export type ComponentBadgeKind = keyof typeof COMPONENT_BADGE;
103
+
104
+ /**
105
+ * Badge and chart colours for the four Claude models, the three external-model slots, and
106
+ * `inherit`.
107
+ *
108
+ * Four strengths, because a badge and a bar cannot share an ink: twenty characters of a
109
+ * colour weigh far more than six, so what reads as a label reads as a slab when it is a
110
+ * chart. `bg`/`fg` are the chip; `bar` is the distribution bar's fill; `label` is the ink a
111
+ * word takes when it is written ON that fill.
112
+ *
113
+ * ## Why `label` exists, and why `fg` could not do its job
114
+ *
115
+ * A segment of the workflow chart is one block of one colour with its model's name written
116
+ * on it. The name used to be drawn as the CHIP — `fg` on `bg`, inset into the bar — which
117
+ * put a second, visibly different rectangle inside a block that is supposed to read as one
118
+ * thing. Reported exactly that way: "do not add additional background colour to model
119
+ * segments".
120
+ *
121
+ * Deleting the chip and keeping `fg` was not an option: MEASURED, `fg` on the same model's
122
+ * `bar` runs 1.74:1 (sonnet dark) to 2.99:1 (haiku dark), which is a smudge rather than a
123
+ * word. `fg` is built to sit on `bg`, and `bg` is not what a bar is painted with.
124
+ *
125
+ * So each model owns a fourth ink, chosen against its OWN bar and nothing else. Every one is
126
+ * the model's own hue taken deep — the same family, not a fifth palette — and every one
127
+ * clears 4.5:1 with margin; the measured value is on each line below. `inherit` is the one
128
+ * that runs the other way: its bar is a dark recessive grey rather than a bright mid-tone,
129
+ * so its label is near-white on the dark page. That is forced by the fill, not a stylistic
130
+ * exception.
131
+ *
132
+ * ## The two pages are built differently, deliberately
133
+ *
134
+ * `COMPONENT_BADGE` above explains why a chip needs two ends at all. This map goes one step
135
+ * further and does not MIRROR them. Cream already carries the light, so a light chip is a
136
+ * pale wash under mid-tone ink and stays quiet. A near-black page carries none, so the same
137
+ * construction turns grey and the screen goes flat — a symmetric palette was built, shown,
138
+ * and rejected on sight as "dark looks like the light theme". A dark chip is therefore a
139
+ * SATURATED MID-TONE under near-white ink: a wash toward black is dark, a wash toward white
140
+ * is the light theme, and neither of those is a colour.
141
+ *
142
+ * ## The dark fills clear a page we cannot see
143
+ *
144
+ * `CONTRAST_REFERENCE_KEYS.dark` is `#1C1C1E`, but a real terminal may be pure black or a
145
+ * slate like One Dark's `#282C34`. MEASURED, the `COMPONENT_BADGE` dark washes sit at
146
+ * 1.00–1.07:1 against that slate — the same luminance as the page — so the chip loses its
147
+ * edge and reads as a hole punched in the row. These fills clear every ground: 3.1–4.3:1 on
148
+ * black, 2.8–2.9 on slate, and they still clear 4.5:1 on cream, so even a mis-detected theme
149
+ * is merely odd rather than unreadable.
150
+ *
151
+ * Hues stay far apart for the same reason the scope squares' do: one or two characters of
152
+ * colour carry no readable hue SHIFT, only a readable hue DIFFERENCE.
153
+ *
154
+ * ## The mates are ONE hue family, and that is the exception that proves the rule
155
+ *
156
+ * `mate1`, `mate2` and `kangaroo` are three different external models, so they need three
157
+ * distinguishable inks — but they all mean the same thing (someone else's model, reached
158
+ * through claudish), and four unrelated hues would say the opposite. They therefore share a
159
+ * warm outback band nothing else on this screen occupies: measured hue angles 39° / 23° / 6°
160
+ * on the dark page and 41° / 25° / 10° on the light one — ochre, terracotta, red earth. The
161
+ * four Claude models sit at teal 190°, green 150°, purple 265° and grey, so a mate cannot be
162
+ * mistaken for one of them at a glance, and a mate cannot be mistaken for another mate
163
+ * either, because ~17° of hue at full saturation is a readable difference where ~17° of
164
+ * LUMINANCE would not be.
165
+ *
166
+ * Measured contrast, ink on its own fill (the number the chip has to clear):
167
+ *
168
+ * mate1 light 5.60:1 dark 4.82:1
169
+ * mate2 light 5.82:1 dark 4.96:1
170
+ * kangaroo light 5.98:1 dark 5.68:1
171
+ *
172
+ * And the dark fills against every plausible dark ground — pure black,
173
+ * `CONTRAST_REFERENCE_KEYS.dark`, One Dark's `#282C34` — plus the cream page a mis-detected
174
+ * theme would put them on:
175
+ *
176
+ * mate1 4.09 / 3.32 / 2.73 cream 4.81
177
+ * mate2 3.83 / 3.10 / 2.55 cream 5.14
178
+ * kangaroo 3.31 / 2.68 / 2.21 cream 5.94
179
+ *
180
+ * which is the same band the four Claude fills occupy, so a mate chip carries the same weight
181
+ * as the models beside it rather than reading as a second-class one.
182
+ *
183
+ * There is a FOURTH warm entry, `mates`, and it is not a slot: it is the family's own ink,
184
+ * worn by the one collapsed segment a workflow bar draws for all external work at once. Its
185
+ * own note below says why a slot's ink could not do that job.
186
+ */
187
+ export const MODEL_BADGE = {
188
+ opus: {
189
+ /** label 4.88:1 on its bar */
190
+ light: { bg: "#D3E7EC", fg: "#0E5F73", bar: "#92CEDC", label: "#125463" },
191
+ /** label 4.93:1 on its bar */
192
+ dark: { bg: "#0E7C94", fg: "#F0FDFF", bar: "#48C8E0", label: "#044B58" },
193
+ },
194
+ sonnet: {
195
+ /** label 4.84:1 on its bar */
196
+ light: { bg: "#D6EEDD", fg: "#166534", bar: "#93D9B2", label: "#145D34" },
197
+ /** label 4.84:1 on its bar */
198
+ dark: { bg: "#12804F", fg: "#EFFFF6", bar: "#4FD98F", label: "#07572C" },
199
+ },
200
+ haiku: {
201
+ /** label 4.81:1 on its bar */
202
+ light: { bg: "#E6E6E0", fg: "#4B5563", bar: "#C9CCD2", label: "#4B5362" },
203
+ /** label 4.91:1 on its bar — the dark grey bar is the dimmest of the four, so
204
+ * its label is the closest to black of them. */
205
+ dark: { bg: "#697180", fg: "#F8FAFC", bar: "#8A92A2", label: "#21252E" },
206
+ },
207
+ fable: {
208
+ /** label 4.86:1 on its bar */
209
+ light: { bg: "#E9DCF5", fg: "#6B21A8", bar: "#C3A6EC", label: "#520FB0" },
210
+ /** label 4.81:1 on its bar */
211
+ dark: { bg: "#7B4CD6", fg: "#F6F0FF", bar: "#A87BE8", label: "#310570" },
212
+ },
213
+ /** Ochre. The first external slot, and the lightest step of the warm family.
214
+ * label 4.87:1 light / 4.81:1 dark, each on its own bar. */
215
+ mate1: {
216
+ light: { bg: "#F6E6C4", fg: "#7A5210", bar: "#E2C27A", label: "#654909" },
217
+ dark: { bg: "#926511", fg: "#FFF7E8", bar: "#D8A445", label: "#533807" },
218
+ },
219
+ /** Terracotta — the middle step, a clear ~17° of hue from either neighbour.
220
+ * label 4.80:1 light / 4.83:1 dark, each on its own bar. */
221
+ mate2: {
222
+ light: { bg: "#F7DDCB", fg: "#8A3E12", bar: "#EAB08A", label: "#793406" },
223
+ dark: { bg: "#A8501B", fg: "#FFF1E8", bar: "#E08A52", label: "#552404" },
224
+ },
225
+ /** Red earth. The third slot wears the reddest step, and it is still nowhere near
226
+ * `theme.colors.danger` — this is a desaturated earth tone, not a warning.
227
+ * label 4.86:1 light / 4.82:1 dark, each on its own bar. */
228
+ kangaroo: {
229
+ light: { bg: "#F6D6D0", fg: "#8E2F26", bar: "#E5A096", label: "#7B1C0E" },
230
+ dark: { bg: "#A83A2E", fg: "#FFEFEC", bar: "#E07A6C", label: "#571107" },
231
+ },
232
+ /**
233
+ * THE FAMILY, not a fourth slot — the ink of the collapsed `mates` bar segment.
234
+ *
235
+ * A workflow bar draws every mate-routed agent as ONE run, because the question a bar
236
+ * answers is "how much of this workflow leaves Claude Code" and that is one number. That
237
+ * run cannot wear a slot's ink: painting a two-slot segment in `mate1`'s ochre says the
238
+ * work went to `mate1`, and painting it in whichever slot contributes most makes the same
239
+ * segment change colour when a count changes — the identical label in two hues down one
240
+ * chart. So the family gets an ink of its own: honey amber, hue 35° light / 31° dark.
241
+ *
242
+ * It does NOT try to be a fourth distinguishable step, and it could not be. The warm band
243
+ * is 0–50° and already holds three slots at ~17° spacing, so the widest gap left is about
244
+ * 11° — under the separation the slots themselves needed. It does not have to be: the
245
+ * family tone and the slot tones never appear in ONE graphic. The chart and the colour key
246
+ * under it use only this; the tier table and the agent table use only the slots.
247
+ *
248
+ * MEASURED, same battery as the slots above:
249
+ * label on its own bar light 4.97:1 dark 4.78:1
250
+ * fg on its own bg light 5.87:1 dark 4.83:1
251
+ * dark bg vs grounds 4.03 (black) / 3.27 (#1C1C1E) / 2.69 (#282C34) cream 4.88
252
+ */
253
+ mates: {
254
+ light: { bg: "#F6E3C9", fg: "#80480F", bar: "#E5B77F", label: "#6B3D07" },
255
+ dark: { bg: "#9E5D16", fg: "#FFF5E8", bar: "#DB9A4C", label: "#553106" },
256
+ },
257
+ /** Not a model — the absence of a routing decision. Grey on both pages, so it never
258
+ * reads as another choice sitting beside the real ones. A mate is NOT this: a mate is
259
+ * a decision to route elsewhere, which is why it gets a colour and this does not.
260
+ *
261
+ * Its label is a NEUTRAL grey at 4.82:1 light / 4.83:1 dark, and on the dark page it
262
+ * is the only label that is pale rather than deep — this is the one bar that is itself
263
+ * dark, so nothing darker could be read on it. */
264
+ inherit: {
265
+ light: { bg: "#EFEFE6", fg: "#8A8A80", bar: "#DEDED2", label: "#5E5E57" },
266
+ dark: { bg: "#4A4A54", fg: "#D0D2D8", bar: "#4E4E58", label: "#C6C6CA" },
267
+ },
268
+ } as const;
269
+
270
+ export type ModelBadgeKind = keyof typeof MODEL_BADGE;
271
+
272
+ export interface ModelInk {
273
+ bg: string;
274
+ fg: string;
275
+ bar: string;
276
+ /** Ink for a word written ON `bar`. Never `fg` — see the note above `MODEL_BADGE`. */
277
+ label: string;
278
+ }
279
+
280
+ /**
281
+ * Chip and bar colours for one model, for the resolved mode.
282
+ *
283
+ * Total by construction: a name this build does not know gets `inherit`'s grey, which reads
284
+ * as "magus does not recognise this" rather than as a missing colour. A hand-edited
285
+ * config can name anything.
286
+ */
287
+ export function modelBadge(kind: string): ModelInk {
288
+ const key = (kind in MODEL_BADGE ? kind : "inherit") as ModelBadgeKind;
289
+ return MODEL_BADGE[key][detected];
290
+ }
291
+
292
+ /**
293
+ * The UNLIT track: the `·` behind an effort dot, the `░` behind a bar.
294
+ *
295
+ * Light reuses `SCOPE_OFF_FILL`, which already solves this there. Dark does NOT: at
296
+ * `#3A3A40` the track measures 1.51:1 on the reference page and simply vanishes, so a bar
297
+ * reads as a floating block with no scale and the effort dots lose the denominator that
298
+ * makes them a level rather than a count. `#4A4A52` is 1.94:1 — still recessive, still
299
+ * visible.
300
+ */
301
+ export const TRACK_FILL = {
302
+ light: SCOPE_OFF_FILL.light,
303
+ dark: "#4A4A52",
304
+ } as const;
305
+
306
+ /** Fill for an unlit track segment, for the resolved mode. */
307
+ export function trackFill(): string {
308
+ return TRACK_FILL[detected];
309
+ }
310
+
311
+ /**
312
+ * Effort's colour ramp — TEXT only, never a fill, so a row never paints two blocks.
313
+ *
314
+ * Effort is an ORDINAL, so these are a ramp rather than four unrelated hues. The ramp avoids
315
+ * green and red, which already mean "on" and "failed" elsewhere on the same screen: one
316
+ * colour, one meaning, app-wide. Dark runs brighter than light for the reason `MODEL_BADGE`
317
+ * gives — a dim colour on a dark page is a grey one.
318
+ */
319
+ export const EFFORT_INK = {
320
+ low: { light: "#6B7280", dark: "#9AA0AC" },
321
+ medium: { light: "#0E7490", dark: "#6FD9F0" },
322
+ high: { light: "#B45309", dark: "#F2B25E" },
323
+ xhigh: { light: "#A21CAF", dark: "#E49BFF" },
324
+ } as const;
325
+
326
+ export type EffortInkKind = keyof typeof EFFORT_INK;
327
+
328
+ /** Ink for one effort level, for the resolved mode. Unknown words take the lowest step. */
329
+ export function effortInk(effort: string): string {
330
+ const key = (effort in EFFORT_INK ? effort : "low") as EffortInkKind;
331
+ return EFFORT_INK[key][detected];
332
+ }
333
+
334
+ /**
335
+ * The two reference pages, mirrored from `theme.ts`'s `CONTRAST_REFERENCE`.
336
+ *
337
+ * Duplicated deliberately: `theme.ts` imports opentui's RGBA, which drags the
338
+ * renderer into anything that touches it. This module is pure data by design so
339
+ * the colour tests can run without a terminal. A test asserts the two agree.
340
+ */
341
+ export const CONTRAST_REFERENCE_KEYS = {
342
+ light: "#FAFAD2",
343
+ dark: "#1C1C1E",
344
+ } as const;
345
+
346
+ /** Chip colours for one component kind, for the resolved mode. */
347
+ export function componentBadge(kind: ComponentBadgeKind): {
348
+ bg: string;
349
+ fg: string;
350
+ } {
351
+ return COMPONENT_BADGE[kind][detected];
352
+ }
@@ -0,0 +1,147 @@
1
+ import type { ThemeMode } from "@opentui/core";
2
+
3
+ /**
4
+ * Terminal theme resolution — the one order magus follows, decided once at
5
+ * process start, first answer wins:
6
+ *
7
+ * 1. `--theme` flag
8
+ * 2. `MAGUS_THEME`
9
+ * 3. `TERM_THEME`
10
+ * 4. OSC 10/11 probe (bounded, interactive tty only)
11
+ * 5. `COLORFGBG`
12
+ * 6. dark
13
+ *
14
+ * A step with no answer says nothing and the next one runs; nothing before
15
+ * step 6 ever asserts dark. Every theme word is compared exactly — the byte
16
+ * strings `light` and `dark`, lowercase, untrimmed — because the same test has
17
+ * to run in a POSIX `case` and a tmux format, and only the exact comparison is
18
+ * free in all three.
19
+ *
20
+ * This module is pure by design: no `process` access except as a *default
21
+ * argument* to `snapshotThemeEnv`, no I/O, no renderer. Everything it needs is
22
+ * passed in, so the whole contract runs in a test without a terminal.
23
+ */
24
+
25
+ export type ThemeSource =
26
+ | "flag"
27
+ | "variable"
28
+ | "TERM_THEME"
29
+ | "osc11"
30
+ | "COLORFGBG"
31
+ | "default";
32
+
33
+ export interface ThemeResolution {
34
+ /** Never null — when nothing answered, it is dark by decision. */
35
+ mode: ThemeMode;
36
+ source: ThemeSource;
37
+ }
38
+
39
+ /** The three variables the resolver reads, captured BEFORE .env is merged. */
40
+ export interface ThemeEnv {
41
+ TERM_THEME?: string;
42
+ MAGUS_THEME?: string;
43
+ COLORFGBG?: string;
44
+ }
45
+
46
+ export interface ResolveThemeInput {
47
+ /** Already validated by the router: only "light" | "dark" | undefined arrive here. */
48
+ flag?: ThemeMode;
49
+ env: ThemeEnv;
50
+ /** stdin AND stdout are TTYs. Decided by the caller; the resolver never touches process. */
51
+ isInteractive: boolean;
52
+ /** Bounded OSC 10/11 probe. Called at most once, and only when step 4 is reached. */
53
+ probe: () => Promise<ThemeMode | null>;
54
+ }
55
+
56
+ /** Exact match on "light" | "dark"; everything else is no opinion. */
57
+ export function parseThemeWord(value: string | undefined): ThemeMode | null {
58
+ if (value === "light") return "light";
59
+ if (value === "dark") return "dark";
60
+ return null;
61
+ }
62
+
63
+ /** The literal background fields that answer — the same set as the statusline's `case`. */
64
+ const LIGHT_BACKGROUNDS: ReadonlySet<string> = new Set(["7", "15"]);
65
+ const DARK_BACKGROUNDS: ReadonlySet<string> = new Set([
66
+ "0",
67
+ "1",
68
+ "2",
69
+ "3",
70
+ "4",
71
+ "5",
72
+ "6",
73
+ "8",
74
+ "9",
75
+ "10",
76
+ "11",
77
+ "12",
78
+ "13",
79
+ "14",
80
+ ]);
81
+
82
+ /**
83
+ * Last ";"-field is the background: 7|15 → light, 0-6|8-14 → dark, else null.
84
+ *
85
+ * Exact string membership, not numeric parsing: the statusline's
86
+ * `parse_colorfgbg` is a POSIX `case` over the literal words, so `07`, `015`
87
+ * and `00` are no opinion there, and that literal set is the contract the two
88
+ * tools share. `Number("07")` would make magus say light where the
89
+ * statusline says nothing.
90
+ *
91
+ * A value without a `;` is not `<fg>;<bg>` at all — a lone `7` is no opinion,
92
+ * matching the statusline's `case "$1" in *\;*)` guard so the two tools agree.
93
+ */
94
+ export function parseColorFgBg(value: string | undefined): ThemeMode | null {
95
+ if (value === undefined || !value.includes(";")) return null;
96
+ const bg = value.slice(value.lastIndexOf(";") + 1);
97
+ if (LIGHT_BACKGROUNDS.has(bg)) return "light";
98
+ if (DARK_BACKGROUNDS.has(bg)) return "dark";
99
+ return null;
100
+ }
101
+
102
+ /**
103
+ * Copy the three variables out of `env` — call this before loadProjectDotenv.
104
+ *
105
+ * Copies, never references: the returned object must not observe later writes
106
+ * to `process.env`, which is exactly what a merged `.env` would be.
107
+ */
108
+ export function snapshotThemeEnv(
109
+ env: Readonly<Record<string, string | undefined>> = process.env,
110
+ ): ThemeEnv {
111
+ const snapshot: ThemeEnv = {};
112
+ if (env.TERM_THEME !== undefined) snapshot.TERM_THEME = env.TERM_THEME;
113
+ if (env.MAGUS_THEME !== undefined) snapshot.MAGUS_THEME = env.MAGUS_THEME;
114
+ if (env.COLORFGBG !== undefined) snapshot.COLORFGBG = env.COLORFGBG;
115
+ return snapshot;
116
+ }
117
+
118
+ export async function resolveTheme(
119
+ input: ResolveThemeInput,
120
+ ): Promise<ThemeResolution> {
121
+ if (input.flag !== undefined) {
122
+ return { mode: input.flag, source: "flag" };
123
+ }
124
+
125
+ const variable = parseThemeWord(input.env.MAGUS_THEME);
126
+ if (variable !== null) return { mode: variable, source: "variable" };
127
+
128
+ const termTheme = parseThemeWord(input.env.TERM_THEME);
129
+ if (termTheme !== null) return { mode: termTheme, source: "TERM_THEME" };
130
+
131
+ // Steps 1-3 answered nothing, so this is the only path that probes. Not
132
+ // reaching here is what keeps first paint undelayed when a word answered.
133
+ if (input.isInteractive) {
134
+ let probed: ThemeMode | null = null;
135
+ try {
136
+ probed = await input.probe();
137
+ } catch {
138
+ probed = null;
139
+ }
140
+ if (probed !== null) return { mode: probed, source: "osc11" };
141
+ }
142
+
143
+ const colorFgBg = parseColorFgBg(input.env.COLORFGBG);
144
+ if (colorFgBg !== null) return { mode: colorFgBg, source: "COLORFGBG" };
145
+
146
+ return { mode: "dark", source: "default" };
147
+ }
@@ -0,0 +1,175 @@
1
+ import { RGBA } from "@opentui/core";
2
+
3
+ /**
4
+ * magus's palette: our own vivid accents, on the terminal's own canvas.
5
+ *
6
+ * Three rules, and the reason for each:
7
+ *
8
+ * 1. **Backgrounds are never ours.** Surfaces and modals use the terminal's
9
+ * background. Painting an absolute fill boxes the UI into whichever theme we
10
+ * guessed, and it was the original sin here — modals hardcoded #1C1C1E.
11
+ * 2. **Body text is never ours.** Normal text uses the terminal's foreground,
12
+ * which is the only colour guaranteed to clear a text-grade contrast ratio
13
+ * against that terminal's own background. `text` used to be the string
14
+ * `"white"` — OpenTUI resolves that to rgb(255,255,255), not to "the
15
+ * terminal's white" — so on a light theme every installed plugin was drawn
16
+ * white-on-cream and vanished while the *un*installed ones, in grey, stayed
17
+ * readable. The list read exactly backwards.
18
+ * 3. **Accents ARE ours, and vivid.** Deferring accents to ANSI slots 0-15 made
19
+ * everything legible but flattened it: a theme that renders slots 2 and 3 as
20
+ * neighbouring olives leaves user/project/local scope squares indistinguishable,
21
+ * and slot 8 chips melt into the page. Distinction is a product decision, so
22
+ * these are chosen, not inherited.
23
+ *
24
+ * ## How the accents were chosen
25
+ *
26
+ * By measurement, not taste. A single colour CANNOT reach 4.5:1 against both a
27
+ * light and a dark terminal — clearing 4.5:1 on cream needs relative luminance
28
+ * <= ~0.17, clearing it on near-black needs >= ~0.22, and those do not overlap.
29
+ * So the bar is 3:1, WCAG's threshold for UI components and large text, which
30
+ * admits a band of mid-dark saturated colours. Every accent below clears 3:1
31
+ * against BOTH reference backgrounds, verified in theme-adaptive-colors.test.ts —
32
+ * so a colour that only works on dark cannot be added without failing the build.
33
+ *
34
+ * Where we own a badge's background we also own its ink, so `ink` is plain white
35
+ * and its contrast is deterministic rather than a function of the user's theme.
36
+ * That is why badge ink is not slot 0 or the terminal background: on a light
37
+ * theme, terminal-background ink on a yellow chip is unreadable.
38
+ */
39
+
40
+ /** Reference backgrounds every accent is measured against. */
41
+ export const CONTRAST_REFERENCE = {
42
+ light: "#FAFAD2",
43
+ dark: "#1C1C1E",
44
+ } as const;
45
+
46
+ /**
47
+ * The vivid set. Ratios are (on light / on dark), measured — see the test.
48
+ * Hues for the three scopes are deliberately far apart (blue / green / amber)
49
+ * because they appear as single-character squares, where a subtle hue shift is
50
+ * not readable at all.
51
+ */
52
+ export const brand = {
53
+ accent: "#9333EA", // 5.04 / 3.16 — brand purple
54
+ link: "#2563EB", // 4.84 / 3.29
55
+ info: "#0E7490", // 5.02 / 3.18 — teal
56
+ success: "#15803D", // 4.70 / 3.39
57
+ warning: "#B45309", // 4.70 / 3.39 — amber
58
+ danger: "#DC2626", // 4.52 / 3.52
59
+ muted: "#6B7280", // 4.53 / 3.52 — de-emphasis, still legible on both
60
+ /** Ink on OUR badges. We own both sides, so contrast is deterministic. */
61
+ ink: "#FFFFFF",
62
+ /**
63
+ * De-emphasised ink on the SELECTION fill, and nowhere else.
64
+ *
65
+ * Measured on a screenshot: `muted` (#6B7280) on the selection purple is
66
+ * 1.11:1, `warning` 1.07:1, `danger` 1.11:1 — every meta tone was invisible
67
+ * the moment its row was selected, and `(default)` on the Models screen
68
+ * disappeared entirely. The page-level greys cannot help here: they are
69
+ * chosen to sit between a cream and a near-black terminal, and the selection
70
+ * is neither.
71
+ *
72
+ * 3.64:1 on the selection fill, and 1.48:1 against `ink` — far enough from
73
+ * white to still read as secondary. Like `ink`, it is measured against a fill
74
+ * WE paint rather than against the terminal, so it is exempt from the
75
+ * both-backgrounds rule the accents above obey.
76
+ */
77
+ selectionDim: "#D4D4D8",
78
+ } as const;
79
+
80
+ /** Any colour a component may hand to a JSX colour prop. */
81
+ export type UiColor = string | RGBA;
82
+
83
+ /** The terminal's own foreground — the only honest "normal text" colour. */
84
+ const fg = () => RGBA.defaultForeground();
85
+
86
+ /** The terminal's own background — for surfaces, never for ink. */
87
+ const bg = () => RGBA.defaultBackground();
88
+
89
+ export const theme = {
90
+ colors: {
91
+ /** Adaptive by rule 2 — never make this one of ours. */
92
+ text: fg(),
93
+ muted: brand.muted,
94
+ /** Same grey as `muted`; a third level should come from layout, not colour. */
95
+ dim: brand.muted,
96
+ border: brand.muted,
97
+ link: brand.link,
98
+ accent: brand.accent,
99
+ /** Low-key brand tint for bylines — readable, deliberately not attention-grabbing. */
100
+ byline: brand.accent,
101
+ success: brand.success,
102
+ warning: brand.warning,
103
+ danger: brand.danger,
104
+ info: brand.info,
105
+ },
106
+
107
+ /**
108
+ * The selected row owns the colour channel.
109
+ *
110
+ * A row's normal palette is chosen against the TERMINAL's background; on the
111
+ * selection fill none of it clears even 1.2:1. So a selected row has exactly
112
+ * two inks — `fg` for what it is saying, `dim` for what it is saying quietly —
113
+ * and any tone a row would otherwise carry is surrendered while it is
114
+ * selected. The words survive; the hue does not, and cannot.
115
+ */
116
+ selection: {
117
+ bg: brand.accent,
118
+ fg: brand.ink,
119
+ dim: brand.selectionDim,
120
+ },
121
+
122
+ /** Blue / green / amber: the widest hue separation available, because these
123
+ * render as one-character squares where a subtle shift reads as noise. */
124
+ scopes: {
125
+ user: brand.link,
126
+ project: brand.success,
127
+ local: brand.warning,
128
+ },
129
+
130
+ /** Badge colours. `fg` is the ink ON the block; `badgeFg` is the same hue used
131
+ * as ink on the normal background instead. */
132
+ category: {
133
+ purple: { bg: brand.accent, fg: brand.ink, badgeFg: brand.accent },
134
+ green: { bg: brand.success, fg: brand.ink, badgeFg: brand.success },
135
+ teal: { bg: brand.info, fg: brand.ink, badgeFg: brand.info },
136
+ yellow: { bg: brand.warning, fg: brand.ink, badgeFg: brand.warning },
137
+ gray: { bg: brand.muted, fg: brand.ink, badgeFg: brand.muted },
138
+ red: { bg: brand.danger, fg: brand.ink, badgeFg: brand.danger },
139
+ },
140
+
141
+ meta: {
142
+ muted: brand.muted,
143
+ warning: brand.warning,
144
+ success: brand.success,
145
+ danger: brand.danger,
146
+ },
147
+
148
+ /**
149
+ * Footer key chips. A vivid block with white ink, because the point of a chip
150
+ * is to read as a pressable key at a glance — the previous neutral-grey chip
151
+ * melted into a light page. Dark slates were measured and rejected: they clear
152
+ * 3:1 on cream but drop to ~2.2 on a dark terminal, i.e. an invisible block.
153
+ */
154
+ hints: {
155
+ defaultBg: brand.accent,
156
+ primaryBg: brand.info,
157
+ dangerBg: brand.danger,
158
+ fg: brand.ink,
159
+ },
160
+
161
+ /** Panels and modals sit on the terminal's own background and are separated by
162
+ * their border, never by an absolute fill that fights the user's theme. */
163
+ surface: {
164
+ bg: bg(),
165
+ border: brand.muted,
166
+ },
167
+
168
+ spacing: {
169
+ rowIndent: 1,
170
+ sectionGap: 1,
171
+ panelPadding: 1,
172
+ },
173
+ } as const;
174
+
175
+ export { fg as defaultFg, bg as defaultBg };