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.
- package/package.json +4 -4
- package/scripts/build-binaries.ts +4 -2
- package/src/__tests__/blind/c1-npm-package.test.ts +206 -0
- package/src/__tests__/blind/c10-colour-roles.test.ts +265 -0
- package/src/__tests__/blind/c11-cli-parse-refusal.test.ts +454 -0
- package/src/__tests__/blind/c12-skill-frontmatter.test.ts +323 -0
- package/src/__tests__/blind/c13-wrong-shape.test.ts +603 -0
- package/src/__tests__/blind/c14-cli-row-label.test.ts +259 -0
- package/src/__tests__/blind/c15-probe-ownership.test.ts +533 -0
- package/src/__tests__/blind/c16-nested-wrong-shape.test.ts +297 -0
- package/src/__tests__/blind/c17-enter-label.test.ts +625 -0
- package/src/__tests__/blind/c18-hook-shape.test.ts +354 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c4-key-dispatch.test.ts +397 -0
- package/src/__tests__/blind/c5-keymap.test.ts +461 -0
- package/src/__tests__/blind/c6-request-gate.test.ts +133 -0
- package/src/__tests__/blind/c7-tab-tiers.test.ts +320 -0
- package/src/__tests__/blind/c8-text-width.test.ts +137 -0
- package/src/__tests__/blind/c9-cli-tool-version.test.ts +239 -0
- package/src/__tests__/blind/child-run.ts +114 -0
- package/src/__tests__/cli-tools-adapter.test.ts +5 -2
- package/src/__tests__/component-badge.test.ts +20 -36
- package/src/__tests__/footer-hints.test.ts +1 -1
- package/src/__tests__/manifest.test.ts +25 -17
- package/src/__tests__/marketplace-badge.test.ts +4 -4
- package/src/__tests__/models-screen-state.test.ts +36 -13
- package/src/__tests__/npm-package-name-api.test.ts +111 -0
- package/src/__tests__/npm-package-name.test.ts +280 -0
- package/src/__tests__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/styles-manager.test.ts +4 -4
- package/src/__tests__/styles-screen-state.test.ts +26 -12
- package/src/__tests__/tabbar-layout.test.ts +44 -32
- package/src/__tests__/theme-adaptive-colors.test.ts +6 -59
- package/src/__tests__/tool-probe.test.ts +47 -22
- package/src/__tests__/upgrade-spawn.test.ts +355 -0
- package/src/__tests__/uppercase-keybindings.test.ts +25 -12
- package/src/cli/ansi.ts +2 -2
- package/src/cli/bootstrap.ts +33 -2
- package/src/cli/doctor.ts +105 -14
- package/src/cli/install.ts +8 -2
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/cli/upgrade.ts +37 -9
- package/src/data/cli-tools.ts +13 -2
- package/src/data/npm-package.ts +15 -0
- package/src/services/claude-settings.ts +151 -73
- package/src/services/cli-tool-commands.ts +15 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/hook-registration.ts +23 -6
- package/src/services/manifest.ts +117 -52
- package/src/services/plugin-manager.ts +19 -5
- package/src/services/profile-adopt.ts +7 -2
- package/src/services/profile-startup.ts +87 -7
- package/src/services/profiles.ts +36 -23
- package/src/services/settings-file.ts +158 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +272 -45
- package/src/services/toolchain.ts +39 -0
- package/src/services/update-engine.ts +5 -0
- package/src/services/update-plan.ts +9 -0
- package/src/services/version-check.ts +20 -10
- package/src/tui.tsx +48 -21
- package/src/ui/App.tsx +58 -197
- package/src/ui/adapters/cliToolsAdapter.ts +141 -12
- package/src/ui/adapters/pluginsAdapter.ts +10 -7
- package/src/ui/adapters/settingsAdapter.ts +0 -21
- package/src/ui/adapters/skillsAdapter.ts +27 -8
- package/src/ui/adapters/stylesAdapter.ts +15 -11
- package/src/ui/components/CategoryHeader.tsx +7 -1
- package/src/ui/components/EmptyFilterState.tsx +2 -2
- package/src/ui/components/FlagDetailEditor.tsx +0 -0
- package/src/ui/components/ScreenErrorBoundary.tsx +105 -0
- package/src/ui/components/TabBar.tsx +93 -21
- package/src/ui/components/layout/FooterHints.tsx +6 -27
- package/src/ui/components/layout/ScreenLayout.tsx +68 -42
- package/src/ui/components/layout/index.ts +1 -5
- package/src/ui/components/modals/ConfirmModal.tsx +2 -19
- package/src/ui/components/modals/HelpOverlay.tsx +186 -0
- package/src/ui/components/modals/MessageModal.tsx +3 -15
- package/src/ui/components/modals/ModalContainer.tsx +99 -60
- package/src/ui/components/primitives/ErrorNotice.tsx +38 -0
- package/src/ui/components/primitives/ListCategoryRow.tsx +18 -7
- package/src/ui/components/primitives/MetaText.tsx +1 -1
- package/src/ui/components/primitives/ScopeDetail.tsx +31 -26
- package/src/ui/components/primitives/ScopeSquares.tsx +23 -28
- package/src/ui/components/primitives/SelectableRow.tsx +24 -1
- package/src/ui/components/primitives/index.ts +4 -3
- package/src/ui/hooks/useProfileStartupModal.ts +15 -3
- package/src/ui/input/KeyRouter.tsx +180 -0
- package/src/ui/input/key-dispatch.ts +149 -0
- package/src/ui/keymap.ts +898 -0
- package/src/ui/renderers/cliToolRenderers.tsx +128 -66
- package/src/ui/renderers/gitignoreRenderers.tsx +41 -43
- package/src/ui/renderers/markdownRenderer.tsx +2 -2
- package/src/ui/renderers/mcpRenderers.tsx +28 -14
- package/src/ui/renderers/modelRenderers.tsx +12 -13
- package/src/ui/renderers/modelVisuals.tsx +0 -2
- package/src/ui/renderers/pluginRenderers.tsx +47 -54
- package/src/ui/renderers/profileRenderers.tsx +32 -37
- package/src/ui/renderers/settingsRenderers.tsx +84 -55
- package/src/ui/renderers/skillRenderers.tsx +237 -190
- package/src/ui/renderers/styleRenderers.tsx +13 -2
- package/src/ui/runtime/shutdown.ts +268 -0
- package/src/ui/screens/AliasScreen.tsx +118 -100
- package/src/ui/screens/CliToolsScreen.tsx +98 -87
- package/src/ui/screens/EnvVarsScreen.tsx +44 -42
- package/src/ui/screens/GitignoreScreen.tsx +50 -55
- package/src/ui/screens/McpRegistryScreen.tsx +52 -86
- package/src/ui/screens/McpScreen.tsx +47 -33
- package/src/ui/screens/ModelsScreen.tsx +59 -104
- package/src/ui/screens/PluginsScreen.tsx +123 -127
- package/src/ui/screens/ProfilesScreen.tsx +49 -46
- package/src/ui/screens/SkillsScreen.tsx +184 -204
- package/src/ui/screens/StylesScreen.tsx +104 -157
- package/src/ui/state/AppContext.tsx +6 -0
- package/src/ui/state/DimensionsContext.tsx +1 -10
- package/src/ui/state/reducer.ts +5 -8
- package/src/ui/state/types.ts +11 -9
- package/src/ui/text-width.ts +341 -0
- package/src/ui/theme-mode.ts +34 -71
- package/src/ui/theme.ts +50 -28
- package/src/utils/request-gate.ts +37 -0
- package/src/ui/components/ScopeIndicator.tsx +0 -63
- package/src/ui/components/SearchInput.tsx +0 -57
- package/src/ui/hooks/index.ts +0 -2
- package/src/ui/hooks/useKeyboard.ts +0 -24
- 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 {
|
|
5
|
-
import {
|
|
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 {
|
|
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
|
|
82
|
-
currentScreen:
|
|
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
|
-
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
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
|
-
|
|
181
|
+
key.name === "u" ? -detailHalfStep : detailHalfStep,
|
|
169
182
|
);
|
|
170
|
-
}
|
|
183
|
+
},
|
|
184
|
+
"scroll-page": (key) => {
|
|
171
185
|
detailScrollRef.current?.scrollBy(
|
|
172
|
-
|
|
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.
|
|
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.
|
|
232
|
-
<span fg={theme.colors.
|
|
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
|
-
|
|
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
|
|
302
|
-
|
|
303
|
-
<box height={1} paddingLeft={
|
|
304
|
-
{
|
|
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
|
-
|
|
17
|
-
|
|
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.
|
|
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
|
-
|
|
23
|
-
|
|
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"
|
|
@@ -1,26 +1,46 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
2
|
+
import React, { useRef, useState } from "react";
|
|
3
|
+
import { useKeyLayer } 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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
26
|
+
* - confirm: y / Enter confirm; n / Esc / q cancel.
|
|
27
|
+
* - select: ↑↓ (j/k) move, Enter picks, Esc / q cancel (a no-op for a
|
|
28
|
+
* question that must be answered).
|
|
29
|
+
* - input: Esc cancels; everything else goes to the focused <input>, which
|
|
30
|
+
* OpenTUI delivers after this layer and which submits on Enter.
|
|
31
|
+
* - message: any key dismisses.
|
|
32
|
+
* - loading: keys are swallowed until the work finishes.
|
|
33
|
+
* - help: ↑↓ / PgUp / PgDn / Ctrl+U / Ctrl+D scroll; Esc or `?` close.
|
|
17
34
|
*/
|
|
18
35
|
export function ModalContainer() {
|
|
19
36
|
const { state } = useApp();
|
|
20
37
|
const { modal } = state;
|
|
38
|
+
const { hideModal } = useModal();
|
|
39
|
+
const dimensions = useDimensions();
|
|
21
40
|
|
|
22
41
|
// Track select modal index here (lifted from SelectModal)
|
|
23
42
|
const [selectIndex, setSelectIndex] = useState(0);
|
|
43
|
+
const helpScrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
24
44
|
|
|
25
45
|
// Reset select index when modal changes
|
|
26
46
|
const modalRef = React.useRef(modal);
|
|
@@ -31,58 +51,82 @@ export function ModalContainer() {
|
|
|
31
51
|
}
|
|
32
52
|
}
|
|
33
53
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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();
|
|
54
|
+
const handleHelpKey = (key: KeyPress) => {
|
|
55
|
+
if (
|
|
56
|
+
key.name === "escape" ||
|
|
57
|
+
(isPlain(key) && (key.name === "?" || key.sequence === "?"))
|
|
58
|
+
) {
|
|
59
|
+
hideModal();
|
|
45
60
|
return;
|
|
46
61
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
const scroll = helpScrollRef.current;
|
|
63
|
+
if (!scroll) return;
|
|
64
|
+
const page = helpOverlaySize(
|
|
65
|
+
dimensions.terminalWidth,
|
|
66
|
+
dimensions.terminalHeight,
|
|
67
|
+
).listHeight;
|
|
68
|
+
if (isScrollKey(key)) {
|
|
69
|
+
const step =
|
|
70
|
+
key.name === "pageup" || key.name === "pagedown"
|
|
71
|
+
? Math.max(1, page - 1)
|
|
72
|
+
: Math.max(1, Math.floor(page / 2));
|
|
73
|
+
const up = key.name === "pageup" || key.name === "u";
|
|
74
|
+
scroll.scrollBy(up ? -step : step);
|
|
75
|
+
} else if (isPlain(key) && (key.name === "up" || key.name === "k")) {
|
|
76
|
+
scroll.scrollBy(-1);
|
|
77
|
+
} else if (isPlain(key) && (key.name === "down" || key.name === "j")) {
|
|
78
|
+
scroll.scrollBy(1);
|
|
54
79
|
}
|
|
80
|
+
};
|
|
55
81
|
|
|
56
|
-
|
|
57
|
-
if (modal
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
82
|
+
useKeyLayer("modal", modal !== null, (key) => {
|
|
83
|
+
if (!modal) return false;
|
|
60
84
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
setSelectIndex((prev) => Math.min(modal.options.length - 1, prev + 1));
|
|
69
|
-
}
|
|
70
|
-
return;
|
|
71
|
-
}
|
|
85
|
+
switch (modal.type) {
|
|
86
|
+
case "loading":
|
|
87
|
+
break;
|
|
88
|
+
|
|
89
|
+
case "help":
|
|
90
|
+
handleHelpKey(key);
|
|
91
|
+
break;
|
|
72
92
|
|
|
73
|
-
|
|
74
|
-
if (modal.type === "message") {
|
|
75
|
-
if (key.name === "return" || key.name === "enter") {
|
|
93
|
+
case "message":
|
|
76
94
|
modal.onDismiss();
|
|
77
|
-
|
|
78
|
-
|
|
95
|
+
break;
|
|
96
|
+
|
|
97
|
+
case "confirm":
|
|
98
|
+
if (key.name === "y" || isEnter(key)) modal.onConfirm();
|
|
99
|
+
else if (
|
|
100
|
+
key.name === "n" ||
|
|
101
|
+
key.name === "q" ||
|
|
102
|
+
key.name === "escape"
|
|
103
|
+
) {
|
|
104
|
+
modal.onCancel();
|
|
105
|
+
}
|
|
106
|
+
break;
|
|
79
107
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
modal.
|
|
84
|
-
|
|
108
|
+
case "input":
|
|
109
|
+
// Not consumed by anything of ours below, and still delivered to
|
|
110
|
+
// the focused <input> afterwards: typing and Enter work there.
|
|
111
|
+
if (key.name === "escape") modal.onCancel();
|
|
112
|
+
break;
|
|
113
|
+
|
|
114
|
+
case "select":
|
|
115
|
+
if (isEnter(key)) {
|
|
116
|
+
const option = modal.options[selectIndex];
|
|
117
|
+
if (option) modal.onSelect(option.value);
|
|
118
|
+
} else if (key.name === "up" || key.name === "k") {
|
|
119
|
+
setSelectIndex((prev) => Math.max(0, prev - 1));
|
|
120
|
+
} else if (key.name === "down" || key.name === "j") {
|
|
121
|
+
setSelectIndex((prev) =>
|
|
122
|
+
Math.min(modal.options.length - 1, prev + 1),
|
|
123
|
+
);
|
|
124
|
+
} else if (key.name === "escape" || key.name === "q") {
|
|
125
|
+
modal.onCancel();
|
|
126
|
+
}
|
|
127
|
+
break;
|
|
85
128
|
}
|
|
129
|
+
return true;
|
|
86
130
|
});
|
|
87
131
|
|
|
88
132
|
if (!modal) {
|
|
@@ -92,14 +136,7 @@ export function ModalContainer() {
|
|
|
92
136
|
const renderModal = () => {
|
|
93
137
|
switch (modal.type) {
|
|
94
138
|
case "confirm":
|
|
95
|
-
return
|
|
96
|
-
<ConfirmModal
|
|
97
|
-
title={modal.title}
|
|
98
|
-
message={modal.message}
|
|
99
|
-
onConfirm={modal.onConfirm}
|
|
100
|
-
onCancel={modal.onCancel}
|
|
101
|
-
/>
|
|
102
|
-
);
|
|
139
|
+
return <ConfirmModal title={modal.title} message={modal.message} />;
|
|
103
140
|
|
|
104
141
|
case "input":
|
|
105
142
|
return (
|
|
@@ -131,13 +168,15 @@ export function ModalContainer() {
|
|
|
131
168
|
title={modal.title}
|
|
132
169
|
message={modal.message}
|
|
133
170
|
variant={modal.variant}
|
|
134
|
-
onDismiss={modal.onDismiss}
|
|
135
171
|
/>
|
|
136
172
|
);
|
|
137
173
|
|
|
138
174
|
case "loading":
|
|
139
175
|
return <LoadingModal message={modal.message} />;
|
|
140
176
|
|
|
177
|
+
case "help":
|
|
178
|
+
return <HelpOverlay screen={modal.screen} scrollRef={helpScrollRef} />;
|
|
179
|
+
|
|
141
180
|
default:
|
|
142
181
|
return null;
|
|
143
182
|
}
|