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
|
@@ -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
|
-
|
|
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 = "
|
|
27
|
+
tone = "neutral",
|
|
27
28
|
selected,
|
|
28
29
|
expandable = true,
|
|
29
30
|
}: ListCategoryRowProps) {
|
|
30
|
-
const colors = theme.
|
|
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>
|
|
41
|
+
<strong>
|
|
42
|
+
{rowLead(false)}
|
|
43
|
+
{label}{" "}
|
|
44
|
+
</strong>
|
|
39
45
|
</span>
|
|
40
46
|
)}
|
|
41
|
-
{selected &&
|
|
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
|
);
|
|
@@ -19,49 +19,54 @@ interface ScopeDetailProps {
|
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
/**
|
|
22
|
-
* The u/p/l
|
|
23
|
-
*
|
|
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
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
/**
|
|
13
|
-
const
|
|
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
|
|
17
|
+
* Scope bar: three positional squares, user / project / local.
|
|
17
18
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
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
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
const
|
|
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
|
-
{
|
|
51
|
-
// biome-ignore lint/suspicious/noArrayIndexKey: fixed positional
|
|
52
|
-
<span key={i} fg={
|
|
53
|
-
{
|
|
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
|
-
{
|
|
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
|
+
}
|