magus-cli 0.0.0-placeholder → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (331) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +684 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +523 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +445 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +174 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +335 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/moved-marketplace.test.ts +86 -0
  96. package/src/__tests__/open-file.test.ts +59 -0
  97. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  98. package/src/__tests__/plugin-checkout.test.ts +66 -0
  99. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  100. package/src/__tests__/plugin-contents.test.ts +371 -0
  101. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  102. package/src/__tests__/plugin-releases.test.ts +98 -0
  103. package/src/__tests__/plugin-requires.test.ts +187 -0
  104. package/src/__tests__/plugin-row-text.test.ts +336 -0
  105. package/src/__tests__/plugin-setup.test.ts +1016 -0
  106. package/src/__tests__/profile-adopt.test.ts +177 -0
  107. package/src/__tests__/profile-command.test.ts +110 -0
  108. package/src/__tests__/profile-materializer.test.ts +238 -0
  109. package/src/__tests__/profile-sync.test.ts +154 -0
  110. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  111. package/src/__tests__/resolve-executable.test.ts +42 -0
  112. package/src/__tests__/resolver.test.ts +351 -0
  113. package/src/__tests__/scope-action.test.ts +134 -0
  114. package/src/__tests__/scope-squares.test.tsx +164 -0
  115. package/src/__tests__/selection-scope.test.ts +100 -0
  116. package/src/__tests__/settings-file.test.ts +179 -0
  117. package/src/__tests__/shell-script-callers.test.ts +92 -0
  118. package/src/__tests__/skill-install-files.test.ts +136 -0
  119. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  120. package/src/__tests__/style-wrap.test.ts +220 -0
  121. package/src/__tests__/styles-manager.test.ts +1098 -0
  122. package/src/__tests__/styles-origins.test.ts +414 -0
  123. package/src/__tests__/styles-screen-state.test.ts +457 -0
  124. package/src/__tests__/styles-status-line.test.ts +72 -0
  125. package/src/__tests__/styles-sync.test.ts +452 -0
  126. package/src/__tests__/symlink-manager.test.ts +180 -0
  127. package/src/__tests__/tabbar-layout.test.ts +100 -0
  128. package/src/__tests__/terminology-filler.test.ts +214 -0
  129. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  130. package/src/__tests__/theme-env.test.ts +281 -0
  131. package/src/__tests__/theme-resolve.test.ts +380 -0
  132. package/src/__tests__/tool-probe.test.ts +1400 -0
  133. package/src/__tests__/toolchain.test.ts +232 -0
  134. package/src/__tests__/ui-version-writers.test.ts +88 -0
  135. package/src/__tests__/update-apply.test.ts +637 -0
  136. package/src/__tests__/update-plan.test.ts +1373 -0
  137. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  138. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  139. package/src/__tests__/version-check-refresh.test.ts +45 -0
  140. package/src/__tests__/version-snapshot.test.ts +212 -0
  141. package/src/__tests__/version-unknown-state.test.ts +388 -0
  142. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  143. package/src/cli/ansi.ts +520 -0
  144. package/src/cli/bootstrap.ts +134 -0
  145. package/src/cli/doctor.ts +218 -0
  146. package/src/cli/hook.ts +129 -0
  147. package/src/cli/install.ts +382 -0
  148. package/src/cli/live.ts +374 -0
  149. package/src/cli/models.ts +297 -0
  150. package/src/cli/profile.ts +197 -0
  151. package/src/cli/prompt.ts +32 -0
  152. package/src/cli/router.ts +207 -0
  153. package/src/cli/update-view.ts +526 -0
  154. package/src/cli/update.ts +613 -0
  155. package/src/cli/upgrade.ts +97 -0
  156. package/src/data/alias-flags.ts +301 -0
  157. package/src/data/cli-tools.ts +146 -0
  158. package/src/data/community-styles.ts +531 -0
  159. package/src/data/gitignore-defaults.ts +33 -0
  160. package/src/data/gitignore-reasons.ts +93 -0
  161. package/src/data/gitignore-templates.ts +25 -0
  162. package/src/data/marketplaces.ts +278 -0
  163. package/src/data/mcp-servers.ts +51 -0
  164. package/src/data/models-presets.ts +329 -0
  165. package/src/data/predefined-profiles.ts +252 -0
  166. package/src/data/settings-catalog.ts +714 -0
  167. package/src/data/skill-repos.ts +91 -0
  168. package/src/data/styles/asd-ste100.md +38 -0
  169. package/src/data/styles/calibrated.md +24 -0
  170. package/src/data/styles/direct.md +23 -0
  171. package/src/data/styles/evidence-first.md +22 -0
  172. package/src/data/styles/explanatory.md +23 -0
  173. package/src/data/styles/index.ts +57 -0
  174. package/src/data/styles/no-slop.md +32 -0
  175. package/src/data/styles/plain-language.md +23 -0
  176. package/src/data/styles/structured.md +30 -0
  177. package/src/data/styles/terminology.md +28 -0
  178. package/src/data/styles/terse.md +20 -0
  179. package/src/main.tsx +110 -0
  180. package/src/markdown.d.ts +13 -0
  181. package/src/opentui.d.ts +203 -0
  182. package/src/services/active-closure.ts +117 -0
  183. package/src/services/alias-settings.ts +60 -0
  184. package/src/services/alias-shell-writer.ts +1244 -0
  185. package/src/services/alias-store.ts +172 -0
  186. package/src/services/binary-signing.ts +78 -0
  187. package/src/services/catalog-cache-store.ts +316 -0
  188. package/src/services/claude-cli.ts +304 -0
  189. package/src/services/claude-runner.ts +31 -0
  190. package/src/services/claude-settings.ts +1589 -0
  191. package/src/services/cli-tool-commands.ts +133 -0
  192. package/src/services/community-fetcher.ts +90 -0
  193. package/src/services/community-styles.ts +1194 -0
  194. package/src/services/content-drift.ts +208 -0
  195. package/src/services/conventions-manager.ts +945 -0
  196. package/src/services/doctor-bins.ts +63 -0
  197. package/src/services/doctor.ts +570 -0
  198. package/src/services/dotenv.ts +181 -0
  199. package/src/services/git-worktree.ts +129 -0
  200. package/src/services/github-budget.ts +274 -0
  201. package/src/services/gitignore-check.ts +63 -0
  202. package/src/services/gitignore-detector.ts +167 -0
  203. package/src/services/gitignore-fixer.ts +280 -0
  204. package/src/services/gitignore-resolver.ts +208 -0
  205. package/src/services/gitignore-service.ts +167 -0
  206. package/src/services/hook-registration.ts +218 -0
  207. package/src/services/install-plan.ts +107 -0
  208. package/src/services/local-marketplace.ts +587 -0
  209. package/src/services/manifest.ts +250 -0
  210. package/src/services/marketplace-catalog-git.ts +173 -0
  211. package/src/services/marketplace-catalog.ts +129 -0
  212. package/src/services/marketplace-fetcher.ts +400 -0
  213. package/src/services/marketplace-refresh.ts +253 -0
  214. package/src/services/marketplace-sync.ts +145 -0
  215. package/src/services/mate-availability.ts +133 -0
  216. package/src/services/mate-catalog.ts +265 -0
  217. package/src/services/mcp-registry.ts +171 -0
  218. package/src/services/models-core.ts +969 -0
  219. package/src/services/models-manager.ts +606 -0
  220. package/src/services/plugin-checkout.ts +186 -0
  221. package/src/services/plugin-manager.ts +878 -0
  222. package/src/services/plugin-mcp-config.ts +242 -0
  223. package/src/services/plugin-releases.ts +47 -0
  224. package/src/services/plugin-requires.ts +227 -0
  225. package/src/services/plugin-setup.ts +965 -0
  226. package/src/services/profile-adopt.ts +206 -0
  227. package/src/services/profile-materializer.ts +133 -0
  228. package/src/services/profile-sync.ts +158 -0
  229. package/src/services/profiles.ts +225 -0
  230. package/src/services/resolver.ts +344 -0
  231. package/src/services/settings-file.ts +69 -0
  232. package/src/services/settings-manager.ts +307 -0
  233. package/src/services/skills-manager.ts +1111 -0
  234. package/src/services/skillsmp-client.ts +193 -0
  235. package/src/services/styles-manager.ts +1294 -0
  236. package/src/services/symlink-manager.ts +189 -0
  237. package/src/services/terminology-filler.ts +266 -0
  238. package/src/services/tool-probe.ts +1637 -0
  239. package/src/services/toolchain.ts +431 -0
  240. package/src/services/update-engine.ts +691 -0
  241. package/src/services/update-plan.ts +863 -0
  242. package/src/services/version-check.ts +127 -0
  243. package/src/services/version-snapshot.ts +189 -0
  244. package/src/tui.tsx +112 -0
  245. package/src/types/bun.d.ts +27 -0
  246. package/src/types/gitignore.ts +68 -0
  247. package/src/types/index.ts +447 -0
  248. package/src/ui/App.tsx +538 -0
  249. package/src/ui/adapters/catalogNotice.ts +122 -0
  250. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  251. package/src/ui/adapters/modelsAdapter.ts +213 -0
  252. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  253. package/src/ui/adapters/settingsAdapter.ts +168 -0
  254. package/src/ui/adapters/skillsAdapter.ts +229 -0
  255. package/src/ui/adapters/stylesAdapter.ts +403 -0
  256. package/src/ui/components/CategoryHeader.tsx +64 -0
  257. package/src/ui/components/EmptyFilterState.tsx +32 -0
  258. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  259. package/src/ui/components/ScopeIndicator.tsx +63 -0
  260. package/src/ui/components/ScrollableList.tsx +95 -0
  261. package/src/ui/components/SearchInput.tsx +57 -0
  262. package/src/ui/components/StyledText.tsx +75 -0
  263. package/src/ui/components/TabBar.tsx +101 -0
  264. package/src/ui/components/layout/FooterHints.tsx +78 -0
  265. package/src/ui/components/layout/Panel.tsx +63 -0
  266. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  267. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  268. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  269. package/src/ui/components/layout/index.ts +9 -0
  270. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  271. package/src/ui/components/modals/InputModal.tsx +75 -0
  272. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  273. package/src/ui/components/modals/MessageModal.tsx +66 -0
  274. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  275. package/src/ui/components/modals/SelectModal.tsx +90 -0
  276. package/src/ui/components/modals/index.ts +6 -0
  277. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  278. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  279. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  280. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  281. package/src/ui/components/primitives/MetaText.tsx +40 -0
  282. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  283. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  284. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  285. package/src/ui/components/primitives/index.ts +9 -0
  286. package/src/ui/hooks/index.ts +2 -0
  287. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  288. package/src/ui/hooks/useKeyboard.ts +24 -0
  289. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  290. package/src/ui/registry.ts +33 -0
  291. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  292. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  293. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  294. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  295. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  296. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  297. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  298. package/src/ui/renderers/pluginRowText.ts +70 -0
  299. package/src/ui/renderers/pluginStatusText.ts +56 -0
  300. package/src/ui/renderers/profileRenderers.tsx +406 -0
  301. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  302. package/src/ui/renderers/skillRenderers.tsx +479 -0
  303. package/src/ui/renderers/styleRenderers.tsx +813 -0
  304. package/src/ui/screens/AliasScreen.tsx +1558 -0
  305. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  306. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  307. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  308. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  309. package/src/ui/screens/McpScreen.tsx +256 -0
  310. package/src/ui/screens/ModelsScreen.tsx +542 -0
  311. package/src/ui/screens/PluginsScreen.tsx +1464 -0
  312. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  313. package/src/ui/screens/SkillsScreen.tsx +853 -0
  314. package/src/ui/screens/StylesScreen.tsx +1064 -0
  315. package/src/ui/screens/index.ts +11 -0
  316. package/src/ui/state/AppContext.tsx +233 -0
  317. package/src/ui/state/DimensionsContext.tsx +154 -0
  318. package/src/ui/state/reducer.ts +810 -0
  319. package/src/ui/state/types.ts +503 -0
  320. package/src/ui/theme-mode.ts +352 -0
  321. package/src/ui/theme-resolve.ts +147 -0
  322. package/src/ui/theme.ts +175 -0
  323. package/src/utils/clipboard.ts +58 -0
  324. package/src/utils/command-utils.ts +144 -0
  325. package/src/utils/config-dir.ts +45 -0
  326. package/src/utils/file-locking.ts +146 -0
  327. package/src/utils/fuzzy-search.ts +138 -0
  328. package/src/utils/go-module.ts +36 -0
  329. package/src/utils/open-file.ts +84 -0
  330. package/src/utils/run.ts +67 -0
  331. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,542 @@
