magus-cli 0.0.0-placeholder → 7.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (330) hide show
  1. package/README.md +309 -0
  2. package/bin/magus.js +170 -0
  3. package/package.json +67 -3
  4. package/scripts/build-binaries.ts +113 -0
  5. package/scripts/capture-builtin.ts +412 -0
  6. package/scripts/check-optional-deps.ts +49 -0
  7. package/scripts/test-isolated.ts +49 -0
  8. package/scripts/verify-binary.ts +95 -0
  9. package/scripts/verify-community-registry.ts +272 -0
  10. package/src/__tests__/active-closure.test.ts +184 -0
  11. package/src/__tests__/alias-adopt.test.ts +365 -0
  12. package/src/__tests__/alias-parser.test.ts +417 -0
  13. package/src/__tests__/alias-shell-writer.test.ts +681 -0
  14. package/src/__tests__/alias-store.test.ts +163 -0
  15. package/src/__tests__/binary-signing.test.ts +120 -0
  16. package/src/__tests__/blackbox-active-closure.test.ts +119 -0
  17. package/src/__tests__/blackbox-convergence.test.ts +334 -0
  18. package/src/__tests__/blackbox-parity.test.ts +725 -0
  19. package/src/__tests__/blackbox-plugin-state.test.ts +545 -0
  20. package/src/__tests__/blackbox-tool-state.test.ts +669 -0
  21. package/src/__tests__/cache-provenance.test.ts +84 -0
  22. package/src/__tests__/capture-builtin.test.ts +92 -0
  23. package/src/__tests__/catalog-cache-store.test.ts +309 -0
  24. package/src/__tests__/catalog-notice.test.ts +155 -0
  25. package/src/__tests__/cli-ansi.test.ts +297 -0
  26. package/src/__tests__/cli-apply-seams.test.ts +288 -0
  27. package/src/__tests__/cli-live.test.ts +391 -0
  28. package/src/__tests__/cli-router.test.ts +240 -0
  29. package/src/__tests__/cli-tool-commands.test.ts +239 -0
  30. package/src/__tests__/cli-tools-adapter.test.ts +294 -0
  31. package/src/__tests__/cli-update-view.test.ts +524 -0
  32. package/src/__tests__/community-fetch.test.ts +545 -0
  33. package/src/__tests__/community-registry.test.ts +269 -0
  34. package/src/__tests__/community-staleness.test.ts +720 -0
  35. package/src/__tests__/component-badge.test.ts +98 -0
  36. package/src/__tests__/component-icons.test.ts +94 -0
  37. package/src/__tests__/content-drift.test.ts +353 -0
  38. package/src/__tests__/conventions-integration.test.ts +830 -0
  39. package/src/__tests__/conventions-manager.test.ts +1236 -0
  40. package/src/__tests__/doctor-bins.test.ts +43 -0
  41. package/src/__tests__/doctor.test.ts +463 -0
  42. package/src/__tests__/dotenv.test.ts +195 -0
  43. package/src/__tests__/enabled-not-installed.test.ts +94 -0
  44. package/src/__tests__/file-locking.test.ts +208 -0
  45. package/src/__tests__/fixtures/tool-probe/brew-list-versions-tmux.txt +2 -0
  46. package/src/__tests__/fixtures/tool-probe/brew-outdated-behind-homebrew-spec.txt +29 -0
  47. package/src/__tests__/fixtures/tool-probe/brew-outdated-tmux-current.txt +7 -0
  48. package/src/__tests__/fixtures/tool-probe/brew-outdated-unknown-formula.txt +6 -0
  49. package/src/__tests__/fixtures/tool-probe/go-version-m-tmux-mcp.txt +27 -0
  50. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-missing-scoped.txt +4 -0
  51. package/src/__tests__/fixtures/tool-probe/npm-dist-tags-openai-codex.txt +4 -0
  52. package/src/__tests__/fixtures/tool-probe/pypi-cowsay.txt +3 -0
  53. package/src/__tests__/fixtures/tool-probe/uv-receipt-browser-use.toml +10 -0
  54. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-git.toml +8 -0
  55. package/src/__tests__/fixtures/tool-probe/uv-receipt-cowsay-pinned.toml +8 -0
  56. package/src/__tests__/fixtures/tool-probe/uv-receipt-graphifyy.toml +7 -0
  57. package/src/__tests__/fixtures/tool-probe/uv-receipt-mcp.toml +6 -0
  58. package/src/__tests__/fixtures/tool-probe/uv-receipt-serena-agent.toml +9 -0
  59. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated-pinned.txt +5 -0
  60. package/src/__tests__/fixtures/tool-probe/uv-tool-list-outdated.txt +11 -0
  61. package/src/__tests__/fixtures/tool-probe/uv-tool-list.txt +22 -0
  62. package/src/__tests__/footer-hints.test.ts +40 -0
  63. package/src/__tests__/format-command.test.ts +78 -0
  64. package/src/__tests__/git-worktree.test.ts +108 -0
  65. package/src/__tests__/github-budget.test.ts +206 -0
  66. package/src/__tests__/gitignore-check.test.ts +71 -0
  67. package/src/__tests__/gitignore-detector.test.ts +176 -0
  68. package/src/__tests__/gitignore-fixer.test.ts +317 -0
  69. package/src/__tests__/gitignore-resolver.test.ts +193 -0
  70. package/src/__tests__/gitignore-service.test.ts +137 -0
  71. package/src/__tests__/go-module.test.ts +43 -0
  72. package/src/__tests__/hook-import-policy.test.ts +90 -0
  73. package/src/__tests__/hook-process.test.ts +256 -0
  74. package/src/__tests__/hook-registration.test.ts +224 -0
  75. package/src/__tests__/install-command.test.ts +81 -0
  76. package/src/__tests__/install-plan.test.ts +104 -0
  77. package/src/__tests__/launcher-signal.test.ts +308 -0
  78. package/src/__tests__/local-marketplace-scan.test.ts +248 -0
  79. package/src/__tests__/manifest.test.ts +281 -0
  80. package/src/__tests__/markdown-renderer.test.ts +142 -0
  81. package/src/__tests__/marketplace-badge.test.ts +140 -0
  82. package/src/__tests__/marketplace-refresh.test.ts +340 -0
  83. package/src/__tests__/marketplace-version.test.ts +84 -0
  84. package/src/__tests__/marketplaces.test.ts +128 -0
  85. package/src/__tests__/mate-availability.test.ts +156 -0
  86. package/src/__tests__/mate-catalog.test.ts +295 -0
  87. package/src/__tests__/mcp-registry.test.ts +158 -0
  88. package/src/__tests__/model-visuals.test.tsx +2466 -0
  89. package/src/__tests__/models-adapter.test.ts +332 -0
  90. package/src/__tests__/models-cli.test.ts +273 -0
  91. package/src/__tests__/models-core.test.ts +802 -0
  92. package/src/__tests__/models-manager.test.ts +500 -0
  93. package/src/__tests__/models-presets-marketplace.test.ts +168 -0
  94. package/src/__tests__/models-screen-state.test.ts +315 -0
  95. package/src/__tests__/open-file.test.ts +59 -0
  96. package/src/__tests__/owned-marketplaces.test.ts +93 -0
  97. package/src/__tests__/plugin-checkout.test.ts +66 -0
  98. package/src/__tests__/plugin-cli-argv.test.ts +109 -0
  99. package/src/__tests__/plugin-contents.test.ts +371 -0
  100. package/src/__tests__/plugin-manager-fallback.test.ts +320 -0
  101. package/src/__tests__/plugin-releases.test.ts +98 -0
  102. package/src/__tests__/plugin-requires.test.ts +187 -0
  103. package/src/__tests__/plugin-row-text.test.ts +345 -0
  104. package/src/__tests__/plugin-setup.test.ts +1016 -0
  105. package/src/__tests__/profile-adopt.test.ts +177 -0
  106. package/src/__tests__/profile-command.test.ts +110 -0
  107. package/src/__tests__/profile-materializer.test.ts +238 -0
  108. package/src/__tests__/profile-sync.test.ts +154 -0
  109. package/src/__tests__/registry-version-resolution.test.ts +88 -0
  110. package/src/__tests__/resolve-executable.test.ts +42 -0
  111. package/src/__tests__/resolver.test.ts +351 -0
  112. package/src/__tests__/scope-action.test.ts +134 -0
  113. package/src/__tests__/scope-squares.test.tsx +164 -0
  114. package/src/__tests__/selection-scope.test.ts +100 -0
  115. package/src/__tests__/settings-file.test.ts +179 -0
  116. package/src/__tests__/shell-script-callers.test.ts +92 -0
  117. package/src/__tests__/skill-install-files.test.ts +136 -0
  118. package/src/__tests__/skills-catalog-client.test.ts +48 -0
  119. package/src/__tests__/style-wrap.test.ts +220 -0
  120. package/src/__tests__/styles-manager.test.ts +1098 -0
  121. package/src/__tests__/styles-origins.test.ts +414 -0
  122. package/src/__tests__/styles-screen-state.test.ts +457 -0
  123. package/src/__tests__/styles-status-line.test.ts +72 -0
  124. package/src/__tests__/styles-sync.test.ts +452 -0
  125. package/src/__tests__/symlink-manager.test.ts +180 -0
  126. package/src/__tests__/tabbar-layout.test.ts +100 -0
  127. package/src/__tests__/terminology-filler.test.ts +214 -0
  128. package/src/__tests__/theme-adaptive-colors.test.ts +354 -0
  129. package/src/__tests__/theme-env.test.ts +310 -0
  130. package/src/__tests__/theme-resolve.test.ts +380 -0
  131. package/src/__tests__/tool-probe.test.ts +1400 -0
  132. package/src/__tests__/toolchain.test.ts +232 -0
  133. package/src/__tests__/ui-version-writers.test.ts +88 -0
  134. package/src/__tests__/update-apply.test.ts +637 -0
  135. package/src/__tests__/update-plan.test.ts +1396 -0
  136. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  137. package/src/__tests__/useGitignoreModal.test.ts +79 -0
  138. package/src/__tests__/version-check-refresh.test.ts +45 -0
  139. package/src/__tests__/version-snapshot.test.ts +212 -0
  140. package/src/__tests__/version-unknown-state.test.ts +403 -0
  141. package/src/__tests__/worktree-registry-resolution.test.ts +107 -0
  142. package/src/cli/ansi.ts +528 -0
  143. package/src/cli/bootstrap.ts +134 -0
  144. package/src/cli/doctor.ts +218 -0
  145. package/src/cli/hook.ts +129 -0
  146. package/src/cli/install.ts +382 -0
  147. package/src/cli/live.ts +374 -0
  148. package/src/cli/models.ts +297 -0
  149. package/src/cli/profile.ts +197 -0
  150. package/src/cli/prompt.ts +32 -0
  151. package/src/cli/router.ts +264 -0
  152. package/src/cli/update-view.ts +717 -0
  153. package/src/cli/update.ts +600 -0
  154. package/src/cli/upgrade.ts +97 -0
  155. package/src/data/alias-flags.ts +301 -0
  156. package/src/data/cli-tools.ts +146 -0
  157. package/src/data/community-styles.ts +531 -0
  158. package/src/data/gitignore-defaults.ts +33 -0
  159. package/src/data/gitignore-reasons.ts +93 -0
  160. package/src/data/gitignore-templates.ts +25 -0
  161. package/src/data/marketplaces.ts +278 -0
  162. package/src/data/mcp-servers.ts +51 -0
  163. package/src/data/models-presets.ts +329 -0
  164. package/src/data/predefined-profiles.ts +252 -0
  165. package/src/data/settings-catalog.ts +714 -0
  166. package/src/data/skill-repos.ts +91 -0
  167. package/src/data/styles/asd-ste100.md +38 -0
  168. package/src/data/styles/calibrated.md +24 -0
  169. package/src/data/styles/direct.md +23 -0
  170. package/src/data/styles/evidence-first.md +22 -0
  171. package/src/data/styles/explanatory.md +23 -0
  172. package/src/data/styles/index.ts +57 -0
  173. package/src/data/styles/no-slop.md +32 -0
  174. package/src/data/styles/plain-language.md +23 -0
  175. package/src/data/styles/structured.md +30 -0
  176. package/src/data/styles/terminology.md +28 -0
  177. package/src/data/styles/terse.md +20 -0
  178. package/src/main.tsx +110 -0
  179. package/src/markdown.d.ts +13 -0
  180. package/src/opentui.d.ts +203 -0
  181. package/src/services/active-closure.ts +117 -0
  182. package/src/services/alias-settings.ts +60 -0
  183. package/src/services/alias-shell-writer.ts +1244 -0
  184. package/src/services/alias-store.ts +172 -0
  185. package/src/services/binary-signing.ts +78 -0
  186. package/src/services/catalog-cache-store.ts +316 -0
  187. package/src/services/claude-cli.ts +304 -0
  188. package/src/services/claude-runner.ts +31 -0
  189. package/src/services/claude-settings.ts +1564 -0
  190. package/src/services/cli-tool-commands.ts +133 -0
  191. package/src/services/community-fetcher.ts +90 -0
  192. package/src/services/community-styles.ts +1194 -0
  193. package/src/services/content-drift.ts +208 -0
  194. package/src/services/conventions-manager.ts +945 -0
  195. package/src/services/doctor-bins.ts +63 -0
  196. package/src/services/doctor.ts +570 -0
  197. package/src/services/dotenv.ts +181 -0
  198. package/src/services/git-worktree.ts +129 -0
  199. package/src/services/github-budget.ts +274 -0
  200. package/src/services/gitignore-check.ts +63 -0
  201. package/src/services/gitignore-detector.ts +167 -0
  202. package/src/services/gitignore-fixer.ts +280 -0
  203. package/src/services/gitignore-resolver.ts +208 -0
  204. package/src/services/gitignore-service.ts +167 -0
  205. package/src/services/hook-registration.ts +218 -0
  206. package/src/services/install-plan.ts +107 -0
  207. package/src/services/local-marketplace.ts +657 -0
  208. package/src/services/manifest.ts +250 -0
  209. package/src/services/marketplace-catalog-git.ts +173 -0
  210. package/src/services/marketplace-catalog.ts +129 -0
  211. package/src/services/marketplace-fetcher.ts +400 -0
  212. package/src/services/marketplace-refresh.ts +245 -0
  213. package/src/services/marketplace-sync.ts +145 -0
  214. package/src/services/mate-availability.ts +133 -0
  215. package/src/services/mate-catalog.ts +265 -0
  216. package/src/services/mcp-registry.ts +171 -0
  217. package/src/services/models-core.ts +969 -0
  218. package/src/services/models-manager.ts +606 -0
  219. package/src/services/plugin-checkout.ts +186 -0
  220. package/src/services/plugin-manager.ts +847 -0
  221. package/src/services/plugin-mcp-config.ts +242 -0
  222. package/src/services/plugin-releases.ts +47 -0
  223. package/src/services/plugin-requires.ts +227 -0
  224. package/src/services/plugin-setup.ts +965 -0
  225. package/src/services/profile-adopt.ts +206 -0
  226. package/src/services/profile-materializer.ts +133 -0
  227. package/src/services/profile-sync.ts +158 -0
  228. package/src/services/profiles.ts +225 -0
  229. package/src/services/resolver.ts +377 -0
  230. package/src/services/settings-file.ts +69 -0
  231. package/src/services/settings-manager.ts +307 -0
  232. package/src/services/skills-manager.ts +1111 -0
  233. package/src/services/skillsmp-client.ts +193 -0
  234. package/src/services/styles-manager.ts +1294 -0
  235. package/src/services/symlink-manager.ts +189 -0
  236. package/src/services/terminology-filler.ts +266 -0
  237. package/src/services/tool-probe.ts +1637 -0
  238. package/src/services/toolchain.ts +431 -0
  239. package/src/services/update-engine.ts +691 -0
  240. package/src/services/update-plan.ts +820 -0
  241. package/src/services/version-check.ts +127 -0
  242. package/src/services/version-snapshot.ts +189 -0
  243. package/src/tui.tsx +112 -0
  244. package/src/types/bun.d.ts +27 -0
  245. package/src/types/gitignore.ts +68 -0
  246. package/src/types/index.ts +447 -0
  247. package/src/ui/App.tsx +535 -0
  248. package/src/ui/adapters/catalogNotice.ts +122 -0
  249. package/src/ui/adapters/cliToolsAdapter.ts +176 -0
  250. package/src/ui/adapters/modelsAdapter.ts +213 -0
  251. package/src/ui/adapters/pluginsAdapter.ts +413 -0
  252. package/src/ui/adapters/settingsAdapter.ts +168 -0
  253. package/src/ui/adapters/skillsAdapter.ts +229 -0
  254. package/src/ui/adapters/stylesAdapter.ts +403 -0
  255. package/src/ui/components/CategoryHeader.tsx +64 -0
  256. package/src/ui/components/EmptyFilterState.tsx +32 -0
  257. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  258. package/src/ui/components/ScopeIndicator.tsx +63 -0
  259. package/src/ui/components/ScrollableList.tsx +95 -0
  260. package/src/ui/components/SearchInput.tsx +57 -0
  261. package/src/ui/components/StyledText.tsx +75 -0
  262. package/src/ui/components/TabBar.tsx +101 -0
  263. package/src/ui/components/layout/FooterHints.tsx +78 -0
  264. package/src/ui/components/layout/Panel.tsx +63 -0
  265. package/src/ui/components/layout/ProgressBar.tsx +44 -0
  266. package/src/ui/components/layout/ScopeTabs.tsx +54 -0
  267. package/src/ui/components/layout/ScreenLayout.tsx +321 -0
  268. package/src/ui/components/layout/index.ts +9 -0
  269. package/src/ui/components/modals/ConfirmModal.tsx +62 -0
  270. package/src/ui/components/modals/InputModal.tsx +75 -0
  271. package/src/ui/components/modals/LoadingModal.tsx +51 -0
  272. package/src/ui/components/modals/MessageModal.tsx +66 -0
  273. package/src/ui/components/modals/ModalContainer.tsx +159 -0
  274. package/src/ui/components/modals/SelectModal.tsx +90 -0
  275. package/src/ui/components/modals/index.ts +6 -0
  276. package/src/ui/components/primitives/ActionHints.tsx +44 -0
  277. package/src/ui/components/primitives/DetailSection.tsx +23 -0
  278. package/src/ui/components/primitives/KeyValueLine.tsx +26 -0
  279. package/src/ui/components/primitives/ListCategoryRow.tsx +45 -0
  280. package/src/ui/components/primitives/MetaText.tsx +40 -0
  281. package/src/ui/components/primitives/ScopeDetail.tsx +67 -0
  282. package/src/ui/components/primitives/ScopeSquares.tsx +58 -0
  283. package/src/ui/components/primitives/SelectableRow.tsx +30 -0
  284. package/src/ui/components/primitives/index.ts +9 -0
  285. package/src/ui/hooks/index.ts +2 -0
  286. package/src/ui/hooks/useGitignoreModal.ts +95 -0
  287. package/src/ui/hooks/useKeyboard.ts +24 -0
  288. package/src/ui/hooks/useKeyboardHandler.ts +63 -0
  289. package/src/ui/registry.ts +33 -0
  290. package/src/ui/renderers/cliToolRenderers.tsx +326 -0
  291. package/src/ui/renderers/gitignoreRenderers.tsx +80 -0
  292. package/src/ui/renderers/markdownRenderer.tsx +302 -0
  293. package/src/ui/renderers/mcpRenderers.tsx +149 -0
  294. package/src/ui/renderers/modelRenderers.tsx +1309 -0
  295. package/src/ui/renderers/modelVisuals.tsx +1402 -0
  296. package/src/ui/renderers/pluginRenderers.tsx +1089 -0
  297. package/src/ui/renderers/pluginRowText.ts +70 -0
  298. package/src/ui/renderers/pluginStatusText.ts +56 -0
  299. package/src/ui/renderers/profileRenderers.tsx +406 -0
  300. package/src/ui/renderers/settingsRenderers.tsx +218 -0
  301. package/src/ui/renderers/skillRenderers.tsx +479 -0
  302. package/src/ui/renderers/styleRenderers.tsx +813 -0
  303. package/src/ui/screens/AliasScreen.tsx +1558 -0
  304. package/src/ui/screens/CliToolsScreen.tsx +367 -0
  305. package/src/ui/screens/EnvVarsScreen.tsx +221 -0
  306. package/src/ui/screens/GitignoreScreen.tsx +562 -0
  307. package/src/ui/screens/McpRegistryScreen.tsx +366 -0
  308. package/src/ui/screens/McpScreen.tsx +256 -0
  309. package/src/ui/screens/ModelsScreen.tsx +542 -0
  310. package/src/ui/screens/PluginsScreen.tsx +1423 -0
  311. package/src/ui/screens/ProfilesScreen.tsx +438 -0
  312. package/src/ui/screens/SkillsScreen.tsx +853 -0
  313. package/src/ui/screens/StylesScreen.tsx +1064 -0
  314. package/src/ui/screens/index.ts +11 -0
  315. package/src/ui/state/AppContext.tsx +233 -0
  316. package/src/ui/state/DimensionsContext.tsx +154 -0
  317. package/src/ui/state/reducer.ts +810 -0
  318. package/src/ui/state/types.ts +503 -0
  319. package/src/ui/theme-mode.ts +440 -0
  320. package/src/ui/theme-resolve.ts +147 -0
  321. package/src/ui/theme.ts +175 -0
  322. package/src/utils/clipboard.ts +58 -0
  323. package/src/utils/command-utils.ts +149 -0
  324. package/src/utils/config-dir.ts +45 -0
  325. package/src/utils/file-locking.ts +146 -0
  326. package/src/utils/fuzzy-search.ts +138 -0
  327. package/src/utils/go-module.ts +36 -0
  328. package/src/utils/open-file.ts +84 -0
  329. package/src/utils/run.ts +67 -0
  330. package/src/utils/string-utils.ts +88 -0
