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,1089 @@
1
+ import type React from "react";
2
+ import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
3
+ import {
4
+ compareVersions,
5
+ isEnabledButNotInstalled,
6
+ isInstalledInScope,
7
+ } from "../../services/plugin-manager.js";
8
+ import type { ScopeStatus } from "../../services/plugin-manager.js";
9
+ import type { PluginComponentKind } from "../../services/skills-manager.js";
10
+ import type {
11
+ PluginUpdateAction,
12
+ PluginUpdateItem,
13
+ } from "../../services/update-plan.js";
14
+ import { isKnownVersion } from "../../services/version-snapshot.js";
15
+ import type { PluginRelease } from "../../types/index.js";
16
+ import { highlightMatches } from "../../utils/fuzzy-search.js";
17
+ import type {
18
+ PluginBrowserItem,
19
+ PluginCategoryItem,
20
+ PluginComponentItem,
21
+ PluginContentsStatusItem,
22
+ PluginPluginItem,
23
+ } from "../adapters/pluginsAdapter.js";
24
+ import { CategoryHeader } from "../components/CategoryHeader.js";
25
+ import {
26
+ ActionHints,
27
+ DetailSection,
28
+ KeyValueLine,
29
+ MetaText,
30
+ ScopeSquares,
31
+ SelectableRow,
32
+ } from "../components/primitives/index.js";
33
+ import type {
34
+ PluginContentsEntry,
35
+ PluginSkillDetailEntry,
36
+ } from "../state/types.js";
37
+ import { componentBadge as badgeColors } from "../theme-mode.js";
38
+ import { type UiColor, theme } from "../theme.js";
39
+ import { renderMarkdown } from "./markdownRenderer.js";
40
+ import { type PluginRowSegment, pluginRowText } from "./pluginRowText.js";
41
+ import { type StatusTone, pluginStatusText } from "./pluginStatusText.js";
42
+
43
+ // ─── Category renderers ───────────────────────────────────────────────────────
44
+
45
+ function categoryRow(
46
+ item: PluginCategoryItem,
47
+ isSelected: boolean,
48
+ ): React.ReactNode {
49
+ const mp = item.marketplace;
50
+ // Marketplaces that declare a `metadata.version` render "Name v8.0.0 (15)";
51
+ // those without keep the plain "Name (15)" form.
52
+ const version = getMarketplaceVersion(mp.name);
53
+
54
+ if (isSelected) {
55
+ const arrow = item.isExpanded ? "▼" : "▶";
56
+ const versionLabel = version ? ` v${version}` : "";
57
+ const count = item.pluginCount > 0 ? ` (${item.pluginCount})` : "";
58
+ return (
59
+ <SelectableRow selected={true}>
60
+ <strong>
61
+ {" "}
62
+ {arrow} {mp.displayName}
63
+ {versionLabel}
64
+ {count}{" "}
65
+ </strong>
66
+ </SelectableRow>
67
+ );
68
+ }
69
+
70
+ // Map tone to a statusColor for CategoryHeader (legacy component)
71
+ const statusColorMap: Record<PluginCategoryItem["tone"], UiColor> = {
72
+ yellow: theme.colors.warning,
73
+ gray: theme.colors.muted,
74
+ green: theme.colors.success,
75
+ red: theme.colors.danger,
76
+ purple: theme.colors.accent,
77
+ teal: theme.colors.info,
78
+ byline: theme.colors.byline,
79
+ };
80
+
81
+ return (
82
+ <CategoryHeader
83
+ title={mp.displayName}
84
+ version={version}
85
+ expanded={item.isExpanded}
86
+ count={item.pluginCount}
87
+ status={item.badge}
88
+ statusColor={statusColorMap[item.tone]}
89
+ />
90
+ );
91
+ }
92
+
93
+ function categoryDetail(
94
+ item: PluginCategoryItem,
95
+ collapsedMarketplaces: Set<string>,
96
+ ): React.ReactNode {
97
+ const mp = item.marketplace;
98
+ const isEnabled = item.marketplaceEnabled;
99
+ const isCollapsed = collapsedMarketplaces.has(mp.name);
100
+ const hasPlugins = item.pluginCount > 0;
101
+
102
+ let actionHint = "Add";
103
+ if (isEnabled) {
104
+ if (isCollapsed) {
105
+ actionHint = "Expand";
106
+ } else if (hasPlugins) {
107
+ actionHint = "Collapse";
108
+ } else {
109
+ actionHint = "Remove";
110
+ }
111
+ }
112
+
113
+ return (
114
+ <box flexDirection="column">
115
+ <text fg={theme.colors.info}>
116
+ <strong>
117
+ {mp.displayName}
118
+ {item.badge ? ` ${item.badge}` : ""}
119
+ </strong>
120
+ </text>
121
+ <text fg={theme.colors.muted}>{mp.description || "No description"}</text>
122
+ <text fg={isEnabled ? theme.colors.success : theme.colors.muted}>
123
+ {isEnabled ? "● Added" : "○ Not added"}
124
+ </text>
125
+ <text fg={theme.colors.link}>github.com/{mp.source.repo}</text>
126
+ <text fg={theme.colors.text}>Plugins: {item.pluginCount}</text>
127
+ <ActionHints
128
+ hints={[
129
+ {
130
+ key: "Enter",
131
+ label: actionHint,
132
+ tone: isEnabled ? "primary" : "default",
133
+ },
134
+ ]}
135
+ />
136
+ {isEnabled ? (
137
+ <box>
138
+ <text fg={theme.colors.muted}>← → to expand/collapse</text>
139
+ </box>
140
+ ) : null}
141
+ </box>
142
+ );
143
+ }
144
+
145
+ // ─── Plugin renderers ─────────────────────────────────────────────────────────
146
+
147
+ /**
148
+ * The row's text after the name, drawn exactly as `pluginRowText` produced it.
149
+ *
150
+ * On a selected row the tone's hue is surrendered to the selection (see
151
+ * MetaText); the words are the same either way.
152
+ */
153
+ function RowSegments({
154
+ segments,
155
+ selected,
156
+ }: {
157
+ segments: PluginRowSegment[];
158
+ selected: boolean;
159
+ }): React.ReactNode {
160
+ return (
161
+ <>
162
+ {segments.map((seg, i) => {
163
+ const key = `${i}:${seg.text}`;
164
+ if (seg.tone === "text") return <span key={key}>{seg.text}</span>;
165
+ if (seg.tone === "info") {
166
+ return (
167
+ <span
168
+ key={key}
169
+ fg={selected ? theme.selection.fg : theme.colors.info}
170
+ >
171
+ {seg.text}
172
+ </span>
173
+ );
174
+ }
175
+ return (
176
+ <MetaText
177
+ key={key}
178
+ text={seg.text}
179
+ tone={seg.tone}
180
+ selected={selected}
181
+ />
182
+ );
183
+ })}
184
+ </>
185
+ );
186
+ }
187
+
188
+ function pluginRow(
189
+ item: PluginPluginItem,
190
+ isSelected: boolean,
191
+ ): React.ReactNode {
192
+ const { plugin } = item;
193
+ const hasUser = !!plugin.userScope?.enabled;
194
+ const hasProject = !!plugin.projectScope?.enabled;
195
+ const hasLocal = !!plugin.localScope?.enabled;
196
+ const hasAnyScope = hasUser || hasProject || hasLocal;
197
+
198
+ // Everything after the name — version, history badge, status — comes from
199
+ // ONE pure function over the classifier's answer, and is drawn verbatim in
200
+ // every branch below. No branch here decides plugin state.
201
+ const { segments } = pluginRowText(item);
202
+
203
+ // Same glyphs as a marketplace row, in a cell that is always two columns
204
+ // wide. Both halves matter: a different arrow at each level reads as a
205
+ // different kind of control, and drawing the cell only on the selected row
206
+ // shifted every plugin name two columns sideways as the cursor passed over
207
+ // it.
208
+ const arrow = item.isExpandable ? (item.isExpanded ? "▼ " : "▶ ") : " ";
209
+
210
+ if (isSelected) {
211
+ return (
212
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
213
+ {" "}
214
+ <ScopeSquares
215
+ user={hasUser}
216
+ project={hasProject}
217
+ local={hasLocal}
218
+ selected={true}
219
+ />{" "}
220
+ {arrow}
221
+ {plugin.name}
222
+ <RowSegments segments={segments} selected={true} />{" "}
223
+ </text>
224
+ );
225
+ }
226
+
227
+ // Fuzzy highlight
228
+ const nameSegments = item.matches
229
+ ? highlightMatches(plugin.name, item.matches)
230
+ : null;
231
+ const displayName = nameSegments
232
+ ? nameSegments.map((seg) => seg.text).join("")
233
+ : plugin.name;
234
+
235
+ if (plugin.isOrphaned) {
236
+ // The red squares are the presence marker for "published by no marketplace
237
+ // we read". WHAT is known about it — moved, withdrawn, or its marketplace
238
+ // was never read — is the status suffix, the same words the CLI prints.
239
+ return (
240
+ <text fg={theme.colors.text}>
241
+ <span fg={theme.colors.danger}> ■■■ </span>
242
+ {/* An orphan is never expandable, but it keeps the arrow cell so its
243
+ name lands in the same column as every other plugin's. */}
244
+ <span>{" "}</span>
245
+ <span fg={theme.colors.muted}>{displayName}</span>
246
+ <RowSegments segments={segments} selected={false} />
247
+ </text>
248
+ );
249
+ }
250
+
251
+ const installedSomewhere =
252
+ isInstalledInScope(plugin.userScope) ||
253
+ isInstalledInScope(plugin.projectScope) ||
254
+ isInstalledInScope(plugin.localScope);
255
+
256
+ return (
257
+ <text fg={theme.colors.text}>
258
+ <span> </span>
259
+ <ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
260
+ <span> </span>
261
+ <span fg={theme.colors.dim}>{arrow}</span>
262
+ <span
263
+ fg={
264
+ hasAnyScope && installedSomewhere
265
+ ? theme.colors.text
266
+ : theme.colors.muted
267
+ }
268
+ >
269
+ {displayName}
270
+ </span>
271
+ <RowSegments segments={segments} selected={false} />
272
+ </text>
273
+ );
274
+ }
275
+
276
+ const ACTION_LABEL: Record<PluginUpdateAction, string> = {
277
+ install: "NOT INSTALLED",
278
+ update: "UPDATE AVAILABLE",
279
+ repair: "NEEDS REINSTALL",
280
+ current: "UP TO DATE",
281
+ unknown: "UNKNOWN",
282
+ };
283
+
284
+ /** Resolved at render time, so a theme switch is honoured. */
285
+ function toneColor(tone: StatusTone): UiColor {
286
+ switch (tone) {
287
+ case "danger":
288
+ return theme.colors.danger;
289
+ case "warning":
290
+ return theme.colors.warning;
291
+ case "success":
292
+ return theme.colors.success;
293
+ case "info":
294
+ return theme.colors.info;
295
+ case "muted":
296
+ return theme.colors.muted;
297
+ }
298
+ }
299
+
300
+ /**
301
+ * The detail panel's `Status:` line: the classifier's action, and its note
302
+ * VERBATIM — the same words `magus update` prints for this plugin.
303
+ */
304
+ function StatusLine({
305
+ status,
306
+ }: {
307
+ status: PluginUpdateItem | null;
308
+ }): React.ReactNode {
309
+ if (!status) return null;
310
+ const tone =
311
+ status.action === "current" ? "success" : pluginStatusText(status).tone;
312
+ const detail = status.note
313
+ ? status.note
314
+ : status.action === "update" && status.target
315
+ ? `→ v${status.target}`
316
+ : "";
317
+ return (
318
+ <KeyValueLine
319
+ label="Status"
320
+ value={
321
+ <span>
322
+ <span fg={toneColor(tone)}>{ACTION_LABEL[status.action]}</span>
323
+ {detail ? <span fg={theme.colors.text}> {detail}</span> : null}
324
+ </span>
325
+ }
326
+ />
327
+ );
328
+ }
329
+
330
+ function pluginDetail(
331
+ item: PluginPluginItem,
332
+ fetched?: PluginContentsEntry,
333
+ ): React.ReactNode {
334
+ const { plugin, status } = item;
335
+ // "Installed" has to mean the same thing here as in the list row. Reading the
336
+ // `enabledPlugins` flag alone made this panel print "● Installed" for the
337
+ // exact plugin the row beside it was labelling "not installed".
338
+ const isInstalled =
339
+ isInstalledInScope(plugin.userScope) ||
340
+ isInstalledInScope(plugin.projectScope) ||
341
+ isInstalledInScope(plugin.localScope);
342
+ const brokenInstall = isEnabledButNotInstalled(plugin);
343
+ // The ⬆ marker and the U hint claim that pressing U does something. That is
344
+ // exactly the classifier's update/repair, and nothing else.
345
+ const needsWork = status?.action === "update" || status?.action === "repair";
346
+
347
+ // Published by no marketplace we read. The status line says which of the
348
+ // three things that means — moved, withdrawn, or its marketplace unread.
349
+ if (plugin.isOrphaned) {
350
+ return (
351
+ <box flexDirection="column">
352
+ <box justifyContent="center">
353
+ <text bg={theme.colors.warning} fg={theme.hints.fg}>
354
+ <strong> {plugin.name} — NOT LISTED </strong>
355
+ </text>
356
+ </box>
357
+ <box marginTop={1}>
358
+ <StatusLine status={status} />
359
+ </box>
360
+ <box marginTop={1}>
361
+ <text fg={theme.colors.warning}>
362
+ No marketplace magus read lists this plugin.
363
+ </text>
364
+ </box>
365
+ <box marginTop={1}>
366
+ <text fg={theme.colors.muted}>
367
+ It is still installed or referenced in your settings. Press d to
368
+ uninstall and clean up, or to migrate it if it moved.
369
+ </text>
370
+ </box>
371
+ <ActionHints
372
+ hints={[
373
+ {
374
+ key: "d",
375
+ label: isInstalled
376
+ ? "Remove from all scopes"
377
+ : "Clean up stale reference",
378
+ tone: "danger",
379
+ },
380
+ ]}
381
+ />
382
+ </box>
383
+ );
384
+ }
385
+
386
+ // Component counts. A listing fetched from the repo tree wins over the
387
+ // manifest scan on every count it can supply: the scan only sees a
388
+ // marketplace that is cloned locally, a `featured` marketplace the user has
389
+ // not added has no clone at all, and the scan misses an MCP server declared
390
+ // in `.mcp.json` rather than an `mcp-servers/` directory — which is how 5 of
391
+ // this marketplace's 7 MCP plugins declare theirs, mnemex included.
392
+ const listed =
393
+ fetched?.status === "success" ? (fetched.components ?? null) : null;
394
+ const countOf = (kind: PluginComponentKind, fallback?: string[]) =>
395
+ listed
396
+ ? listed.filter((c) => c.kind === kind).length
397
+ : (fallback?.length ?? 0);
398
+
399
+ const components: string[] = [];
400
+ const agentCount = countOf("agent", plugin.agents);
401
+ const commandCount = countOf("command", plugin.commands);
402
+ const skillCount = countOf("skill", plugin.skills);
403
+ const mcpCount = countOf("mcp", plugin.mcpServers);
404
+ /** "1 agent", "2 agents" — a count of one is common enough to read wrong. */
405
+ const plural = (n: number, noun: string) =>
406
+ `${n} ${noun}${n === 1 ? "" : "s"}`;
407
+ if (agentCount) components.push(plural(agentCount, "agent"));
408
+ if (commandCount) components.push(plural(commandCount, "command"));
409
+ if (skillCount) components.push(plural(skillCount, "skill"));
410
+ if (mcpCount) components.push(plural(mcpCount, "MCP server"));
411
+ if (plugin.lspServers && Object.keys(plugin.lspServers).length) {
412
+ components.push(`${Object.keys(plugin.lspServers).length} LSP`);
413
+ }
414
+
415
+ const showVersion = isKnownVersion(plugin.version ?? undefined);
416
+ // The shared comparator, not `!==`: "1.2" and "1.2.0" are the same version,
417
+ // and the classifier's `Status:` line right above says so. Raw inequality
418
+ // made this line contradict it. `null` (no shared ordering) is a genuine
419
+ // difference and shows.
420
+ const showInstalledVersion =
421
+ isInstalled &&
422
+ isKnownVersion(plugin.installedVersion) &&
423
+ plugin.installedVersion !== undefined &&
424
+ plugin.version !== null &&
425
+ compareVersions(plugin.version, plugin.installedVersion) !== 0;
426
+
427
+ return (
428
+ <box flexDirection="column">
429
+ {/* Plugin name header */}
430
+ <box justifyContent="center">
431
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
432
+ <strong>
433
+ {" "}
434
+ {plugin.name}
435
+ {needsWork ? " ⬆" : ""}{" "}
436
+ </strong>
437
+ </text>
438
+ </box>
439
+
440
+ {/* Status line */}
441
+ <box marginTop={1}>
442
+ <text
443
+ fg={
444
+ isInstalled
445
+ ? theme.colors.success
446
+ : brokenInstall
447
+ ? theme.colors.warning
448
+ : theme.colors.muted
449
+ }
450
+ >
451
+ {isInstalled
452
+ ? "● Installed"
453
+ : brokenInstall
454
+ ? "○ Not installed — enabled in settings, no files on disk"
455
+ : "○ Not installed"}
456
+ </text>
457
+ </box>
458
+ {brokenInstall ? (
459
+ <box>
460
+ <text fg={theme.colors.muted}>
461
+ Claude Code cannot load it in this state. Press the scope key to
462
+ repair.
463
+ </text>
464
+ </box>
465
+ ) : null}
466
+
467
+ {/* Description */}
468
+ <box marginTop={1} marginBottom={1}>
469
+ <text fg={theme.colors.text}>{plugin.description}</text>
470
+ </box>
471
+
472
+ {/* Metadata */}
473
+ <StatusLine status={status} />
474
+ {showVersion ? (
475
+ <KeyValueLine
476
+ label="Version"
477
+ value={
478
+ <span>
479
+ <span fg={theme.colors.link}>v{plugin.version}</span>
480
+ {showInstalledVersion ? (
481
+ <span> (v{plugin.installedVersion} installed)</span>
482
+ ) : null}
483
+ </span>
484
+ }
485
+ />
486
+ ) : null}
487
+ {/*
488
+ * Name the source of the version above whenever it is not authoritative.
489
+ * Without this the panel showed a version and an installed version that
490
+ * happened to match, and the user reasonably read that as "current" —
491
+ * when in fact GitHub was never reached and the number came off a clone
492
+ * that had not moved in days.
493
+ */}
494
+ {status?.reason === "catalog-unverified" ? (
495
+ <>
496
+ <KeyValueLine
497
+ label="Latest"
498
+ value={
499
+ <span fg={theme.colors.warning}>unverified — not checked</span>
500
+ }
501
+ />
502
+ <box>
503
+ <text fg={theme.colors.muted}>
504
+ {" "}
505
+ {plugin.catalogSource === "local-clone"
506
+ ? "Version shown is from the pinned marketplace clone, so a newer release may exist. Press r to retry."
507
+ : "Press r to retry."}
508
+ </text>
509
+ </box>
510
+ </>
511
+ ) : null}
512
+ {plugin.category ? (
513
+ <KeyValueLine
514
+ label="Category"
515
+ value={<span fg={theme.colors.accent}>{plugin.category}</span>}
516
+ />
517
+ ) : null}
518
+ {plugin.author ? (
519
+ <KeyValueLine
520
+ label="Author"
521
+ value={<span>{plugin.author.name}</span>}
522
+ />
523
+ ) : null}
524
+ {components.length > 0 ? (
525
+ <KeyValueLine
526
+ label="Contains"
527
+ value={
528
+ <span fg={theme.colors.warning}>{components.join(" · ")}</span>
529
+ }
530
+ />
531
+ ) : null}
532
+ {item.isExpandable ? (
533
+ <box>
534
+ <text fg={theme.colors.muted}>
535
+ ← → to {item.isExpanded ? "hide" : "list"} what it contains
536
+ </text>
537
+ </box>
538
+ ) : null}
539
+
540
+ {/* Scope Status */}
541
+ <DetailSection>
542
+ <text fg={theme.colors.text}>{"─".repeat(24)}</text>
543
+ <text fg={theme.colors.text}>
544
+ <strong>Scopes:</strong>
545
+ </text>
546
+ <box marginTop={1} flexDirection="column">
547
+ <ScopeLine
548
+ keyHint="u"
549
+ name="User"
550
+ qualifier="global"
551
+ color={theme.scopes.user}
552
+ scope={plugin.userScope}
553
+ />
554
+ <ScopeLine
555
+ keyHint="p"
556
+ name="Project"
557
+ qualifier="team"
558
+ color={theme.scopes.project}
559
+ scope={plugin.projectScope}
560
+ />
561
+ <ScopeLine
562
+ keyHint="l"
563
+ name="Local"
564
+ qualifier="private"
565
+ color={theme.scopes.local}
566
+ scope={plugin.localScope}
567
+ />
568
+ </box>
569
+ </DetailSection>
570
+
571
+ {/* Recent releases */}
572
+ <ReleaseHistory
573
+ releases={plugin.releases}
574
+ installedVersion={plugin.installedVersion}
575
+ />
576
+
577
+ {/* Update action */}
578
+ {needsWork ? (
579
+ <ActionHints
580
+ hints={[
581
+ {
582
+ key: "U",
583
+ label:
584
+ status?.action === "repair"
585
+ ? "Reinstall (files changed)"
586
+ : status?.target
587
+ ? `Update to v${status.target}`
588
+ : "Update",
589
+ tone: "primary",
590
+ },
591
+ ]}
592
+ />
593
+ ) : null}
594
+ </box>
595
+ );
596
+ }
597
+
598
+ /**
599
+ * One line of the detail panel's scope breakdown.
600
+ *
601
+ * The filled dot means installed — registry-backed, same as everywhere else.
602
+ * A scope that is enabled with nothing installed is called out explicitly
603
+ * rather than shown as a plain empty dot, because those two states need
604
+ * different actions from the user and used to look identical.
605
+ */
606
+ function ScopeLine({
607
+ keyHint,
608
+ name,
609
+ qualifier,
610
+ color,
611
+ scope,
612
+ }: {
613
+ keyHint: string;
614
+ name: string;
615
+ qualifier: string;
616
+ color: UiColor;
617
+ scope?: ScopeStatus;
618
+ }): React.ReactNode {
619
+ const installed = isInstalledInScope(scope);
620
+ return (
621
+ <text fg={theme.colors.text}>
622
+ <span bg={color} fg={theme.hints.fg}>
623
+ {" "}
624
+ {keyHint}{" "}
625
+ </span>
626
+ <span fg={installed ? color : theme.colors.muted}>
627
+ {installed ? " ● " : " ○ "}
628
+ </span>
629
+ <span fg={color}>{name}</span>
630
+ <span> {qualifier}</span>
631
+ {scope?.version ? <span fg={color}> v{scope.version}</span> : null}
632
+ {!installed && scope?.enabled ? (
633
+ <span fg={theme.colors.warning}> enabled, not installed</span>
634
+ ) : null}
635
+ </text>
636
+ );
637
+ }
638
+
639
+ /**
640
+ * Recent release history, newest first.
641
+ *
642
+ * Releases the user does not have yet are marked and coloured; the installed one is
643
+ * flagged; older ones are dimmed. The list is already ordered newest-first, so the
644
+ * installed entry's index is all that's needed to tell those three groups apart —
645
+ * no version comparison, and no way for it to disagree with the published order.
646
+ * When the installed version isn't in the window (unknown "0.0.0", or older than the
647
+ * releases published), everything renders neutrally rather than guessing.
648
+ */
649
+ function ReleaseHistory({
650
+ releases,
651
+ installedVersion,
652
+ }: {
653
+ releases?: PluginRelease[];
654
+ installedVersion?: string;
655
+ }): React.ReactNode {
656
+ if (!releases?.length) return null;
657
+
658
+ const installedAt = installedVersion
659
+ ? releases.findIndex((r) => r.version === installedVersion)
660
+ : -1;
661
+
662
+ return (
663
+ <DetailSection>
664
+ <text fg={theme.colors.text}>{"─".repeat(24)}</text>
665
+ <text fg={theme.colors.text}>
666
+ <strong>Recent releases:</strong>
667
+ </text>
668
+ <box marginTop={1} flexDirection="column">
669
+ {releases.map((release, i) => {
670
+ const isNewer = installedAt > 0 && i < installedAt;
671
+ const isInstalled = installedAt >= 0 && i === installedAt;
672
+ const versionColor = isNewer
673
+ ? theme.colors.warning
674
+ : isInstalled
675
+ ? theme.colors.success
676
+ : theme.colors.link;
677
+
678
+ return (
679
+ <box key={release.version} flexDirection="column" marginBottom={1}>
680
+ <text fg={theme.colors.text}>
681
+ <span fg={versionColor}>
682
+ {isNewer ? "▲ " : isInstalled ? "● " : " "}v{release.version}
683
+ </span>
684
+ {release.date ? (
685
+ <span fg={theme.colors.muted}> {release.date}</span>
686
+ ) : null}
687
+ {release.kinds?.length ? (
688
+ <span fg={theme.colors.dim}>
689
+ {" "}
690
+ {release.kinds.join(" · ")}
691
+ </span>
692
+ ) : null}
693
+ {isInstalled ? (
694
+ <span fg={theme.colors.success}> installed</span>
695
+ ) : null}
696
+ </text>
697
+ <text fg={isNewer ? theme.colors.text : theme.colors.muted}>
698
+ {" "}
699
+ {release.summary}
700
+ </text>
701
+ </box>
702
+ );
703
+ })}
704
+ </box>
705
+ </DetailSection>
706
+ );
707
+ }
708
+
709
+ // ─── Skill renderers ─────────────────────────────────────────────────────────
710
+
711
+ /**
712
+ * Left padding for a component row.
713
+ *
714
+ * A plugin name starts at column 7: one leading space, three scope segments,
715
+ * one space, then the two-column arrow cell. A component's badge occupies
716
+ * columns 5-7 and its name starts at 9.
717
+ *
718
+ * Deliberately no tree glyph. An earlier version drew a `└` on every row, which
719
+ * introduced a second visual grammar halfway down the list — scope segments
720
+ * above, box-drawing below — and read as a broken tree, since a real one would
721
+ * branch with `├` on every row but the last. The badge anchors the row instead,
722
+ * and says what kind of thing it is at the same time.
723
+ */
724
+ const COMPONENT_INDENT = " ".repeat(5);
725
+
726
+ /**
727
+ * Width of the badge cell plus the gap after it, so status rows line their text
728
+ * up with the component names.
729
+ *
730
+ * The gap is outside the chip, not inside it. Widening the chip to four cells
731
+ * would have put the extra space on its coloured fill, which makes the chip
732
+ * bigger rather than separating it from the name.
733
+ */
734
+ const BADGE_WIDTH = 3;
735
+ const BADGE_GAP = 1;
736
+
737
+ /**
738
+ * Type icons.
739
+ *
740
+ * Commands and agents take the glyph you address them with — `/name`, `@name` —
741
+ * so the badge and the thing you type match.
742
+ *
743
+ * The skill glyph follows the only convention the ecosystem has. There is no
744
+ * agreed terminal symbol for a skill: Anthropic ships none (its Skills write-up
745
+ * uses a folder metaphor in prose only), and the large community directories
746
+ * distinguish skills from plugins by section heading rather than by symbol. The
747
+ * two products that do mark them converged on the same idea — Cursor's docs say
748
+ * a skill badge "defaults to a lightning icon", and the community Skills
749
+ * sidebar for VS Code uses a sparkle.
750
+ *
751
+ * `✱` is the sparkle idea at single width. The canonical glyphs cannot be used:
752
+ * `⚡` (U+26A1) and `✨` (U+2728) are both East-Asian-Width Wide, i.e. two cells,
753
+ * which would shift every name on the row. An *asterisk* rather than a
754
+ * four-pointed star also keeps it clear of `◆` and `✦`, which read as a generic
755
+ * ornament next to `/` and `@`.
756
+ *
757
+ * `⬢` for an MCP server is ours; nobody has a convention there either.
758
+ *
759
+ * Every glyph here is single-width — check with `unicodedata.east_asian_width`
760
+ * before swapping one, and reject anything reporting W or F.
761
+ */
762
+ export const COMPONENT_ICON: Record<PluginComponentKind, string> = {
763
+ skill: "✱",
764
+ command: "/",
765
+ agent: "@",
766
+ mcp: "⬢",
767
+ };
768
+
769
+ const COMPONENT_COLOR: Record<PluginComponentKind, UiColor> = {
770
+ skill: theme.colors.accent,
771
+ command: theme.colors.info,
772
+ agent: theme.colors.link,
773
+ mcp: theme.colors.warning,
774
+ };
775
+
776
+ const COMPONENT_NOUN: Record<PluginComponentKind, string> = {
777
+ skill: "Skill",
778
+ command: "Command",
779
+ agent: "Subagent",
780
+ mcp: "MCP server",
781
+ };
782
+
783
+ /**
784
+ * The type badge: one glyph in a padded, washed cell.
785
+ *
786
+ * Two states, and each exists for a reason.
787
+ *
788
+ * On a *selected* row the whole line is painted in the selection colour, so the
789
+ * badge drops its own fill — a chip of a third colour inside an inverted row is
790
+ * the noisiest thing on the screen, and the selection already says where you
791
+ * are.
792
+ *
793
+ * Otherwise: a wash of the kind's hue for the resolved mode, quiet enough to
794
+ * sit on hundreds of rows.
795
+ */
796
+ function componentBadge(
797
+ kind: PluginComponentKind,
798
+ isSelected: boolean,
799
+ ): React.ReactNode {
800
+ const icon = COMPONENT_ICON[kind];
801
+ if (isSelected) return ` ${icon} `;
802
+
803
+ const chip = badgeColors(kind);
804
+ return <span bg={chip.bg} fg={chip.fg}>{` ${icon} `}</span>;
805
+ }
806
+
807
+ function componentRow(
808
+ item: PluginComponentItem,
809
+ isSelected: boolean,
810
+ ): React.ReactNode {
811
+ const { component } = item;
812
+ const label = component.group
813
+ ? `${component.group}/${component.name}`
814
+ : component.name;
815
+
816
+ if (isSelected) {
817
+ return (
818
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
819
+ {COMPONENT_INDENT}
820
+ {componentBadge(component.kind, true)}
821
+ {`${" ".repeat(BADGE_GAP)}${label} `}
822
+ </text>
823
+ );
824
+ }
825
+ return (
826
+ <text fg={theme.colors.muted}>
827
+ {COMPONENT_INDENT}
828
+ {componentBadge(component.kind, false)}
829
+ {`${" ".repeat(BADGE_GAP)}${label}`}
830
+ </text>
831
+ );
832
+ }
833
+
834
+ function contentsStatusRow(
835
+ item: PluginContentsStatusItem,
836
+ isSelected: boolean,
837
+ ): React.ReactNode {
838
+ const pad = COMPONENT_INDENT + " ".repeat(BADGE_WIDTH + BADGE_GAP);
839
+ if (isSelected) {
840
+ return (
841
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
842
+ {`${pad}${item.label} `}
843
+ </text>
844
+ );
845
+ }
846
+ return (
847
+ <text
848
+ fg={item.status === "error" ? theme.colors.warning : theme.colors.dim}
849
+ >
850
+ {`${pad}${item.label}`}
851
+ </text>
852
+ );
853
+ }
854
+
855
+ /**
856
+ * How much of a component's file the panel prints.
857
+ *
858
+ * The detail pane is a scroll box (PgUp/PgDn, Ctrl+U/Ctrl+D), so this is a
859
+ * render-cost guard, not a reading limit — high enough that essentially every
860
+ * real file prints whole. When it does bite, the panel says how many lines it
861
+ * withheld: a body that simply stops reads as a truncated *file*, which is a
862
+ * different and more alarming claim than a truncated view.
863
+ */
864
+ const BODY_LINES = 400;
865
+
866
+ /** Frontmatter keys already shown in the header, so the table skips them. */
867
+ const HEADER_KEYS = new Set(["name", "description"]);
868
+
869
+ function componentDetail(
870
+ item: PluginComponentItem,
871
+ entry?: PluginSkillDetailEntry,
872
+ width = 80,
873
+ ): React.ReactNode {
874
+ const { component, plugin } = item;
875
+ const detail = entry?.status === "success" ? entry.detail : undefined;
876
+ const isMcp = component.kind === "mcp";
877
+
878
+ const described = detail?.frontmatter.find((f) => f.key === "description");
879
+ const titled = detail?.frontmatter.find((f) => f.key === "name");
880
+ const rest = (detail?.frontmatter ?? []).filter(
881
+ (f) => !HEADER_KEYS.has(f.key) && f.value,
882
+ );
883
+
884
+ const rendered = detail
885
+ ? renderMarkdown(detail.body, { width, maxLines: BODY_LINES })
886
+ : null;
887
+
888
+ return (
889
+ <box flexDirection="column">
890
+ <box justifyContent="center">
891
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
892
+ <strong>
893
+ {" "}
894
+ {COMPONENT_ICON[component.kind]} {titled?.value ||
895
+ component.name}{" "}
896
+ </strong>
897
+ </text>
898
+ </box>
899
+
900
+ {described ? (
901
+ <box marginTop={1}>
902
+ <text fg={theme.colors.text}>{described.value}</text>
903
+ </box>
904
+ ) : null}
905
+
906
+ <box marginTop={1} flexDirection="column">
907
+ <KeyValueLine
908
+ label="Kind"
909
+ value={
910
+ <span>
911
+ {componentBadge(component.kind, false)}
912
+ <span fg={COMPONENT_COLOR[component.kind]}>
913
+ {` ${COMPONENT_NOUN[component.kind]}`}
914
+ </span>
915
+ </span>
916
+ }
917
+ />
918
+ <KeyValueLine
919
+ label="Plugin"
920
+ value={<span fg={theme.colors.accent}>{plugin.name}</span>}
921
+ />
922
+ {component.group ? (
923
+ <KeyValueLine
924
+ label="Group"
925
+ value={<span fg={theme.colors.accent}>{component.group}</span>}
926
+ />
927
+ ) : null}
928
+ {component.mcpCommand ? (
929
+ <KeyValueLine
930
+ label="Runs"
931
+ value={<span fg={theme.colors.link}>{component.mcpCommand}</span>}
932
+ />
933
+ ) : null}
934
+ <KeyValueLine
935
+ label="Path"
936
+ value={<span fg={theme.colors.link}>{component.repoPath}</span>}
937
+ />
938
+ {rest.map((field) => (
939
+ <KeyValueLine
940
+ key={field.key}
941
+ label={field.key}
942
+ value={<span fg={theme.colors.warning}>{field.value}</span>}
943
+ />
944
+ ))}
945
+ </box>
946
+
947
+ {/* An MCP server is declared in JSON, not a markdown document, so there
948
+ is no body to fetch — the Runs line above is the whole of it. */}
949
+ {!isMcp && (entry?.status === "loading" || !entry) ? (
950
+ <box marginTop={1}>
951
+ <text fg={theme.colors.muted}>Reading {component.repoPath}…</text>
952
+ </box>
953
+ ) : null}
954
+
955
+ {entry?.status === "error" ? (
956
+ <box marginTop={1} flexDirection="column">
957
+ <text fg={theme.colors.warning}>
958
+ Could not read it: {entry.error}
959
+ </text>
960
+ <text fg={theme.colors.muted}>
961
+ Set GITHUB_TOKEN to raise the 60-per-hour unauthenticated limit.
962
+ </text>
963
+ </box>
964
+ ) : null}
965
+
966
+ {rendered && rendered.nodes.length > 0 ? (
967
+ <DetailSection>
968
+ <text fg={theme.colors.text}>{"─".repeat(24)}</text>
969
+ <box marginTop={1} flexDirection="column">
970
+ {rendered.nodes}
971
+ </box>
972
+ {rendered.withheld > 0 ? (
973
+ <box marginTop={1}>
974
+ <text fg={theme.colors.dim}>
975
+ … {rendered.withheld} more lines in the file
976
+ </text>
977
+ </box>
978
+ ) : null}
979
+ </DetailSection>
980
+ ) : null}
981
+
982
+ {detail && detail.body.length === 0 ? (
983
+ <box marginTop={1}>
984
+ <text fg={theme.colors.muted}>Frontmatter only — no body.</text>
985
+ </box>
986
+ ) : null}
987
+
988
+ <box marginTop={1}>
989
+ <text fg={theme.colors.dim}>
990
+ Installing {plugin.name} installs this. Plugin components are not
991
+ installed one at a time.
992
+ </text>
993
+ </box>
994
+ </box>
995
+ );
996
+ }
997
+
998
+ function contentsStatusDetail(item: PluginContentsStatusItem): React.ReactNode {
999
+ if (item.status === "error") {
1000
+ return (
1001
+ <box flexDirection="column">
1002
+ <text fg={theme.colors.warning}>
1003
+ <strong>Could not read {item.plugin.name}</strong>
1004
+ </text>
1005
+ <box marginTop={1}>
1006
+ <text fg={theme.colors.muted}>{item.error || "Unknown error."}</text>
1007
+ </box>
1008
+ <box marginTop={1}>
1009
+ <text fg={theme.colors.muted}>
1010
+ A marketplace you have added is read from its local clone and needs
1011
+ no network. This one is not added, so its contents come from the
1012
+ GitHub API — where an unauthenticated client gets 60 requests an
1013
+ hour across every repo on screen combined.
1014
+ </text>
1015
+ </box>
1016
+ <box marginTop={1}>
1017
+ <text fg={theme.colors.muted}>
1018
+ Add the marketplace to read it from disk instead, or set
1019
+ GITHUB_TOKEN to raise the limit. Collapse and expand to retry — the
1020
+ failure is not cached.
1021
+ </text>
1022
+ </box>
1023
+ </box>
1024
+ );
1025
+ }
1026
+ if (item.status === "empty") {
1027
+ return (
1028
+ <box flexDirection="column">
1029
+ <text fg={theme.colors.text}>
1030
+ <strong>{item.plugin.name} ships no components</strong>
1031
+ </text>
1032
+ <box marginTop={1}>
1033
+ <text fg={theme.colors.muted}>
1034
+ No skills, commands, subagents or MCP servers were found in its
1035
+ directory. It may ship hooks, an output style, or nothing but
1036
+ documentation.
1037
+ </text>
1038
+ </box>
1039
+ </box>
1040
+ );
1041
+ }
1042
+ return <text fg={theme.colors.muted}>Reading the repo tree…</text>;
1043
+ }
1044
+
1045
+ // ─── Public dispatch functions ────────────────────────────────────────────────
1046
+
1047
+ /**
1048
+ * Render a plugin browser list row by item kind.
1049
+ */
1050
+ export function renderPluginRow(
1051
+ item: PluginBrowserItem,
1052
+ _index: number,
1053
+ isSelected: boolean,
1054
+ ): React.ReactNode {
1055
+ if (item.kind === "category") {
1056
+ return categoryRow(item, isSelected);
1057
+ }
1058
+ if (item.kind === "component") {
1059
+ return componentRow(item, isSelected);
1060
+ }
1061
+ if (item.kind === "contents-status") {
1062
+ return contentsStatusRow(item, isSelected);
1063
+ }
1064
+ return pluginRow(item, isSelected);
1065
+ }
1066
+
1067
+ /**
1068
+ * Render the detail panel for a plugin browser item.
1069
+ */
1070
+ export function renderPluginDetail(
1071
+ item: PluginBrowserItem | undefined,
1072
+ collapsedMarketplaces: Set<string>,
1073
+ pluginContents?: Map<string, PluginContentsEntry>,
1074
+ skillDetails?: Map<string, PluginSkillDetailEntry>,
1075
+ /** Columns the detail pane has, so a markdown table can size its columns. */
1076
+ width?: number,
1077
+ ): React.ReactNode {
1078
+ if (!item) return <text fg={theme.colors.muted}>Select an item</text>;
1079
+ if (item.kind === "category") {
1080
+ return categoryDetail(item, collapsedMarketplaces);
1081
+ }
1082
+ if (item.kind === "component") {
1083
+ return componentDetail(item, skillDetails?.get(item.detailKey), width);
1084
+ }
1085
+ if (item.kind === "contents-status") {
1086
+ return contentsStatusDetail(item);
1087
+ }
1088
+ return pluginDetail(item, pluginContents?.get(item.plugin.id));
1089
+ }