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.
- package/package.json +4 -4
- 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/c19-paste-routing.test.ts +252 -0
- package/src/__tests__/blind/c2-settings-parse.test.ts +350 -0
- package/src/__tests__/blind/c20-registry-and-file-safety.test.ts +560 -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__/scope-squares.test.tsx +28 -114
- package/src/__tests__/shutdown.test.ts +578 -0
- package/src/__tests__/stale-load-repair.test.ts +5 -4
- 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__/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 +120 -15
- package/src/cli/install.ts +35 -4
- package/src/cli/profile.ts +18 -1
- package/src/cli/router.ts +31 -18
- package/src/cli/update-view.ts +1 -1
- package/src/data/cli-tools.ts +9 -1
- package/src/services/claude-settings.ts +434 -198
- package/src/services/cli-tool-commands.ts +45 -5
- package/src/services/cli-tool-version.ts +101 -0
- package/src/services/gitignore-service.ts +39 -22
- 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 +164 -4
- package/src/services/settings-manager.ts +19 -5
- package/src/services/skills-manager.ts +46 -0
- package/src/services/tool-probe.ts +316 -44
- 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/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 +107 -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 +304 -0
- package/src/ui/input/key-dispatch.ts +197 -0
- package/src/ui/keymap.ts +905 -0
- package/src/ui/renderers/cliToolRenderers.tsx +192 -92
- 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 +173 -101
- 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 +129 -129
- 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,26 +1,52 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core";
|
|
2
|
+
import React, { useRef, useState } from "react";
|
|
3
|
+
import { useKeyLayer, usePasteLayer } from "../../input/KeyRouter.js";
|
|
4
|
+
import { type KeyPress, isScrollKey } from "../../input/key-dispatch.js";
|
|
5
|
+
import { useApp, useModal } from "../../state/AppContext.js";
|
|
6
|
+
import { useDimensions } from "../../state/DimensionsContext.js";
|
|
4
7
|
import { ConfirmModal } from "./ConfirmModal.js";
|
|
8
|
+
import { HelpOverlay, helpOverlaySize } from "./HelpOverlay.js";
|
|
5
9
|
import { InputModal } from "./InputModal.js";
|
|
6
10
|
import { LoadingModal } from "./LoadingModal.js";
|
|
7
11
|
import { MessageModal } from "./MessageModal.js";
|
|
8
12
|
import { SelectModal } from "./SelectModal.js";
|
|
9
13
|
|
|
14
|
+
const isEnter = (key: KeyPress) =>
|
|
15
|
+
key.name === "return" || key.name === "enter";
|
|
16
|
+
const isPlain = (key: KeyPress) => !key.ctrl && !key.meta;
|
|
17
|
+
|
|
10
18
|
/**
|
|
11
|
-
*
|
|
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
|
+
* It takes every PASTE for the same reason, and does nothing with it: a search
|
|
27
|
+
* box on the screen behind still has focus, and without this its query would
|
|
28
|
+
* grow from a paste aimed at the dialog in front of it. The event itself is not
|
|
29
|
+
* stopped, so the input modal's focused `<input>` still receives it — the
|
|
30
|
+
* arrangement the `input` case below already relies on for keys.
|
|
31
|
+
*
|
|
32
|
+
* - confirm: y / Enter confirm; n / Esc / q cancel.
|
|
33
|
+
* - select: ↑↓ (j/k) move, Enter picks, Esc / q cancel (a no-op for a
|
|
34
|
+
* question that must be answered).
|
|
35
|
+
* - input: Esc cancels; everything else goes to the focused <input>, which
|
|
36
|
+
* OpenTUI delivers after this layer and which submits on Enter.
|
|
37
|
+
* - message: any key dismisses.
|
|
38
|
+
* - loading: keys are swallowed until the work finishes.
|
|
39
|
+
* - help: ↑↓ / PgUp / PgDn / Ctrl+U / Ctrl+D scroll; Esc or `?` close.
|
|
17
40
|
*/
|
|
18
41
|
export function ModalContainer() {
|
|
19
42
|
const { state } = useApp();
|
|
20
43
|
const { modal } = state;
|
|
44
|
+
const { hideModal } = useModal();
|
|
45
|
+
const dimensions = useDimensions();
|
|
21
46
|
|
|
22
47
|
// Track select modal index here (lifted from SelectModal)
|
|
23
48
|
const [selectIndex, setSelectIndex] = useState(0);
|
|
49
|
+
const helpScrollRef = useRef<ScrollBoxRenderable | null>(null);
|
|
24
50
|
|
|
25
51
|
// Reset select index when modal changes
|
|
26
52
|
const modalRef = React.useRef(modal);
|
|
@@ -31,58 +57,84 @@ export function ModalContainer() {
|
|
|
31
57
|
}
|
|
32
58
|
}
|
|
33
59
|
|
|
34
|
-
|
|
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();
|
|
60
|
+
const handleHelpKey = (key: KeyPress) => {
|
|
61
|
+
if (
|
|
62
|
+
key.name === "escape" ||
|
|
63
|
+
(isPlain(key) && (key.name === "?" || key.sequence === "?"))
|
|
64
|
+
) {
|
|
65
|
+
hideModal();
|
|
45
66
|
return;
|
|
46
67
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
68
|
+
const scroll = helpScrollRef.current;
|
|
69
|
+
if (!scroll) return;
|
|
70
|
+
const page = helpOverlaySize(
|
|
71
|
+
dimensions.terminalWidth,
|
|
72
|
+
dimensions.terminalHeight,
|
|
73
|
+
).listHeight;
|
|
74
|
+
if (isScrollKey(key)) {
|
|
75
|
+
const step =
|
|
76
|
+
key.name === "pageup" || key.name === "pagedown"
|
|
77
|
+
? Math.max(1, page - 1)
|
|
78
|
+
: Math.max(1, Math.floor(page / 2));
|
|
79
|
+
const up = key.name === "pageup" || key.name === "u";
|
|
80
|
+
scroll.scrollBy(up ? -step : step);
|
|
81
|
+
} else if (isPlain(key) && (key.name === "up" || key.name === "k")) {
|
|
82
|
+
scroll.scrollBy(-1);
|
|
83
|
+
} else if (isPlain(key) && (key.name === "down" || key.name === "j")) {
|
|
84
|
+
scroll.scrollBy(1);
|
|
54
85
|
}
|
|
86
|
+
};
|
|
55
87
|
|
|
56
|
-
|
|
57
|
-
if (modal.type === "input") {
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
88
|
+
usePasteLayer("modal", modal !== null, () => true);
|
|
60
89
|
|
|
61
|
-
|
|
62
|
-
if (modal
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
90
|
+
useKeyLayer("modal", modal !== null, (key) => {
|
|
91
|
+
if (!modal) return false;
|
|
92
|
+
|
|
93
|
+
switch (modal.type) {
|
|
94
|
+
case "loading":
|
|
95
|
+
break;
|
|
96
|
+
|
|
97
|
+
case "help":
|
|
98
|
+
handleHelpKey(key);
|
|
99
|
+
break;
|
|
72
100
|
|
|
73
|
-
|
|
74
|
-
if (modal.type === "message") {
|
|
75
|
-
if (key.name === "return" || key.name === "enter") {
|
|
101
|
+
case "message":
|
|
76
102
|
modal.onDismiss();
|
|
77
|
-
|
|
78
|
-
|
|
103
|
+
break;
|
|
104
|
+
|
|
105
|
+
case "confirm":
|
|
106
|
+
if (key.name === "y" || isEnter(key)) modal.onConfirm();
|
|
107
|
+
else if (
|
|
108
|
+
key.name === "n" ||
|
|
109
|
+
key.name === "q" ||
|
|
110
|
+
key.name === "escape"
|
|
111
|
+
) {
|
|
112
|
+
modal.onCancel();
|
|
113
|
+
}
|
|
114
|
+
break;
|
|
79
115
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
modal.
|
|
84
|
-
|
|
116
|
+
case "input":
|
|
117
|
+
// Not consumed by anything of ours below, and still delivered to
|
|
118
|
+
// the focused <input> afterwards: typing and Enter work there.
|
|
119
|
+
if (key.name === "escape") modal.onCancel();
|
|
120
|
+
break;
|
|
121
|
+
|
|
122
|
+
case "select":
|
|
123
|
+
if (isEnter(key)) {
|
|
124
|
+
const option = modal.options[selectIndex];
|
|
125
|
+
if (option) modal.onSelect(option.value);
|
|
126
|
+
} else if (key.name === "up" || key.name === "k") {
|
|
127
|
+
setSelectIndex((prev) => Math.max(0, prev - 1));
|
|
128
|
+
} else if (key.name === "down" || key.name === "j") {
|
|
129
|
+
setSelectIndex((prev) =>
|
|
130
|
+
Math.min(modal.options.length - 1, prev + 1),
|
|
131
|
+
);
|
|
132
|
+
} else if (key.name === "escape" || key.name === "q") {
|
|
133
|
+
modal.onCancel();
|
|
134
|
+
}
|
|
135
|
+
break;
|
|
85
136
|
}
|
|
137
|
+
return true;
|
|
86
138
|
});
|
|
87
139
|
|
|
88
140
|
if (!modal) {
|
|
@@ -92,14 +144,7 @@ export function ModalContainer() {
|
|
|
92
144
|
const renderModal = () => {
|
|
93
145
|
switch (modal.type) {
|
|
94
146
|
case "confirm":
|
|
95
|
-
return
|
|
96
|
-
<ConfirmModal
|
|
97
|
-
title={modal.title}
|
|
98
|
-
message={modal.message}
|
|
99
|
-
onConfirm={modal.onConfirm}
|
|
100
|
-
onCancel={modal.onCancel}
|
|
101
|
-
/>
|
|
102
|
-
);
|
|
147
|
+
return <ConfirmModal title={modal.title} message={modal.message} />;
|
|
103
148
|
|
|
104
149
|
case "input":
|
|
105
150
|
return (
|
|
@@ -131,13 +176,15 @@ export function ModalContainer() {
|
|
|
131
176
|
title={modal.title}
|
|
132
177
|
message={modal.message}
|
|
133
178
|
variant={modal.variant}
|
|
134
|
-
onDismiss={modal.onDismiss}
|
|
135
179
|
/>
|
|
136
180
|
);
|
|
137
181
|
|
|
138
182
|
case "loading":
|
|
139
183
|
return <LoadingModal message={modal.message} />;
|
|
140
184
|
|
|
185
|
+
case "help":
|
|
186
|
+
return <HelpOverlay screen={modal.screen} scrollRef={helpScrollRef} />;
|
|
187
|
+
|
|
141
188
|
default:
|
|
142
189
|
return null;
|
|
143
190
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { theme } from "../../theme.js";
|
|
3
|
+
|
|
4
|
+
interface ErrorNoticeProps {
|
|
5
|
+
/** What could not be done, in words: "Could not read the MCP configuration." */
|
|
6
|
+
title: string;
|
|
7
|
+
/**
|
|
8
|
+
* The error's own message. Shown verbatim, because for a settings file that
|
|
9
|
+
* does not parse it is the message that names the file — a summary here
|
|
10
|
+
* would drop the one fact the reader needs to fix it.
|
|
11
|
+
*/
|
|
12
|
+
detail: string;
|
|
13
|
+
/** Optional next step, e.g. "Fix the file, then press r." */
|
|
14
|
+
hint?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A failure shown in place of content that could not be loaded.
|
|
19
|
+
*
|
|
20
|
+
* It exists so a failed read never renders as an empty list: an empty list
|
|
21
|
+
* says "nothing is configured", which for a settings file that merely failed
|
|
22
|
+
* to parse is false and invites the user to configure it again.
|
|
23
|
+
*/
|
|
24
|
+
export function ErrorNotice({ title, detail, hint }: ErrorNoticeProps) {
|
|
25
|
+
return (
|
|
26
|
+
<box flexDirection="column" paddingLeft={2} paddingRight={2}>
|
|
27
|
+
<text fg={theme.colors.danger}>{title}</text>
|
|
28
|
+
<box marginTop={1}>
|
|
29
|
+
<text fg={theme.colors.muted}>{detail}</text>
|
|
30
|
+
</box>
|
|
31
|
+
{hint && (
|
|
32
|
+
<box marginTop={1}>
|
|
33
|
+
<text fg={theme.colors.muted}>{hint}</text>
|
|
34
|
+
</box>
|
|
35
|
+
)}
|
|
36
|
+
</box>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { theme } from "../../theme.js";
|
|
3
|
-
import { SelectableRow } from "./SelectableRow.js";
|
|
2
|
+
import { type BadgeTone, theme } from "../../theme.js";
|
|
3
|
+
import { SelectableRow, rowLead } from "./SelectableRow.js";
|
|
4
4
|
|
|
5
5
|
interface ListCategoryRowProps {
|
|
6
6
|
title: string;
|
|
7
7
|
expanded?: boolean;
|
|
8
8
|
count?: number;
|
|
9
9
|
badge?: string;
|
|
10
|
-
|
|
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
|
}),
|