magus-cli 7.3.0 → 7.4.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 (130) hide show
  1. package/package.json +4 -4
  2. package/scripts/build-binaries.ts +4 -2
  3. package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
  4. package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
  5. package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
  6. package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
  7. package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
  8. package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
  9. package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
  10. package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
  11. package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
  12. package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
  13. package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
  14. package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
  15. package/src/__tests__/blind/c5-keymap.test.ts +461 -0
  16. package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
  17. package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
  18. package/src/__tests__/blind/c8-text-width.test.ts +137 -0
  19. package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
  20. package/src/__tests__/blind/child-run.ts +114 -0
  21. package/src/__tests__/cli-tools-adapter.test.ts +5 -2
  22. package/src/__tests__/component-badge.test.ts +20 -36
  23. package/src/__tests__/footer-hints.test.ts +1 -1
  24. package/src/__tests__/manifest.test.ts +25 -17
  25. package/src/__tests__/marketplace-badge.test.ts +4 -4
  26. package/src/__tests__/models-screen-state.test.ts +36 -13
  27. package/src/__tests__/npm-package-name-api.test.ts +111 -0
  28. package/src/__tests__/npm-package-name.test.ts +280 -0
  29. package/src/__tests__/scope-squares.test.tsx +28 -114
  30. package/src/__tests__/shutdown.test.ts +578 -0
  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__/upgrade-spawn.test.ts +355 -0
  37. package/src/__tests__/uppercase-keybindings.test.ts +25 -12
  38. package/src/cli/ansi.ts +2 -2
  39. package/src/cli/bootstrap.ts +33 -2
  40. package/src/cli/doctor.ts +105 -14
  41. package/src/cli/install.ts +8 -2
  42. package/src/cli/profile.ts +18 -1
  43. package/src/cli/router.ts +31 -18
  44. package/src/cli/update-view.ts +1 -1
  45. package/src/cli/upgrade.ts +37 -9
  46. package/src/data/cli-tools.ts +13 -2
  47. package/src/data/npm-package.ts +15 -0
  48. package/src/services/claude-settings.ts +151 -73
  49. package/src/services/cli-tool-commands.ts +15 -5
  50. package/src/services/cli-tool-version.ts +101 -0
  51. package/src/services/hook-registration.ts +23 -6
  52. package/src/services/manifest.ts +117 -52
  53. package/src/services/plugin-manager.ts +19 -5
  54. package/src/services/profile-adopt.ts +7 -2
  55. package/src/services/profile-startup.ts +87 -7
  56. package/src/services/profiles.ts +36 -23
  57. package/src/services/settings-file.ts +158 -4
  58. package/src/services/settings-manager.ts +19 -5
  59. package/src/services/skills-manager.ts +46 -0
  60. package/src/services/tool-probe.ts +272 -45
  61. package/src/services/toolchain.ts +39 -0
  62. package/src/services/update-engine.ts +5 -0
  63. package/src/services/update-plan.ts +9 -0
  64. package/src/services/version-check.ts +20 -10
  65. package/src/tui.tsx +48 -21
  66. package/src/ui/App.tsx +58 -197
  67. package/src/ui/adapters/cliToolsAdapter.ts +141 -12
  68. package/src/ui/adapters/pluginsAdapter.ts +10 -7
  69. package/src/ui/adapters/settingsAdapter.ts +0 -21
  70. package/src/ui/adapters/skillsAdapter.ts +27 -8
  71. package/src/ui/adapters/stylesAdapter.ts +15 -11
  72. package/src/ui/components/CategoryHeader.tsx +7 -1
  73. package/src/ui/components/EmptyFilterState.tsx +2 -2
  74. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  75. package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
  76. package/src/ui/components/TabBar.tsx +93 -21
  77. package/src/ui/components/layout/FooterHints.tsx +6 -27
  78. package/src/ui/components/layout/ScreenLayout.tsx +68 -42
  79. package/src/ui/components/layout/index.ts +1 -5
  80. package/src/ui/components/modals/ConfirmModal.tsx +2 -19
  81. package/src/ui/components/modals/HelpOverlay.tsx +186 -0
  82. package/src/ui/components/modals/MessageModal.tsx +3 -15
  83. package/src/ui/components/modals/ModalContainer.tsx +99 -60
  84. package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
  85. package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
  86. package/src/ui/components/primitives/MetaText.tsx +1 -1
  87. package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
  88. package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
  89. package/src/ui/components/primitives/SelectableRow.tsx +24 -1
  90. package/src/ui/components/primitives/index.ts +4 -3
  91. package/src/ui/hooks/useProfileStartupModal.ts +15 -3
  92. package/src/ui/input/KeyRouter.tsx +180 -0
  93. package/src/ui/input/key-dispatch.ts +149 -0
  94. package/src/ui/keymap.ts +898 -0
  95. package/src/ui/renderers/cliToolRenderers.tsx +128 -66
  96. package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
  97. package/src/ui/renderers/markdownRenderer.tsx +2 -2
  98. package/src/ui/renderers/mcpRenderers.tsx +28 -14
  99. package/src/ui/renderers/modelRenderers.tsx +12 -13
  100. package/src/ui/renderers/modelVisuals.tsx +0 -2
  101. package/src/ui/renderers/pluginRenderers.tsx +47 -54
  102. package/src/ui/renderers/profileRenderers.tsx +32 -37
  103. package/src/ui/renderers/settingsRenderers.tsx +84 -55
  104. package/src/ui/renderers/skillRenderers.tsx +237 -190
  105. package/src/ui/renderers/styleRenderers.tsx +13 -2
  106. package/src/ui/runtime/shutdown.ts +268 -0
  107. package/src/ui/screens/AliasScreen.tsx +118 -100
  108. package/src/ui/screens/CliToolsScreen.tsx +98 -87
  109. package/src/ui/screens/EnvVarsScreen.tsx +44 -42
  110. package/src/ui/screens/GitignoreScreen.tsx +50 -55
  111. package/src/ui/screens/McpRegistryScreen.tsx +52 -86
  112. package/src/ui/screens/McpScreen.tsx +47 -33
  113. package/src/ui/screens/ModelsScreen.tsx +59 -104
  114. package/src/ui/screens/PluginsScreen.tsx +123 -127
  115. package/src/ui/screens/ProfilesScreen.tsx +49 -46
  116. package/src/ui/screens/SkillsScreen.tsx +184 -204
  117. package/src/ui/screens/StylesScreen.tsx +104 -157
  118. package/src/ui/state/AppContext.tsx +6 -0
  119. package/src/ui/state/DimensionsContext.tsx +1 -10
  120. package/src/ui/state/reducer.ts +5 -8
  121. package/src/ui/state/types.ts +11 -9
  122. package/src/ui/text-width.ts +341 -0
  123. package/src/ui/theme-mode.ts +34 -71
  124. package/src/ui/theme.ts +50 -28
  125. package/src/utils/request-gate.ts +37 -0
  126. package/src/ui/components/ScopeIndicator.tsx +0 -63
  127. package/src/ui/components/SearchInput.tsx +0 -57
  128. package/src/ui/hooks/index.ts +0 -2
  129. package/src/ui/hooks/useKeyboard.ts +0 -24
  130. package/src/ui/hooks/useKeyboardHandler.ts +0 -63
