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,1558 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile } from "node:fs/promises";
3
+ import type { ScrollBoxRenderable } from "@opentui/core";
4
+ import type React from "react";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import {
7
+ ALIAS_FLAGS,
8
+ type AliasFlag,
9
+ FLAG_GROUPS,
10
+ } from "../../data/alias-flags.js";
11
+ import { loadAliasName, saveAliasName } from "../../services/alias-settings.js";
12
+ import {
13
+ type AdoptableAlias,
14
+ type FlagValidationIssue,
15
+ type Segment,
16
+ type ShellTarget,
17
+ detectShells,
18
+ findAdoptableAlias,
19
+ parseAliasFromRc,
20
+ renderArgs,
21
+ validateConfig,
22
+ writeAliasToShell,
23
+ } from "../../services/alias-shell-writer.js";
24
+ import {
25
+ type AliasConfig,
26
+ CHANNELS_FLAG_ID,
27
+ DEFAULT_ALIAS_NAME,
28
+ type FlagValue,
29
+ defaultAliasConfig,
30
+ defaultValueFor,
31
+ derivedChannelValues,
32
+ validateAliasName,
33
+ withoutDerivedChannel,
34
+ } from "../../services/alias-store.js";
35
+ import { FlagDetailEditor } from "../components/FlagDetailEditor.js";
36
+ import { type FooterHint, ScreenLayout } from "../components/layout/index.js";
37
+ import { useKeyboardHandler } from "../hooks/useKeyboardHandler.js";
38
+ import { useApp, useModal } from "../state/AppContext.js";
39
+ import { theme } from "../theme.js";
40
+
41
+ interface FlagRow {
42
+ kind: "flag";
43
+ flag: AliasFlag;
44
+ value: FlagValue;
45
+ /** Pre-computed validation issue for this row, if any. */
46
+ issue?: FlagValidationIssue;
47
+ /**
48
+ * Override for the checkbox's enabled state when the rendered alias would
49
+ * carry this flag even though its OWN value is disabled. Used by --channels:
50
+ * a dev-loaded channel forces `--channels` to emit (the render-time union),
51
+ * so the row must show `[x]` to match what gets written — even when the
52
+ * user's own channels list is off. Undefined → use the flag's own enabled.
53
+ */
54
+ effectiveEnabled?: boolean;
55
+ /**
56
+ * Override for the trailing detail (e.g. the --channels union count: own +
57
+ * derived). Undefined → derive from the flag's own value. Empty string is a
58
+ * valid override meaning "show nothing".
59
+ */
60
+ effectiveDetail?: string;
61
+ /**
62
+ * Override for the detail-pane `State:` line. For --channels this is the
63
+ * effective channel-list summary (the union of own + derived), so the detail
64
+ * agrees with the list count and the Preview. Undefined → summarizeValue.
65
+ */
66
+ stateSummary?: string;
67
+ }
68
+
69
+ interface AliasNameRow {
70
+ kind: "alias-name";
71
+ value: string;
72
+ }
73
+
74
+ type Row = AliasNameRow | FlagRow;
75
+
76
+ /**
77
+ * Flat-list items for the windowed `<ScrollableList>`. Selectable rows
78
+ * (`alias-name`, `flag`) accept the cursor; presentation rows (`header`)
79
+ * render in-line so groups scroll with their flags, but `j`/`k` skip them.
80
+ *
81
+ * The shells legend is NOT in this list — it lives outside the scroll area
82
+ * as a fixed footer so it's always visible. Keeping non-selectable items
83
+ * trailing the scrollable list would let `↓ N more` lie about reachable
84
+ * content.
85
+ */
86
+ type Item =
87
+ | { kind: "alias-name"; row: AliasNameRow }
88
+ | { kind: "flag"; row: FlagRow }
89
+ | { kind: "header"; label: string };
90
+
91
+ function isSelectableItem(item: Item): boolean {
92
+ return item.kind === "alias-name" || item.kind === "flag";
93
+ }
94
+
95
+ /**
96
+ * View modes for the Alias screen.
97
+ *
98
+ * - `main`: the default list of all flags grouped by section.
99
+ * - `flag-detail`: a dedicated sub-screen for editing a single `text-list` or
100
+ * `multi-with-custom` flag's value. Has its own keybindings (`a` add, `d`
101
+ * delete, `space`/`enter` toggle, `h` back to main) so each row's primary
102
+ * action is unambiguous. Esc still exits the screen via the global handler.
103
+ *
104
+ * Other flag kinds (boolean, tri-state, select, text, optional-text) use
105
+ * modals because they're single-action — no benefit from a sub-screen.
106
+ */
107
+ type ViewMode = { kind: "main" } | { kind: "flag-detail"; flagId: string };
108
+
109
+ export function AliasScreen() {
110
+ const { state } = useApp();
111
+ const modal = useModal();
112
+
113
+ const [config, setConfig] = useState<AliasConfig | null>(null);
114
+ const [shells, setShells] = useState<ShellTarget[]>([]);
115
+ const [selectedIdx, setSelectedIdx] = useState(0);
116
+ const [error, setError] = useState<string | null>(null);
117
+ const [viewMode, setViewMode] = useState<ViewMode>({ kind: "main" });
118
+ // Snapshot of the rc file's flag values as parsed on mount / write. Used
119
+ // to compute the dirty indicator: in-memory != rcSnapshot ⇒ unsaved.
120
+ const [rcSnapshot, setRcSnapshot] = useState<Record<
121
+ string,
122
+ FlagValue
123
+ > | null>(null);
124
+ // Transient write feedback shown on the header status line (not a blocking
125
+ // modal). Auto-clears after a few seconds. Errors still use a modal since
126
+ // they need to be read and acknowledged.
127
+ const [writeStatus, setWriteStatus] = useState<string | null>(null);
128
+
129
+ // A hand-written `claude` alias found OUTSIDE any managed block on mount.
130
+ // Set when the rc file has no managed block but a stray alias we could
131
+ // adopt — drives the one-time "import it?" offer below. Cleared once the
132
+ // offer has been shown (whether accepted or declined).
133
+ const [adoptable, setAdoptable] = useState<AdoptableAlias | null>(null);
134
+ // When the user accepts a LOSSLESS adoption, we remember the line range of
135
+ // their original alias so the next write replaces it in place (no duplicate
136
+ // alias). Cleared after that write absorbs it into the managed block.
137
+ const [pendingAdoptRange, setPendingAdoptRange] = useState<
138
+ [number, number] | null
139
+ >(null);
140
+
141
+ // Clear the write status line a few seconds after it appears.
142
+ useEffect(() => {
143
+ if (!writeStatus) return;
144
+ const t = setTimeout(() => setWriteStatus(null), 4000);
145
+ return () => clearTimeout(t);
146
+ }, [writeStatus]);
147
+
148
+ // One-time "adopt a hand-written alias?" offer. Fires when the mount load
149
+ // found a stray `claude` alias outside any managed block. We import only the
150
+ // flags we recognized; whether we also TAKE OWNERSHIP (remove the original
151
+ // line on the next write) depends on `lossless`:
152
+ // - lossless: every token round-trips → safe to absorb. We stash the line
153
+ // range so the next write replaces it in place (no duplicate alias).
154
+ // - not lossless: we couldn't parse some tokens (e.g. an uncatalogued
155
+ // flag like --model) → import the known flags for convenience but LEAVE
156
+ // the original line untouched. CRITICAL: we must NOT adopt its name here.
157
+ // Shell alias resolution is last-definition-wins, so a managed block
158
+ // sharing the name would shadow the surviving original at `source` time —
159
+ // silently killing the very tokens that made it not-lossless. Keeping the
160
+ // tool's own name lets both coexist: the original keeps working, the
161
+ // managed block lives under a different name.
162
+ // True once we've shown the offer. A ref (not state) so it survives re-render
163
+ // without being a dependency — critical because this effect mutates its own
164
+ // `adoptable` dep, and tying "fire once" to state/deps would let the dep
165
+ // change abort the in-flight modal mid-await (the cleanup would fire before
166
+ // the user answered). The ref decouples one-shot from the dependency array.
167
+ const didOfferAdoptRef = useRef(false);
168
+ useEffect(() => {
169
+ if (!adoptable || !config || didOfferAdoptRef.current) return;
170
+ didOfferAdoptRef.current = true;
171
+ (async () => {
172
+ const a = adoptable;
173
+
174
+ const body = a.rawLine.trim();
175
+ const others =
176
+ a.others.length > 0
177
+ ? `\n\n(Also found ${a.others.length} other claude alias${
178
+ a.others.length === 1 ? "" : "es"
179
+ } — leaving those alone.)`
180
+ : "";
181
+
182
+ // On a not-lossless adopt the managed block must NOT share the original's
183
+ // name — same name + last-wins shell resolution = the block shadows the
184
+ // surviving original, killing its uncatalogued tokens. Compute a managed
185
+ // name distinct from `a.name`: keep the tool's current name if it already
186
+ // differs, else derive a suffixed variant that's free in the rc file.
187
+ const managedName = a.lossless
188
+ ? a.name
189
+ : pickDistinctName(config.aliasName, a.name, a.others);
190
+
191
+ const message = a.lossless
192
+ ? `Found a hand-written alias:\n\n ${body}\n\nImport its flags into the editor? On the next write it'll be absorbed into a managed block (your original line is replaced — no duplicate).${others}`
193
+ : `Found a hand-written alias:\n\n ${body}\n\nI can import the flags I recognize, but it uses ${
194
+ a.leftovers.length
195
+ } token${a.leftovers.length === 1 ? "" : "s"} I don't manage (${a.leftovers
196
+ .map((t) => JSON.stringify(t))
197
+ .join(
198
+ ", ",
199
+ )}). I'll import the known flags and LEAVE your original "${a.name}" line untouched so it keeps working. The managed alias will use the name "${managedName}" — distinct from "${a.name}" so it can't override it. Import the known flags?${others}`;
200
+
201
+ const accepted = await modal.confirm("Adopt existing alias?", message);
202
+ if (accepted) {
203
+ // Lossless: adopt the name AND take ownership (replace the line on
204
+ // write). Not-lossless: import flags under a distinct managed name and
205
+ // never touch the original line — so it survives AND keeps resolving.
206
+ setConfig((prev) =>
207
+ prev
208
+ ? {
209
+ ...prev,
210
+ aliasName: managedName,
211
+ flags: { ...prev.flags, ...a.flags },
212
+ }
213
+ : prev,
214
+ );
215
+ if (a.lossless) {
216
+ // Persist the adopted name and stash the line range so the next
217
+ // write replaces the original in place (no duplicate, no shadow).
218
+ void persistAliasName(a.name);
219
+ setPendingAdoptRange(a.lineRange);
220
+ } else {
221
+ // Persist the distinct managed name so it survives a reopen.
222
+ void persistAliasName(managedName);
223
+ }
224
+ }
225
+ // Clear the candidate now that the decision is made — after the await,
226
+ // never before (clearing before would abort this very async via the
227
+ // dep-change cleanup, the bug this effect previously had).
228
+ setAdoptable(null);
229
+ })();
230
+ // persistAliasName is stable (useCallback); excluded to keep this one-shot.
231
+ // eslint-disable-next-line react-hooks/exhaustive-deps
232
+ }, [adoptable, config, modal]);
233
+
234
+ // ── Load alias name + parse rc file + detect shells on mount ─────────
235
+ // The alias name is computer-wide and persisted in ~/.claude/settings.json.
236
+ // Flag values are read directly from the shell rc file's managed block —
237
+ // the rc file IS the source of truth so what the user sees matches what
238
+ // they're actually running.
239
+ useEffect(() => {
240
+ let cancelled = false;
241
+ (async () => {
242
+ try {
243
+ const [name, s] = await Promise.all([loadAliasName(), detectShells()]);
244
+ if (cancelled) return;
245
+
246
+ // Pick the same rc file we'd write to with `w` (default shell that
247
+ // exists; else first existing). If none exists, start from defaults.
248
+ const readTarget =
249
+ s.find((sh) => sh.isDefault && sh.exists) ??
250
+ s.find((sh) => sh.exists);
251
+
252
+ const base = defaultAliasConfig();
253
+ base.aliasName = name;
254
+
255
+ let parsedFlags: Record<string, FlagValue> | null = null;
256
+ let foundAdoptable: AdoptableAlias | null = null;
257
+ if (readTarget && existsSync(readTarget.path)) {
258
+ try {
259
+ const rcText = await readFile(readTarget.path, "utf8");
260
+ const parsed = parseAliasFromRc(rcText);
261
+ if (parsed) {
262
+ parsedFlags = { ...base.flags, ...parsed.flags };
263
+ base.flags = parsedFlags;
264
+ // Honour an existing alias name from the rc if settings is at default.
265
+ // But the user's stored preference wins when set explicitly.
266
+ // (No-op here — `name` already loaded above.)
267
+ } else {
268
+ // No managed block on disk. The user may still have a hand-written
269
+ // `claude` alias elsewhere — detect it so we can offer to adopt it
270
+ // instead of looking broken. Non-destructive: just a candidate.
271
+ foundAdoptable = findAdoptableAlias(rcText);
272
+ }
273
+ } catch {
274
+ // rc unreadable — fall back to defaults; not a hard error.
275
+ }
276
+ }
277
+
278
+ if (cancelled) return;
279
+ // No mirror normalization: channels.values holds OWN channels only;
280
+ // derived channels come from the dev-load flag at display/render time.
281
+ // The parser already subtracts derived values from the parsed channels
282
+ // list, so `base.flags` is correct as-is.
283
+ setConfig(base);
284
+ setShells(s);
285
+ setRcSnapshot(parsedFlags ?? { ...base.flags });
286
+ if (foundAdoptable) setAdoptable(foundAdoptable);
287
+ } catch (err) {
288
+ if (cancelled) return;
289
+ setError(err instanceof Error ? err.message : String(err));
290
+ }
291
+ })();
292
+ return () => {
293
+ cancelled = true;
294
+ };
295
+ }, []);
296
+
297
+ // ── Build the flat list of items ──────────────────────────────────────
298
+ // Group headers and the shell legend live in the same flat array as
299
+ // alias-name + flag rows so that ScrollableList can window them together.
300
+ // Navigation skips non-selectable kinds.
301
+ const items: Item[] = useMemo(() => {
302
+ if (!config) return [];
303
+ const issues = validateConfig(config);
304
+ const issueMap = new Map(issues.map((i) => [i.flagId, i]));
305
+
306
+ const out: Item[] = [
307
+ {
308
+ kind: "alias-name",
309
+ row: { kind: "alias-name", value: config.aliasName },
310
+ },
311
+ ];
312
+
313
+ for (const group of FLAG_GROUPS) {
314
+ const groupFlags = ALIAS_FLAGS.filter((f) => f.group === group.id);
315
+ if (groupFlags.length === 0) continue;
316
+ out.push({ kind: "header", label: group.label });
317
+ for (const flag of groupFlags) {
318
+ // --channels shows enabled when the rendered alias would emit it —
319
+ // i.e. own channels are on OR a dev-loaded channel is derived in. This
320
+ // keeps the checkbox honest about what gets written without mutating
321
+ // the own-channels enabled flag (which would silently re-emit own
322
+ // values the user disabled).
323
+ let effectiveEnabled: boolean | undefined;
324
+ let effectiveDetail: string | undefined;
325
+ let stateSummary: string | undefined;
326
+ if (flag.id === CHANNELS_FLAG_ID) {
327
+ const own = config.flags[flag.id];
328
+ const ownValues =
329
+ own && own.kind === "text-list" && own.enabled
330
+ ? own.values.filter((v) => v.length > 0)
331
+ : [];
332
+ const derived = derivedChannelValues(config.flags);
333
+ // Deduped union — matches the writer's render-time union and the
334
+ // detail-pane summary, so the count is consistent everywhere.
335
+ const total = new Set([...ownValues, ...derived]).size;
336
+ effectiveEnabled = total > 0;
337
+ // Show the union count so the row matches what's written. When the
338
+ // count is purely derived, this reads e.g. `[x] --channels 1`.
339
+ effectiveDetail = total > 0 ? `${total}` : "";
340
+ // The detail-pane State line shows the same final channel list.
341
+ stateSummary = channelsStateSummary(config.flags);
342
+ }
343
+ out.push({
344
+ kind: "flag",
345
+ row: {
346
+ kind: "flag",
347
+ flag,
348
+ value: config.flags[flag.id] ?? defaultValueFor(flag),
349
+ issue: issueMap.get(flag.id),
350
+ effectiveEnabled,
351
+ effectiveDetail,
352
+ stateSummary,
353
+ },
354
+ });
355
+ }
356
+ }
357
+
358
+ return out;
359
+ }, [config]);
360
+
361
+ /** Indices in `items` that the cursor may land on. */
362
+ const selectableIndices = useMemo(
363
+ () => items.flatMap((it, i) => (isSelectableItem(it) ? [i] : [])),
364
+ [items],
365
+ );
366
+
367
+ // ── Flag-detail sub-screen items ──────────────────────────────────────
368
+ // Computed only when viewMode is flag-detail. Built off the flag's current
369
+ // The flag-detail sub-screen is now self-contained in <FlagDetailEditor>.
370
+ // We just resolve the flag + value pair here so the parent can render
371
+ // the editor and the right-pane preview.
372
+ const detailFlag: AliasFlag | null =
373
+ viewMode.kind === "flag-detail"
374
+ ? (ALIAS_FLAGS.find((f) => f.id === viewMode.flagId) ?? null)
375
+ : null;
376
+ const detailValue: FlagValue | null =
377
+ detailFlag && config ? (config.flags[detailFlag.id] ?? null) : null;
378
+
379
+ // ── Helper: check whether a row is gated (requires unmet) ─────────────
380
+ const isGated = useCallback(
381
+ (flag: AliasFlag): boolean => {
382
+ if (!flag.requires || !config) return false;
383
+ return !isFlagEnabled(config.flags[flag.requires]);
384
+ },
385
+ [config],
386
+ );
387
+
388
+ const selectedItem = items[selectedIdx];
389
+ const selectedRow: Row | undefined =
390
+ selectedItem &&
391
+ (selectedItem.kind === "alias-name" || selectedItem.kind === "flag")
392
+ ? selectedItem.row
393
+ : undefined;
394
+
395
+ // Clamp selection: snap to the nearest selectable index whenever items shift.
396
+ useEffect(() => {
397
+ if (selectableIndices.length === 0) return;
398
+ if (!isSelectableItem(items[selectedIdx])) {
399
+ // Find the nearest selectable index <= selectedIdx (or the first one)
400
+ const fallback =
401
+ [...selectableIndices].reverse().find((i) => i <= selectedIdx) ??
402
+ selectableIndices[0];
403
+ setSelectedIdx(fallback);
404
+ } else if (selectedIdx >= items.length) {
405
+ setSelectedIdx(selectableIndices[selectableIndices.length - 1]);
406
+ }
407
+ }, [items, selectableIndices, selectedIdx]);
408
+
409
+ // ── Mutators ──────────────────────────────────────────────────────────
410
+ // Flag mutations stay in memory. Only the alias name is persisted (to
411
+ // ~/.claude/settings.json) — flag values reach disk only when the user
412
+ // explicitly presses `w` to write the shell rc file.
413
+ const persistAliasName = useCallback(
414
+ async (name: string) => {
415
+ try {
416
+ await saveAliasName(name);
417
+ } catch (err) {
418
+ await modal.message(
419
+ "Save failed",
420
+ err instanceof Error ? err.message : String(err),
421
+ "error",
422
+ );
423
+ }
424
+ },
425
+ [modal],
426
+ );
427
+
428
+ // Cascade dependencies on every mutation:
429
+ // - Enabling a flag → enable its `requires` chain (no modal nagging).
430
+ // - Disabling a flag → disable anything whose `requires === this flag id`,
431
+ // because the renderer would silently drop them otherwise and the UI
432
+ // would lie about what's going to be written.
433
+ const updateFlag = useCallback((id: string, next: FlagValue) => {
434
+ setConfig((prev) => {
435
+ if (!prev) return prev;
436
+ const flags = { ...prev.flags, [id]: next };
437
+ const target = ALIAS_FLAGS.find((f) => f.id === id);
438
+ if (!target) return { ...prev, flags };
439
+
440
+ const becomingEnabled = isFlagEnabled(next);
441
+
442
+ if (becomingEnabled) {
443
+ // Walk `requires` chain and force-enable each ancestor to its
444
+ // catalog-default-enabled state if it's currently off.
445
+ let cursor = target.requires;
446
+ const seen = new Set<string>();
447
+ while (cursor && !seen.has(cursor)) {
448
+ seen.add(cursor);
449
+ const dep = ALIAS_FLAGS.find((f) => f.id === cursor);
450
+ if (!dep) break;
451
+ if (!isFlagEnabled(flags[dep.id])) {
452
+ flags[dep.id] = enableDefault(dep);
453
+ }
454
+ cursor = dep.requires;
455
+ }
456
+ } else {
457
+ // Cascade-disable any flag that requires this one (one level deep is
458
+ // enough today; recurse defensively in case a chain emerges).
459
+ const queue = [id];
460
+ const seen = new Set<string>();
461
+ for (let cur = queue.shift(); cur !== undefined; cur = queue.shift()) {
462
+ if (seen.has(cur)) continue;
463
+ seen.add(cur);
464
+ for (const dependent of ALIAS_FLAGS) {
465
+ if (dependent.requires !== cur) continue;
466
+ if (!isFlagEnabled(flags[dependent.id])) continue;
467
+ flags[dependent.id] = disableValue(flags[dependent.id]);
468
+ queue.push(dependent.id);
469
+ }
470
+ }
471
+ }
472
+
473
+ return { ...prev, flags };
474
+ });
475
+ }, []);
476
+
477
+ // Remove a value from the dev-load flag — invoked when the user deletes a
478
+ // "Dev-loaded channels" row inside the --channels editor. The derived
479
+ // section is sourced from this flag, so the deletion lands here, not in
480
+ // channels.values. updateFlag keeps enabled-state and cascades consistent.
481
+ const removeDevloadChannel = useCallback((val: string) => {
482
+ setConfig((prev) =>
483
+ prev ? { ...prev, flags: withoutDerivedChannel(prev.flags, val) } : prev,
484
+ );
485
+ }, []);
486
+
487
+ const editAliasName = useCallback(async () => {
488
+ if (!config) return;
489
+ const v = await modal.input(
490
+ "Alias name",
491
+ `Shell alias name (default: "${DEFAULT_ALIAS_NAME}"). Letters, digits, _ or - only.`,
492
+ config.aliasName,
493
+ );
494
+ if (v === null) return;
495
+ const reason = validateAliasName(v);
496
+ if (reason) {
497
+ await modal.message("Invalid alias name", reason, "error");
498
+ return;
499
+ }
500
+ setConfig((prev) => (prev ? { ...prev, aliasName: v } : prev));
501
+ void persistAliasName(v);
502
+ }, [config, modal, persistAliasName]);
503
+
504
+ const editSelected = useCallback(async () => {
505
+ if (!selectedRow) return;
506
+ if (selectedRow.kind === "alias-name") {
507
+ await editAliasName();
508
+ return;
509
+ }
510
+ // No more "Requires another flag" modal — `updateFlag` auto-enables the
511
+ // `requires` chain when the user activates a gated flag.
512
+ //
513
+ // Collection editors live in a dedicated sub-screen, not a modal —
514
+ // each row gets its own keybindings (space toggle, a add, d delete)
515
+ // so there's no "what does enter do here?" overloading.
516
+ const k = selectedRow.value.kind;
517
+ if (k === "text-list" || k === "multi-with-custom") {
518
+ // FlagDetailEditor manages its own cursor — parent just flips mode.
519
+ setViewMode({ kind: "flag-detail", flagId: selectedRow.flag.id });
520
+ return;
521
+ }
522
+ const next = await editFlagValue(
523
+ selectedRow.flag,
524
+ selectedRow.value,
525
+ modal,
526
+ );
527
+ if (next) updateFlag(selectedRow.flag.id, next);
528
+ }, [selectedRow, modal, updateFlag, editAliasName]);
529
+
530
+ const toggleSelected = useCallback(async () => {
531
+ if (!selectedRow) return;
532
+ if (selectedRow.kind === "alias-name") {
533
+ // No "toggle" semantic for the name — fall through to edit.
534
+ await editAliasName();
535
+ return;
536
+ }
537
+ // Only booleans and tri-states have a meaningful single-press cycle.
538
+ // Everything else (select, text, list, multi-with-custom) needs a value
539
+ // chooser; falling back to edit prevents the "I pressed space and got the
540
+ // first option without seeing the choices" footgun.
541
+ const k = selectedRow.value.kind;
542
+ if (k !== "boolean" && k !== "tri-state") {
543
+ await editSelected();
544
+ return;
545
+ }
546
+ const next = quickToggle(selectedRow.flag, selectedRow.value);
547
+ if (next) updateFlag(selectedRow.flag.id, next);
548
+ }, [selectedRow, updateFlag, editSelected, editAliasName]);
549
+
550
+ const handleWrite = useCallback(async () => {
551
+ if (!config) return;
552
+
553
+ // Pick the target shell automatically: default shell (matches $SHELL)
554
+ // if its rc file exists; otherwise the first existing rc file. Users
555
+ // who care about other shells edit them directly — the alias config
556
+ // itself is stored in ~/.claude and the user can run `w` again from
557
+ // a different $SHELL session if they want to mirror it.
558
+ const target =
559
+ shells.find((s) => s.isDefault && s.exists) ??
560
+ shells.find((s) => s.exists);
561
+
562
+ if (!target) {
563
+ await modal.message(
564
+ "No shells detected",
565
+ "No supported rc files found. Create ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish first.",
566
+ "info",
567
+ );
568
+ return;
569
+ }
570
+
571
+ try {
572
+ // If we adopted a lossless hand-written alias, pass its line range so
573
+ // the FIRST write replaces that line in place (absorbing it into the
574
+ // managed block) rather than appending a second alias. Once the block
575
+ // exists, the writer ignores the range and splices by markers.
576
+ const r = await writeAliasToShell(
577
+ config,
578
+ target,
579
+ pendingAdoptRange ? { adoptLineRange: pendingAdoptRange } : {},
580
+ );
581
+ // The adopted line is now gone (replaced by the block); drop the range
582
+ // so subsequent writes don't try to re-target a line that moved.
583
+ if (pendingAdoptRange) setPendingAdoptRange(null);
584
+ // Non-blocking confirmation on the status line — no "press any key".
585
+ const name = r.path.split("/").pop() || r.path;
586
+ setWriteStatus(
587
+ `✓ ${r.action === "created" ? "Created" : "Updated"} ${name} — source it or open a new shell`,
588
+ );
589
+ setShells(await detectShells());
590
+ // We just wrote the rc file with current in-memory flag values, so
591
+ // the snapshot now matches in-memory state. Dirty indicator clears.
592
+ setRcSnapshot(config.flags);
593
+ } catch (err) {
594
+ await modal.message(
595
+ "Write failed",
596
+ err instanceof Error ? err.message : String(err),
597
+ "error",
598
+ );
599
+ }
600
+ }, [config, shells, modal, pendingAdoptRange]);
601
+
602
+ // ── Keyboard ──────────────────────────────────────────────────────────
603
+ // j/k navigate the selectable subset, jumping over headers/legend lines.
604
+ const moveSelection = useCallback(
605
+ (dir: 1 | -1) => {
606
+ if (selectableIndices.length === 0) return;
607
+ const cursorPos = selectableIndices.indexOf(selectedIdx);
608
+ const nextPos =
609
+ cursorPos === -1
610
+ ? // Currently parked on a non-selectable; move to first/last selectable.
611
+ dir === 1
612
+ ? 0
613
+ : selectableIndices.length - 1
614
+ : Math.min(
615
+ selectableIndices.length - 1,
616
+ Math.max(0, cursorPos + dir),
617
+ );
618
+ setSelectedIdx(selectableIndices[nextPos]);
619
+ },
620
+ [selectableIndices, selectedIdx],
621
+ );
622
+
623
+ // The flag-detail sub-screen handles its own cursor + key + render
624
+ // (see FlagDetailEditor component). The parent only needs to know how
625
+ // to leave it.
626
+ const exitDetail = useCallback(() => {
627
+ setViewMode({ kind: "main" });
628
+ }, []);
629
+
630
+ // ── Keyboard handler ──────────────────────────────────────────────────
631
+ // When the sub-screen is mounted, FlagDetailEditor installs its own
632
+ // keyboard handler. Both handlers fire (OpenTUI's useKeyboard delivers
633
+ // to every subscriber), so this handler must SKIP the keys the child
634
+ // owns to avoid double-dispatch. We bail entirely while in detail mode.
635
+ useKeyboardHandler((input, key) => {
636
+ if (state.modal) return;
637
+ if (viewMode.kind === "flag-detail") return;
638
+
639
+ // Main mode.
640
+ if (input === "j" || key.downArrow) {
641
+ moveSelection(1);
642
+ return;
643
+ }
644
+ if (input === "k" || key.upArrow) {
645
+ moveSelection(-1);
646
+ return;
647
+ }
648
+ if (key.return || input === " " || key.name === "space") {
649
+ void toggleSelected();
650
+ return;
651
+ }
652
+ if (input === "e") {
653
+ void editSelected();
654
+ return;
655
+ }
656
+ if (input === "w") {
657
+ void handleWrite();
658
+ return;
659
+ }
660
+ if (input === "r") {
661
+ // Reset the selected row to its default.
662
+ if (!selectedRow) return;
663
+ if (selectedRow.kind === "alias-name") {
664
+ setConfig((prev) =>
665
+ prev ? { ...prev, aliasName: DEFAULT_ALIAS_NAME } : prev,
666
+ );
667
+ void persistAliasName(DEFAULT_ALIAS_NAME);
668
+ return;
669
+ }
670
+ // Flag reset stays in memory until `w`.
671
+ updateFlag(selectedRow.flag.id, defaultValueFor(selectedRow.flag));
672
+ }
673
+ });
674
+
675
+ // ── Render ────────────────────────────────────────────────────────────
676
+ const previewSegments = config ? renderArgs(config) : [];
677
+ const previewAliasName = config?.aliasName ?? "";
678
+
679
+ // Native OpenTUI scrollboxes own the scroll math. We render every item
680
+ // into the scrollbox's content area and ask the renderable to scroll the
681
+ // selected child into view whenever the cursor moves. The previous JS-
682
+ // windowed approach (ScrollableList) overstrike-rendered rows when its
683
+ // height computation drifted off the actual panel size.
684
+ const mainScrollRef = useRef<ScrollBoxRenderable | null>(null);
685
+
686
+ useEffect(() => {
687
+ const item = items[selectedIdx];
688
+ if (!item) return;
689
+ mainScrollRef.current?.scrollChildIntoView(`alias-row-${getItemKey(item)}`);
690
+ }, [items, selectedIdx]);
691
+
692
+ const inDetail = viewMode.kind === "flag-detail" && detailFlag && detailValue;
693
+ // FlagDetailEditor only handles text-list and multi-with-custom values;
694
+ // narrow the type for the JSX below.
695
+ const editableDetailValue =
696
+ inDetail &&
697
+ (detailValue.kind === "text-list" ||
698
+ detailValue.kind === "multi-with-custom")
699
+ ? detailValue
700
+ : null;
701
+
702
+ const mainListPanel = !config ? (
703
+ <text fg={theme.colors.muted}>
704
+ {error ? `Error: ${error}` : "Loading..."}
705
+ </text>
706
+ ) : (
707
+ <box flexDirection="column" height="100%">
708
+ <scrollbox
709
+ ref={mainScrollRef}
710
+ scrollY={true}
711
+ scrollX={false}
712
+ flexGrow={1}
713
+ >
714
+ <box flexDirection="column">
715
+ {items.map((item, idx) => (
716
+ // id is content-derived (not positional `${idx}`) so OpenTUI's
717
+ // reconciler stays in lockstep with React's key-based reconcile.
718
+ <box key={getItemKey(item)} id={`alias-row-${getItemKey(item)}`}>
719
+ {renderItem(item, idx === selectedIdx, isGated)}
720
+ </box>
721
+ ))}
722
+ </box>
723
+ </scrollbox>
724
+ </box>
725
+ );
726
+
727
+ const listPanel =
728
+ inDetail && editableDetailValue ? (
729
+ <FlagDetailEditor
730
+ flag={detailFlag}
731
+ value={editableDetailValue}
732
+ onChange={(next) => updateFlag(detailFlag.id, next)}
733
+ onBack={exitDetail}
734
+ derivedValues={
735
+ detailFlag.id === CHANNELS_FLAG_ID && config
736
+ ? derivedChannelValues(config.flags)
737
+ : undefined
738
+ }
739
+ derivedLabel="From --dangerously-load:"
740
+ toggleLabel={
741
+ detailFlag.id === CHANNELS_FLAG_ID ? "Enable own channels" : undefined
742
+ }
743
+ onDerivedDelete={(v) => removeDevloadChannel(v)}
744
+ />
745
+ ) : (
746
+ mainListPanel
747
+ );
748
+
749
+ const mainDetailPanel = renderDetail(
750
+ selectedRow,
751
+ isGated,
752
+ previewAliasName,
753
+ previewSegments,
754
+ );
755
+ // When editing --channels and there are derived rows, the right pane carries
756
+ // the contextual instruction about what deleting a derived row does — that
757
+ // explanation belongs here, not crammed into the left list.
758
+ const channelsHasDerived =
759
+ inDetail &&
760
+ detailFlag.id === CHANNELS_FLAG_ID &&
761
+ config &&
762
+ derivedChannelValues(config.flags).length > 0;
763
+ const derivedNote = channelsHasDerived
764
+ ? "Rows under “From --dangerously-load:” are derived from the --dangerously-load-development-channels flag. Deleting one here removes it from that flag."
765
+ : undefined;
766
+ // The right-pane `State:` line is informational — it describes what the alias
767
+ // actually runs. For --channels that's the FINAL channel list: the union of
768
+ // the user's own channels and the derived (dev-loaded) ones. We show the
769
+ // union count so it agrees with the list row's count and the Preview, and
770
+ // annotate how many are derived so the source is clear. Computed only when
771
+ // editing --channels; other flags fall back to their own summary.
772
+ const stateOverride =
773
+ inDetail && detailFlag.id === CHANNELS_FLAG_ID && config
774
+ ? channelsStateSummary(config.flags)
775
+ : undefined;
776
+ const detailDetailPanel = inDetail
777
+ ? renderFlagDetailRightPane(
778
+ detailFlag,
779
+ detailValue,
780
+ previewAliasName,
781
+ previewSegments,
782
+ derivedNote,
783
+ stateOverride,
784
+ )
785
+ : null;
786
+ const detailPanel = inDetail ? detailDetailPanel : mainDetailPanel;
787
+
788
+ const enabledCount = config
789
+ ? Object.values(config.flags).filter(isFlagEnabled).length
790
+ : 0;
791
+ const subtitle = inDetail
792
+ ? `editing ${detailFlag.flag}`
793
+ : writeStatus
794
+ ? writeStatus
795
+ : config
796
+ ? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled`
797
+ : "";
798
+
799
+ // Resolve the rc-file name we'd actually write to (basename of the chosen
800
+ // shell's path). Falls back to "shell" if no shells were detected — UI
801
+ // surface mirrors the runtime behavior of `handleWrite`.
802
+ const writeTarget =
803
+ shells.find((s) => s.isDefault && s.exists) ?? shells.find((s) => s.exists);
804
+ const writeLabel = writeTarget
805
+ ? writeTarget.path.split("/").pop() || "shell"
806
+ : "shell";
807
+
808
+ // Dirty when current in-memory flags differ from the rc-file snapshot.
809
+ // Initial mount sets rcSnapshot from the parsed rc, so this is false
810
+ // until the user edits.
811
+ const dirty = config
812
+ ? rcSnapshot !== null && !shallowFlagsEqual(config.flags, rcSnapshot)
813
+ : false;
814
+
815
+ const footerHints: FooterHint[] = inDetail
816
+ ? [
817
+ { keys: ["↑", "↓"], label: "move" },
818
+ { keys: ["space"], label: "toggle" },
819
+ { keys: ["a"], label: "add" },
820
+ { keys: ["d"], label: "delete" },
821
+ { keys: ["h"], label: "back" },
822
+ ]
823
+ : [
824
+ { keys: ["↑", "↓"], label: "move" },
825
+ { keys: ["space"], label: "toggle" },
826
+ { keys: ["e"], label: "edit" },
827
+ { keys: ["r"], label: "reset" },
828
+ {
829
+ keys: ["w"],
830
+ label: `${dirty ? "*" : ""}write to ${writeLabel}`,
831
+ },
832
+ ];
833
+
834
+ return (
835
+ <ScreenLayout
836
+ title="Claude Alias"
837
+ subtitle={subtitle}
838
+ currentScreen="alias"
839
+ footerHints={footerHints}
840
+ listPanel={listPanel}
841
+ detailPanel={detailPanel}
842
+ />
843
+ );
844
+ }
845
+
846
+ // ─── Helpers ────────────────────────────────────────────────────────────
847
+
848
+ /**
849
+ * Summarize the EFFECTIVE state of `--channels` for the detail `State:` line:
850
+ * the final channel list that the alias will actually run, i.e. the union of
851
+ * the user's own (enabled) channels and the derived dev-loaded ones. This must
852
+ * agree with the list row's union count and the Preview.
853
+ *
854
+ * Returns "off" when nothing is emitted, or e.g.:
855
+ * - "2 channels" (all own, no derived)
856
+ * - "1 channel (from --dangerously-load)" (purely derived, own off/empty)
857
+ * - "2 channels (1 from --dangerously-load)" (mixed)
858
+ */
859
+ function channelsStateSummary(flags: Record<string, FlagValue>): string {
860
+ const ownValue = flags[CHANNELS_FLAG_ID];
861
+ const ownValues =
862
+ ownValue && ownValue.kind === "text-list" && ownValue.enabled
863
+ ? ownValue.values.filter((v) => v.length > 0)
864
+ : [];
865
+ const derived = derivedChannelValues(flags);
866
+ // Union, deduped, matching the writer's render-time union.
867
+ const total = new Set([...ownValues, ...derived]).size;
868
+ if (total === 0) return "off";
869
+ const noun = total === 1 ? "channel" : "channels";
870
+ if (derived.length === 0) return `${total} ${noun}`;
871
+ // How many of the union come from the derived flag (after dedup with own).
872
+ const derivedInUnion = new Set(derived).size;
873
+ const derivedNote =
874
+ derivedInUnion === total
875
+ ? "from --dangerously-load"
876
+ : `${derivedInUnion} from --dangerously-load`;
877
+ return `${total} ${noun} (${derivedNote})`;
878
+ }
879
+
880
+ /**
881
+ * Compare two flag maps for value equality. We render-then-diff via JSON
882
+ * because FlagValues are small, fully serialisable, and order-insensitive
883
+ * inside the maps. A property-by-property deep-equal would be more efficient
884
+ * but the maps have ~14 entries — diff cost is negligible.
885
+ */
886
+ function shallowFlagsEqual(
887
+ a: Record<string, FlagValue>,
888
+ b: Record<string, FlagValue>,
889
+ ): boolean {
890
+ const aKeys = Object.keys(a).sort();
891
+ const bKeys = Object.keys(b).sort();
892
+ if (aKeys.length !== bKeys.length) return false;
893
+ for (let i = 0; i < aKeys.length; i++) {
894
+ if (aKeys[i] !== bKeys[i]) return false;
895
+ }
896
+ for (const k of aKeys) {
897
+ if (JSON.stringify(a[k]) !== JSON.stringify(b[k])) return false;
898
+ }
899
+ return true;
900
+ }
901
+
902
+ function isFlagEnabled(value: FlagValue | undefined): boolean {
903
+ if (!value) return false;
904
+ switch (value.kind) {
905
+ case "boolean":
906
+ return value.enabled;
907
+ case "tri-state":
908
+ return value.state !== "unset";
909
+ case "select":
910
+ case "text":
911
+ case "optional-text":
912
+ case "text-list":
913
+ case "multi-with-custom":
914
+ return value.enabled;
915
+ }
916
+ }
917
+
918
+ /**
919
+ * Pick a managed-alias name that can't shadow a surviving hand-written alias.
920
+ *
921
+ * On a not-lossless adoption we keep the user's original `<original>` line in
922
+ * the rc file, so the managed block must NOT share its name (shell aliases are
923
+ * last-definition-wins — a same-named block would override the original and
924
+ * silently drop the very tokens that made it not-lossless).
925
+ *
926
+ * Strategy: prefer the tool's `current` name if it already differs from the
927
+ * original. Otherwise derive `<original>m`, `<original>m2`, … until we find one
928
+ * that is valid AND collides with neither the original nor the other detected
929
+ * claude aliases. Falls back to a timestamp-free deterministic suffix.
930
+ */
931
+ function pickDistinctName(
932
+ current: string,
933
+ original: string,
934
+ others: string[],
935
+ ): string {
936
+ // Names already taken by aliases we're leaving in place.
937
+ const taken = new Set<string>([original]);
938
+ for (const line of others) {
939
+ const m = /^alias\s+([A-Za-z_][A-Za-z0-9_-]*)\s*=/.exec(line.trim());
940
+ if (m) taken.add(m[1]);
941
+ }
942
+
943
+ // If our current name is already distinct + valid, just use it.
944
+ if (
945
+ current !== original &&
946
+ !taken.has(current) &&
947
+ !validateAliasName(current)
948
+ ) {
949
+ return current;
950
+ }
951
+
952
+ // Derive a suffixed variant from the original.
953
+ const base = `${original}m`;
954
+ if (!taken.has(base) && !validateAliasName(base)) return base;
955
+ for (let n = 2; n < 100; n++) {
956
+ const cand = `${original}m${n}`;
957
+ if (!taken.has(cand) && !validateAliasName(cand)) return cand;
958
+ }
959
+ // Pathological fallback — should never be reached for sane inputs.
960
+ return `${original}managed`;
961
+ }
962
+
963
+ /**
964
+ * Produce an "enabled by default" value for a flag — what we'd give it when
965
+ * the user activates a dependent that needs this one on. For booleans this
966
+ * is just `enabled: true`; for kinds that carry a value (select/text/etc.)
967
+ * we set `enabled: true` with the catalog default value, leaving the user
968
+ * free to refine it after.
969
+ */
970
+ function enableDefault(flag: AliasFlag): FlagValue {
971
+ switch (flag.kind) {
972
+ case "boolean":
973
+ return { kind: "boolean", enabled: true };
974
+ case "tri-state":
975
+ return { kind: "tri-state", state: "on" };
976
+ case "select":
977
+ return {
978
+ kind: "select",
979
+ enabled: true,
980
+ value: flag.options?.[0]?.value ?? "",
981
+ };
982
+ case "text":
983
+ return { kind: "text", enabled: true, value: "" };
984
+ case "optional-text":
985
+ return { kind: "optional-text", enabled: true, value: "" };
986
+ case "text-list":
987
+ return {
988
+ kind: "text-list",
989
+ enabled: true,
990
+ values: [...(flag.defaultValues ?? [])],
991
+ };
992
+ case "multi-with-custom":
993
+ return {
994
+ kind: "multi-with-custom",
995
+ enabled: true,
996
+ picked: [],
997
+ custom: [...(flag.defaultValues ?? [])],
998
+ };
999
+ }
1000
+ }
1001
+
1002
+ /**
1003
+ * Disable a flag while preserving any values the user has already entered.
1004
+ * Used by the cascade-disable path: turning off `--worktree` shouldn't lose
1005
+ * the user's `--tmux=classic` choice — only re-enable the dependency and
1006
+ * the value reappears.
1007
+ */
1008
+ function disableValue(value: FlagValue): FlagValue {
1009
+ switch (value.kind) {
1010
+ case "boolean":
1011
+ return { kind: "boolean", enabled: false };
1012
+ case "tri-state":
1013
+ return { kind: "tri-state", state: "unset" };
1014
+ case "select":
1015
+ case "text":
1016
+ case "optional-text":
1017
+ case "text-list":
1018
+ case "multi-with-custom":
1019
+ return { ...value, enabled: false };
1020
+ }
1021
+ }
1022
+
1023
+ function summarizeValue(value: FlagValue): string {
1024
+ switch (value.kind) {
1025
+ case "boolean":
1026
+ return value.enabled ? "on" : "off";
1027
+ case "tri-state":
1028
+ return value.state;
1029
+ case "select":
1030
+ return value.enabled ? value.value || "(bare)" : "off";
1031
+ case "text":
1032
+ case "optional-text":
1033
+ return value.enabled
1034
+ ? value.value
1035
+ ? truncate(value.value, 32)
1036
+ : "(empty)"
1037
+ : "off";
1038
+ case "text-list":
1039
+ return value.enabled ? `${value.values.length} item(s)` : "off";
1040
+ case "multi-with-custom": {
1041
+ if (!value.enabled) return "off";
1042
+ const total = new Set([...value.picked, ...value.custom]).size;
1043
+ return total === 0 ? "(no filter)" : `${total} token(s)`;
1044
+ }
1045
+ }
1046
+ }
1047
+
1048
+ function truncate(s: string, n: number): string {
1049
+ return s.length <= n ? s : `${s.slice(0, n - 1)}…`;
1050
+ }
1051
+
1052
+ /**
1053
+ * Quick toggle (enter/space). For boolean → flip. For tri-state → cycle
1054
+ * unset → on → off → unset. For others, just flip the `enabled` field; the
1055
+ * user must press `e` to set the value.
1056
+ */
1057
+ function quickToggle(_flag: AliasFlag, value: FlagValue): FlagValue | null {
1058
+ switch (value.kind) {
1059
+ case "boolean":
1060
+ return { kind: "boolean", enabled: !value.enabled };
1061
+ case "tri-state": {
1062
+ const next: "unset" | "on" | "off" =
1063
+ value.state === "unset" ? "on" : value.state === "on" ? "off" : "unset";
1064
+ return { kind: "tri-state", state: next };
1065
+ }
1066
+ case "select":
1067
+ return { ...value, enabled: !value.enabled };
1068
+ case "text":
1069
+ case "optional-text":
1070
+ return { ...value, enabled: !value.enabled };
1071
+ case "text-list":
1072
+ return { ...value, enabled: !value.enabled };
1073
+ case "multi-with-custom":
1074
+ return { ...value, enabled: !value.enabled };
1075
+ }
1076
+ }
1077
+
1078
+ /**
1079
+ * `e` — edit the value. Modal flow varies by flag kind.
1080
+ */
1081
+ async function editFlagValue(
1082
+ flag: AliasFlag,
1083
+ value: FlagValue,
1084
+ modal: ReturnType<typeof useModal>,
1085
+ ): Promise<FlagValue | null> {
1086
+ switch (value.kind) {
1087
+ case "boolean":
1088
+ return { kind: "boolean", enabled: !value.enabled };
1089
+
1090
+ case "tri-state": {
1091
+ const choice = await modal.select(
1092
+ flag.label,
1093
+ flag.description,
1094
+ [
1095
+ { label: "Unset (no opinion)", value: "unset" },
1096
+ { label: `On (${flag.flag})`, value: "on" },
1097
+ ...(flag.triStateOff
1098
+ ? [{ label: `Off (${flag.triStateOff})`, value: "off" }]
1099
+ : []),
1100
+ ],
1101
+ ["unset", "on", "off"].indexOf(value.state),
1102
+ );
1103
+ if (!choice) return null;
1104
+ return { kind: "tri-state", state: choice as "unset" | "on" | "off" };
1105
+ }
1106
+
1107
+ case "select": {
1108
+ const opts = flag.options ?? [];
1109
+ const choice = await modal.select(
1110
+ flag.label,
1111
+ flag.description,
1112
+ [
1113
+ { label: "(disable)", value: "__off__" },
1114
+ ...opts.map((o) => ({ label: o.label, value: `v:${o.value}` })),
1115
+ ],
1116
+ value.enabled
1117
+ ? Math.max(
1118
+ 0,
1119
+ opts.findIndex((o) => o.value === value.value),
1120
+ ) + 1
1121
+ : 0,
1122
+ );
1123
+ if (choice === null) return null;
1124
+ if (choice === "__off__") return { ...value, enabled: false };
1125
+ return {
1126
+ kind: "select",
1127
+ enabled: true,
1128
+ value: choice.replace(/^v:/, ""),
1129
+ };
1130
+ }
1131
+
1132
+ case "text": {
1133
+ // Lead with a 2-option chooser so the user can disable without going
1134
+ // through `r` reset. Selecting "Set value" jumps to the input modal.
1135
+ const action = await modal.select(
1136
+ flag.label,
1137
+ flag.description,
1138
+ value.enabled
1139
+ ? [
1140
+ {
1141
+ label: `Edit value (current: "${truncate(value.value, 32)}")`,
1142
+ value: "__edit__",
1143
+ },
1144
+ { label: "Disable flag", value: "__off__" },
1145
+ ]
1146
+ : [
1147
+ { label: "Enable with value...", value: "__edit__" },
1148
+ { label: "Keep disabled", value: "__off__" },
1149
+ ],
1150
+ 0,
1151
+ );
1152
+ if (action === null) return null;
1153
+ if (action === "__off__") {
1154
+ return { kind: "text", enabled: false, value: value.value };
1155
+ }
1156
+ const v = await modal.input(flag.label, flag.description, value.value);
1157
+ if (v === null) return null;
1158
+ return { kind: "text", enabled: v.length > 0, value: v };
1159
+ }
1160
+
1161
+ case "optional-text": {
1162
+ // Three-option lead: edit value, bare (no value), or disable.
1163
+ const action = await modal.select(
1164
+ flag.label,
1165
+ flag.description,
1166
+ [
1167
+ {
1168
+ label:
1169
+ value.enabled && value.value
1170
+ ? `Edit value (current: "${truncate(value.value, 32)}")`
1171
+ : "Set value...",
1172
+ value: "__edit__",
1173
+ },
1174
+ { label: "Bare flag (no value)", value: "__bare__" },
1175
+ { label: "Disable flag", value: "__off__" },
1176
+ ],
1177
+ value.enabled ? (value.value ? 0 : 1) : 2,
1178
+ );
1179
+ if (action === null) return null;
1180
+ if (action === "__off__") {
1181
+ return { kind: "optional-text", enabled: false, value: value.value };
1182
+ }
1183
+ if (action === "__bare__") {
1184
+ return { kind: "optional-text", enabled: true, value: "" };
1185
+ }
1186
+ const v = await modal.input(flag.label, flag.description, value.value);
1187
+ if (v === null) return null;
1188
+ return { kind: "optional-text", enabled: true, value: v };
1189
+ }
1190
+
1191
+ case "text-list":
1192
+ case "multi-with-custom":
1193
+ // These kinds are routed to the flag-detail sub-screen by
1194
+ // `editSelected`, never to this modal-based editor. If you see this
1195
+ // throw, the caller bypassed the routing in editSelected — fix that
1196
+ // instead of re-introducing the modal cascade.
1197
+ throw new Error(
1198
+ `${flag.kind} flags use the flag-detail sub-screen, not the modal editor`,
1199
+ );
1200
+ }
1201
+ }
1202
+
1203
+ // ─── Render helpers ─────────────────────────────────────────────────────
1204
+
1205
+ /**
1206
+ * The alias-name row sits above all flag groups. We render it with a clear
1207
+ * "Alias:" label and the value styled as the editable field, plus an `[edit]`
1208
+ * hint, so it doesn't blend in with the flag rows below.
1209
+ */
1210
+ function renderAliasNameRow(name: string, selected: boolean): React.ReactNode {
1211
+ if (selected) {
1212
+ return (
1213
+ <box key="alias-name-row" flexDirection="row">
1214
+ <text bg={theme.colors.accent} fg={theme.hints.fg}>
1215
+ {`▶ Alias name: ${name} [enter to edit]`}
1216
+ </text>
1217
+ </box>
1218
+ );
1219
+ }
1220
+ return (
1221
+ <text key="alias-name-row">
1222
+ <span fg={theme.colors.muted}>{" Alias name: "}</span>
1223
+ <span fg={theme.colors.info}>
1224
+ <strong>{name}</strong>
1225
+ </span>
1226
+ <span fg={theme.colors.muted}>{" [enter to edit]"}</span>
1227
+ </text>
1228
+ );
1229
+ }
1230
+
1231
+ /** Stable key per item for the windowed list. */
1232
+ function getItemKey(item: Item): string {
1233
+ switch (item.kind) {
1234
+ case "alias-name":
1235
+ return "alias-name";
1236
+ case "flag":
1237
+ return `flag:${item.row.flag.id}`;
1238
+ case "header":
1239
+ return `header:${item.label}`;
1240
+ }
1241
+ }
1242
+
1243
+ /** Dispatch render for one flat-list item. */
1244
+ function renderItem(
1245
+ item: Item,
1246
+ isSelected: boolean,
1247
+ isGated: (f: AliasFlag) => boolean,
1248
+ ): React.ReactNode {
1249
+ switch (item.kind) {
1250
+ case "alias-name":
1251
+ return renderAliasNameRow(item.row.value, isSelected);
1252
+ case "flag":
1253
+ return renderRow(item.row, isSelected, isGated(item.row.flag));
1254
+ case "header":
1255
+ return <text fg={theme.colors.accent}>{item.label}</text>;
1256
+ }
1257
+ }
1258
+
1259
+ function renderRow(
1260
+ row: FlagRow,
1261
+ selected: boolean,
1262
+ gated: boolean,
1263
+ ): React.ReactNode {
1264
+ // Prefer the precomputed effective state (e.g. --channels shows enabled when
1265
+ // a dev-loaded channel forces it to emit) over the flag's own enabled flag.
1266
+ const enabled = row.effectiveEnabled ?? isFlagEnabled(row.value);
1267
+ const marker = enabled ? "[x]" : "[ ]";
1268
+ const issueMark = row.issue ? " ⚠" : "";
1269
+ const fg = gated
1270
+ ? theme.colors.muted
1271
+ : enabled
1272
+ ? theme.colors.text
1273
+ : theme.colors.muted;
1274
+ // Only show a value suffix when the flag carries non-redundant detail.
1275
+ // Boolean / tri-state state is fully conveyed by the checkbox. A precomputed
1276
+ // effectiveDetail (e.g. --channels union count) wins over the own-value one.
1277
+ const detail = row.effectiveDetail ?? enabledDetail(row.value);
1278
+ const detailSuffix = detail ? ` ${detail}` : "";
1279
+ const line = `${selected ? "▶ " : " "}${marker} ${row.flag.flag}${detailSuffix}${issueMark}`;
1280
+ if (selected) {
1281
+ return (
1282
+ <text key={row.flag.id} bg={theme.colors.accent} fg={theme.hints.fg}>
1283
+ {line}
1284
+ </text>
1285
+ );
1286
+ }
1287
+ return (
1288
+ <text key={row.flag.id} fg={fg}>
1289
+ {line}
1290
+ </text>
1291
+ );
1292
+ }
1293
+
1294
+ /**
1295
+ * Per-row trailing detail. Returns "" for booleans and tri-states (their state
1296
+ * is already in the checkbox) and for disabled flags (no need to advertise an
1297
+ * unset value). Non-empty for flags that carry an extra value the checkbox
1298
+ * doesn't capture.
1299
+ */
1300
+ function enabledDetail(value: FlagValue): string {
1301
+ switch (value.kind) {
1302
+ case "boolean":
1303
+ case "tri-state":
1304
+ return "";
1305
+ case "select":
1306
+ if (!value.enabled) return "";
1307
+ return value.value === "" ? "(bare)" : value.value;
1308
+ case "text":
1309
+ case "optional-text":
1310
+ if (!value.enabled) return "";
1311
+ return value.value ? truncate(value.value, 24) : "(empty)";
1312
+ case "text-list":
1313
+ if (!value.enabled) return "";
1314
+ return value.values.length === 0 ? "(empty)" : `${value.values.length}`;
1315
+ case "multi-with-custom": {
1316
+ if (!value.enabled) return "";
1317
+ const total = new Set([...value.picked, ...value.custom]).size;
1318
+ return total === 0 ? "(no filter)" : `${total} token(s)`;
1319
+ }
1320
+ }
1321
+ }
1322
+
1323
+ function renderDetail(
1324
+ row: Row | undefined,
1325
+ isGated: (f: AliasFlag) => boolean,
1326
+ aliasName: string,
1327
+ segments: Segment[],
1328
+ ): React.ReactNode {
1329
+ if (!row) {
1330
+ return <text fg={theme.colors.muted}>No row selected.</text>;
1331
+ }
1332
+
1333
+ const headerAndBody =
1334
+ row.kind === "alias-name"
1335
+ ? renderAliasNameDetail(row)
1336
+ : renderFlagDetail(row, isGated);
1337
+
1338
+ // Preview is the only "global" element kept in the detail panel — it
1339
+ // updates as the user toggles, which is the strongest visual feedback for
1340
+ // "what alias am I building". We show argv one-per-line rather than the
1341
+ // raw POSIX-escaped alias body (which contains `'\''` sequences that look
1342
+ // alarming to readers who don't know the close-escape-reopen pattern).
1343
+ return (
1344
+ <box flexDirection="column">
1345
+ {headerAndBody}
1346
+ <text fg={theme.colors.text}> </text>
1347
+ <text fg={theme.colors.muted}>Preview:</text>
1348
+ {renderPreviewLines(aliasName, segments)}
1349
+ </box>
1350
+ );
1351
+ }
1352
+
1353
+ function renderAliasNameDetail(row: AliasNameRow): React.ReactNode {
1354
+ return (
1355
+ <box flexDirection="column">
1356
+ <text fg={theme.colors.text}>
1357
+ <strong>Alias name</strong>
1358
+ </text>
1359
+ <text fg={theme.colors.muted}>
1360
+ Shell name for the wrapped claude command. Default: "c". Letters,
1361
+ digits, _ or - only.
1362
+ </text>
1363
+ <text fg={theme.colors.text}> </text>
1364
+ <text fg={theme.colors.text}>
1365
+ <span fg={theme.colors.muted}>Current: </span>
1366
+ <span fg={theme.colors.text}>
1367
+ <strong>{row.value}</strong>
1368
+ </span>
1369
+ </text>
1370
+ <text fg={theme.colors.text}> </text>
1371
+ <text fg={theme.colors.muted}>Enter to rename. r resets to "c".</text>
1372
+ </box>
1373
+ );
1374
+ }
1375
+
1376
+ function renderFlagDetail(
1377
+ row: FlagRow,
1378
+ isGated: (f: AliasFlag) => boolean,
1379
+ ): React.ReactNode {
1380
+ const gated = isGated(row.flag);
1381
+ // State must agree with the checkbox and the Preview. For --channels we show
1382
+ // the effective channel-list summary (union of own + derived); other flags
1383
+ // use their own value summary.
1384
+ const stateText = row.stateSummary ?? summarizeValue(row.value);
1385
+ return (
1386
+ <box flexDirection="column">
1387
+ <text fg={theme.colors.text}>
1388
+ <strong>{row.flag.flag}</strong>
1389
+ </text>
1390
+ <text fg={theme.colors.muted}>{row.flag.description}</text>
1391
+ <text fg={theme.colors.text}> </text>
1392
+ <text fg={theme.colors.text}>
1393
+ <span fg={theme.colors.muted}>State: </span>
1394
+ <span fg={theme.colors.text}>{stateText}</span>
1395
+ </text>
1396
+ {gated && (
1397
+ <text fg={theme.colors.muted}>
1398
+ {`Depends on --${row.flag.requires}. Enabling this will turn that on too.`}
1399
+ </text>
1400
+ )}
1401
+ {row.issue && (
1402
+ <text fg={theme.colors.warning}>{`Conflict: ${row.issue.reason}`}</text>
1403
+ )}
1404
+ </box>
1405
+ );
1406
+ }
1407
+
1408
+ /**
1409
+ * Render the alias preview as one argv arg per line. This replaces the raw
1410
+ * POSIX-escaped alias body (`alias c='claude '\''text with spaces'\'' …'`),
1411
+ * which is correct but visually alarming — readers without POSIX shell
1412
+ * fluency mistake `'\''` for broken quoting.
1413
+ *
1414
+ * We show each argv token on its own row, with values that need quoting
1415
+ * wrapped in plain double quotes (display-only — these are NOT what gets
1416
+ * written to the rc file). Composite segments (templated values) display
1417
+ * with their `{token}` placeholders intact in cyan so users see exactly
1418
+ * what will expand at shell-eval time.
1419
+ *
1420
+ * Example output:
1421
+ * alias c='claude
1422
+ * --dangerously-skip-permissions
1423
+ * --append-system-prompt "you ae the smaretes model ever"
1424
+ * --debug plugins
1425
+ * '
1426
+ */
1427
+ function renderPreviewLines(
1428
+ aliasName: string,
1429
+ segments: Segment[],
1430
+ ): React.ReactNode {
1431
+ if (segments.length === 0) {
1432
+ return (
1433
+ <text fg={theme.colors.text}>{`alias ${aliasName || "?"}='claude'`}</text>
1434
+ );
1435
+ }
1436
+ const lines: React.ReactNode[] = [
1437
+ <text
1438
+ key="head"
1439
+ fg={theme.colors.text}
1440
+ >{`alias ${aliasName || "?"}='claude`}</text>,
1441
+ ];
1442
+ segments.forEach((seg, i) => {
1443
+ lines.push(
1444
+ // biome-ignore lint/suspicious/noArrayIndexKey: alias arguments are positional by definition and identical tokens can legitimately repeat; the preview re-renders whole
1445
+ <text key={`arg-${i}`} fg={theme.colors.text}>
1446
+ {" "}
1447
+ {renderPreviewSegment(seg)}
1448
+ </text>,
1449
+ );
1450
+ });
1451
+ lines.push(
1452
+ <text key="tail" fg={theme.colors.text}>
1453
+ {"'"}
1454
+ </text>,
1455
+ );
1456
+ return <>{lines}</>;
1457
+ }
1458
+
1459
+ function renderPreviewSegment(seg: Segment): React.ReactNode {
1460
+ if (seg.kind === "literal") {
1461
+ return <span fg={theme.colors.text}>{quoteForDisplay(seg.text)}</span>;
1462
+ }
1463
+ // Composite: literal parts as-is, raw parts as `{token}` reconstructed
1464
+ // from the writer's TOKEN_TO_SUB map. We don't have direct access to the
1465
+ // map here, so we reverse-lookup using the same forms.
1466
+ const spans: React.ReactNode[] = [];
1467
+ seg.parts.forEach((p, i) => {
1468
+ if (p.kind === "literal") {
1469
+ spans.push(
1470
+ // biome-ignore lint/suspicious/noArrayIndexKey: parts of one composite argument are positional text fragments with no identity of their own
1471
+ <span key={`p-${i}`} fg={theme.colors.text}>
1472
+ {p.text}
1473
+ </span>,
1474
+ );
1475
+ } else {
1476
+ spans.push(
1477
+ // biome-ignore lint/suspicious/noArrayIndexKey: same positional fragment list as above
1478
+ <span key={`p-${i}`} fg={theme.colors.info}>
1479
+ {substitutionToToken(p.posix)}
1480
+ </span>,
1481
+ );
1482
+ }
1483
+ });
1484
+ return <span>{spans}</span>;
1485
+ }
1486
+
1487
+ const PREVIEW_SAFE = /^[A-Za-z0-9_\-=,:.\/!@%+]+$/;
1488
+
1489
+ function quoteForDisplay(text: string): string {
1490
+ if (text === "") return '""';
1491
+ if (PREVIEW_SAFE.test(text)) return text;
1492
+ // Display-only quoting. We don't have to escape anything for the shell
1493
+ // since this string never reaches a parser — it's just for the user to
1494
+ // read. We do escape an embedded `"` to keep visual balance.
1495
+ return `"${text.replace(/"/g, '\\"')}"`;
1496
+ }
1497
+
1498
+ /**
1499
+ * Reverse-lookup the writer's POSIX substitution codes to their `{token}`
1500
+ * forms. Mirror of `TOKEN_TO_SUB` in `alias-shell-writer.ts`.
1501
+ */
1502
+ function substitutionToToken(posixCode: string): string {
1503
+ switch (posixCode) {
1504
+ case '$(basename "$PWD")':
1505
+ return "{folder}";
1506
+ case "$(date +%d)":
1507
+ return "{day}";
1508
+ case "$(date +%m)":
1509
+ return "{month}";
1510
+ case "$(date +%Y)":
1511
+ return "{year}";
1512
+ default:
1513
+ return posixCode;
1514
+ }
1515
+ }
1516
+
1517
+ function renderFlagDetailRightPane(
1518
+ flag: AliasFlag,
1519
+ value: FlagValue,
1520
+ aliasName: string,
1521
+ segments: Segment[],
1522
+ derivedNote?: string,
1523
+ stateOverride?: string,
1524
+ ): React.ReactNode {
1525
+ return (
1526
+ <box flexDirection="column">
1527
+ <text fg={theme.colors.text}>
1528
+ <strong>{flag.flag}</strong>
1529
+ </text>
1530
+ <text fg={theme.colors.muted}>{flag.description}</text>
1531
+ {derivedNote && (
1532
+ <>
1533
+ <text fg={theme.colors.text}> </text>
1534
+ <text fg={theme.colors.muted}>{derivedNote}</text>
1535
+ </>
1536
+ )}
1537
+ <text fg={theme.colors.text}> </text>
1538
+ <text fg={theme.colors.text}>
1539
+ <span fg={theme.colors.muted}>State: </span>
1540
+ <span fg={theme.colors.text}>
1541
+ {stateOverride ?? summarizeValue(value)}
1542
+ </span>
1543
+ </text>
1544
+ <text fg={theme.colors.text}> </text>
1545
+ <text fg={theme.colors.muted}>Preview:</text>
1546
+ {renderPreviewLines(aliasName, segments)}
1547
+ <text fg={theme.colors.text}> </text>
1548
+ <text fg={theme.colors.muted}>Keys:</text>
1549
+ <text fg={theme.colors.text}>
1550
+ {" "}
1551
+ space/enter toggle the highlighted row
1552
+ </text>
1553
+ <text fg={theme.colors.text}> a add a new value</text>
1554
+ <text fg={theme.colors.text}> d delete the highlighted item</text>
1555
+ <text fg={theme.colors.text}> h back to flag list</text>
1556
+ </box>
1557
+ );
1558
+ }