magus-cli 7.3.1 → 7.4.1

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 (127) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  3. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  4. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  5. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  6. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  7. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  8. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  9. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  10. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  11. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  12. package/src/__tests__/blind/c19-paste-routing.test.ts +252 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -0
  15. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  16. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  17. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  18. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  19. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  20. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  21. package/src/__tests__/blind/child-run.ts +114 -0
  22. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  23. package/src/__tests__/component-badge.test.ts +20 -36
  24. package/src/__tests__/footer-hints.test.ts +1 -1
  25. package/src/__tests__/manifest.test.ts +25 -17
  26. package/src/__tests__/marketplace-badge.test.ts +4 -4
  27. package/src/__tests__/models-screen-state.test.ts +36 -13
  28. package/src/__tests__/scope-squares.test.tsx +28 -114
  29. package/src/__tests__/shutdown.test.ts +578 -0
  30. package/src/__tests__/stale-load-repair.test.ts +5 -4
  31. package/src/__tests__/styles-manager.test.ts +4 -4
  32. package/src/__tests__/styles-screen-state.test.ts +26 -12
  33. package/src/__tests__/tabbar-layout.test.ts +44 -32
  34. package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
  35. package/src/__tests__/tool-probe.test.ts +47 -22
  36. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  37. package/src/cli/ansi.ts +2 -2
  38. package/src/cli/bootstrap.ts +33 -2
  39. package/src/cli/doctor.ts +120 -15
  40. package/src/cli/install.ts +35 -4
  41. package/src/cli/profile.ts +18 -1
  42. package/src/cli/router.ts +31 -18
  43. package/src/cli/update-view.ts +1 -1
  44. package/src/data/cli-tools.ts +9 -1
  45. package/src/services/claude-settings.ts +434 -198
  46. package/src/services/cli-tool-commands.ts +45 -5
  47. package/src/services/cli-tool-version.ts +101 -0
  48. package/src/services/gitignore-service.ts +39 -22
  49. package/src/services/hook-registration.ts +23 -6
  50. package/src/services/manifest.ts +117 -52
  51. package/src/services/plugin-manager.ts +19 -5
  52. package/src/services/profile-adopt.ts +7 -2
  53. package/src/services/profile-startup.ts +87 -7
  54. package/src/services/profiles.ts +36 -23
  55. package/src/services/settings-file.ts +164 -4
  56. package/src/services/settings-manager.ts +19 -5
  57. package/src/services/skills-manager.ts +46 -0
  58. package/src/services/tool-probe.ts +316 -44
  59. package/src/services/toolchain.ts +39 -0
  60. package/src/services/update-engine.ts +5 -0
  61. package/src/services/update-plan.ts +9 -0
  62. package/src/tui.tsx +48 -21
  63. package/src/ui/App.tsx +58 -197
  64. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  65. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  66. package/src/ui/adapters/settingsAdapter.ts +0 -21
  67. package/src/ui/adapters/skillsAdapter.ts +27 -8
  68. package/src/ui/adapters/stylesAdapter.ts +15 -11
  69. package/src/ui/components/CategoryHeader.tsx +7 -1
  70. package/src/ui/components/EmptyFilterState.tsx +2 -2
  71. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  72. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  73. package/src/ui/components/TabBar.tsx +93 -21
  74. package/src/ui/components/layout/FooterHints.tsx +6 -27
  75. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  76. package/src/ui/components/layout/index.ts +1 -5
  77. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  78. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  79. package/src/ui/components/modals/MessageModal.tsx +3 -15
  80. package/src/ui/components/modals/ModalContainer.tsx +107 -60
  81. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  82. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  83. package/src/ui/components/primitives/MetaText.tsx +1 -1
  84. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  85. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  86. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  87. package/src/ui/components/primitives/index.ts +4 -3
  88. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  89. package/src/ui/input/KeyRouter.tsx +304 -0
  90. package/src/ui/input/key-dispatch.ts +197 -0
  91. package/src/ui/keymap.ts +905 -0
  92. package/src/ui/renderers/cliToolRenderers.tsx +192 -92
  93. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  94. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  95. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  96. package/src/ui/renderers/modelRenderers.tsx +12 -13
  97. package/src/ui/renderers/modelVisuals.tsx +0 -2
  98. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  99. package/src/ui/renderers/profileRenderers.tsx +32 -37
  100. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  101. package/src/ui/renderers/skillRenderers.tsx +237 -190
  102. package/src/ui/renderers/styleRenderers.tsx +13 -2
  103. package/src/ui/runtime/shutdown.ts +268 -0
  104. package/src/ui/screens/AliasScreen.tsx +118 -100
  105. package/src/ui/screens/CliToolsScreen.tsx +173 -101
  106. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  107. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  108. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  109. package/src/ui/screens/McpScreen.tsx +47 -33
  110. package/src/ui/screens/ModelsScreen.tsx +59 -104
  111. package/src/ui/screens/PluginsScreen.tsx +129 -129
  112. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  113. package/src/ui/screens/SkillsScreen.tsx +184 -204
  114. package/src/ui/screens/StylesScreen.tsx +104 -157
  115. package/src/ui/state/AppContext.tsx +6 -0
  116. package/src/ui/state/DimensionsContext.tsx +1 -10
  117. package/src/ui/state/reducer.ts +5 -8
  118. package/src/ui/state/types.ts +11 -9
  119. package/src/ui/text-width.ts +341 -0
  120. package/src/ui/theme-mode.ts +34 -71
  121. package/src/ui/theme.ts +50 -28
  122. package/src/utils/request-gate.ts +37 -0
  123. package/src/ui/components/ScopeIndicator.tsx +0 -63
  124. package/src/ui/components/SearchInput.tsx +0 -57
  125. package/src/ui/hooks/index.ts +0 -2
  126. package/src/ui/hooks/useKeyboard.ts +0 -24
  127. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -1,26 +1,52 @@
