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,13 +1,20 @@
1
1
  import type { ScrollBoxRenderable } from "@opentui/core";
2
2
  import type React from "react";
3
3
  import { useEffect, useRef } from "react";
4
- import { useKeyboard } from "../../hooks/useKeyboard.js";
5
- import { useApp } from "../../state/AppContext.js";
4
+ import { useKeyLayer } from "../../input/KeyRouter.js";
5
+ import { isScrollKey } from "../../input/key-dispatch.js";
6
+ import {
7
+ type FooterOptions,
8
+ SCROLL_BINDINGS,
9
+ type ScrollHandlers,
10
+ footerHints,
11
+ runBindings,
12
+ } from "../../keymap.js";
6
13
  import { useDimensions } from "../../state/DimensionsContext.js";
7
14
  import type { Screen } from "../../state/types.js";
8
15
  import { theme } from "../../theme.js";
9
16
  import { TabBar } from "../TabBar.js";
10
- import { type FooterHint, FooterHints } from "./FooterHints.js";
17
+ import { FooterHints } from "./FooterHints.js";
11
18
 
12
19
  /**
13
20
  * Panel content: a node, or a function handed the panel's usable columns.
@@ -30,6 +37,8 @@ export type PanelContent =
30
37
  * `terminalWidth - 2`.
31
38
  */
32
39
  const APP_SIDE_PADDING = 2;
40
+ /** The footer row's own `paddingLeft`. */
41
+ const FOOTER_PADDING = 1;
33
42
  const LIST_FRACTION = 0.49;
34
43
  const DETAIL_FRACTION = 0.5;
35
44
  /** The list panel's own `paddingRight`. */
@@ -73,13 +82,13 @@ export function detailPanelWidth(terminalWidth: number): number {
73
82
  return panelWidth(terminalWidth, DETAIL_FRACTION, DETAIL_CHROME);
74
83
  }
75
84
 
