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,440 @@
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
+ /**
273
+ * Fill, ink and page-text for the report's state badges — one set per page.
274
+ *
275
+ * Every value here is LIFTED from `MODEL_BADGE` above rather than invented, so
276
+ * the contrast measurements recorded there carry over. Ink on fill, and the
277
+ * page text on its own ground, both measured here:
278
+ *
279
+ * update `sonnet` green ink/fill 5.82 light, 4.80 dark
280
+ * text 6.68 on cream, 9.44 on #1C1C1E
281
+ * deprecated `kangaroo` red earth label 4.86 light on its own bar
282
+ * fix `fable` purple label 4.86 light on its own bar
283
+ *
284
+ * `text` is the tone for a heading or glyph drawn ON THE PAGE, which is not the
285
+ * same question as ink on a fill: on cream it is the deep member of the family,
286
+ * on near-black the pale one. Using the fill for both put a mid-amber heading on
287
+ * a dark page, where it reads as dirt.
288
+ *
289
+ * Green, not ochre, because green is what an update wears elsewhere in magus
290
+ * and the report was the odd one out. It is a like-for-like swap inside the same
291
+ * measured system: the green fill separates from the page by 1.15 on cream and
292
+ * 3.42 on near-black, against the ochre's 1.15 and 3.32.
293
+ *
294
+ * Amber is NOT freed up by this — `unknown` still wears it, and that is the
295
+ * distinction worth keeping: green says "there is a newer version", amber says
296
+ * "we could not tell".
297
+ *
298
+ * Purple carries "fix required" because the warm band is spoken for and the
299
+ * measured set has no blue. Hue, not meaning — the badge says the word.
300
+ */
301
+ const REPORT_BADGE = {
302
+ update: {
303
+ light: { fill: "#D6EEDD", ink: "#166534", text: "#166534" },
304
+ dark: { fill: "#12804F", ink: "#EFFFF6", text: "#4FD98F" },
305
+ },
306
+ deprecated: {
307
+ light: { fill: "#F6D6D0", ink: "#8E2F26", text: "#8E2F26" },
308
+ dark: { fill: "#A83A2E", ink: "#FFEFEC", text: "#E07A6C" },
309
+ },
310
+ fix: {
311
+ light: { fill: "#E9DCF5", ink: "#6B21A8", text: "#6B21A8" },
312
+ dark: { fill: "#7B4CD6", ink: "#F6F0FF", text: "#A87BE8" },
313
+ },
314
+ } as const;
315
+
316
+ export type ReportBadgeKind = keyof typeof REPORT_BADGE;
317
+
318
+ export interface ReportInk {
319
+ /** The badge's fill. */
320
+ fill: string;
321
+ /** Ink for the word written on `fill`. */
322
+ ink: string;
323
+ /** The tone for a heading or glyph on the page itself. */
324
+ text: string;
325
+ }
326
+
327
+ /** Badge colours for one report state, for the resolved page. */
328
+ export function reportBadge(kind: ReportBadgeKind): ReportInk {
329
+ return REPORT_BADGE[kind][detected];
330
+ }
331
+
332
+ /**
333
+ * The unfilled part of a bar — `░` — for the page this run resolved.
334
+ *
335
+ * A track is CHROME. It exists so the eye can see how long the bar could have
336
+ * been, and it must never compete with the fill. "Quiet" is only definable
337
+ * against a page, which is why this lives here and not beside the ramps.
338
+ *
339
+ * It was a single dark grey, `darken(brand.muted, 0.68)` = #222429, chosen
340
+ * against a near-black terminal and applied on both. MEASURED against the two
341
+ * reference grounds:
342
+ *
343
+ * #222429 (was) this table
344
+ * dark 1.10:1 quiet 1.10:1 quiet
345
+ * cream 14.54:1 LOUDEST 1.24:1 quiet
346
+ *
347
+ * 14.54:1 is higher contrast than the body text. On a light terminal the empty
348
+ * half of every step bar was the most prominent thing in the report — a dense
349
+ * stipple that read as hatched texture and buried a 1 ms step's actual fill.
350
+ * The light value steps from the page TOWARD `brand.muted` rather than scaling
351
+ * a grey, so it stays in the page's own hue instead of laying cold grey on warm
352
+ * cream.
353
+ */
354
+ const BAR_TRACK = { light: "#E0E2C3", dark: "#222429" } as const;
355
+
356
+ export function barTrack(): string {
357
+ return BAR_TRACK[detected];
358
+ }
359
+
360
+ export interface ModelInk {
361
+ bg: string;
362
+ fg: string;
363
+ bar: string;
364
+ /** Ink for a word written ON `bar`. Never `fg` — see the note above `MODEL_BADGE`. */
365
+ label: string;
366
+ }
367
+
368
+ /**
369
+ * Chip and bar colours for one model, for the resolved mode.
370
+ *
371
+ * Total by construction: a name this build does not know gets `inherit`'s grey, which reads
372
+ * as "magus does not recognise this" rather than as a missing colour. A hand-edited
373
+ * config can name anything.
374
+ */
375
+ export function modelBadge(kind: string): ModelInk {
376
+ const key = (kind in MODEL_BADGE ? kind : "inherit") as ModelBadgeKind;
377
+ return MODEL_BADGE[key][detected];
378
+ }
379
+
380
+ /**
381
+ * The UNLIT track: the `·` behind an effort dot, the `░` behind a bar.
382
+ *
383
+ * Light reuses `SCOPE_OFF_FILL`, which already solves this there. Dark does NOT: at
384
+ * `#3A3A40` the track measures 1.51:1 on the reference page and simply vanishes, so a bar
385
+ * reads as a floating block with no scale and the effort dots lose the denominator that
386
+ * makes them a level rather than a count. `#4A4A52` is 1.94:1 — still recessive, still
387
+ * visible.
388
+ */
389
+ export const TRACK_FILL = {
390
+ light: SCOPE_OFF_FILL.light,
391
+ dark: "#4A4A52",
392
+ } as const;
393
+
394
+ /** Fill for an unlit track segment, for the resolved mode. */
395
+ export function trackFill(): string {
396
+ return TRACK_FILL[detected];
397
+ }
398
+
399
+ /**
400
+ * Effort's colour ramp — TEXT only, never a fill, so a row never paints two blocks.
401
+ *
402
+ * Effort is an ORDINAL, so these are a ramp rather than four unrelated hues. The ramp avoids
403
+ * green and red, which already mean "on" and "failed" elsewhere on the same screen: one
404
+ * colour, one meaning, app-wide. Dark runs brighter than light for the reason `MODEL_BADGE`
405
+ * gives — a dim colour on a dark page is a grey one.
406
+ */
407
+ export const EFFORT_INK = {
408
+ low: { light: "#6B7280", dark: "#9AA0AC" },
409
+ medium: { light: "#0E7490", dark: "#6FD9F0" },
410
+ high: { light: "#B45309", dark: "#F2B25E" },
411
+ xhigh: { light: "#A21CAF", dark: "#E49BFF" },
412
+ } as const;
413
+
414
+ export type EffortInkKind = keyof typeof EFFORT_INK;
415
+
416
+ /** Ink for one effort level, for the resolved mode. Unknown words take the lowest step. */
417
+ export function effortInk(effort: string): string {
418
+ const key = (effort in EFFORT_INK ? effort : "low") as EffortInkKind;
419
+ return EFFORT_INK[key][detected];
420
+ }
421
+
422
+ /**
423
+ * The two reference pages, mirrored from `theme.ts`'s `CONTRAST_REFERENCE`.
424
+ *
425
+ * Duplicated deliberately: `theme.ts` imports opentui's RGBA, which drags the
426
+ * renderer into anything that touches it. This module is pure data by design so
427
+ * the colour tests can run without a terminal. A test asserts the two agree.
428
+ */
429
+ export const CONTRAST_REFERENCE_KEYS = {
430
+ light: "#FAFAD2",
431
+ dark: "#1C1C1E",
432
+ } as const;
433
+
434
+ /** Chip colours for one component kind, for the resolved mode. */
435
+ export function componentBadge(kind: ComponentBadgeKind): {
436
+ bg: string;
437
+ fg: string;
438
+ } {
439
+ return COMPONENT_BADGE[kind][detected];
440
+ }
@@ -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
+ }