1
- import React, { useState } from "react";
2
- import { useKeyboard } from "../../hooks/useKeyboard.js";
3
- import { useApp } from "../../state/AppContext.js";
1
+ import type { ScrollBoxRenderable } from "@opentui/core";
2
+ import React, { useRef, useState } from "react";
3
+ import { useKeyLayer, usePasteLayer } from "../../input/KeyRouter.js";
4
+ import { type KeyPress, isScrollKey } from "../../input/key-dispatch.js";
5
+ import { useApp, useModal } from "../../state/AppContext.js";
6
+ import { useDimensions } from "../../state/DimensionsContext.js";
4
7
  import { ConfirmModal } from "./ConfirmModal.js";
8
+ import { HelpOverlay, helpOverlaySize } from "./HelpOverlay.js";
5
9
  import { InputModal } from "./InputModal.js";
6
10
  import { LoadingModal } from "./LoadingModal.js";
7
11
  import { MessageModal } from "./MessageModal.js";
8
12
  import { SelectModal } from "./SelectModal.js";
9
13
 
14
+ const isEnter = (key: KeyPress) =>
15
+ key.name === "return" || key.name === "enter";
16
+ const isPlain = (key: KeyPress) => !key.ctrl && !key.meta;
17
+
10
18
  /**
11
- * Container that renders the active modal as an overlay
12
- * Handles ALL keyboard events when a modal is open to avoid
13
- * conflicts with multiple useKeyboard hooks in child components
19
+ * Renders the open modal as an overlay and is the modal KEY LAYER.
20
+ *
21
+ * While any modal is open this layer takes every key, so nothing reaches a
22
+ * search box, the screen, the panel scroll or the global keys underneath — the
23
+ * Enter that confirms a dialog confirms only the dialog. Ctrl+C still quits: it
24
+ * is taken above every layer, in src/tui.tsx.
14
25
  *
15
- * Input modal: Enter/Escape handled by OpenTUI <input> onSubmit + ModalContainer escape
16
- * Select modal: keyboard fully handled here (index tracking + Enter/arrows)
26
+ * It takes every PASTE for the same reason, and does nothing with it: a search
27
+ * box on the screen behind still has focus, and without this its query would
28
+ * grow from a paste aimed at the dialog in front of it. The event itself is not
29
+ * stopped, so the input modal's focused `<input>` still receives it — the
30
+ * arrangement the `input` case below already relies on for keys.
31
+ *
32
+ * - confirm: y / Enter confirm; n / Esc / q cancel.
33
+ * - select: ↑↓ (j/k) move, Enter picks, Esc / q cancel (a no-op for a
34
+ * question that must be answered).
35
+ * - input: Esc cancels; everything else goes to the focused <input>, which
36
+ * OpenTUI delivers after this layer and which submits on Enter.
37
+ * - message: any key dismisses.
38
+ * - loading: keys are swallowed until the work finishes.
39
+ * - help: ↑↓ / PgUp / PgDn / Ctrl+U / Ctrl+D scroll; Esc or `?` close.
17
40
  */
