magus-cli 7.5.4 → 7.7.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/src/__tests__/format-update-outcome.test.ts +9 -8
- package/src/__tests__/gitignore-profile.test.ts +9 -5
- package/src/__tests__/key-focus-guard.test.tsx +260 -0
- package/src/__tests__/notices-rules.test.ts +476 -0
- package/src/__tests__/notices.test.tsx +604 -0
- package/src/__tests__/profile-name-on-save.test.ts +443 -0
- package/src/__tests__/profile-startup.test.ts +1 -1
- package/src/__tests__/prompt-eof.test.ts +245 -0
- package/src/__tests__/update-apply.test.ts +3 -3
- package/src/__tests__/useStaleLoadModal.test.ts +27 -12
- package/src/cli/bootstrap.ts +34 -2
- package/src/cli/prompt.ts +12 -2
- package/src/cli/update-view.ts +6 -4
- package/src/services/gitignore-service.ts +0 -19
- package/src/services/profile-absorb.ts +9 -0
- package/src/services/profile-activate.ts +4 -2
- package/src/services/profile-adopt.ts +9 -2
- package/src/services/profile-startup.ts +91 -18
- package/src/services/profile-state.ts +10 -1
- package/src/ui/App.tsx +295 -141
- package/src/ui/adapters/pluginsAdapter.ts +28 -9
- package/src/ui/components/modals/MessageModal.tsx +1 -2
- package/src/ui/components/modals/ModalContainer.tsx +3 -0
- package/src/ui/hooks/useGitignoreModal.ts +58 -18
- package/src/ui/hooks/useProfileStartupModal.ts +232 -85
- package/src/ui/hooks/useStaleLoadModal.ts +43 -29
- package/src/ui/input/KeyRouter.tsx +43 -2
- package/src/ui/screens/AliasScreen.tsx +7 -6
- package/src/ui/screens/CliToolsScreen.tsx +19 -16
- package/src/ui/screens/EnvVarsScreen.tsx +4 -1
- package/src/ui/screens/GitignoreScreen.tsx +20 -27
- package/src/ui/screens/McpRegistryScreen.tsx +9 -6
- package/src/ui/screens/McpScreen.tsx +16 -12
- package/src/ui/screens/PluginsScreen.tsx +35 -56
- package/src/ui/screens/ProfilesScreen.tsx +27 -51
- package/src/ui/state/AppContext.tsx +46 -3
- package/src/ui/state/reducer.ts +100 -1
- package/src/ui/state/types.ts +53 -2
|
@@ -25,6 +25,10 @@
|
|
|
25
25
|
* The paste event is deliberately NOT stopped: OpenTUI hands it to the focused
|
|
26
26
|
* renderable afterwards, which is how the input modal's `<input>` still takes a
|
|
27
27
|
* paste of its own — exactly the arrangement the modal layer relies on for keys.
|
|
28
|
+
*
|
|
29
|
+
* With ONE exception, for keys and pastes alike: an event never reaches a
|
|
30
|
+
* renderable that took focus while this router was dispatching it. See
|
|
31
|
+
* {@link dispatchThenGuardFocus}.
|
|
28
32
|
*/
|
|
29
33
|
|
|
30
34
|
import { appendFileSync } from "node:fs";
|
|
@@ -133,6 +137,41 @@ function describeKey(key: KeyPress): string {
|
|
|
133
137
|
return `name=${JSON.stringify(key.name)} seq=${JSON.stringify(key.sequence)} ctrl=${key.ctrl} meta=${key.meta} shift=${key.shift}`;
|
|
134
138
|
}
|
|
135
139
|
|
|
140
|
+
/**
|
|
141
|
+
* Run `dispatch` for one key or paste event, committed synchronously, and then
|
|
142
|
+
* keep that event away from any renderable that took focus DURING it.
|
|
143
|
+
*
|
|
144
|
+
* OpenTUI delivers an event in two phases: every global listener first (this
|
|
145
|
+
* router is one), then every focused renderable. The renderable set is read
|
|
146
|
+
* AFTER the global phase returns (`InternalKeyHandler.emitWithPriority` in
|
|
147
|
+
* @opentui/core 0.1.107), not when the event arrived. And `flushSync` commits
|
|
148
|
+
* inside the global phase, so a modal this key opened is mounted, and its
|
|
149
|
+
* `<input focused>` has registered its handler, before that read happens.
|
|
150
|
+
* Left alone, the one keypress acts twice. MEASURED: Enter on "Save what you
|
|
151
|
+
* have" in the startup select opened the "Save as a new profile" input AND
|
|
152
|
+
* submitted its prefilled name, so the profile was created with no chance to
|
|
153
|
+
* type. By the same path, a screen key that opens an input before its first
|
|
154
|
+
* `await` (`n` on Styles, `r` on Profiles) reaches the new box as a typed
|
|
155
|
+
* letter, and Enter on such a row submits it.
|
|
156
|
+
*
|
|
157
|
+
* So the rule: an event goes to the renderable that held focus when it was
|
|
158
|
+
* pressed, or to nobody. When focus moved during dispatch, `preventDefault()`
|
|
159
|
+
* makes OpenTUI skip the renderable phase. Keys meant for an input modal that
|
|
160
|
+
* was already open are untouched: the router's modal layer does not move focus
|
|
161
|
+
* for them, and they reach the `<input>` as before.
|
|
162
|
+
*/
|
|
163
|
+
function dispatchThenGuardFocus(
|
|
164
|
+
renderer: { readonly currentFocusedRenderable: unknown },
|
|
165
|
+
event: { preventDefault(): void },
|
|
166
|
+
dispatch: () => void,
|
|
167
|
+
): void {
|
|
168
|
+
const focusedBefore = renderer.currentFocusedRenderable;
|
|
169
|
+
flushSync(dispatch);
|
|
170
|
+
if (renderer.currentFocusedRenderable !== focusedBefore) {
|
|
171
|
+
event.preventDefault();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
136
175
|
export function KeyRouter({ children }: { children: ReactNode }) {
|
|
137
176
|
const [layers] = useState<LayerRegistry>(() => ({
|
|
138
177
|
keys: new Set<KeyLayer>(),
|
|
@@ -143,7 +182,7 @@ export function KeyRouter({ children }: { children: ReactNode }) {
|
|
|
143
182
|
useKeyboard((event) => {
|
|
144
183
|
const key = toKeyPress(event);
|
|
145
184
|
let consumer: KeyLayerId | null = null;
|
|
146
|
-
|
|
185
|
+
dispatchThenGuardFocus(renderer, event, () => {
|
|
147
186
|
consumer = dispatchKey([...layers.keys], key);
|
|
148
187
|
});
|
|
149
188
|
logKey(describeKey(key), consumer);
|
|
@@ -156,7 +195,9 @@ export function KeyRouter({ children }: { children: ReactNode }) {
|
|
|
156
195
|
const onPaste = (event: PasteEvent): void => {
|
|
157
196
|
const text = pastedText(event);
|
|
158
197
|
let consumer: KeyLayerId | null = null;
|
|
159
|
-
|
|
198
|
+
// Same guard as keys: no paste handler moves focus today, but a paste
|
|
199
|
+
// that did must not land in the box it just opened.
|
|
200
|
+
dispatchThenGuardFocus(renderer, event, () => {
|
|
160
201
|
consumer = dispatchPaste([...layers.pastes], text);
|
|
161
202
|
});
|
|
162
203
|
logKey(`paste=${JSON.stringify(text)}`, consumer);
|
|
@@ -39,7 +39,7 @@ import {
|
|
|
39
39
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
40
40
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
41
41
|
import { moveDirection } from "../keymap.js";
|
|
42
|
-
import { useApp, useModal } from "../state/AppContext.js";
|
|
42
|
+
import { useApp, useModal, useNotify } from "../state/AppContext.js";
|
|
43
43
|
import { displayWidth, truncate } from "../text-width.js";
|
|
44
44
|
import { theme } from "../theme.js";
|
|
45
45
|
|
|
@@ -114,6 +114,7 @@ type ViewMode = { kind: "main" } | { kind: "flag-detail"; flagId: string };
|
|
|
114
114
|
export function AliasScreen() {
|
|
115
115
|
const { state } = useApp();
|
|
116
116
|
const modal = useModal();
|
|
117
|
+
const notify = useNotify();
|
|
117
118
|
|
|
118
119
|
const [config, setConfig] = useState<AliasConfig | null>(null);
|
|
119
120
|
const [shells, setShells] = useState<ShellTarget[]>([]);
|
|
@@ -565,10 +566,10 @@ export function AliasScreen() {
|
|
|
565
566
|
shells.find((s) => s.exists);
|
|
566
567
|
|
|
567
568
|
if (!target) {
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
"No
|
|
571
|
-
"
|
|
569
|
+
// Nothing was written. Named files, because creating one is the fix.
|
|
570
|
+
notify(
|
|
571
|
+
"No shell config to write to — create ~/.zshrc, ~/.bashrc or ~/.config/fish/config.fish first",
|
|
572
|
+
"warning",
|
|
572
573
|
);
|
|
573
574
|
return;
|
|
574
575
|
}
|
|
@@ -602,7 +603,7 @@ export function AliasScreen() {
|
|
|
602
603
|
"error",
|
|
603
604
|
);
|
|
604
605
|
}
|
|
605
|
-
}, [config, shells, modal, pendingAdoptRange]);
|
|
606
|
+
}, [config, shells, modal, notify, pendingAdoptRange]);
|
|
606
607
|
|
|
607
608
|
// ── Keyboard ──────────────────────────────────────────────────────────
|
|
608
609
|
// j/k navigate the selectable subset, jumping over headers/legend lines.
|
|
@@ -4,7 +4,7 @@ import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
|
4
4
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
5
5
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
6
6
|
import { moveDirection } from "../keymap.js";
|
|
7
|
-
import { useApp, useModal } from "../state/AppContext.js";
|
|
7
|
+
import { useApp, useModal, useNotify } from "../state/AppContext.js";
|
|
8
8
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
9
9
|
|
|
10
10
|
import { cliTools } from "../../data/cli-tools.js";
|
|
@@ -91,6 +91,7 @@ export function CliToolsScreen() {
|
|
|
91
91
|
const { state, dispatch } = useApp();
|
|
92
92
|
const { cliTools: cliToolsState } = state;
|
|
93
93
|
const modal = useModal();
|
|
94
|
+
const notify = useNotify();
|
|
94
95
|
const dimensions = useDimensions();
|
|
95
96
|
|
|
96
97
|
const [rows, setRows] = useState<CliToolRow[]>(
|
|
@@ -333,21 +334,27 @@ export function CliToolsScreen() {
|
|
|
333
334
|
|
|
334
335
|
modal.hideModal();
|
|
335
336
|
|
|
336
|
-
const
|
|
337
|
-
errors.length > 0
|
|
338
|
-
? `These removals failed. Run them by hand:\n${errors.join("\n")}`
|
|
339
|
-
: "",
|
|
337
|
+
const stays =
|
|
340
338
|
leftBehind.length > 0
|
|
341
339
|
? `${stuckList(leftBehind)}\nRemove it by hand if you want it gone.`
|
|
342
|
-
: ""
|
|
343
|
-
].filter((part) => part !== "");
|
|
340
|
+
: "";
|
|
344
341
|
|
|
345
|
-
|
|
342
|
+
// A failed removal is an error, and stays a dialog with the commands to
|
|
343
|
+
// run. An install magus could never remove is only a warning: nothing
|
|
344
|
+
// failed, and the row still shows the conflict until it is dealt with.
|
|
345
|
+
if (errors.length > 0) {
|
|
346
346
|
await modal.message(
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
347
|
+
"Partly removed",
|
|
348
|
+
[
|
|
349
|
+
`These removals failed. Run them by hand:\n${errors.join("\n")}`,
|
|
350
|
+
stays,
|
|
351
|
+
]
|
|
352
|
+
.filter((part) => part !== "")
|
|
353
|
+
.join("\n\n"),
|
|
354
|
+
"error",
|
|
350
355
|
);
|
|
356
|
+
} else if (stays !== "") {
|
|
357
|
+
notify(stays.replace(/\n/g, " "), "warning");
|
|
351
358
|
}
|
|
352
359
|
|
|
353
360
|
handleRefresh();
|
|
@@ -359,11 +366,7 @@ export function CliToolsScreen() {
|
|
|
359
366
|
// it anyway" the CLI no longer does either.
|
|
360
367
|
const updatable = rows.filter((r) => r.status?.state === "upgrade");
|
|
361
368
|
if (updatable.length === 0) {
|
|
362
|
-
|
|
363
|
-
"Up to Date",
|
|
364
|
-
"No tool is known to be behind.",
|
|
365
|
-
"info",
|
|
366
|
-
);
|
|
369
|
+
notify("No tool is known to be behind");
|
|
367
370
|
return;
|
|
368
371
|
}
|
|
369
372
|
|
|
@@ -54,9 +54,12 @@ export function SettingsScreen() {
|
|
|
54
54
|
}
|
|
55
55
|
}, [dispatch, state.projectPath]);
|
|
56
56
|
|
|
57
|
+
// Load data on mount or when dataRefreshVersion changes — a profile
|
|
58
|
+
// activating changes what this screen shows.
|
|
59
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the fetch even though the body never reads it
|
|
57
60
|
useEffect(() => {
|
|
58
61
|
fetchData();
|
|
59
|
-
}, [fetchData]);
|
|
62
|
+
}, [fetchData, state.dataRefreshVersion]);
|
|
60
63
|
|
|
61
64
|
const listItems = useMemo((): SettingsBrowserItem[] => {
|
|
62
65
|
if (settings.values.status !== "success") return [];
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type RuleRow,
|
|
20
20
|
renderRuleRow,
|
|
21
21
|
} from "../renderers/gitignoreRenderers.js";
|
|
22
|
-
import { useApp, useModal } from "../state/AppContext.js";
|
|
22
|
+
import { useApp, useModal, useNotify } from "../state/AppContext.js";
|
|
23
23
|
import { theme } from "../theme.js";
|
|
24
24
|
|
|
25
25
|
// Clean rows are grouped by the manifest that defined them. Order = display
|
|
@@ -36,6 +36,7 @@ const SOURCE_LABELS: Record<Tier, string> = {
|
|
|
36
36
|
export function GitignoreScreen() {
|
|
37
37
|
const { state } = useApp();
|
|
38
38
|
const modal = useModal();
|
|
39
|
+
const notify = useNotify();
|
|
39
40
|
const renderer = useRenderer();
|
|
40
41
|
const cwd = state.projectPath;
|
|
41
42
|
|
|
@@ -57,9 +58,12 @@ export function GitignoreScreen() {
|
|
|
57
58
|
}
|
|
58
59
|
}, [cwd]);
|
|
59
60
|
|
|
61
|
+
// Load on mount or when dataRefreshVersion changes — a profile activating
|
|
62
|
+
// can turn committed files into generated ones this screen lists.
|
|
63
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the load even though the body never reads it
|
|
60
64
|
useEffect(() => {
|
|
61
65
|
void reload();
|
|
62
|
-
}, [reload]);
|
|
66
|
+
}, [reload, state.dataRefreshVersion]);
|
|
63
67
|
|
|
64
68
|
// Build the managed list: every desired gitignore rule, with all matching
|
|
65
69
|
// violations grouped under the rule that owns them.
|
|
@@ -163,19 +167,11 @@ export function GitignoreScreen() {
|
|
|
163
167
|
// Fix just the highlighted row.
|
|
164
168
|
const handleFixOne = useCallback(async () => {
|
|
165
169
|
if (!selectedRow) {
|
|
166
|
-
|
|
167
|
-
"No row selected",
|
|
168
|
-
"Use ↑↓ to highlight a row that needs fixing, then press f.",
|
|
169
|
-
"info",
|
|
170
|
-
);
|
|
170
|
+
notify("Highlight a row that needs fixing, then press f", "warning");
|
|
171
171
|
return;
|
|
172
172
|
}
|
|
173
173
|
if (selectedRow.violations.length === 0) {
|
|
174
|
-
|
|
175
|
-
"Nothing to fix",
|
|
176
|
-
`${selectedRow.rule.pattern} already matches. f only applies to rows that need fixing.`,
|
|
177
|
-
"info",
|
|
178
|
-
);
|
|
174
|
+
notify(`${selectedRow.rule.pattern} already matches — nothing to fix`);
|
|
179
175
|
return;
|
|
180
176
|
}
|
|
181
177
|
|
|
@@ -204,16 +200,12 @@ export function GitignoreScreen() {
|
|
|
204
200
|
"error",
|
|
205
201
|
);
|
|
206
202
|
}
|
|
207
|
-
}, [selectedRow, modal, cwd, reload]);
|
|
203
|
+
}, [selectedRow, modal, notify, cwd, reload]);
|
|
208
204
|
|
|
209
205
|
const handleApplyTemplate = useCallback(async () => {
|
|
210
206
|
const names = Object.keys(BUILTIN_TEMPLATES);
|
|
211
207
|
if (names.length === 0) {
|
|
212
|
-
|
|
213
|
-
"No templates",
|
|
214
|
-
"No built-in templates available.",
|
|
215
|
-
"info",
|
|
216
|
-
);
|
|
208
|
+
notify("No built-in templates available", "warning");
|
|
217
209
|
return;
|
|
218
210
|
}
|
|
219
211
|
const selected = await modal.select(
|
|
@@ -224,15 +216,16 @@ export function GitignoreScreen() {
|
|
|
224
216
|
if (!selected) return;
|
|
225
217
|
try {
|
|
226
218
|
const r = await applyTemplate(cwd, selected);
|
|
227
|
-
|
|
228
|
-
r.
|
|
229
|
-
? `\n\nSkipped due to conflicts: ${r.conflicts.join(", ")}`
|
|
230
|
-
: "";
|
|
231
|
-
await modal.message(
|
|
232
|
-
"Template applied",
|
|
233
|
-
`Wrote ${r.appliedTo}\n\nAdded ${r.added.ignore.length} ignore + ${r.added.track.length} track entries.${conflictNote}`,
|
|
234
|
-
"success",
|
|
219
|
+
notify(
|
|
220
|
+
`Applied "${selected}": ${r.added.ignore.length} ignore + ${r.added.track.length} track entries`,
|
|
235
221
|
);
|
|
222
|
+
// Skipped entries are not visible in the list, so they get their own line.
|
|
223
|
+
if (r.conflicts.length > 0) {
|
|
224
|
+
notify(
|
|
225
|
+
`Skipped, conflicting with existing rules: ${r.conflicts.join(", ")}`,
|
|
226
|
+
"warning",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
236
229
|
} catch (err) {
|
|
237
230
|
await modal.message(
|
|
238
231
|
"Template failed",
|
|
@@ -241,7 +234,7 @@ export function GitignoreScreen() {
|
|
|
241
234
|
);
|
|
242
235
|
}
|
|
243
236
|
await reload();
|
|
244
|
-
}, [cwd, modal, reload]);
|
|
237
|
+
}, [cwd, modal, notify, reload]);
|
|
245
238
|
|
|
246
239
|
/**
|
|
247
240
|
* Open the project manifest in $EDITOR, creating it first if needed.
|
|
@@ -2,7 +2,12 @@ import React, { useEffect, useCallback, useState, useRef } from "react";
|
|
|
2
2
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
3
3
|
import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
|
|
4
4
|
import { moveDirection } from "../keymap.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
useApp,
|
|
7
|
+
useModal,
|
|
8
|
+
useNavigation,
|
|
9
|
+
useNotify,
|
|
10
|
+
} from "../state/AppContext.js";
|
|
6
11
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
7
12
|
|
|
8
13
|
import { addMcpServer, setAllowMcp } from "../../services/claude-settings.js";
|
|
@@ -75,6 +80,7 @@ export function McpRegistryScreen() {
|
|
|
75
80
|
const { state, dispatch } = useApp();
|
|
76
81
|
const { mcpRegistry } = state;
|
|
77
82
|
const modal = useModal();
|
|
83
|
+
const notify = useNotify();
|
|
78
84
|
const { navigateToScreen } = useNavigation();
|
|
79
85
|
const dimensions = useDimensions();
|
|
80
86
|
|
|
@@ -190,11 +196,8 @@ export function McpRegistryScreen() {
|
|
|
190
196
|
await setAllowMcp(true, state.projectPath);
|
|
191
197
|
await addMcpServer(server.name, config, state.projectPath);
|
|
192
198
|
modal.hideModal();
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
`${server.name} has been configured.\n\nRestart Claude Code to activate.`,
|
|
196
|
-
"success",
|
|
197
|
-
);
|
|
199
|
+
// A warning: the server does nothing until the user restarts.
|
|
200
|
+
notify(`Added ${server.name} — restart Claude Code to use it`, "warning");
|
|
198
201
|
} catch (error) {
|
|
199
202
|
modal.hideModal();
|
|
200
203
|
await modal.message("Error", `Failed to install: ${error}`, "error");
|
|
@@ -2,7 +2,12 @@ import React, { useEffect, useCallback, useMemo } from "react";
|
|
|
2
2
|
import { ScreenLayout } from "../components/layout/index.js";
|
|
3
3
|
import { useScreenKeys } from "../input/KeyRouter.js";
|
|
4
4
|
import { moveDirection } from "../keymap.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
useApp,
|
|
7
|
+
useModal,
|
|
8
|
+
useNavigation,
|
|
9
|
+
useNotify,
|
|
10
|
+
} from "../state/AppContext.js";
|
|
6
11
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
7
12
|
|
|
8
13
|
import {
|
|
@@ -30,6 +35,7 @@ export function McpScreen() {
|
|
|
30
35
|
const { state, dispatch } = useApp();
|
|
31
36
|
const { mcp } = state;
|
|
32
37
|
const modal = useModal();
|
|
38
|
+
const notify = useNotify();
|
|
33
39
|
const { navigateToScreen } = useNavigation();
|
|
34
40
|
const dimensions = useDimensions();
|
|
35
41
|
|
|
@@ -69,9 +75,12 @@ export function McpScreen() {
|
|
|
69
75
|
}
|
|
70
76
|
}, [dispatch, state.projectPath]);
|
|
71
77
|
|
|
78
|
+
// Load data on mount or when dataRefreshVersion changes — a profile
|
|
79
|
+
// activating changes what this screen shows.
|
|
80
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: dataRefreshVersion is the external refresh trigger; bumping it must re-run the fetch even though the body never reads it
|
|
72
81
|
useEffect(() => {
|
|
73
82
|
fetchData();
|
|
74
|
-
}, [fetchData]);
|
|
83
|
+
}, [fetchData, state.dataRefreshVersion]);
|
|
75
84
|
|
|
76
85
|
const allListItems = useMemo((): McpListItem[] => {
|
|
77
86
|
if (mcp.servers.status !== "success") return [];
|
|
@@ -138,11 +147,7 @@ export function McpScreen() {
|
|
|
138
147
|
try {
|
|
139
148
|
await removeMcpServer(server.name, state.projectPath);
|
|
140
149
|
modal.hideModal();
|
|
141
|
-
|
|
142
|
-
"Removed",
|
|
143
|
-
`${server.name} has been removed.`,
|
|
144
|
-
"success",
|
|
145
|
-
);
|
|
150
|
+
// The row turns uninstalled; nothing else to say.
|
|
146
151
|
fetchData();
|
|
147
152
|
} catch (error) {
|
|
148
153
|
modal.hideModal();
|
|
@@ -216,11 +221,10 @@ export function McpScreen() {
|
|
|
216
221
|
try {
|
|
217
222
|
await addMcpServer(server.name, config, state.projectPath);
|
|
218
223
|
modal.hideModal();
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
);
|
|
224
|
+
// The row shows it installed; the restart it needs does not show,
|
|
225
|
+
// and it is something the user has to do — so a warning, which also
|
|
226
|
+
// stays long enough to be read.
|
|
227
|
+
notify(`Added ${server.name} — restart Claude Code to use it`, "warning");
|
|
224
228
|
fetchData();
|
|
225
229
|
} catch (error) {
|
|
226
230
|
modal.hideModal();
|
|
@@ -91,7 +91,12 @@ import {
|
|
|
91
91
|
renderPluginDetail,
|
|
92
92
|
renderPluginRow,
|
|
93
93
|
} from "../renderers/pluginRenderers.js";
|
|
94
|
-
import {
|
|
94
|
+
import {
|
|
95
|
+
useApp,
|
|
96
|
+
useModal,
|
|
97
|
+
useNotify,
|
|
98
|
+
useProgress,
|
|
99
|
+
} from "../state/AppContext.js";
|
|
95
100
|
import { useDimensions } from "../state/DimensionsContext.js";
|
|
96
101
|
import { asyncValue } from "../state/types.js";
|
|
97
102
|
import { theme } from "../theme.js";
|
|
@@ -121,6 +126,7 @@ export function PluginsScreen() {
|
|
|
121
126
|
const { plugins: pluginsState } = state;
|
|
122
127
|
const modal = useModal();
|
|
123
128
|
const progress = useProgress();
|
|
129
|
+
const notify = useNotify();
|
|
124
130
|
const dimensions = useDimensions();
|
|
125
131
|
|
|
126
132
|
const isSearchActive =
|
|
@@ -558,21 +564,15 @@ export function PluginsScreen() {
|
|
|
558
564
|
clearContentDriftCache();
|
|
559
565
|
progress.hide();
|
|
560
566
|
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
0,
|
|
571
|
-
);
|
|
572
|
-
message += `\n\nAuto-repaired ${totalRepaired} plugin(s) with missing agents/commands.`;
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
await modal.message("Refreshed", message, "success");
|
|
567
|
+
const totalRepaired = result.repair.reduce(
|
|
568
|
+
(sum, r) => sum + r.repaired.length,
|
|
569
|
+
0,
|
|
570
|
+
);
|
|
571
|
+
notify(
|
|
572
|
+
totalRepaired > 0
|
|
573
|
+
? `Refreshed — repaired ${totalRepaired} plugin(s) with missing files`
|
|
574
|
+
: "Refreshed",
|
|
575
|
+
);
|
|
576
576
|
fetchData();
|
|
577
577
|
} catch (error) {
|
|
578
578
|
progress.hide();
|
|
@@ -697,10 +697,9 @@ export function PluginsScreen() {
|
|
|
697
697
|
);
|
|
698
698
|
|
|
699
699
|
if (!wantToConfigure) {
|
|
700
|
-
|
|
701
|
-
"
|
|
702
|
-
|
|
703
|
-
"info",
|
|
700
|
+
notify(
|
|
701
|
+
`${serverNames.join(", ")} not configured — set the variables in the Settings tab (press ${tabKey("settings")})`,
|
|
702
|
+
"warning",
|
|
704
703
|
);
|
|
705
704
|
return true;
|
|
706
705
|
}
|
|
@@ -725,10 +724,9 @@ export function PluginsScreen() {
|
|
|
725
724
|
);
|
|
726
725
|
|
|
727
726
|
if (value === null) {
|
|
728
|
-
|
|
729
|
-
"
|
|
730
|
-
|
|
731
|
-
"info",
|
|
727
|
+
notify(
|
|
728
|
+
`${serverNames.join(", ")} partly configured — set the rest in the Settings tab (press ${tabKey("settings")})`,
|
|
729
|
+
"warning",
|
|
732
730
|
);
|
|
733
731
|
return true;
|
|
734
732
|
}
|
|
@@ -807,11 +805,7 @@ export function PluginsScreen() {
|
|
|
807
805
|
"error",
|
|
808
806
|
);
|
|
809
807
|
} else if (result.installed.length > 0) {
|
|
810
|
-
|
|
811
|
-
"Dependencies Installed",
|
|
812
|
-
`Installed ${result.installed.length} package(s):\n${result.installed.join(", ")}`,
|
|
813
|
-
"success",
|
|
814
|
-
);
|
|
808
|
+
notify(`Installed ${result.installed.join(", ")}`);
|
|
815
809
|
}
|
|
816
810
|
} catch (error) {
|
|
817
811
|
console.error("Error installing plugin deps:", error);
|
|
@@ -1047,13 +1041,8 @@ export function PluginsScreen() {
|
|
|
1047
1041
|
// Ran, exited 0, moved nothing — and the row will not have moved
|
|
1048
1042
|
// either. Saying nothing here is what made "press U, press U,
|
|
1049
1043
|
// forever": the CLI names the cause, so the screen does too.
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
"No change",
|
|
1053
|
-
reasons.lines(result.unchanged),
|
|
1054
|
-
"info",
|
|
1055
|
-
);
|
|
1056
|
-
}
|
|
1044
|
+
const unchanged = reasons.notice(result.unchanged);
|
|
1045
|
+
if (unchanged) notify(unchanged, "warning");
|
|
1057
1046
|
fetchData();
|
|
1058
1047
|
} catch (error) {
|
|
1059
1048
|
modal.hideModal();
|
|
@@ -1121,13 +1110,10 @@ export function PluginsScreen() {
|
|
|
1121
1110
|
"error",
|
|
1122
1111
|
);
|
|
1123
1112
|
}
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
"info",
|
|
1129
|
-
);
|
|
1130
|
-
}
|
|
1113
|
+
// One line however many were left where they were: a line each
|
|
1114
|
+
// filled the banner and pushed out everything else it had to say.
|
|
1115
|
+
const unchanged = reasons.notice(result.unchanged);
|
|
1116
|
+
if (unchanged) notify(unchanged, "warning");
|
|
1131
1117
|
fetchData();
|
|
1132
1118
|
} catch (error) {
|
|
1133
1119
|
modal.hideModal();
|
|
@@ -1278,10 +1264,8 @@ export function PluginsScreen() {
|
|
|
1278
1264
|
try {
|
|
1279
1265
|
await saveProfile(name.trim(), enabledPlugins, scope, state.projectPath);
|
|
1280
1266
|
modal.hideModal();
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
`Profile "${name.trim()}" saved.\nPress ${tabKey("profiles")} to manage profiles.`,
|
|
1284
|
-
"success",
|
|
1267
|
+
notify(
|
|
1268
|
+
`Saved profile "${name.trim()}" at ${scope === "user" ? "USER" : "PROJECT"} scope`,
|
|
1285
1269
|
);
|
|
1286
1270
|
} catch (error) {
|
|
1287
1271
|
modal.hideModal();
|
|
@@ -1379,18 +1363,13 @@ export function PluginsScreen() {
|
|
|
1379
1363
|
width: dimensions.terminalWidth,
|
|
1380
1364
|
now: nowTick,
|
|
1381
1365
|
});
|
|
1382
|
-
// Without a resolved profile
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1366
|
+
// Without a resolved profile every row is judged against "latest". That is
|
|
1367
|
+
// not said here: the startup question already tells the user there is no
|
|
1368
|
+
// active profile, and "profile not resolved" was mechanism, not state.
|
|
1385
1369
|
const notice = isReloading ? (
|
|
1386
1370
|
<text fg={theme.colors.muted}>⟳ Refreshing catalogs from GitHub…</text>
|
|
1387
1371
|
) : (
|
|
1388
|
-
|
|
1389
|
-
(pins === null ? (
|
|
1390
|
-
<text fg={theme.colors.muted}>
|
|
1391
|
-
profile not resolved — states judged against latest
|
|
1392
|
-
</text>
|
|
1393
|
-
) : null))
|
|
1372
|
+
catalogNotice
|
|
1394
1373
|
);
|
|
1395
1374
|
|
|
1396
1375
|
return (
|