76
- interface ScreenLayoutProps {
85
+ interface ScreenLayoutProps<S extends Screen> {
77
86
  /** Screen title (e.g., "magus Plugins") */
78
87
  title: string;
79
88
  /** Optional subtitle shown to the right of title */
80
89
  subtitle?: string;
81
- /** Current screen for tab highlighting */
82
- currentScreen: Screen;
90
+ /** Current screen: highlights its tab and picks its footer bindings. */
91
+ currentScreen: S;
83
92
  /** Search bar configuration (for screens with search) */
84
93
  search?: {
85
94
  /** Is search currently active */
@@ -100,10 +109,13 @@ interface ScreenLayoutProps {
100
109
  * while it is present.
101
110
  */
102
111
  notice?: React.ReactNode;
103
- /** Footer hints (left side). Pass a FooterHint[] to render key badges
104
- * (the standard look). A string or ReactNode is still accepted for
105
- * free-form footers. */
106
- footerHints: FooterHint[] | string | React.ReactNode;
112
+ /**
113
+ * What the footer advertises. The hints themselves always come from the
114
+ * keymap (ui/keymap.ts) for `currentScreen`, fitted to the width; this only
115
+ * says which of them are worth showing now. While `search.isActive` the
116
+ * footer shows the search box's keys instead.
117
+ */
118
+ footer?: FooterOptions<S>;
107
119
  /** Left panel content, or a function given the list panel's usable columns. */
108
120
  listPanel: PanelContent;
109
121
  /** Right panel content, or a function given the detail panel's usable columns. */
@@ -118,20 +130,19 @@ interface ScreenLayoutProps {
118
130
 
119
131
  const HEADER_COLOR = theme.colors.accent;
120
132
 
121
- export function ScreenLayout({
133
+ export function ScreenLayout<S extends Screen>({
122
134
  title,
123
135
  subtitle,
124
136
  currentScreen,
125
137
  search,
126
138
  statusLine,
127
139
  notice,
128
- footerHints,
140
+ footer,
129
141
  listPanel,
130
142
  detailPanel,
131
143
  detailKey,
132
- }: ScreenLayoutProps) {
144
+ }: ScreenLayoutProps<S>) {
133
145
  const dimensions = useDimensions();
134
- const { state } = useApp();
135
146
 
136
147
  const hasSearchBar = search && (search.isActive || search.query);
137
148
 
@@ -158,21 +169,38 @@ export function ScreenLayout({
158
169
  // of overlap so a page turn doesn't lose the reader's place; Ctrl+U/Ctrl+D
159
170
  // half-page in the pager tradition — and exist because a MacBook keyboard
160
171
  // hides PgDn behind Fn+↓, where nobody finds it.
172
+ //
173
+ // This is the SCROLL key layer: below the screen and the search box, so a
174
+ // focused search box — which never takes these keys — still scrolls, and a
175
+ // modal above it stops them.
161
176
  const detailPageStep = Math.max(1, panelHeight - 2);
162
177
  const detailHalfStep = Math.max(1, Math.floor(panelHeight / 2));
163
- useKeyboard((event) => {
164
- if (state.modal) return;
165
- const ctrl = event.ctrl === true;
166
- if (event.name === "pageup" || (ctrl && event.name === "u")) {
178
+ const scrollHandlers: ScrollHandlers = {
179
+ "scroll-half": (key) => {
167
180
  detailScrollRef.current?.scrollBy(
168
- event.name === "pageup" ? -detailPageStep : -detailHalfStep,
181
+ key.name === "u" ? -detailHalfStep : detailHalfStep,
169
182
  );
170
- } else if (event.name === "pagedown" || (ctrl && event.name === "d")) {
183
+ },
184
+ "scroll-page": (key) => {
171
185
  detailScrollRef.current?.scrollBy(
172
- event.name === "pagedown" ? detailPageStep : detailHalfStep,
186
+ key.name === "pageup" ? -detailPageStep : detailPageStep,
173
187
  );
174
- }
175
- });
188
+ },
189
+ };
190
+ useKeyLayer(
191
+ "scroll",
192
+ true,
193
+ (key) =>
194
+ isScrollKey(key) && runBindings(SCROLL_BINDINGS, scrollHandlers, key),
195
+ );
196
+
197
+ // Fitted to the row, highest priority first, so no hint is ever cut
198
+ // mid-word at the terminal's edge — see `footerHints`.
199
+ const footerRow = footerHints(
200
+ currentScreen,
201
+ Math.max(0, dimensions.terminalWidth - APP_SIDE_PADDING - FOOTER_PADDING),
202
+ search?.isActive ? { owner: "input" } : footer,
203
+ );
176
204
 
177
205
  useEffect(() => {
178
206
  if (detailKey !== undefined) detailScrollRef.current?.scrollTo(0);
@@ -220,7 +248,7 @@ export function ScreenLayout({
220
248
  <box height={1} paddingLeft={1} paddingRight={1}>
221
249
  {search.isActive ? (
222
250
  <text fg={theme.colors.text}>
223
- <span fg={theme.colors.success}>{"Filter: "}</span>
251
+ <span fg={theme.colors.muted}>{"Filter: "}</span>
224
252
  <span fg={theme.colors.text}>{search.query}</span>
225
253
  <span bg={theme.colors.text} fg={theme.hints.fg}>
226
254
  {" "}
@@ -228,8 +256,8 @@ export function ScreenLayout({
228
256
  </text>
229
257
  ) : (
230
258
  <text fg={theme.colors.text}>
231
- <span fg={theme.colors.success}>{"Filter: "}</span>
232
- <span fg={theme.colors.warning}>{search.query}</span>
259
+ <span fg={theme.colors.muted}>{"Filter: "}</span>
260
+ <span fg={theme.colors.info}>{search.query}</span>
233
261
  <span fg={theme.colors.muted}> (Esc to clear)</span>
234
262
  </text>
235
263
  )}
@@ -293,26 +321,24 @@ export function ScreenLayout({
293
321
  },
294
322
  }}
295
323
  >
296
- <box flexDirection="column">{detailContent}</box>
324
+ {/* The scrollbox gives its content no width, so a row sized itself to its
325
+ own text and ran past the visible column; the panel then clipped its
326
+ tail with no sign anything was lost. Every screen gets the usable
327
+ width here, whether or not its renderer asks for it. */}
328
+ <box
329
+ flexDirection="column"
330
+ width={detailPanelWidth(dimensions.terminalWidth)}
331
+ >
332
+ {detailContent}
333
+ </box>
297
334
  </scrollbox>
298
335
  </box>
299
336
  </box>
300
337
 
301
- {/* Footer — the layout appends its own scroll hint so every screen
302
- advertises the detail-pane keys without each screen repeating it */}
303
- <box height={1} paddingLeft={1}>
304
- {Array.isArray(footerHints) ? (
305
- <FooterHints
306
- hints={[
307
- ...(footerHints as FooterHint[]),
308
- { keys: ["^U/^D", "PgUp/Dn"], label: "scroll" },
309
- ]}
310
- />
311
- ) : typeof footerHints === "string" ? (
312
- <text fg={theme.colors.muted}>{footerHints}</text>
313
- ) : (
314
- footerHints
315
- )}
338
+ {/* Footer — the keymap's hints for this screen, scroll and global
339
+ keys included, as many as fit */}
340
+ <box height={1} paddingLeft={FOOTER_PADDING}>
341
+ <FooterHints hints={footerRow} />
316
342
  </box>
317
343
  </box>
318
344
  );
@@ -2,8 +2,4 @@ export { Panel } from "./Panel.js";
2
2
  export { ScopeTabs } from "./ScopeTabs.js";
3
3
  export { ProgressBar } from "./ProgressBar.js";
4
4
  export { ScreenLayout } from "./ScreenLayout.js";
5
- export {
6
- FooterHints,
7
- renderFooterHints,
8
- type FooterHint,
9
- } from "./FooterHints.js";
5
+ export { FooterHints, type FooterHint } from "./FooterHints.js";
@@ -1,5 +1,4 @@
1
1
  import React from "react";
2
- import { useKeyboard } from "../../hooks/useKeyboard.js";
3
2
  import { theme } from "../../theme.js";
4
3
 
5
4
  interface ConfirmModalProps {
@@ -7,26 +6,10 @@ interface ConfirmModalProps {
7
6
  title: string;
8
7
  /** Modal message */
9
8
  message: string;
10
- /** Callback when confirmed */
11
- onConfirm: () => void;
12
- /** Callback when cancelled */
13
- onCancel: () => void;
14
9
  }
15
10
 
16
- export function ConfirmModal({
17
- title,
18
- message,
19
- onConfirm,
20
- onCancel,
21
- }: ConfirmModalProps) {
22
- useKeyboard((key) => {
23
- if (key.name === "y") {
24
- onConfirm();
25
- } else if (key.name === "n" || key.name === "escape") {
26
- onCancel();
27
- }
28
- });
29
-
11
+ /** Draws the question. Its keys (y / Enter, n / Esc / q) are ModalContainer's. */
12
+ export function ConfirmModal({ title, message }: ConfirmModalProps) {
30
13
  return (
31
14
  <box
32
15
  flexDirection="column"
@@ -0,0 +1,186 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core";
2
+ import type React from "react";
3
+ import {
4
+ type HelpSection,
5
+ type KeyBinding,
6
+ helpSections,
7
+ joinKeys,
8
+ } from "../../keymap.js";
9
+ import { useDimensions } from "../../state/DimensionsContext.js";
10
+ import type { Screen } from "../../state/types.js";
11
+ import { theme } from "../../theme.js";
12
+ import { FooterHints } from "../layout/FooterHints.js";
13
+
14
+ /** Border (2), the gap above the hint row (1) and the hint row itself (1). */
15
+ const CHROME_ROWS = 4;
16
+ const MAX_WIDTH = 92;
17
+ /** The key column never grows past this; a longer key group is cut. */
18
+ const MAX_KEY_WIDTH = 12;
19
+
20
+ export interface HelpOverlaySize {
21
+ width: number;
22
+ height: number;
23
+ /** Rows the scrolling list gets — also one page for PgUp / PgDn. */
24
+ listHeight: number;
25
+ /** Cells one list row may use: inside the border, padding and scrollbar. */
26
+ textWidth: number;
27
+ }
28
+
29
+ /**
30
+ * The overlay's box, from the terminal's size. Never taller than the terminal:
31
+ * two rows short of it when there is room, the whole height when there is not.
32
+ */
33
+ export function helpOverlaySize(
34
+ terminalWidth: number,
35
+ terminalHeight: number,
36
+ ): HelpOverlaySize {
37
+ const height =
38
+ terminalHeight >= 8 ? terminalHeight - 2 : Math.max(1, terminalHeight);
39
+ const width = Math.max(20, Math.min(MAX_WIDTH, terminalWidth - 2));
40
+ return {
41
+ width,
42
+ height,
43
+ listHeight: Math.max(1, height - CHROME_ROWS),
44
+ // Border 2, padding 2, scrollbar 1, and one spare cell so a glyph a
45
+ // terminal draws two cells wide cannot push a row onto a second line.
46
+ textWidth: Math.max(8, width - 6),
47
+ };
48
+ }
49
+
50
+ function cells(text: string): number {
51
+ return [...text].length;
52
+ }
53
+
54
+ /** Cut `text` to `width` cells, ending in "…" when anything was lost. */
55
+ function fit(text: string, width: number): string {
56
+ if (cells(text) <= width) return text;
57
+ if (width <= 0) return "";
58
+ return `${[...text].slice(0, width - 1).join("")}…`;
59
+ }
60
+
61
+ function padTo(text: string, width: number): string {
62
+ const cut = fit(text, width);
63
+ return cut + " ".repeat(Math.max(0, width - cells(cut)));
64
+ }
65
+
66
+ function keyColumnWidth(sections: readonly HelpSection[]): number {
67
+ let widest = 1;
68
+ for (const section of sections) {
69
+ for (const binding of section.bindings) {
70
+ widest = Math.max(widest, cells(joinKeys(binding.keys)));
71
+ }
72
+ for (const entry of section.legend ?? []) {
73
+ widest = Math.max(widest, cells(entry.symbol));
74
+ }
75
+ }
76
+ return Math.min(MAX_KEY_WIDTH, widest);
77
+ }
78
+
79
+ /**
80
+ * Every key, from the keymap: the current screen first, then the others, the
81
+ * search box, the global keys and the scroll keys.
82
+ *
83
+ * One `<text>` per entry, cut to the row width, so an entry never wraps onto a
84
+ * second line and never splits from its key. The ModalContainer owns the keys:
85
+ * ↑↓ / PgUp / PgDn / Ctrl+U / Ctrl+D scroll `scrollRef`, Esc or `?` close.
86
+ */
87
+ export function HelpOverlay({
88
+ screen,
89
+ scrollRef,
90
+ }: {
91
+ screen: Screen;
92
+ scrollRef: React.RefObject<ScrollBoxRenderable | null>;
93
+ }) {
94
+ const { terminalWidth, terminalHeight } = useDimensions();
95
+ const size = helpOverlaySize(terminalWidth, terminalHeight);
96
+ const sections = helpSections(screen);
97
+ const keyWidth = keyColumnWidth(sections);
98
+ const textRoom = Math.max(1, size.textWidth - keyWidth - 4);
99
+
100
+ const entry = (id: string, keys: string, text: string, muted: boolean) => (
101
+ <text key={id} fg={theme.colors.text}>
102
+ <span fg={theme.colors.accent}>{` ${padTo(keys, keyWidth)} `}</span>
103
+ <span fg={muted ? theme.colors.muted : theme.colors.text}>
104
+ {fit(text, textRoom)}
105
+ </span>
106
+ </text>
107
+ );
108
+
109
+ const rows: React.ReactNode[] = [];
110
+ sections.forEach((section, index) => {
111
+ if (index > 0) {
112
+ rows.push(
113
+ <text key={`gap:${section.title}`} fg={theme.colors.text}>
114
+ {" "}
115
+ </text>,
116
+ );
117
+ }
118
+ const title =
119
+ section.screen === screen
120
+ ? `${section.title} (this screen)`
121
+ : section.title;
122
+ rows.push(
123
+ <text key={`title:${section.title}`} fg={theme.colors.accent}>
124
+ <strong>{fit(title, size.textWidth)}</strong>
125
+ </text>,
126
+ );
127
+ for (const binding of section.bindings as readonly KeyBinding[]) {
128
+ rows.push(
129
+ entry(
130
+ `${section.title}:${binding.id}`,
131
+ joinKeys(binding.keys),
132
+ binding.description,
133
+ false,
134
+ ),
135
+ );
136
+ }
137
+ for (const item of section.legend ?? []) {
138
+ rows.push(
139
+ entry(
140
+ `${section.title}:legend:${item.symbol}`,
141
+ item.symbol,
142
+ item.meaning,
143
+ true,
144
+ ),
145
+ );
146
+ }
147
+ });
148
+
149
+ return (
150
+ <box
151
+ flexDirection="column"
152
+ border
153
+ borderStyle="rounded"
154
+ borderColor={theme.colors.accent}
155
+ backgroundColor={theme.surface.bg}
156
+ title="magus keys"
157
+ titleAlignment="left"
158
+ paddingLeft={1}
159
+ paddingRight={1}
160
+ width={size.width}
161
+ height={size.height}
162
+ >
163
+ <scrollbox
164
+ ref={scrollRef}
165
+ height={size.listHeight}
166
+ scrollY={true}
167
+ scrollX={false}
168
+ verticalScrollbarOptions={{
169
+ showArrows: false,
170
+ trackOptions: { foregroundColor: theme.colors.muted },
171
+ }}
172
+ >
173
+ <box flexDirection="column">{rows}</box>
174
+ </scrollbox>
175
+ <box height={1} marginTop={1} flexShrink={0}>
176
+ <FooterHints
177
+ hints={[
178
+ { keys: ["↑", "↓"], label: "scroll" },
179
+ { keys: ["PgUp/PgDn"], label: "page" },
180
+ { keys: ["Esc", "?"], label: "close" },
181
+ ]}
182
+ />
183
+ </box>
184
+ </box>
185
+ );
186
+ }
@@ -1,5 +1,4 @@
1
1
  import React from "react";
2
- import { useKeyboard } from "../../hooks/useKeyboard.js";
3
2
  import { theme } from "../../theme.js";
4
3
 
5
4
  interface MessageModalProps {
@@ -9,29 +8,18 @@ interface MessageModalProps {
9
8
  message: string;
10
9
  /** Message variant */
11
10
  variant: "info" | "success" | "error";
12
- /** Callback when dismissed */
13
- onDismiss: () => void;
14
11
  }
15
12
 
16
13
  const variantConfig = {
17
- info: { icon: "ℹ", color: theme.colors.link },
14
+ info: { icon: "ℹ", color: theme.colors.info },
18
15
  success: { icon: "✓", color: theme.colors.success },
19
16
  error: { icon: "✗", color: theme.colors.danger },
20
17
  } as const;
21
18
 
22
- export function MessageModal({
23
- title,
24
- message,
25
- variant,
26
- onDismiss,
27
- }: MessageModalProps) {
19
+ /** Draws the message. Any key dismisses it — ModalContainer handles that. */
20
+ export function MessageModal({ title, message, variant }: MessageModalProps) {
28
21
  const config = variantConfig[variant];
29
22
 
30
- useKeyboard(() => {
31
- // Any key dismisses
32
- onDismiss();
33
- });
34
-
35
23
  return (
36
24
  <box
37
25
  flexDirection="column"