@@ -0,0 +1,321 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core";
2
+ import type React from "react";
3
+ import { useEffect, useRef } from "react";
4
+ import { useKeyboard } from "../../hooks/useKeyboard.js";
5
+ import { useApp } from "../../state/AppContext.js";
6
+ import { useDimensions } from "../../state/DimensionsContext.js";
7
+ import type { Screen } from "../../state/types.js";
8
+ import { theme } from "../../theme.js";
9
+ import { TabBar } from "../TabBar.js";
10
+ import { type FooterHint, FooterHints } from "./FooterHints.js";
11
+
12
+ /**
13
+ * Panel content: a node, or a function handed the panel's usable columns.
14
+ *
15
+ * The function form exists because the geometry below — the app frame's side
16
+ * padding, the 49/50 split, the panel padding and the scrollbar column — is
17
+ * ScreenLayout's, and a screen that re-derives it from `terminalWidth` is
18
+ * guessing at constants it cannot see. Both screens that wrap body text did
19
+ * exactly that, with the same hand-copied formula, and both were wrong.
20
+ *
21
+ * `React.ReactNode` does not include functions, so the union is unambiguous.
22
+ */
23
+ export type PanelContent =
24
+ | React.ReactNode
25
+ | ((width: number) => React.ReactNode);
26
+
27
+ /**
28
+ * The app frame (App.tsx) pads the screen one column each side before
29
+ * ScreenLayout ever renders, so every percentage below resolves against
30
+ * `terminalWidth - 2`.
31
+ */
32
+ const APP_SIDE_PADDING = 2;
33
+ const LIST_FRACTION = 0.49;
34
+ const DETAIL_FRACTION = 0.5;
35
+ /** The list panel's own `paddingRight`. */
36
+ const LIST_CHROME = 1;
37
+ /** The detail panel's `paddingLeft`, plus the scrollbox's scrollbar column. */
38
+ const DETAIL_CHROME = 2;
39
+
40
+ /** Columns a panel's content can actually use, at this terminal width. */
41
+ function panelWidth(
42
+ terminalWidth: number,
43
+ fraction: number,
44
+ chrome: number,
45
+ ): number {
46
+ const inner = Math.max(1, terminalWidth - APP_SIDE_PADDING);
47
+ return Math.max(8, Math.floor(inner * fraction) - chrome);
48
+ }
49
+
50
+ /**
51
+ * Usable columns in the list panel.
52
+ *
53
+ * MEASURED against real renders at 70…200 columns, not derived from the JSX:
54
+ * Yoga rounds a fractional percentage to whole cells, so the arithmetic only
55
+ * matches the pixels if you check. This is exact at 8 of the 12 widths sampled
56
+ * and one column short at the other four, which is the safe direction — a
57
+ * budget one column under folds nothing, a budget one column over paints into
58
+ * the separator.
59
+ */
60
+ export function listPanelWidth(terminalWidth: number): number {
61
+ return panelWidth(terminalWidth, LIST_FRACTION, LIST_CHROME);
62
+ }
63
+
64
+ /**
65
+ * Usable columns in the detail panel, scrollbar excluded.
66
+ *
67
+ * Exact at 11 of the 12 widths sampled, one column short at the twelfth. The
68
+ * formula each screen used to carry instead was `floor(width * 0.5) - 4`: it
69
+ * forgot the app frame's padding, counted the scrollbar twice, and then floored
70
+ * at 24 — so below about 56 columns it asked for MORE room than the panel has.
71
+ */
72
+ export function detailPanelWidth(terminalWidth: number): number {
73
+ return panelWidth(terminalWidth, DETAIL_FRACTION, DETAIL_CHROME);
74
+ }
75
+
76
+ interface ScreenLayoutProps {
77
+ /** Screen title (e.g., "magus Plugins") */
78
+ title: string;
79
+ /** Optional subtitle shown to the right of title */
80
+ subtitle?: string;
81
+ /** Current screen for tab highlighting */
82
+ currentScreen: Screen;
83
+ /** Search bar configuration (for screens with search) */
84
+ search?: {
85
+ /** Is search currently active */
86
+ isActive: boolean;
87
+ /** Current search query */
88
+ query: string;
89
+ /** Placeholder when not searching (default: "/") */
90
+ placeholder?: string;
91
+ };
92
+ /** Status line content (for screens without search) - shown in second row */
93
+ statusLine?: React.ReactNode;
94
+ /**
95
+ * One-line warning shown under the header, above the content.
96
+ *
97
+ * For conditions that make the data on screen untrustworthy — not for
98
+ * transient progress. It sits in the fixed chrome rather than in a panel so it
99
+ * cannot be scrolled out of view, and it takes a row from the panels only
100
+ * while it is present.
101
+ */
102
+ notice?: React.ReactNode;
103
+ /** Footer hints (left side). Pass a FooterHint[] to render key badges
104
+ * (the standard look). A string or ReactNode is still accepted for
105
+ * free-form footers. */
106
+ footerHints: FooterHint[] | string | React.ReactNode;
107
+ /** Left panel content, or a function given the list panel's usable columns. */
108
+ listPanel: PanelContent;
109
+ /** Right panel content, or a function given the detail panel's usable columns. */
110
+ detailPanel: PanelContent;
111
+ /**
112
+ * Identity of the detail content, e.g. the selected row's id. When it
113
+ * changes, the detail pane scrolls back to the top — a reading position
114
+ * belongs to one document, not to the panel.
115
+ */
116
+ detailKey?: string;
117
+ }
118
+
119
+ const HEADER_COLOR = theme.colors.accent;
120
+
121
+ export function ScreenLayout({
122
+ title,
123
+ subtitle,
124
+ currentScreen,
125
+ search,
126
+ statusLine,
127
+ notice,
128
+ footerHints,
129
+ listPanel,
130
+ detailPanel,
131
+ detailKey,
132
+ }: ScreenLayoutProps) {
133
+ const dimensions = useDimensions();
134
+ const { state } = useApp();
135
+
136
+ const hasSearchBar = search && (search.isActive || search.query);
137
+
138
+ // Fixed chrome: top line + tabs + line + header + separator + footer = 6
139
+ // Search bar adds 1 when active; the notice adds 1 while present.
140
+ const fixedHeight = 6 + (hasSearchBar ? 1 : 0) + (notice ? 1 : 0);
141
+ const panelHeight = Math.max(5, dimensions.contentHeight - fixedHeight);
142
+ const lineWidth = Math.max(10, dimensions.terminalWidth - 4);
143
+
144
+ const listContent =
145
+ typeof listPanel === "function"
146
+ ? listPanel(listPanelWidth(dimensions.terminalWidth))
147
+ : listPanel;
148
+ const detailContent =
149
+ typeof detailPanel === "function"
150
+ ? detailPanel(detailPanelWidth(dimensions.terminalWidth))
151
+ : detailPanel;
152
+
153
+ const detailScrollRef = useRef<ScrollBoxRenderable | null>(null);
154
+
155
+ // The detail pane has no cursor, so it needs its own scroll keys: arrows
156
+ // move the LIST selection, and the wheel only helps when the terminal
157
+ // delivers mouse reports (see src/main.tsx). PgUp/PgDn page with two rows
158
+ // of overlap so a page turn doesn't lose the reader's place; Ctrl+U/Ctrl+D
159
+ // half-page in the pager tradition — and exist because a MacBook keyboard
160
+ // hides PgDn behind Fn+↓, where nobody finds it.
161
+ const detailPageStep = Math.max(1, panelHeight - 2);
162
+ const detailHalfStep = Math.max(1, Math.floor(panelHeight / 2));
163
+ useKeyboard((event) => {
164
+ if (state.modal) return;
165
+ const ctrl = event.ctrl === true;
166
+ if (event.name === "pageup" || (ctrl && event.name === "u")) {
167
+ detailScrollRef.current?.scrollBy(
168
+ event.name === "pageup" ? -detailPageStep : -detailHalfStep,
169
+ );
170
+ } else if (event.name === "pagedown" || (ctrl && event.name === "d")) {
171
+ detailScrollRef.current?.scrollBy(
172
+ event.name === "pagedown" ? detailPageStep : detailHalfStep,
173
+ );
174
+ }
175
+ });
176
+
177
+ useEffect(() => {
178
+ if (detailKey !== undefined) detailScrollRef.current?.scrollTo(0);
179
+ }, [detailKey]);
180
+
181
+ return (
182
+ <box flexDirection="column" height={dimensions.contentHeight}>
183
+ {/* Line above tabs */}
184
+ <box height={1} paddingLeft={1} paddingRight={1}>
185
+ <text fg={theme.colors.dim}>{"─".repeat(lineWidth)}</text>
186
+ </box>
187
+
188
+ {/* Tab bar */}
189
+ <box height={1} paddingLeft={1} paddingRight={1}>
190
+ <TabBar currentScreen={currentScreen} />
191
+ </box>
192
+
193
+ {/* Line below tabs */}
194
+ <box height={1} paddingLeft={1} paddingRight={1}>
195
+ <text fg={theme.colors.dim}>{"─".repeat(lineWidth)}</text>
196
+ </box>
197
+
198
+ {/* Header — title on left, subtitle/status on right */}
199
+ <box
200
+ height={1}
201
+ paddingLeft={1}
202
+ paddingRight={1}
203
+ flexDirection="row"
204
+ justifyContent="space-between"
205
+ >
206
+ {/* The title never yields: an overlong right-hand status once
207
+ shrank it away and the two texts read as one sentence. Long
208
+ content belongs in the `notice` row, not beside the title. */}
209
+ <box flexShrink={0} marginRight={2}>
210
+ <text fg={HEADER_COLOR}>
211
+ <strong>{title}</strong>
212
+ </text>
213
+ </box>
214
+ {subtitle && <text fg={theme.colors.muted}>{subtitle}</text>}
215
+ {!subtitle && statusLine ? statusLine : null}
216
+ </box>
217
+
218
+ {/* Search bar — own line, only when active */}
219
+ {hasSearchBar && (
220
+ <box height={1} paddingLeft={1} paddingRight={1}>
221
+ {search.isActive ? (
222
+ <text fg={theme.colors.text}>
223
+ <span fg={theme.colors.success}>{"Filter: "}</span>
224
+ <span fg={theme.colors.text}>{search.query}</span>
225
+ <span bg={theme.colors.text} fg={theme.hints.fg}>
226
+ {" "}
227
+ </span>
228
+ </text>
229
+ ) : (
230
+ <text fg={theme.colors.text}>
231
+ <span fg={theme.colors.success}>{"Filter: "}</span>
232
+ <span fg={theme.colors.warning}>{search.query}</span>
233
+ <span fg={theme.colors.muted}> (Esc to clear)</span>
234
+ </text>
235
+ )}
236
+ </box>
237
+ )}
238
+
239
+ {/* Warning line — only when the data on screen cannot be trusted */}
240
+ {notice && (
241
+ <box height={1} paddingLeft={1} paddingRight={1}>
242
+ {notice}
243
+ </box>
244
+ )}
245
+
246
+ {/* Separator below header */}
247
+ <box height={1} paddingLeft={1} paddingRight={1}>
248
+ <text fg={theme.colors.border}>{"─".repeat(lineWidth)}</text>
249
+ </box>
250
+
251
+ {/* Main content area */}
252
+ <box flexDirection="row" height={panelHeight}>
253
+ {/* List panel. `overflow="hidden"` is the safety net, not the plan:
254
+ the box has a fixed height, so content taller than it does NOT
255
+ scroll and does NOT clip on its own — Yoga shrinks the children
256
+ and they composite on top of each other. MEASURED at 80×24:
257
+ "fable-advisor" and "fable-lead" rendered as "fable-leadical".
258
+ A screen that can overflow should budget its own rows; this
259
+ stops the failure being unreadable when one does not. */}
260
+ <box
261
+ flexDirection="column"
262
+ width="49%"
263
+ height={panelHeight}
264
+ paddingRight={1}
265
+ overflow="hidden"
266
+ >
267
+ {listContent}
268
+ </box>
269
+
270
+ {/* Vertical separator */}
271
+ <box flexDirection="column" width={1} height={panelHeight}>
272
+ <text fg={theme.colors.border}>{"│".repeat(panelHeight)}</text>
273
+ </box>
274
+
275
+ {/* Detail panel — scrollable. The thumb is themed so the reader can
276
+ see there is more below the fold; the track keeps the library
277
+ default so it stays chrome, not signal. */}
278
+ {/* `overflow: hidden`, like the list panel opposite. Without it a line wider
279
+ than the panel is drawn OUTSIDE it, over the terminal's right edge, and the
280
+ reader loses characters off the end of a sentence with nothing to say they
281
+ were lost. Clipping at the panel is the guarantee; wrapping to
282
+ `detailPanelWidth` is what keeps there being nothing to clip. */}
283
+ <box width="50%" height={panelHeight} paddingLeft={1} overflow="hidden">
284
+ <scrollbox
285
+ ref={detailScrollRef}
286
+ height={panelHeight}
287
+ scrollY={true}
288
+ scrollX={false}
289
+ verticalScrollbarOptions={{
290
+ showArrows: false,
291
+ trackOptions: {
292
+ foregroundColor: theme.colors.muted,
293
+ },
294
+ }}
295
+ >
296
+ <box flexDirection="column">{detailContent}</box>
297
+ </scrollbox>
298
+ </box>
299
+ </box>
300
+
301
+ {/* Footer — the layout appends its own scroll hint so every screen
302
+ advertises the detail-pane keys without each screen repeating it */}
303
+ <box height={1} paddingLeft={1}>
304
+ {Array.isArray(footerHints) ? (
305
+ <FooterHints
306
+ hints={[
307
+ ...(footerHints as FooterHint[]),
308
+ { keys: ["^U/^D", "PgUp/Dn"], label: "scroll" },
309
+ ]}
310
+ />
311
+ ) : typeof footerHints === "string" ? (
312
+ <text fg={theme.colors.muted}>{footerHints}</text>
313
+ ) : (
314
+ footerHints
315
+ )}
316
+ </box>
317
+ </box>
318
+ );
319
+ }
320
+
321
+ export default ScreenLayout;
@@ -0,0 +1,9 @@
1
+ export { Panel } from "./Panel.js";
2
+ export { ScopeTabs } from "./ScopeTabs.js";
3
+ export { ProgressBar } from "./ProgressBar.js";
4
+ export { ScreenLayout } from "./ScreenLayout.js";
5
+ export {
6
+ FooterHints,
7
+ renderFooterHints,
8
+ type FooterHint,
9
+ } from "./FooterHints.js";
@@ -0,0 +1,62 @@
1
+ import React from "react";
2
+ import { useKeyboard } from "../../hooks/useKeyboard.js";
3
+ import { theme } from "../../theme.js";
4
+
5
+ interface ConfirmModalProps {
6
+ /** Modal title */
7
+ title: string;
8
+ /** Modal message */
9
+ message: string;
10
+ /** Callback when confirmed */
11
+ onConfirm: () => void;
12
+ /** Callback when cancelled */
13
+ onCancel: () => void;
14
+ }
15
+
16
+ export function ConfirmModal({
17
+ title,
18
+ message,
19
+ onConfirm,
20
+ onCancel,
21
+ }: ConfirmModalProps) {
22
+ useKeyboard((key) => {
23
+ if (key.name === "y") {
24
+ onConfirm();
25
+ } else if (key.name === "n" || key.name === "escape") {
26
+ onCancel();
27
+ }
28
+ });
29
+
30
+ return (
31
+ <box
32
+ flexDirection="column"
33
+ border
34
+ borderStyle="rounded"
35
+ borderColor={theme.colors.dim}
36
+ backgroundColor={theme.surface.bg}
37
+ paddingLeft={3}
38
+ paddingRight={3}
39
+ paddingTop={1}
40
+ paddingBottom={1}
41
+ width={60}
42
+ >
43
+ <box marginBottom={1}>
44
+ <text fg={theme.colors.text}>
45
+ <strong>{title}</strong>
46
+ </text>
47
+ </box>
48
+ <box marginBottom={1}>
49
+ <text fg={theme.colors.muted}>{message}</text>
50
+ </box>
51
+ <box marginTop={1} flexDirection="row">
52
+ <text fg={theme.colors.muted}>Press </text>
53
+ <text fg={theme.colors.text}>Y</text>
54
+ <text fg={theme.colors.muted}> to confirm • </text>
55
+ <text fg={theme.colors.text}>N</text>
56
+ <text fg={theme.colors.muted}> to cancel</text>
57
+ </box>
58
+ </box>
59
+ );
60
+ }
61
+
62
+ export default ConfirmModal;
@@ -0,0 +1,75 @@
1
+ import type { SubmitEvent } from "@opentui/core";
2
+ import React from "react";
3
+ import { theme } from "../../theme.js";
4
+
5
+ interface InputModalProps {
6
+ /** Modal title */
7
+ title: string;
8
+ /** Input label */
9
+ label: string;
10
+ /** Default input value */
11
+ defaultValue?: string;
12
+ /** Callback when submitted */
13
+ onSubmit: (value: string) => void;
14
+ /** Callback when cancelled */
15
+ onCancel: () => void;
16
+ }
17
+
18
+ export function InputModal({
19
+ title,
20
+ label,
21
+ defaultValue = "",
22
+ onSubmit,
23
+ }: InputModalProps) {
24
+ return (
25
+ <box
26
+ flexDirection="column"
27
+ border
28
+ borderStyle="rounded"
29
+ borderColor={theme.colors.dim}
30
+ backgroundColor={theme.surface.bg}
31
+ paddingLeft={3}
32
+ paddingRight={3}
33
+ paddingTop={1}
34
+ paddingBottom={1}
35
+ width={60}
36
+ >
37
+ <box marginBottom={1}>
38
+ <text fg={theme.colors.text}>
39
+ <strong>{title}</strong>
40
+ </text>
41
+ </box>
42
+
43
+ <box marginBottom={1}>
44
+ <text fg={theme.colors.muted}>{label}</text>
45
+ </box>
46
+
47
+ <box
48
+ border
49
+ borderStyle="rounded"
50
+ borderColor={theme.colors.dim}
51
+ paddingLeft={1}
52
+ paddingRight={1}
53
+ width={54}
54
+ >
55
+ <input
56
+ value={defaultValue}
57
+ onSubmit={(value: string | SubmitEvent) => {
58
+ // @opentui/react types the prop as an intersection of the core
59
+ // `(event: SubmitEvent) => void` and its own `(value: string) => void`;
60
+ // at runtime the input hands over the string.
61
+ if (typeof value === "string") onSubmit(value);
62
+ }}
63
+ focused
64
+ width={50}
65
+ />
66
+ </box>
67
+
68
+ <box marginTop={1}>
69
+ <text fg={theme.colors.muted}>↵ to confirm • Esc to cancel</text>
70
+ </box>
71
+ </box>
72
+ );
73
+ }
74
+
75
+ export default InputModal;
@@ -0,0 +1,51 @@
1
+ import React, { useState, useEffect } from "react";
2
+ import { theme } from "../../theme.js";
3
+
4
+ interface LoadingModalProps {
5
+ /** Loading message — supports multiline (first line = status, subsequent = detail) */
6
+ message: string;
7
+ }
8
+
9
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
10
+
11
+ export function LoadingModal({ message }: LoadingModalProps) {
12
+ const [frame, setFrame] = useState(0);
13
+
14
+ useEffect(() => {
15
+ const interval = setInterval(() => {
16
+ setFrame((prev) => (prev + 1) % SPINNER_FRAMES.length);
17
+ }, 80);
18
+
19
+ return () => clearInterval(interval);
20
+ }, []);
21
+
22
+ const lines = message.split("\n");
23
+ const mainMessage = lines[0];
24
+ const detail = lines.slice(1).join("\n");
25
+
26
+ return (
27
+ <box
28
+ flexDirection="column"
29
+ border
30
+ borderStyle="rounded"
31
+ borderColor={theme.colors.dim}
32
+ backgroundColor={theme.surface.bg}
33
+ paddingLeft={3}
34
+ paddingRight={3}
35
+ paddingTop={1}
36
+ paddingBottom={1}
37
+ >
38
+ <box flexDirection="row">
39
+ <text fg={theme.colors.muted}>{SPINNER_FRAMES[frame]}</text>
40
+ <text fg={theme.colors.text}> {mainMessage}</text>
41
+ </box>
42
+ {detail ? (
43
+ <box marginTop={0} paddingLeft={2}>
44
+ <text fg={theme.colors.dim}>{detail}</text>
45
+ </box>
46
+ ) : null}
47
+ </box>
48
+ );
49
+ }
50
+
51
+ export default LoadingModal;
@@ -0,0 +1,66 @@
1
+ import React from "react";
2
+ import { useKeyboard } from "../../hooks/useKeyboard.js";
3
+ import { theme } from "../../theme.js";
4
+
5
+ interface MessageModalProps {
6
+ /** Modal title */
7
+ title: string;
8
+ /** Modal message */
9
+ message: string;
10
+ /** Message variant */
11
+ variant: "info" | "success" | "error";
12
+ /** Callback when dismissed */
13
+ onDismiss: () => void;
14
+ }
15
+
16
+ const variantConfig = {
17
+ info: { icon: "ℹ", color: theme.colors.link },
18
+ success: { icon: "✓", color: theme.colors.success },
19
+ error: { icon: "✗", color: theme.colors.danger },
20
+ } as const;
21
+
22
+ export function MessageModal({
23
+ title,
24
+ message,
25
+ variant,
26
+ onDismiss,
27
+ }: MessageModalProps) {
28
+ const config = variantConfig[variant];
29
+
30
+ useKeyboard(() => {
31
+ // Any key dismisses
32
+ onDismiss();
33
+ });
34
+
35
+ return (
36
+ <box
37
+ flexDirection="column"
38
+ border
39
+ borderStyle="rounded"
40
+ borderColor={theme.colors.dim}
41
+ backgroundColor={theme.surface.bg}
42
+ paddingLeft={3}
43
+ paddingRight={3}
44
+ paddingTop={1}
45
+ paddingBottom={1}
46
+ width={60}
47
+ >
48
+ <box marginBottom={1}>
49
+ <text fg={config.color}>{config.icon} </text>
50
+ <text fg={theme.colors.text}>
51
+ <strong>{title}</strong>
52
+ </text>
53
+ </box>
54
+
55
+ <box marginBottom={1}>
56
+ <text fg={theme.colors.muted}>{message}</text>
57
+ </box>
58
+
59
+ <box marginTop={1}>
60
+ <text fg={theme.colors.muted}>Press any key to continue</text>
61
+ </box>
62
+ </box>
63
+ );
64
+ }
65
+
66
+ export default MessageModal;