@@ -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
  }),
@@ -0,0 +1,180 @@
1
+ /**
2
+ * The one keyboard subscriber in the React tree.
3
+ *
4
+ * Components do not call `useKeyboard`. They register a LAYER — modal, input,
5
+ * screen, scroll or global — and this router offers each key to the live layers
6
+ * through `dispatchKey`, top first, so exactly one of them acts on it. The
7
+ * system layer (Ctrl+C) is not here: `src/tui.tsx` takes that key on the
8
+ * renderer before this subscriber runs and stops it there.
9
+ *
10
+ * Each key is dispatched inside `flushSync`, so everything a key changes is
11
+ * committed — and every layer's `active` flag and handler refreshed — before the
12
+ * next key is looked at. A paste or a fast typist delivers several keys in one
13
+ * stdin read; without this, `/abc` would open the search box and then send `a`,
14
+ * `b` and `c` to the screen underneath, whose handlers still believed the box
15
+ * was closed.
16
+ */
17
+
18
+ import { appendFileSync } from "node:fs";
19
+ import os from "node:os";
20
+ import path from "node:path";
21
+ import type { KeyEvent } from "@opentui/core";
22
+ import { flushSync, useKeyboard } from "@opentui/react";
23
+ import {
24
+ type ReactNode,
25
+ createContext,
26
+ useContext,
27
+ useLayoutEffect,
28
+ useState,
29
+ } from "react";
30
+ import {
31
+ SCREEN_BINDINGS,
32
+ type ScreenHandlers,
33
+ runBindings,
34
+ } from "../keymap.js";
35
+ import type { Screen } from "../state/types.js";
36
+ import {
37
+ type KeyLayer,
38
+ type KeyLayerId,
39
+ type KeyPress,
40
+ dispatchKey,
41
+ inputLayerConsumes,
42
+ typedCharacter,
43
+ } from "./key-dispatch.js";
44
+
45
+ const LayersContext = createContext<Set<KeyLayer> | null>(null);
46
+
47
+ /** OpenTUI's key event, reduced to the fields the dispatcher reads. */
48
+ export function toKeyPress(event: KeyEvent): KeyPress {
49
+ return {
50
+ name: event.name ?? "",
51
+ sequence: event.sequence ?? "",
52
+ ctrl: event.ctrl === true,
53
+ meta: event.meta === true,
54
+ shift: event.shift === true,
55
+ };
56
+ }
57
+
58
+ /**
59
+ * `MAGUS_DEBUG_KEYS=1` appends every key and the layer that took it to
60
+ * `magus-keys.log` in the temp directory — the first thing to look at when a
61
+ * key "does nothing".
62
+ */
63
+ function logKey(key: KeyPress, consumer: KeyLayerId | null): void {
64
+ if (process.env.MAGUS_DEBUG_KEYS !== "1") return;
65
+ try {
66
+ appendFileSync(
67
+ path.join(os.tmpdir(), "magus-keys.log"),
68
+ `${new Date().toISOString()} name=${JSON.stringify(key.name)} seq=${JSON.stringify(key.sequence)} ctrl=${key.ctrl} meta=${key.meta} shift=${key.shift} -> ${consumer ?? "nobody"}\n`,
69
+ );
70
+ } catch {
71
+ // Best-effort logging: never let it break a keypress.
72
+ }
73
+ }
74
+
75
+ export function KeyRouter({ children }: { children: ReactNode }) {
76
+ const [layers] = useState(() => new Set<KeyLayer>());
77
+
78
+ useKeyboard((event) => {
79
+ const key = toKeyPress(event);
80
+ let consumer: KeyLayerId | null = null;
81
+ flushSync(() => {
82
+ consumer = dispatchKey([...layers], key);
83
+ });
84
+ logKey(key, consumer);
85
+ });
86
+
87
+ return (
88
+ <LayersContext.Provider value={layers}>{children}</LayersContext.Provider>
89
+ );
90
+ }
91
+
92
+ /**
93
+ * Register a key layer for as long as the calling component is mounted.
94
+ *
95
+ * `active` and `handle` are refreshed on every commit, so the router always
96
+ * calls the handler from the latest render — never a stale closure.
97
+ */
98
+ export function useKeyLayer(
99
+ id: KeyLayerId,
100
+ active: boolean,
101
+ handle: (key: KeyPress) => boolean,
102
+ ): void {
103
+ const layers = useContext(LayersContext);
104
+ if (!layers) throw new Error("useKeyLayer needs a <KeyRouter> above it");
105
+ const [entry] = useState<KeyLayer>(() => ({ id, active, handle }));
106
+
107
+ useLayoutEffect(() => {
108
+ entry.id = id;
109
+ entry.active = active;
110
+ entry.handle = handle;
111
+ });
112
+
113
+ useLayoutEffect(() => {
114
+ layers.add(entry);
115
+ return () => {
116
+ layers.delete(entry);
117
+ };
118
+ }, [layers, entry]);
119
+ }
120
+
121
+ /**
122
+ * The screen layer: one handler per binding the keymap declares for `screen`.
123
+ * `active: false` stands the layer down without unmounting it.
124
+ */
125
+ export function useScreenKeys<S extends Screen>(
126
+ screen: S,
127
+ handlers: ScreenHandlers<S>,
128
+ active = true,
129
+ ): void {
130
+ useKeyLayer("screen", active, (key) =>
131
+ runBindings(SCREEN_BINDINGS[screen], handlers, key),
132
+ );
133
+ }
134
+
135
+ /** A screen's search box, as the input layer drives it. */
136
+ export interface SearchBox {
137
+ /** The box has focus: what the user types goes into it. */
138
+ focused: boolean;
139
+ query: string;
140
+ /** Add one typed character. */
141
+ append(char: string): void;
142
+ /** Delete the last character. */
143
+ erase(): void;
144
+ /** Empty the box and stay in it. */
145
+ clear(): void;
146
+ /** Leave the box. The filter stays as typed. */
147
+ blur(): void;
148
+ }
149
+
150
+ /**
151
+ * The input layer for a search box.
152
+ *
153
+ * While focused it takes every printable key — digits, `j`, `k`, `q`, `?` and
154
+ * space are typed, not acted on — plus Backspace, Enter (keep the filter, leave
155
+ * the box) and Esc (clear the text; on an empty box, leave). Up/Down, Tab and the
156
+ * scroll keys pass through: the list still moves, tabs still cycle, the panel
157
+ * still scrolls.
158
+ */
159
+ export function useSearchInput(box: SearchBox): void {
160
+ useKeyLayer("input", box.focused, (key) => {
161
+ if (!inputLayerConsumes(key)) return false;
162
+ switch (key.name) {
163
+ case "return":
164
+ case "enter":
165
+ box.blur();
166
+ return true;
167
+ case "escape":
168
+ if (box.query.length > 0) box.clear();
169
+ else box.blur();
170
+ return true;
171
+ case "backspace":
172
+ case "delete":
173
+ box.erase();
174
+ return true;
175
+ }
176
+ const char = typedCharacter(key);
177
+ if (char !== null) box.append(char);
178
+ return true;
179
+ });
180
+ }