magus-cli 0.0.0-placeholder → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +309 -0
- package/bin/magus.js +170 -0
- package/package.json +67 -3
- package/scripts/build-binaries.ts +113 -0
- package/scripts/capture-builtin.ts +412 -0
- package/scripts/check-optional-deps.ts +49 -0
- package/scripts/test-isolated.ts +49 -0
- package/scripts/verify-binary.ts +95 -0
- package/scripts/verify-community-registry.ts +272 -0
- package/src/__tests__/active-closure.test.ts +184 -0
- package/src/__tests__/alias-adopt.test.ts +365 -0
- package/src/__tests__/alias-parser.test.ts +417 -0
- package/src/__tests__/alias-shell-writer.test.ts +681 -0
- package/src/__tests__/alias-store.test.ts +163 -0
- package/src/__tests__/binary-signing.test.ts +120 -0
- package/src/__tests__/blackbox-active-closure.test.ts +119 -0
- package/src/__tests__/blackbox-convergence.test.ts +334 -0
- package/src/__tests__/blackbox-parity.test.ts +684 -0
- package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
- package/src/__tests__/blackbox-tool-state.test.ts +669 -0
- package/src/__tests__/cache-provenance.test.ts +84 -0
- package/src/__tests__/capture-builtin.test.ts +92 -0
- package/src/__tests__/catalog-cache-store.test.ts +309 -0
- package/src/__tests__/catalog-notice.test.ts +155 -0
- package/src/__tests__/cli-ansi.test.ts +297 -0
- package/src/__tests__/cli-apply-seams.test.ts +288 -0
- package/src/__tests__/cli-live.test.ts +391 -0
- package/src/__tests__/cli-router.test.ts +240 -0
- package/src/__tests__/cli-tool-commands.test.ts +239 -0
- package/src/__tests__/cli-tools-adapter.test.ts +294 -0
- package/src/__tests__/cli-update-view.test.ts +445 -0
- package/src/__tests__/community-fetch.test.ts +545 -0
- package/src/__tests__/community-registry.test.ts +269 -0
- package/src/__tests__/community-staleness.test.ts +720 -0
- package/src/__tests__/component-badge.test.ts +98 -0
- package/src/__tests__/component-icons.test.ts +94 -0
- package/src/__tests__/content-drift.test.ts +353 -0
- package/src/__tests__/conventions-integration.test.ts +830 -0
- package/src/__tests__/conventions-manager.test.ts +1236 -0
- package/src/__tests__/doctor-bins.test.ts +43 -0
- package/src/__tests__/doctor.test.ts +463 -0
- package/src/__tests__/dotenv.test.ts +195 -0
- package/src/__tests__/enabled-not-installed.test.ts +94 -0
- package/src/__tests__/file-locking.test.ts +208 -0
- package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
- package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
- package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
- package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
- package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
- package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
- package/src/__tests__/footer-hints.test.ts +40 -0
- package/src/__tests__/format-command.test.ts +78 -0
- package/src/__tests__/git-worktree.test.ts +108 -0
- package/src/__tests__/github-budget.test.ts +206 -0
- package/src/__tests__/gitignore-check.test.ts +71 -0
- package/src/__tests__/gitignore-detector.test.ts +176 -0
- package/src/__tests__/gitignore-fixer.test.ts +317 -0
- package/src/__tests__/gitignore-resolver.test.ts +193 -0
- package/src/__tests__/gitignore-service.test.ts +137 -0
- package/src/__tests__/go-module.test.ts +43 -0
- package/src/__tests__/hook-import-policy.test.ts +90 -0
- package/src/__tests__/hook-process.test.ts +256 -0
- package/src/__tests__/hook-registration.test.ts +224 -0
- package/src/__tests__/install-command.test.ts +81 -0
- package/src/__tests__/install-plan.test.ts +104 -0
- package/src/__tests__/launcher-signal.test.ts +308 -0
- package/src/__tests__/local-marketplace-scan.test.ts +174 -0
- package/src/__tests__/manifest.test.ts +281 -0
- package/src/__tests__/markdown-renderer.test.ts +142 -0
- package/src/__tests__/marketplace-badge.test.ts +140 -0
- package/src/__tests__/marketplace-refresh.test.ts +335 -0
- package/src/__tests__/marketplace-version.test.ts +84 -0
- package/src/__tests__/marketplaces.test.ts +128 -0
- package/src/__tests__/mate-availability.test.ts +156 -0
- package/src/__tests__/mate-catalog.test.ts +295 -0
- package/src/__tests__/mcp-registry.test.ts +158 -0
- package/src/__tests__/model-visuals.test.tsx +2466 -0
- package/src/__tests__/models-adapter.test.ts +332 -0
- package/src/__tests__/models-cli.test.ts +273 -0
- package/src/__tests__/models-core.test.ts +802 -0
- package/src/__tests__/models-manager.test.ts +500 -0
- package/src/__tests__/models-presets-marketplace.test.ts +168 -0
- package/src/__tests__/models-screen-state.test.ts +315 -0
- package/src/__tests__/moved-marketplace.test.ts +86 -0
- package/src/__tests__/open-file.test.ts +59 -0
- package/src/__tests__/owned-marketplaces.test.ts +93 -0
- package/src/__tests__/plugin-checkout.test.ts +66 -0
- package/src/__tests__/plugin-cli-argv.test.ts +109 -0
- package/src/__tests__/plugin-contents.test.ts +371 -0
- package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
- package/src/__tests__/plugin-releases.test.ts +98 -0
- package/src/__tests__/plugin-requires.test.ts +187 -0
- package/src/__tests__/plugin-row-text.test.ts +336 -0
- package/src/__tests__/plugin-setup.test.ts +1016 -0
- package/src/__tests__/profile-adopt.test.ts +177 -0
- package/src/__tests__/profile-command.test.ts +110 -0
- package/src/__tests__/profile-materializer.test.ts +238 -0
- package/src/__tests__/profile-sync.test.ts +154 -0
- package/src/__tests__/registry-version-resolution.test.ts +88 -0
- package/src/__tests__/resolve-executable.test.ts +42 -0
- package/src/__tests__/resolver.test.ts +351 -0
- package/src/__tests__/scope-action.test.ts +134 -0
- package/src/__tests__/scope-squares.test.tsx +164 -0
- package/src/__tests__/selection-scope.test.ts +100 -0
- package/src/__tests__/settings-file.test.ts +179 -0
- package/src/__tests__/shell-script-callers.test.ts +92 -0
- package/src/__tests__/skill-install-files.test.ts +136 -0
- package/src/__tests__/skills-catalog-client.test.ts +48 -0
- package/src/__tests__/style-wrap.test.ts +220 -0
- package/src/__tests__/styles-manager.test.ts +1098 -0
- package/src/__tests__/styles-origins.test.ts +414 -0
- package/src/__tests__/styles-screen-state.test.ts +457 -0
- package/src/__tests__/styles-status-line.test.ts +72 -0
- package/src/__tests__/styles-sync.test.ts +452 -0
- package/src/__tests__/symlink-manager.test.ts +180 -0
- package/src/__tests__/tabbar-layout.test.ts +100 -0
- package/src/__tests__/terminology-filler.test.ts +214 -0
- package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
- package/src/__tests__/theme-env.test.ts +281 -0
- package/src/__tests__/theme-resolve.test.ts +380 -0
- package/src/__tests__/tool-probe.test.ts +1400 -0
- package/src/__tests__/toolchain.test.ts +232 -0
- package/src/__tests__/ui-version-writers.test.ts +88 -0
- package/src/__tests__/update-apply.test.ts +637 -0
- package/src/__tests__/update-plan.test.ts +1373 -0
- package/src/__tests__/uppercase-keybindings.test.ts +101 -0
- package/src/__tests__/useGitignoreModal.test.ts +79 -0
- package/src/__tests__/version-check-refresh.test.ts +45 -0
- package/src/__tests__/version-snapshot.test.ts +212 -0
- package/src/__tests__/version-unknown-state.test.ts +388 -0
- package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
- package/src/cli/ansi.ts +520 -0
- package/src/cli/bootstrap.ts +134 -0
- package/src/cli/doctor.ts +218 -0
- package/src/cli/hook.ts +129 -0
- package/src/cli/install.ts +382 -0
- package/src/cli/live.ts +374 -0
- package/src/cli/models.ts +297 -0
- package/src/cli/profile.ts +197 -0
- package/src/cli/prompt.ts +32 -0
- package/src/cli/router.ts +207 -0
- package/src/cli/update-view.ts +526 -0
- package/src/cli/update.ts +613 -0
- package/src/cli/upgrade.ts +97 -0
- package/src/data/alias-flags.ts +301 -0
- package/src/data/cli-tools.ts +146 -0
- package/src/data/community-styles.ts +531 -0
- package/src/data/gitignore-defaults.ts +33 -0
- package/src/data/gitignore-reasons.ts +93 -0
- package/src/data/gitignore-templates.ts +25 -0
- package/src/data/marketplaces.ts +278 -0
- package/src/data/mcp-servers.ts +51 -0
- package/src/data/models-presets.ts +329 -0
- package/src/data/predefined-profiles.ts +252 -0
- package/src/data/settings-catalog.ts +714 -0
- package/src/data/skill-repos.ts +91 -0
- package/src/data/styles/asd-ste100.md +38 -0
- package/src/data/styles/calibrated.md +24 -0
- package/src/data/styles/direct.md +23 -0
- package/src/data/styles/evidence-first.md +22 -0
- package/src/data/styles/explanatory.md +23 -0
- package/src/data/styles/index.ts +57 -0
- package/src/data/styles/no-slop.md +32 -0
- package/src/data/styles/plain-language.md +23 -0
- package/src/data/styles/structured.md +30 -0
- package/src/data/styles/terminology.md +28 -0
- package/src/data/styles/terse.md +20 -0
- package/src/main.tsx +110 -0
- package/src/markdown.d.ts +13 -0
- package/src/opentui.d.ts +203 -0
- package/src/services/active-closure.ts +117 -0
- package/src/services/alias-settings.ts +60 -0
- package/src/services/alias-shell-writer.ts +1244 -0
- package/src/services/alias-store.ts +172 -0
- package/src/services/binary-signing.ts +78 -0
- package/src/services/catalog-cache-store.ts +316 -0
- package/src/services/claude-cli.ts +304 -0
- package/src/services/claude-runner.ts +31 -0
- package/src/services/claude-settings.ts +1589 -0
- package/src/services/cli-tool-commands.ts +133 -0
- package/src/services/community-fetcher.ts +90 -0
- package/src/services/community-styles.ts +1194 -0
- package/src/services/content-drift.ts +208 -0
- package/src/services/conventions-manager.ts +945 -0
- package/src/services/doctor-bins.ts +63 -0
- package/src/services/doctor.ts +570 -0
- package/src/services/dotenv.ts +181 -0
- package/src/services/git-worktree.ts +129 -0
- package/src/services/github-budget.ts +274 -0
- package/src/services/gitignore-check.ts +63 -0
- package/src/services/gitignore-detector.ts +167 -0
- package/src/services/gitignore-fixer.ts +280 -0
- package/src/services/gitignore-resolver.ts +208 -0
- package/src/services/gitignore-service.ts +167 -0
- package/src/services/hook-registration.ts +218 -0
- package/src/services/install-plan.ts +107 -0
- package/src/services/local-marketplace.ts +587 -0
- package/src/services/manifest.ts +250 -0
- package/src/services/marketplace-catalog-git.ts +173 -0
- package/src/services/marketplace-catalog.ts +129 -0
- package/src/services/marketplace-fetcher.ts +400 -0
- package/src/services/marketplace-refresh.ts +253 -0
- package/src/services/marketplace-sync.ts +145 -0
- package/src/services/mate-availability.ts +133 -0
- package/src/services/mate-catalog.ts +265 -0
- package/src/services/mcp-registry.ts +171 -0
- package/src/services/models-core.ts +969 -0
- package/src/services/models-manager.ts +606 -0
- package/src/services/plugin-checkout.ts +186 -0
- package/src/services/plugin-manager.ts +878 -0
- package/src/services/plugin-mcp-config.ts +242 -0
- package/src/services/plugin-releases.ts +47 -0
- package/src/services/plugin-requires.ts +227 -0
- package/src/services/plugin-setup.ts +965 -0
- package/src/services/profile-adopt.ts +206 -0
- package/src/services/profile-materializer.ts +133 -0
- package/src/services/profile-sync.ts +158 -0
- package/src/services/profiles.ts +225 -0
- package/src/services/resolver.ts +344 -0
- package/src/services/settings-file.ts +69 -0
- package/src/services/settings-manager.ts +307 -0
- package/src/services/skills-manager.ts +1111 -0
- package/src/services/skillsmp-client.ts +193 -0
- package/src/services/styles-manager.ts +1294 -0
- package/src/services/symlink-manager.ts +189 -0
- package/src/services/terminology-filler.ts +266 -0
- package/src/services/tool-probe.ts +1637 -0
- package/src/services/toolchain.ts +431 -0
- package/src/services/update-engine.ts +691 -0
- package/src/services/update-plan.ts +863 -0
- package/src/services/version-check.ts +127 -0
- package/src/services/version-snapshot.ts +189 -0
- package/src/tui.tsx +112 -0
- package/src/types/bun.d.ts +27 -0
- package/src/types/gitignore.ts +68 -0
- package/src/types/index.ts +447 -0
- package/src/ui/App.tsx +538 -0
- package/src/ui/adapters/catalogNotice.ts +122 -0
- package/src/ui/adapters/cliToolsAdapter.ts +176 -0
- package/src/ui/adapters/modelsAdapter.ts +213 -0
- package/src/ui/adapters/pluginsAdapter.ts +413 -0
- package/src/ui/adapters/settingsAdapter.ts +168 -0
- package/src/ui/adapters/skillsAdapter.ts +229 -0
- package/src/ui/adapters/stylesAdapter.ts +403 -0
- package/src/ui/components/CategoryHeader.tsx +64 -0
- package/src/ui/components/EmptyFilterState.tsx +32 -0
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScopeIndicator.tsx +63 -0
- package/src/ui/components/ScrollableList.tsx +95 -0
- package/src/ui/components/SearchInput.tsx +57 -0
- package/src/ui/components/StyledText.tsx +75 -0
- package/src/ui/components/TabBar.tsx +101 -0
- package/src/ui/components/layout/FooterHints.tsx +78 -0
- package/src/ui/components/layout/Panel.tsx +63 -0
- package/src/ui/components/layout/ProgressBar.tsx +44 -0
- package/src/ui/components/layout/ScopeTabs.tsx +54 -0
- package/src/ui/components/layout/ScreenLayout.tsx +321 -0
- package/src/ui/components/layout/index.ts +9 -0
- package/src/ui/components/modals/ConfirmModal.tsx +62 -0
- package/src/ui/components/modals/InputModal.tsx +75 -0
- package/src/ui/components/modals/LoadingModal.tsx +51 -0
- package/src/ui/components/modals/MessageModal.tsx +66 -0
- package/src/ui/components/modals/ModalContainer.tsx +159 -0
- package/src/ui/components/modals/SelectModal.tsx +90 -0
- package/src/ui/components/modals/index.ts +6 -0
- package/src/ui/components/primitives/ActionHints.tsx +44 -0
- package/src/ui/components/primitives/DetailSection.tsx +23 -0
- package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
- package/src/ui/components/primitives/MetaText.tsx +40 -0
- package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
- package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
- package/src/ui/components/primitives/SelectableRow.tsx +30 -0
- package/src/ui/components/primitives/index.ts +9 -0
- package/src/ui/hooks/index.ts +2 -0
- package/src/ui/hooks/useGitignoreModal.ts +95 -0
- package/src/ui/hooks/useKeyboard.ts +24 -0
- package/src/ui/hooks/useKeyboardHandler.ts +63 -0
- package/src/ui/registry.ts +33 -0
- package/src/ui/renderers/cliToolRenderers.tsx +326 -0
- package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
- package/src/ui/renderers/markdownRenderer.tsx +302 -0
- package/src/ui/renderers/mcpRenderers.tsx +149 -0
- package/src/ui/renderers/modelRenderers.tsx +1309 -0
- package/src/ui/renderers/modelVisuals.tsx +1402 -0
- package/src/ui/renderers/pluginRenderers.tsx +1089 -0
- package/src/ui/renderers/pluginRowText.ts +70 -0
- package/src/ui/renderers/pluginStatusText.ts +56 -0
- package/src/ui/renderers/profileRenderers.tsx +406 -0
- package/src/ui/renderers/settingsRenderers.tsx +218 -0
- package/src/ui/renderers/skillRenderers.tsx +479 -0
- package/src/ui/renderers/styleRenderers.tsx +813 -0
- package/src/ui/screens/AliasScreen.tsx +1558 -0
- package/src/ui/screens/CliToolsScreen.tsx +367 -0
- package/src/ui/screens/EnvVarsScreen.tsx +221 -0
- package/src/ui/screens/GitignoreScreen.tsx +562 -0
- package/src/ui/screens/McpRegistryScreen.tsx +366 -0
- package/src/ui/screens/McpScreen.tsx +256 -0
- package/src/ui/screens/ModelsScreen.tsx +542 -0
- package/src/ui/screens/PluginsScreen.tsx +1464 -0
- package/src/ui/screens/ProfilesScreen.tsx +438 -0
- package/src/ui/screens/SkillsScreen.tsx +853 -0
- package/src/ui/screens/StylesScreen.tsx +1064 -0
- package/src/ui/screens/index.ts +11 -0
- package/src/ui/state/AppContext.tsx +233 -0
- package/src/ui/state/DimensionsContext.tsx +154 -0
- package/src/ui/state/reducer.ts +810 -0
- package/src/ui/state/types.ts +503 -0
- package/src/ui/theme-mode.ts +352 -0
- package/src/ui/theme-resolve.ts +147 -0
- package/src/ui/theme.ts +175 -0
- package/src/utils/clipboard.ts +58 -0
- package/src/utils/command-utils.ts +144 -0
- package/src/utils/config-dir.ts +45 -0
- package/src/utils/file-locking.ts +146 -0
- package/src/utils/fuzzy-search.ts +138 -0
- package/src/utils/go-module.ts +36 -0
- package/src/utils/open-file.ts +84 -0
- package/src/utils/run.ts +67 -0
- package/src/utils/string-utils.ts +88 -0
|
@@ -0,0 +1,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
|
+
}
|
package/src/ui/theme.ts
ADDED
|
@@ -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 };
|