18
41
  export function ModalContainer() {
19
42
  const { state } = useApp();
20
43
  const { modal } = state;
44
+ const { hideModal } = useModal();
45
+ const dimensions = useDimensions();
21
46
 
22
47
  // Track select modal index here (lifted from SelectModal)
23
48
  const [selectIndex, setSelectIndex] = useState(0);
49
+ const helpScrollRef = useRef<ScrollBoxRenderable | null>(null);
24
50
 
25
51
  // Reset select index when modal changes
26
52
  const modalRef = React.useRef(modal);
@@ -31,58 +57,84 @@ export function ModalContainer() {
31
57
  }
32
58
  }
33
59
 
34
- // Handle keyboard events for modals
35
- useKeyboard((key) => {
36
- if (!modal) return;
37
- if (modal.type === "loading") return;
38
-
39
- // Escape — close any modal
40
- if (key.name === "escape") {
41
- if (modal.type === "confirm") modal.onCancel();
42
- else if (modal.type === "input") modal.onCancel();
43
- else if (modal.type === "select") modal.onCancel();
44
- else if (modal.type === "message") modal.onDismiss();
60
+ const handleHelpKey = (key: KeyPress) => {
61
+ if (
62
+ key.name === "escape" ||
63
+ (isPlain(key) && (key.name === "?" || key.sequence === "?"))
64
+ ) {
65
+ hideModal();
45
66
  return;
46
67
  }
47
-
48
- // 'q' to close — but NOT for input modals (need to type 'q')
49
- if (key.name === "q" && modal.type !== "input") {
50
- if (modal.type === "confirm") modal.onCancel();
51
- else if (modal.type === "select") modal.onCancel();
52
- else if (modal.type === "message") modal.onDismiss();
53
- return;
68
+ const scroll = helpScrollRef.current;
69
+ if (!scroll) return;
70
+ const page = helpOverlaySize(
71
+ dimensions.terminalWidth,
72
+ dimensions.terminalHeight,
73
+ ).listHeight;
74
+ if (isScrollKey(key)) {
75
+ const step =
76
+ key.name === "pageup" || key.name === "pagedown"
77
+ ? Math.max(1, page - 1)
78
+ : Math.max(1, Math.floor(page / 2));
79
+ const up = key.name === "pageup" || key.name === "u";
80
+ scroll.scrollBy(up ? -step : step);
81
+ } else if (isPlain(key) && (key.name === "up" || key.name === "k")) {
82
+ scroll.scrollBy(-1);
83
+ } else if (isPlain(key) && (key.name === "down" || key.name === "j")) {
84
+ scroll.scrollBy(1);
54
85
  }
86
+ };
55
87
 
56
- // Input modal — let OpenTUI <input> handle Enter via onSubmit
57
- if (modal.type === "input") {
58
- return;
59
- }
88
+ usePasteLayer("modal", modal !== null, () => true);
60
89
 
61
- // Select modal — handle navigation and selection
62
- if (modal.type === "select") {
63
- if (key.name === "return" || key.name === "enter") {
64
- modal.onSelect(modal.options[selectIndex].value);
65
- } else if (key.name === "up" || key.name === "k") {
66
- setSelectIndex((prev) => Math.max(0, prev - 1));
67
- } else if (key.name === "down" || key.name === "j") {
68
- setSelectIndex((prev) => Math.min(modal.options.length - 1, prev + 1));
69
- }
70
- return;
71
- }
90
+ useKeyLayer("modal", modal !== null, (key) => {
91
+ if (!modal) return false;
92
+
93
+ switch (modal.type) {
94
+ case "loading":
95
+ break;
96
+
97
+ case "help":
98
+ handleHelpKey(key);
99
+ break;
72
100
 
73
- // Message modal — Enter to dismiss
74
- if (modal.type === "message") {
75
- if (key.name === "return" || key.name === "enter") {
101
+ case "message":
76
102
  modal.onDismiss();
77
- }
78
- }
103
+ break;
104
+
105
+ case "confirm":
106
+ if (key.name === "y" || isEnter(key)) modal.onConfirm();
107
+ else if (
108
+ key.name === "n" ||
109
+ key.name === "q" ||
110
+ key.name === "escape"
111
+ ) {
112
+ modal.onCancel();
113
+ }
114
+ break;
79
115
 
80
- // Confirm modal — Enter to confirm
81
- if (modal.type === "confirm") {
82
- if (key.name === "return" || key.name === "enter") {
83
- modal.onConfirm();
84
- }
116
+ case "input":
117
+ // Not consumed by anything of ours below, and still delivered to
118
+ // the focused <input> afterwards: typing and Enter work there.
119
+ if (key.name === "escape") modal.onCancel();
120
+ break;
121
+
122
+ case "select":
123
+ if (isEnter(key)) {
124
+ const option = modal.options[selectIndex];
125
+ if (option) modal.onSelect(option.value);
126
+ } else if (key.name === "up" || key.name === "k") {
127
+ setSelectIndex((prev) => Math.max(0, prev - 1));
128
+ } else if (key.name === "down" || key.name === "j") {
129
+ setSelectIndex((prev) =>
130
+ Math.min(modal.options.length - 1, prev + 1),
131
+ );
132
+ } else if (key.name === "escape" || key.name === "q") {
133
+ modal.onCancel();
134
+ }
135
+ break;
85
136
  }
137
+ return true;
86
138
  });
87
139
 
88
140
  if (!modal) {
@@ -92,14 +144,7 @@ export function ModalContainer() {
92
144
  const renderModal = () => {
93
145
  switch (modal.type) {
94
146
  case "confirm":
95
- return (
96
- <ConfirmModal
97
- title={modal.title}
98
- message={modal.message}
99
- onConfirm={modal.onConfirm}
100
- onCancel={modal.onCancel}
101
- />
102
- );
147
+ return <ConfirmModal title={modal.title} message={modal.message} />;
103
148
 
104
149
  case "input":
105
150
  return (
@@ -131,13 +176,15 @@ export function ModalContainer() {
131
176
  title={modal.title}
132
177
  message={modal.message}
133
178
  variant={modal.variant}
134
- onDismiss={modal.onDismiss}
135
179
  />
136
180
  );
137
181
 
138
182
  case "loading":
139
183
  return <LoadingModal message={modal.message} />;
140
184
 
185
+ case "help":
186
+ return <HelpOverlay screen={modal.screen} scrollRef={helpScrollRef} />;
187
+
141
188
  default:
142
189
  return null;
143
190
  }
@@ -0,0 +1,38 @@
1
+ import React from "react";
2
+ import { theme } from "../../theme.js";
3
+
4
+ interface ErrorNoticeProps {
5
+ /** What could not be done, in words: "Could not read the MCP configuration." */
6
+ title: string;
7
+ /**
8
+ * The error's own message. Shown verbatim, because for a settings file that
9
+ * does not parse it is the message that names the file — a summary here
10
+ * would drop the one fact the reader needs to fix it.
11
+ */
12
+ detail: string;
13
+ /** Optional next step, e.g. "Fix the file, then press r." */
14
+ hint?: string;
15
+ }
16
+
17
+ /**
18
+ * A failure shown in place of content that could not be loaded.
19
+ *
20
+ * It exists so a failed read never renders as an empty list: an empty list
21
+ * says "nothing is configured", which for a settings file that merely failed
22
+ * to parse is false and invites the user to configure it again.
23
+ */
24
+ export function ErrorNotice({ title, detail, hint }: ErrorNoticeProps) {
25
+ return (
26
+ <box flexDirection="column" paddingLeft={2} paddingRight={2}>
27
+ <text fg={theme.colors.danger}>{title}</text>
28
+ <box marginTop={1}>
29
+ <text fg={theme.colors.muted}>{detail}</text>
30
+ </box>
31
+ {hint && (
32
+ <box marginTop={1}>
33
+ <text fg={theme.colors.muted}>{hint}</text>
34
+ </box>
35
+ )}
36
+ </box>
37
+ );
38
+ }
@@ -1,13 +1,14 @@
1
1
  import React from "react";
2
- import { theme } from "../../theme.js";
3
- import { SelectableRow } from "./SelectableRow.js";
2
+ import { type BadgeTone, theme } from "../../theme.js";
3
+ import { SelectableRow, rowLead } from "./SelectableRow.js";
4
4
 
5
5
  interface ListCategoryRowProps {
6
6
  title: string;
7
7
  expanded?: boolean;
8
8
  count?: number;
9
9
  badge?: string;
10
- tone?: keyof typeof theme.category;
10
+ /** What the group is. `neutral` unless its name is a state — see `theme.badge`. */
11
+ tone?: BadgeTone;
11
12
  selected: boolean;
12
13
  /**
13
14
  * Whether the row can be opened and closed. Default true, which is what
@@ -23,22 +24,32 @@ export function ListCategoryRow({
23
24
  expanded = false,
24
25
  count,
25
26
  badge,
26
- tone = "gray",
27
+ tone = "neutral",
27
28
  selected,
28
29
  expandable = true,
29
30
  }: ListCategoryRowProps) {
30
- const colors = theme.category[tone];
31
+ const colors = theme.badge[tone];
31
32
  const marker = expandable ? (expanded ? "▼ " : "▶ ") : "";
32
33
  const label = `${marker}${title}${count !== undefined ? ` (${count})` : ""}`;
33
34
 
35
+ // Both forms start with the row's lead cell — a blank inside the chip, the
36
+ // selection mark on the cursor — so the title never moves as it is selected.
34
37
  return (
35
38
  <SelectableRow selected={selected}>
36
39
  {!selected && (
37
40
  <span bg={colors.bg} fg={colors.fg}>
38
- <strong> {label} </strong>
41
+ <strong>
42
+ {rowLead(false)}
43
+ {label}{" "}
44
+ </strong>
39
45
  </span>
40
46
  )}
41
- {selected && <strong> {label} </strong>}
47
+ {selected && (
48
+ <strong>
49
+ {rowLead(true)}
50
+ {label}{" "}
51
+ </strong>
52
+ )}
42
53
  {!selected && badge ? <span fg={colors.badgeFg}> {badge}</span> : null}
43
54
  </SelectableRow>
44
55
  );
@@ -3,7 +3,7 @@ import { theme } from "../../theme.js";
3
3
 
4
4
  interface MetaTextProps {
5
5
  text: string;
6
- tone?: "muted" | "warning" | "success" | "danger";
6
+ tone?: keyof typeof theme.meta;
7
7
  /**
8
8
  * True when this sits inside a SELECTED row. Not cosmetic.
9
9
  *
@@ -19,49 +19,54 @@ interface ScopeDetailProps {
19
19
  }
20
20
 
21
21
  /**
22
- * The u/p/l badges used in detail panels.
23
- * Renders each scope as: [bg=color] key [/bg] ● or ○ Label path
22
+ * The u/p/l lines used in detail panels:
23
+ * `[key] ● Label path` — the key chip, installed or not, the scope's name.
24
+ *
25
+ * The key chip is neutral: it names a key to press, which is chrome. The scopes
26
+ * no longer own a hue each (see theme.ts), so the dot carries the state — `●`
27
+ * in `success` when installed, `○` in `muted` when not — and the words carry
28
+ * which scope it is.
24
29
  */
25
30
  export function ScopeDetail({ scopes, paths }: ScopeDetailProps) {
26
31
  const items = [
27
- {
28
- key: "u",
29
- label: "User",
30
- color: theme.scopes.user,
31
- active: scopes.user,
32
- path: paths?.user,
33
- },
32
+ { key: "u", label: "User", active: scopes.user, path: paths?.user },
34
33
  {
35
34
  key: "p",
36
35
  label: "Project",
37
- color: theme.scopes.project,
38
36
  active: scopes.project,
39
37
  path: paths?.project,
40
38
  },
41
- {
42
- key: "l",
43
- label: "Local",
44
- color: theme.scopes.local,
45
- active: scopes.local,
46
- path: paths?.local,
47
- },
39
+ { key: "l", label: "Local", active: scopes.local, path: paths?.local },
48
40
  ].filter((i) => i.path !== undefined || i.active !== undefined);
49
41
 
50
42
  return (
51
43
  <box flexDirection="column">
52
44
  {items.map((item) => (
53
- <text key={item.key}>
54
- <span bg={item.color} fg={theme.hints.fg}>
55
- {" "}
56
- {item.key}{" "}
57
- </span>
58
- <span fg={item.active ? item.color : theme.colors.muted}>
59
- {item.active ? " ● " : " ○ "}
60
- </span>
61
- <span fg={item.color}>{item.label}</span>
45
+ <text key={item.key} fg={theme.colors.text}>
46
+ <ScopeKeyChip scopeKey={item.key} />
47
+ <ScopeDot installed={!!item.active} />
48
+ <span fg={theme.colors.text}>{item.label}</span>
62
49
  {item.path && <span fg={theme.colors.muted}> {item.path}</span>}
63
50
  </text>
64
51
  ))}
65
52
  </box>
66
53
  );
67
54
  }
55
+
56
+ /** ` u ` — the key that toggles a scope, as a neutral chip. Inside a `<text>`. */
57
+ export function ScopeKeyChip({ scopeKey }: { scopeKey: string }) {
58
+ return (
59
+ <span bg={theme.colors.dim} fg={theme.hints.fg}>
60
+ {` ${scopeKey} `}
61
+ </span>
62
+ );
63
+ }
64
+
65
+ /** ` ● ` installed, ` ○ ` not. Inside a `<text>`. */
66
+ export function ScopeDot({ installed }: { installed: boolean }) {
67
+ return (
68
+ <span fg={installed ? theme.colors.success : theme.colors.muted}>
69
+ {installed ? " ● " : " ○ "}
70
+ </span>
71
+ );
72
+ }
@@ -1,5 +1,4 @@
1
1
  import type React from "react";
2
- import { scopeOffFill } from "../../theme-mode.js";
3
2
  import { theme } from "../../theme.js";
4
3
 
5
4
  interface ScopeSquaresProps {
@@ -9,22 +8,26 @@ interface ScopeSquaresProps {
9
8
  selected?: boolean;
10
9
  }
11
10
 
12
- /** Every segment is a filled block; only the fill colour says lit or unlit. */
13
- const SEGMENT = "■";
11
+ /** Installed at that scope. */
12
+ export const SCOPE_ON = "■";
13
+ /** Not installed at that scope. */
14
+ export const SCOPE_OFF = "□";
14
15
 
15
16
  /**
16
- * Scope bar: three positional segments, user / project / local.
17
+ * Scope bar: three positional squares, user / project / local.
17
18
  *
18
- * Reads as a segmented indicator — each segment lit in its scope colour, or
19
- * unlit in a fill that recedes toward the page. Position identifies the scope;
20
- * the first segment is always user, the second project, the third local.
19
+ * POSITION names the scope — the first square is always user, the second
20
+ * project, the third local — and SHAPE says the state: `■` installed, `□` not.
21
+ * That reads with no colour at all, which the bar never used to: every square
22
+ * was the same filled block, lit in a scope hue or unlit in a fill near the
23
+ * page, so on a monochrome terminal, to a colour-blind reader, or on a theme
24
+ * that drew two hues alike, all three states were one shape.
21
25
  *
22
- * The unlit fill used to be `theme.colors.muted` (#6B7280), the same mid-dark
23
- * grey as de-emphasised body text. Against a light page that reads as a *dark*
24
- * block, giving an unlit segment the same visual weight as a lit one — "non
25
- * installed state is dark as well - confusing". The unlit fill now comes from
26
- * `scopeOffFill()`, which steps toward the page the resolved mode names; see
27
- * theme-mode.ts for why that one value cannot be fixed.
26
+ * Colour now only repeats what the shape says. A filled square wears `success`
27
+ * (installed is what it means, on every screen); a hollow one wears `muted`,
28
+ * which a thin outline needs to stay visible — the shape, not a pale fill, is
29
+ * what makes it recede. On a selected row the selection owns the colour
30
+ * channel: filled squares take its ink, hollow ones its dim ink.
28
31
  */
29
32
  export function ScopeSquares({
30
33
  user,
@@ -32,25 +35,17 @@ export function ScopeSquares({
32
35
  local,
33
36
  selected = false,
34
37
  }: ScopeSquaresProps) {
35
- // On a selected row the background is our own accent, so an unlit segment
36
- // recedes toward that block rather than toward the page.
37
- const off = selected ? theme.selection.bg : scopeOffFill();
38
- const lit = (on: boolean, color: string) =>
39
- on ? (selected ? theme.selection.fg : color) : off;
40
-
41
- const segments = [
42
- lit(user, theme.scopes.user),
43
- lit(project, theme.scopes.project),
44
- ...(local !== undefined ? [lit(local, theme.scopes.local)] : []),
45
- ];
38
+ const on = selected ? theme.selection.fg : theme.colors.success;
39
+ const off = selected ? theme.selection.dim : theme.colors.muted;
40
+ const states = [user, project, ...(local !== undefined ? [local] : [])];
46
41
 
47
42
  // Fragments of <span> — safe to embed inside a coloured <text> parent.
48
43
  return (
49
44
  <>
50
- {segments.map((fg, i) => (
51
- // biome-ignore lint/suspicious/noArrayIndexKey: fixed positional segments
52
- <span key={i} fg={fg}>
53
- {SEGMENT}
45
+ {states.map((installed, i) => (
46
+ // biome-ignore lint/suspicious/noArrayIndexKey: fixed positional squares
47
+ <span key={i} fg={installed ? on : off}>
48
+ {installed ? SCOPE_ON : SCOPE_OFF}
54
49
  </span>
55
50
  ))}
56
51
  </>
@@ -1,8 +1,29 @@
1
1
  import type React from "react";
2
2
  import { theme } from "../../theme.js";
3
3
 
4
+ /**
5
+ * The glyph a selected row starts with.
6
+ *
7
+ * The selection used to be a background fill and nothing else, so it vanished
8
+ * wherever colour does — a monochrome terminal, a screenshot pasted as text, a
9
+ * reader who cannot tell the fill from the page. A left-edge bar is the cursor
10
+ * in shape as well as in colour, and it takes the row's first cell, which every
11
+ * list row already leaves blank, so no name moves as the cursor passes.
12
+ */
13
+ export const SELECTED_MARK = "▌";
14
+
15
+ /** A list row's first cell: the selection mark, or the blank it replaces. */
16
+ export function rowLead(selected: boolean): string {
17
+ return selected ? SELECTED_MARK : " ";
18
+ }
19
+
4
20
  interface SelectableRowProps {
5
21
  selected: boolean;
22
+ /**
23
+ * Leading cells before the children. The first of them is the lead cell,
24
+ * which carries `SELECTED_MARK` on the selected row. At 0 the children own
25
+ * their first cell and must draw `rowLead` themselves.
26
+ */
6
27
  indent?: number;
7
28
  children: React.ReactNode;
8
29
  }
@@ -12,6 +33,8 @@ export function SelectableRow({
12
33
  indent = 0,
13
34
  children,
14
35
  }: SelectableRowProps) {
36
+ const lead =
37
+ indent > 0 ? `${rowLead(selected)}${" ".repeat(indent - 1)}` : "";
15
38
  return (
16
39
  <text
17
40
  // Unselected: no background, so the row sits on the terminal's own page.
@@ -23,7 +46,7 @@ export function SelectableRow({
23
46
  // reached by omission rather than by a wrong literal.
24
47
  fg={selected ? theme.selection.fg : theme.colors.text}
25
48
  >
26
- {" ".repeat(indent)}
49
+ {lead}
27
50
  {children}
28
51
  </text>
29
52
  );
@@ -1,9 +1,10 @@
1
- export { SelectableRow } from "./SelectableRow.js";
1
+ export { SELECTED_MARK, SelectableRow, rowLead } from "./SelectableRow.js";
2
2
  export { ListCategoryRow } from "./ListCategoryRow.js";
3
- export { ScopeSquares } from "./ScopeSquares.js";
4
- export { ScopeDetail } from "./ScopeDetail.js";
3
+ export { SCOPE_OFF, SCOPE_ON, ScopeSquares } from "./ScopeSquares.js";
4
+ export { ScopeDetail, ScopeDot, ScopeKeyChip } from "./ScopeDetail.js";
5
5
  export { ActionHints } from "./ActionHints.js";
6
6
  export type { Hint } from "./ActionHints.js";
7
7
  export { MetaText } from "./MetaText.js";
8
8
  export { KeyValueLine } from "./KeyValueLine.js";
9
9
  export { DetailSection } from "./DetailSection.js";
10
+ export { ErrorNotice } from "./ErrorNotice.js";
@@ -16,6 +16,7 @@
16
16
  */
17
17
 
18
18
  import { useCallback } from "react";
19
+ import { getManifestPath } from "../../services/manifest.js";
19
20
  import {
20
21
  type ProfileChoice,
21
22
  applyProfileChoice,
@@ -26,7 +27,9 @@ import {
26
27
  defaultOptionIndex,
27
28
  describeNeed,
28
29
  parseProfileChoice,
30
+ unparseableNeed,
29
31
  } from "../../services/profile-startup.js";
32
+ import { SettingsParseError } from "../../services/settings-file.js";
30
33
  import type { DisplacedArtifact } from "../../services/symlink-manager.js";
31
34
  import { useApp, useNavigation } from "../state/AppContext.js";
32
35
  import type { ModalState } from "../state/types.js";
@@ -207,6 +210,15 @@ export function useProfileStartupModal(onQuit: () => void) {
207
210
  });
208
211
  })
209
212
  .catch((error: unknown) => {
213
+ // The manifest stopped parsing between launch and this answer —
214
+ // someone is mid-edit — and saving refused to write over it.
215
+ // Asking the same question again would offer that save again, so
216
+ // the question becomes the one for a file that does not parse.
217
+ const next =
218
+ error instanceof SettingsParseError &&
219
+ error.path === getManifestPath(projectPath)
220
+ ? unparseableNeed(error)
221
+ : need;
210
222
  dispatch({
211
223
  type: "SHOW_MODAL",
212
224
  modal: {
@@ -226,17 +238,17 @@ export function useProfileStartupModal(onQuit: () => void) {
226
238
  // dismissing returns to the question — including when the
227
239
  // attempt was automatic, which is the one case where there
228
240
  // was no question on screen yet.
229
- onDismiss: ask,
241
+ onDismiss: () => ask(next),
230
242
  },
231
243
  });
232
244
  });
233
245
  };
234
246
 
235
- function ask(): void {
247
+ function ask(current: ProfileStartupNeed = need): void {
236
248
  dispatch({
237
249
  type: "SHOW_MODAL",
238
250
  modal: buildProfileStartupModal({
239
- need,
251
+ need: current,
240
252
  onChoose: (choice) => apply(choice, false),
241
253
  onQuit,
242
254
  }),