1
+ import React, { useCallback, useEffect, useMemo } from "react";
2
+ import { areMatesAvailable } from "../../services/mate-availability.js";
3
+ import { loadMateCatalog } from "../../services/mate-catalog.js";
4
+ import {
5
+ applyModelPreset,
6
+ clearModels,
7
+ readModelsConfig,
8
+ readModelsStatus,
9
+ } from "../../services/models-manager.js";
10
+ import {
11
+ type ModelsBrowserItem,
12
+ buildModelsItems,
13
+ firstSelectableIndex,
14
+ } from "../adapters/modelsAdapter.js";
15
+ import { EmptyFilterState } from "../components/EmptyFilterState.js";
16
+ import { ScrollableList } from "../components/ScrollableList.js";
17
+ import { ScreenLayout } from "../components/layout/index.js";
18
+ import { useKeyboard } from "../hooks/useKeyboard.js";
19
+ import {
20
+ fitPresetList,
21
+ renderModelDetail,
22
+ renderModelRow,
23
+ renderModelsSummary,
24
+ } from "../renderers/modelRenderers.js";
25
+ import { useApp } from "../state/AppContext.js";
26
+ import { useDimensions } from "../state/DimensionsContext.js";
27
+ import { asyncValue } from "../state/types.js";
28
+ import { theme } from "../theme.js";
29
+
30
+ export function ModelsScreen() {
31
+ const { state, dispatch } = useApp();
32
+ const { models: modelsState } = state;
33
+ const dimensions = useDimensions();
34
+
35
+ const isSearchActive =
36
+ state.isSearching && state.currentRoute.screen === "models" && !state.modal;
37
+
38
+ // ── Data ──────────────────────────────────────────────────────────────────
39
+
40
+ /**
41
+ * Both reads, in one dispatch.
42
+ *
43
+ * `readModelsStatus` answers "is this routing in force"; `readModelsConfig`
44
+ * is the routing itself, which the list needs in full to give a hand-edited
45
+ * config a row of its own. Neither is derivable from the other — a `status`
46
+ * of `invalid` reports no preset at all, and an `off` project still has a
47
+ * path worth naming.
48
+ */
49
+ const fetchData = useCallback(async () => {
50
+ dispatch({ type: "MODELS_DATA_LOADING" });
51
+ try {
52
+ const [status, config] = await Promise.all([
53
+ readModelsStatus(state.projectPath),
54
+ readModelsConfig(state.projectPath),
55
+ ]);
56
+ dispatch({
57
+ type: "MODELS_DATA_SUCCESS",
58
+ snapshot: { status, config: config.config, path: config.path },
59
+ });
60
+ } catch (error) {
61
+ dispatch({
62
+ type: "MODELS_DATA_ERROR",
63
+ error: error instanceof Error ? error : new Error(String(error)),
64
+ });
65
+ }
66
+ }, [dispatch, state.projectPath]);
67
+
68
+ // dataRefreshVersion is a refetch signal, not a value this effect reads:
69
+ // bumping it is how the app tells every screen its data changed.
70
+ // biome-ignore lint/correctness/useExhaustiveDependencies: signal, not a read
71
+ useEffect(() => {
72
+ fetchData();
73
+ }, [fetchData, state.dataRefreshVersion]);
74
+
75
+ const snapshot = asyncValue(modelsState.data) ?? null;
76
+
77
+ /**
78
+ * Whether the mate slots have anything to serve them.
79
+ *
80
+ * Its OWN effect, deliberately outside the `fetchData` pair above. That pair is two file
81
+ * reads and lands in milliseconds; this one goes through the plugin registry and the
82
+ * marketplace resolution behind it, which does network work with its own timeouts.
83
+ * Awaiting it in the same `Promise.all` would hold the whole screen — the presets, the
84
+ * tier table, the status — on a question that decides three rows.
85
+ *
86
+ * App state rather than `useState`, and NOT only to satisfy the rule: `Router` swaps the
87
+ * component type on a tab change, so a local flag dies on `0 → 1 → 0` and this is the one
88
+ * piece of this screen's state that is expensive to fetch again. Held here, the lookup
89
+ * happens once per session instead of once per visit.
90
+ *
91
+ * It starts false and the rows appear when the answer lands. False is also what a failure
92
+ * yields (`areMatesAvailable` fails closed), so both render identically: the screen
93
+ * exactly as it was before mates existed.
94
+ */
95
+ useEffect(() => {
96
+ let live = true;
97
+ areMatesAvailable(state.projectPath).then((available) => {
98
+ // The screen can be left while this is in flight. Dispatching into a reducer that
99
+ // outlives the component is harmless, but re-dispatching a stale answer after the
100
+ // project path changed is not.
101
+ if (live) dispatch({ type: "MODELS_MATES_AVAILABLE", available });
102
+ });
103
+ return () => {
104
+ live = false;
105
+ };
106
+ }, [state.projectPath, dispatch]);
107
+
108
+ /**
109
+ * claudish's live model catalogue, for the staleness advisory.
110
+ *
111
+ * A third effect rather than a branch of the second, even though both are about mates:
112
+ * this one spawns a process and the other reads a registry, and chaining them would make
113
+ * the advisory wait on a plugin lookup it does not depend on. Neither blocks the screen.
114
+ *
115
+ * NOT gated on `matesAvailable`. The catalogue answers "is this bound id real", which is
116
+ * worth knowing about a committed config whether or not this particular machine has the
117
+ * plugin installed — and gating it would make the note appear only on machines that need
118
+ * it least. The lookup is cached per session, so it costs one spawn either way.
119
+ *
120
+ * No project path in the deps: a model catalogue is a property of the world, not of the
121
+ * project. `loadMateCatalog` caches it for the session, so this re-runs into a resolved
122
+ * promise on every remount.
123
+ */
124
+ useEffect(() => {
125
+ let live = true;
126
+ // Failure is already [] — `loadMateCatalog` never rejects — so there is no `.catch`
127
+ // here to swallow. If one becomes necessary the module's contract has been broken.
128
+ loadMateCatalog().then((catalog) => {
129
+ if (live) dispatch({ type: "MODELS_CATALOG_LOADED", catalog });
130
+ });
131
+ return () => {
132
+ live = false;
133
+ };
134
+ }, [dispatch]);
135
+
136
+ // ── Status line ───────────────────────────────────────────────────────────
137
+
138
+ /**
139
+ * Set the status line.
140
+ *
141
+ * No timer, matching the Styles screen: a message that erased itself after a
142
+ * few seconds was usually gone by the time anyone looked. It is cleared when
143
+ * the NEXT action starts instead.
144
+ */
145
+ const showStatus = useCallback(
146
+ (text: string, tone: "success" | "error" = "success") => {
147
+ dispatch({ type: "MODELS_STATUS_SET", status: { text, tone } });
148
+ },
149
+ [dispatch],
150
+ );
151
+ const statusMsg = modelsState.status;
152
+
153
+ // ── Derived ───────────────────────────────────────────────────────────────
154
+
155
+ const allItems = useMemo(
156
+ () =>
157
+ buildModelsItems({
158
+ status: snapshot?.status ?? null,
159
+ config: snapshot?.config ?? null,
160
+ query: modelsState.searchQuery,
161
+ }),
162
+ [snapshot, modelsState.searchQuery],
163
+ );
164
+
165
+ // Keep the cursor off the status header — it is a label, not a row.
166
+ useEffect(() => {
167
+ const item = allItems[modelsState.selectedIndex];
168
+ if (allItems.length > 0 && (!item || item.kind === "status")) {
169
+ dispatch({
170
+ type: "MODELS_SELECT",
171
+ index: firstSelectableIndex(allItems),
172
+ });
173
+ }
174
+ }, [allItems, modelsState.selectedIndex, dispatch]);
175
+
176
+ const selectedItem: ModelsBrowserItem | undefined =
177
+ allItems[modelsState.selectedIndex];
178
+
179
+ // ── Actions ───────────────────────────────────────────────────────────────
180
+
181
+ /**
182
+ * Write the selected preset's routing to this project.
183
+ *
184
+ * A hand-edited config is re-applied as an OBJECT rather than by name —
185
+ * `applyModelPreset` resolves a string against the built-ins, and this one is
186
+ * by definition not among them. Re-applying it is also the fix for `stale`:
187
+ * it rewrites the settings the config implies.
188
+ *
189
+ * No `ensureManifest` is passed. That helper prompts on the terminal, and
190
+ * this process's terminal belongs to OpenTUI — a project with no profiles
191
+ * gets a plain `.claude/models.json`, and the status line says so.
192
+ */
193
+ const handleApply = useCallback(async () => {
194
+ if (modelsState.isApplying) {
195
+ showStatus("Already writing the routing — one at a time", "error");
196
+ return;
197
+ }
198
+ if (!selectedItem || selectedItem.kind !== "preset") {
199
+ showStatus("Move to a preset row and press a", "error");
200
+ return;
201
+ }
202
+ const { config, custom } = selectedItem;
203
+
204
+ dispatch({ type: "MODELS_APPLY_START" });
205
+ try {
206
+ const result = await applyModelPreset({
207
+ // `config.preset`, NOT `label`. The row is titled with a human name now
208
+ // ("Opus with Fable help"), and `applyModelPreset` resolves an id against the
209
+ // built-ins — so passing the label refused every apply with `Unknown preset
210
+ // "Opus with Fable help"`. Found by pressing the key, not by a test: the
211
+ // types agree, both are strings.
212
+ projectPath: state.projectPath,
213
+ preset: custom ? config : config.preset,
214
+ });
215
+ await fetchData();
216
+ const where = result.profile
217
+ ? `profile "${result.profile}"`
218
+ : "this project";
219
+ // Say plainly when the manifest was NOT updated: without that record a
220
+ // `magus install` rewrites the profile and the routing disappears.
221
+ const durability =
222
+ result.profile && !result.recordedInManifest
223
+ ? " — not in profiles.json, so `magus install` will drop it"
224
+ : "";
225
+ showStatus(
226
+ `Applied ${selectedItem.label} in ${where}, hook ${result.hook}${durability}`,
227
+ result.profile && !result.recordedInManifest ? "error" : "success",
228
+ );
229
+ } catch (error) {
230
+ showStatus(
231
+ error instanceof Error ? error.message : String(error),
232
+ "error",
233
+ );
234
+ } finally {
235
+ dispatch({ type: "MODELS_APPLY_END" });
236
+ }
237
+ }, [
238
+ modelsState.isApplying,
239
+ selectedItem,
240
+ state.projectPath,
241
+ fetchData,
242
+ showStatus,
243
+ dispatch,
244
+ ]);
245
+
246
+ const handleClear = useCallback(async () => {
247
+ if (modelsState.isApplying) {
248
+ showStatus("Already writing the routing — one at a time", "error");
249
+ return;
250
+ }
251
+ dispatch({ type: "MODELS_APPLY_START" });
252
+ try {
253
+ await clearModels(state.projectPath);
254
+ await fetchData();
255
+ showStatus(
256
+ "Model tiers off — subagents inherit the session model again. The hook stays registered and is a no-op without a config.",
257
+ );
258
+ } catch (error) {
259
+ showStatus(
260
+ error instanceof Error ? error.message : String(error),
261
+ "error",
262
+ );
263
+ } finally {
264
+ dispatch({ type: "MODELS_APPLY_END" });
265
+ }
266
+ }, [
267
+ modelsState.isApplying,
268
+ state.projectPath,
269
+ fetchData,
270
+ showStatus,
271
+ dispatch,
272
+ ]);
273
+
274
+ // ── Keyboard ──────────────────────────────────────────────────────────────
275
+
276
+ const move = useCallback(
277
+ (delta: -1 | 1) => {
278
+ let index = modelsState.selectedIndex + delta;
279
+ while (index >= 0 && index < allItems.length) {
280
+ if (allItems[index] && allItems[index].kind !== "status") {
281
+ dispatch({ type: "MODELS_SELECT", index });
282
+ return;
283
+ }
284
+ index += delta;
285
+ }
286
+ },
287
+ [modelsState.selectedIndex, allItems, dispatch],
288
+ );
289
+
290
+ useKeyboard((event) => {
291
+ if (state.modal) return;
292
+
293
+ // The previous message belongs to the previous ACTION, so clearing it here
294
+ // covers every action key in one place. Navigation is excluded: a digit or
295
+ // Tab is handled by the global handler, and clearing on it wiped the
296
+ // message on the way OUT of the tab, losing it on a round trip.
297
+ const isNavigation =
298
+ /^[0-9]$/.test(event.name ?? "") || event.name === "tab";
299
+ if (modelsState.status && !isNavigation) {
300
+ dispatch({ type: "MODELS_STATUS_CLEAR" });
301
+ }
302
+
303
+ const hasQuery = modelsState.searchQuery.length > 0;
304
+
305
+ if (event.name === "escape") {
306
+ if (hasQuery || isSearchActive) {
307
+ dispatch({ type: "MODELS_SET_SEARCH", query: "" });
308
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
309
+ dispatch({ type: "MODELS_SELECT", index: 0 });
310
+ }
311
+ return;
312
+ }
313
+
314
+ if (event.name === "backspace" || event.name === "delete") {
315
+ if (hasQuery) {
316
+ dispatch({ type: "MODELS_SEARCH_BACKSPACE" });
317
+ // Driven by what was there BEFORE this keystroke — the one thing the
318
+ // closure knows reliably.
319
+ if (modelsState.searchQuery.length <= 1) {
320
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
321
+ }
322
+ }
323
+ return;
324
+ }
325
+
326
+ if (event.name === "up") {
327
+ if (isSearchActive)
328
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
329
+ move(-1);
330
+ return;
331
+ }
332
+ if (event.name === "down") {
333
+ if (isSearchActive)
334
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
335
+ move(1);
336
+ return;
337
+ }
338
+
339
+ if (event.name === "return" || event.name === "enter") {
340
+ if (isSearchActive) {
341
+ dispatch({ type: "SET_SEARCHING", isSearching: false });
342
+ return;
343
+ }
344
+ // Enter and `a` are the same action here. There is nothing to "open" on
345
+ // this screen — a preset row has one thing it can do — so binding Enter
346
+ // to anything else would leave the most obvious key doing nothing.
347
+ handleApply();
348
+ return;
349
+ }
350
+
351
+ // While typing a filter, letters are text — not commands.
352
+ if (isSearchActive) {
353
+ if (
354
+ event.name &&
355
+ event.name.length === 1 &&
356
+ !event.ctrl &&
357
+ !event.meta &&
358
+ !/[0-9]/.test(event.name)
359
+ ) {
360
+ dispatch({ type: "MODELS_SEARCH_APPEND", char: event.name });
361
+ }
362
+ return;
363
+ }
364
+
365
+ if (event.name === "k") {
366
+ move(-1);
367
+ } else if (event.name === "j") {
368
+ move(1);
369
+ } else if (event.name === "a") {
370
+ handleApply();
371
+ } else if (event.name === "c") {
372
+ handleClear();
373
+ } else if (event.name === "r") {
374
+ fetchData();
375
+ } else if (event.name === "/") {
376
+ dispatch({ type: "SET_SEARCHING", isSearching: true });
377
+ }
378
+ });
379
+
380
+ // ── Chrome ────────────────────────────────────────────────────────────────
381
+
382
+ // Transient feedback takes the full-width notice row under the header, never
383
+ // the header itself: a long message beside the title shrinks the title away
384
+ // and the two read as one garbled sentence.
385
+ const noticeContent = statusMsg ? (
386
+ <text
387
+ fg={
388
+ statusMsg.tone === "success"
389
+ ? theme.colors.success
390
+ : theme.colors.danger
391
+ }
392
+ >
393
+ {statusMsg.text}
394
+ </text>
395
+ ) : undefined;
396
+
397
+ // The file's `preset` string is deliberately not shown beside the state: it records what
398
+ // was last APPLIED, so a config edited afterwards would wear a built-in's name here while
399
+ // the list correctly calls it `Custom`.
400
+ const routingState = snapshot?.status.state ?? null;
401
+ const statusContent = (
402
+ <text fg={theme.colors.text}>
403
+ <span fg={theme.colors.muted}>Model tiers: </span>
404
+ <span
405
+ fg={
406
+ routingState === "on"
407
+ ? theme.colors.success
408
+ : routingState === "off" || routingState === null
409
+ ? theme.colors.muted
410
+ : theme.colors.danger
411
+ }
412
+ >
413
+ {routingState ?? "reading…"}
414
+ </span>
415
+ {modelsState.isApplying ? (
416
+ <span fg={theme.colors.warning}> │ writing…</span>
417
+ ) : null}
418
+ </text>
419
+ );
420
+
421
+ // ── Render ────────────────────────────────────────────────────────────────
422
+
423
+ // The panel widths come from ScreenLayout, which owns the split, the padding
424
+ // and the scrollbar column. This screen used to re-derive them from
425
+ // `terminalWidth` with a copy of the formula, and the copy was wrong.
426
+ const query = modelsState.searchQuery.trim();
427
+
428
+ return (
429
+ <ScreenLayout
430
+ title="magus Models"
431
+ currentScreen="models"
432
+ statusLine={statusContent}
433
+ notice={noticeContent}
434
+ search={
435
+ modelsState.searchQuery || isSearchActive
436
+ ? {
437
+ isActive: isSearchActive,
438
+ query: modelsState.searchQuery,
439
+ placeholder: "type to filter",
440
+ }
441
+ : undefined
442
+ }
443
+ footerHints={
444
+ isSearchActive
445
+ ? [
446
+ { keys: ["type"], label: "filter" },
447
+ { keys: ["Enter"], label: "done" },
448
+ { keys: ["Esc"], label: "clear" },
449
+ ]
450
+ : [
451
+ { keys: ["Enter", "a"], label: "apply" },
452
+ { keys: ["c"], label: "off" },
453
+ { keys: ["r"], label: "reload" },
454
+ { keys: ["/"], label: "filter" },
455
+ ]
456
+ }
457
+ listPanel={(listWidth) => {
458
+ // ONE layout for every row, measured from the widest of them.
459
+ // Rows used to fit themselves, so at 120×30 the selected row
460
+ // dropped its `main` / `critical` labels while its neighbours kept
461
+ // theirs — four rows in two formats, nothing lining up.
462
+ const rowLayout = fitPresetList(allItems, listWidth);
463
+ return (
464
+ <box flexDirection="column">
465
+ {modelsState.data.status === "loading" && !snapshot && (
466
+ <box paddingLeft={2}>
467
+ <text fg={theme.colors.warning}>Reading the routing…</text>
468
+ </box>
469
+ )}
470
+
471
+ {modelsState.data.status === "error" && (
472
+ <box flexDirection="column" paddingLeft={2} paddingRight={2}>
473
+ <text fg={theme.colors.danger}>
474
+ Could not read the model routing.
475
+ </text>
476
+ <box marginTop={1}>
477
+ <text fg={theme.colors.muted}>
478
+ {modelsState.data.error.message}
479
+ </text>
480
+ </box>
481
+ </box>
482
+ )}
483
+
484
+ {allItems.length > 0 && (
485
+ <ScrollableList
486
+ items={allItems}
487
+ selectedIndex={modelsState.selectedIndex}
488
+ renderItem={(item, index, isSelected) =>
489
+ renderModelRow(item, index, isSelected, rowLayout)
490
+ }
491
+ maxHeight={dimensions.listPanelHeight}
492
+ getKey={(item, index) => `${index}:${item.id}`}
493
+ />
494
+ )}
495
+
496
+ {query.length > 0 &&
497
+ allItems.length === 0 &&
498
+ modelsState.data.status !== "error" && (
499
+ <EmptyFilterState
500
+ query={modelsState.searchQuery}
501
+ entityName="presets"
502
+ />
503
+ )}
504
+
505
+ {/* Under the list, not in the detail pane. Four preset rows left
506
+ the rest of this panel unpainted, and the screen's one graphic
507
+ was buried below the fold of a pane nobody had scrolled. It
508
+ follows the cursor, so it answers "what would THIS preset do"
509
+ while you are choosing. */}
510
+ {allItems.length > 0 &&
511
+ renderModelsSummary({
512
+ selected: selectedItem,
513
+ status: snapshot?.status ?? null,
514
+ configPath: snapshot?.path ?? null,
515
+ width: listWidth,
516
+ // Rows the list did not use. One held back because the
517
+ // search bar and the notice each take a row from the panel
518
+ // without taking one from `listPanelHeight` — and an
519
+ // over-tall panel overprints rather than clipping.
520
+ available:
521
+ dimensions.listPanelHeight -
522
+ Math.min(allItems.length, dimensions.listPanelHeight) -
523
+ 1,
524
+ })}
525
+ </box>
526
+ );
527
+ }}
528
+ detailPanel={(detailWidth) =>
529
+ renderModelDetail(
530
+ selectedItem,
531
+ snapshot?.status ?? null,
532
+ detailWidth,
533
+ modelsState.matesAvailable,
534
+ modelsState.mateCatalog,
535
+ )
536
+ }
537
+ detailKey={selectedItem?.id ?? ""}
538
+ />
539
+ );
540
+ }
541
+
542
+ export default ModelsScreen;