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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
2
|
+
import type { StartupFixResult } from "../../services/gitignore-service.js";
|
|
3
|
+
import { useApp, useNavigation, useNotify } from "../state/AppContext.js";
|
|
4
|
+
import type { ModalState, NoticeLine } from "../state/types.js";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Startup gitignore-violation modal: magus launches, detects a problem, and
|
|
@@ -79,17 +80,49 @@ export function buildGitignoreModal(args: BuildModalArgs): ModalState {
|
|
|
79
80
|
};
|
|
80
81
|
}
|
|
81
82
|
|
|
83
|
+
/**
|
|
84
|
+
* What the startup fix did, as notices. Untracking is a warning because it is
|
|
85
|
+
* not finished until the user commits the staged removal — the one thing here
|
|
86
|
+
* they must still do. The files are named only there, and in a failure, which
|
|
87
|
+
* the user has to deal with by hand.
|
|
88
|
+
*/
|
|
89
|
+
export function startupFixNotices(result: StartupFixResult): NoticeLine[] {
|
|
90
|
+
const lines: NoticeLine[] = [];
|
|
91
|
+
if (result.untracked.length > 0) {
|
|
92
|
+
lines.push({
|
|
93
|
+
text: `Stopped tracking ${result.untracked.join(", ")} — commit the staged removal`,
|
|
94
|
+
tone: "warning",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
if (result.appended.length > 0) {
|
|
98
|
+
lines.push({
|
|
99
|
+
text: `Added ${entries(result.appended.length)}`,
|
|
100
|
+
tone: "success",
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
for (const f of result.failed) {
|
|
104
|
+
lines.push({
|
|
105
|
+
text: `Could not stop tracking ${f.path}: ${f.reason}`,
|
|
106
|
+
tone: "warning",
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return lines.length > 0
|
|
110
|
+
? lines
|
|
111
|
+
: [{ text: "Gitignore already up to date", tone: "success" }];
|
|
112
|
+
}
|
|
113
|
+
|
|
82
114
|
export function useGitignoreModal() {
|
|
83
115
|
const { dispatch } = useApp();
|
|
84
116
|
const { navigateToScreen } = useNavigation();
|
|
117
|
+
const notify = useNotify();
|
|
85
118
|
|
|
86
119
|
const show = useCallback(
|
|
87
120
|
(args: {
|
|
88
121
|
violationCount: number;
|
|
89
122
|
safeCount: number;
|
|
90
123
|
generated?: readonly string[];
|
|
91
|
-
/** Returns what it did,
|
|
92
|
-
onFixSafe: () => Promise<
|
|
124
|
+
/** Returns what it did, reported as notices once the fix settles. */
|
|
125
|
+
onFixSafe: () => Promise<readonly NoticeLine[]> | readonly NoticeLine[];
|
|
93
126
|
}) => {
|
|
94
127
|
if (args.violationCount === 0) return;
|
|
95
128
|
const dismiss = () => dispatch({ type: "HIDE_MODAL" });
|
|
@@ -98,20 +131,27 @@ export function useGitignoreModal() {
|
|
|
98
131
|
safeCount: args.safeCount,
|
|
99
132
|
generated: args.generated,
|
|
100
133
|
onFixSafe: () => {
|
|
101
|
-
|
|
102
|
-
|
|
134
|
+
// A loading modal, NOT HIDE_MODAL. The fix is async, and an empty
|
|
135
|
+
// modal slot is App's signal that the next startup question may be
|
|
136
|
+
// asked: hiding here let the profile question show during the await,
|
|
137
|
+
// be marked as asked, and then be replaced by the fix's result —
|
|
138
|
+
// lost for the whole session. The slot stays occupied until the
|
|
139
|
+
// fix settles.
|
|
140
|
+
dispatch({
|
|
141
|
+
type: "SHOW_MODAL",
|
|
142
|
+
modal: { type: "loading", message: "Fixing gitignore…" },
|
|
143
|
+
});
|
|
144
|
+
// Called inside `.then`, not passed to `Promise.resolve`: a fixer
|
|
145
|
+
// that throws synchronously must land in `.catch` below too, or
|
|
146
|
+
// the loading modal above stays up and swallows every key.
|
|
147
|
+
void Promise.resolve()
|
|
148
|
+
.then(() => args.onFixSafe())
|
|
103
149
|
.then((report) => {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
title: "Gitignore fixed",
|
|
110
|
-
message: report,
|
|
111
|
-
variant: "success",
|
|
112
|
-
onDismiss: dismiss,
|
|
113
|
-
},
|
|
114
|
-
});
|
|
150
|
+
// The loading modal must go — it swallows every key — and the
|
|
151
|
+
// result goes to the notice banner, which is outside the modal
|
|
152
|
+
// slot, so the next startup question can take the slot now.
|
|
153
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
154
|
+
for (const line of report) notify(line.text, line.tone);
|
|
115
155
|
})
|
|
116
156
|
.catch((err) => {
|
|
117
157
|
dispatch({
|
|
@@ -134,7 +174,7 @@ export function useGitignoreModal() {
|
|
|
134
174
|
});
|
|
135
175
|
dispatch({ type: "SHOW_MODAL", modal });
|
|
136
176
|
},
|
|
137
|
-
[dispatch, navigateToScreen],
|
|
177
|
+
[dispatch, navigateToScreen, notify],
|
|
138
178
|
);
|
|
139
179
|
|
|
140
180
|
return { show };
|
|
@@ -15,12 +15,11 @@
|
|
|
15
15
|
* leave, which is why it needs the app's own exit.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { useCallback } from "react";
|
|
18
|
+
import { useCallback, useRef } from "react";
|
|
19
19
|
import { getManifestPath } from "../../services/manifest.js";
|
|
20
|
-
import {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
activationNotes,
|
|
20
|
+
import type {
|
|
21
|
+
ActivationResult,
|
|
22
|
+
ProfileChoice,
|
|
24
23
|
} from "../../services/profile-activate.js";
|
|
25
24
|
import type { DisplacedArtifact } from "../../services/profile-live.js";
|
|
26
25
|
import {
|
|
@@ -32,12 +31,13 @@ import {
|
|
|
32
31
|
formatUpdateOutcome,
|
|
33
32
|
parseProfileChoice,
|
|
34
33
|
prepareProfileStartup,
|
|
34
|
+
profileIdForName,
|
|
35
35
|
unparseableNeed,
|
|
36
36
|
} from "../../services/profile-startup.js";
|
|
37
37
|
import { applyProfileChoice } from "../../services/profile-state.js";
|
|
38
38
|
import { SettingsParseError } from "../../services/settings-file.js";
|
|
39
|
-
import { useApp,
|
|
40
|
-
import type { ModalState } from "../state/types.js";
|
|
39
|
+
import { useApp, useNotify } from "../state/AppContext.js";
|
|
40
|
+
import type { ModalState, NoticeLine } from "../state/types.js";
|
|
41
41
|
|
|
42
42
|
export interface BuildProfileModalArgs {
|
|
43
43
|
need: ProfileStartupNeed;
|
|
@@ -84,38 +84,79 @@ export function buildProfileStartupModal(
|
|
|
84
84
|
};
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
87
|
+
/** `typescript-lsp@claude-plugins-official` → `typescript-lsp`. */
|
|
88
|
+
function pluginName(pluginId: string): string {
|
|
89
|
+
const at = pluginId.lastIndexOf("@");
|
|
90
|
+
return at > 0 ? pluginId.slice(0, at) : pluginId;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** "a", "a and b", "a, b and c". */
|
|
94
|
+
function listed(names: readonly string[]): string {
|
|
95
|
+
if (names.length <= 1) return names.join("");
|
|
96
|
+
return `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What an activation did, as notices — for the startup question and the
|
|
101
|
+
* Profiles tab alike: one line saying which profile is in use, then
|
|
102
|
+
* {@link activationWarnings}.
|
|
103
|
+
*/
|
|
104
|
+
export function activationNotices(result: ActivationResult): NoticeLine[] {
|
|
105
|
+
return [
|
|
106
|
+
{ text: `Using profile "${result.profileId}"`, tone: "success" },
|
|
107
|
+
...activationWarnings(result),
|
|
101
108
|
];
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* What an activation left for the user to act on, one warning per thing, in
|
|
113
|
+
* user terms. Alone, without {@link activationNotices}' "Using profile" line,
|
|
114
|
+
* for an activation the user did not ask for — a re-render at startup, a
|
|
115
|
+
* profile format update — where which profile is in use is not news.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately NOT {@link activationNotes}, which the CLI prints. Several of
|
|
118
|
+
* its lines are for a terminal log and would only be noise on this banner:
|
|
119
|
+
* where displaced files were backed up (the backup still happens; nobody needs
|
|
120
|
+
* to be told where), edits recorded into the profile being left (the switch
|
|
121
|
+
* confirmation already said so), skills handed back to the team. Tracked
|
|
122
|
+
* generated files are left out too: every activation signals
|
|
123
|
+
* PROFILE_ACTIVATED, and the app re-runs its gitignore check on it — the
|
|
124
|
+
* startup question and a Profiles-tab switch alike — which asks about exactly
|
|
125
|
+
* those files, with a way to fix them.
|
|
126
|
+
*/
|
|
127
|
+
export function activationWarnings(result: ActivationResult): NoticeLine[] {
|
|
128
|
+
const lines: NoticeLine[] = [];
|
|
129
|
+
const report = result.dependencies;
|
|
130
|
+
for (const { plugin, dependency } of report?.blocked ?? []) {
|
|
131
|
+
lines.push({
|
|
132
|
+
text: `${pluginName(plugin)} won't load: profile "${result.profileId}" turns off ${pluginName(dependency)}, which it needs`,
|
|
133
|
+
tone: "warning",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
// The plugin's details are not on this machine yet, so what it depends on
|
|
137
|
+
// could not be enabled. One line for all of them: the fix is the same.
|
|
138
|
+
const missing = (report?.unreadable ?? []).map(pluginName);
|
|
139
|
+
if (missing.length > 0) {
|
|
140
|
+
lines.push({
|
|
141
|
+
text: `${listed(missing)} ${missing.length === 1 ? "isn't" : "aren't"} downloaded yet — run magus install`,
|
|
142
|
+
tone: "warning",
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
for (const f of result.skills.failed) {
|
|
146
|
+
lines.push({
|
|
147
|
+
text: `Skill "${f.skill}" could not be installed: ${f.reason}`,
|
|
148
|
+
tone: "warning",
|
|
149
|
+
});
|
|
116
150
|
}
|
|
117
|
-
|
|
118
|
-
|
|
151
|
+
// The one case that names a path: two copies of a skill, and which to keep
|
|
152
|
+
// is the user's call — they need to know where the other one is.
|
|
153
|
+
for (const c of result.conversion.conflicts) {
|
|
154
|
+
lines.push({
|
|
155
|
+
text: `Skill "${c.folder}" exists in two versions; kept ${c.kept}, the other is at ${c.other}`,
|
|
156
|
+
tone: "warning",
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
return lines;
|
|
119
160
|
}
|
|
120
161
|
|
|
121
162
|
/** Project-relative, so a path does not wrap over two lines. */
|
|
@@ -166,17 +207,71 @@ export function describeFailure(
|
|
|
166
207
|
].join("\n");
|
|
167
208
|
}
|
|
168
209
|
|
|
169
|
-
export
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
210
|
+
export interface ProfileStartupModalCallbacks {
|
|
211
|
+
/** Leave magus: the modal's only non-action. */
|
|
212
|
+
onQuit: () => void;
|
|
213
|
+
/**
|
|
214
|
+
* Called once a choice has activated a profile, or a format update has
|
|
215
|
+
* rewritten it — the app dispatches PROFILE_ACTIVATED on it.
|
|
216
|
+
*/
|
|
217
|
+
onActivated?: () => void;
|
|
218
|
+
/**
|
|
219
|
+
* The question is ANSWERED: a profile is active, or the user chose to quit.
|
|
220
|
+
* Until this fires the caller must treat the question as outstanding and
|
|
221
|
+
* call `show` again whenever the modal slot is empty — see `show`.
|
|
222
|
+
*/
|
|
223
|
+
onAnswered: () => void;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Where the profile question stands between `show` calls. `need` is the
|
|
228
|
+
* question on screen NOW, which is not always the one magus opened with: "Update
|
|
229
|
+
* it now" asks a new one, and a manifest that stopped parsing turns the question
|
|
230
|
+
* into the unparseable one. `working` is true while an activation or a format
|
|
231
|
+
* update is running — when it settles it empties the slot (success, reported as
|
|
232
|
+
* a notice) or puts its error modal there.
|
|
233
|
+
*/
|
|
234
|
+
interface ProfileFlow {
|
|
235
|
+
need: ProfileStartupNeed;
|
|
236
|
+
working: boolean;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function useProfileStartupModal({
|
|
240
|
+
onQuit,
|
|
241
|
+
onActivated,
|
|
242
|
+
onAnswered,
|
|
243
|
+
}: ProfileStartupModalCallbacks) {
|
|
174
244
|
const { state, dispatch } = useApp();
|
|
175
|
-
const
|
|
245
|
+
const notify = useNotify();
|
|
176
246
|
const projectPath = state.projectPath;
|
|
247
|
+
// A ref, not state: `show` is recreated on every render, and the flow must
|
|
248
|
+
// survive that — a re-show has to know what was last asked, and that work is
|
|
249
|
+
// in flight, without a render in between.
|
|
250
|
+
const flowRef = useRef<ProfileFlow | null>(null);
|
|
177
251
|
|
|
252
|
+
/**
|
|
253
|
+
* Show the profile question, or put it back.
|
|
254
|
+
*
|
|
255
|
+
* The caller calls this again whenever the modal slot is empty and the
|
|
256
|
+
* question is unanswered. The modal cannot be dismissed, so an empty slot
|
|
257
|
+
* then means something else took the slot and closed it — a screen's
|
|
258
|
+
* `confirm` resolving, say, before its loading modal lands. So a second call
|
|
259
|
+
* re-asks the question that was on screen, and never re-runs an automatic
|
|
260
|
+
* activation. While work is in flight it does nothing: that work puts its own
|
|
261
|
+
* result up when it settles, and asking again meanwhile would let a second
|
|
262
|
+
* choice race the first.
|
|
263
|
+
*/
|
|
178
264
|
const show = useCallback(
|
|
179
265
|
(need: ProfileStartupNeed) => {
|
|
266
|
+
const answered = (): void => {
|
|
267
|
+
flowRef.current = null;
|
|
268
|
+
onAnswered();
|
|
269
|
+
};
|
|
270
|
+
const quit = (): void => {
|
|
271
|
+
answered();
|
|
272
|
+
onQuit();
|
|
273
|
+
};
|
|
274
|
+
|
|
180
275
|
/**
|
|
181
276
|
* Apply a choice, reporting the outcome either way. `current` is the
|
|
182
277
|
* question it answers — after "Update it now" that is a NEW question,
|
|
@@ -184,16 +279,16 @@ export function useProfileStartupModal(
|
|
|
184
279
|
*/
|
|
185
280
|
const apply = (
|
|
186
281
|
choice: ProfileChoice,
|
|
187
|
-
automatic: boolean,
|
|
188
282
|
current: ProfileStartupNeed = need,
|
|
189
283
|
): void => {
|
|
284
|
+
flowRef.current = { need: current, working: true };
|
|
190
285
|
dispatch({
|
|
191
286
|
type: "SHOW_MODAL",
|
|
192
287
|
modal: {
|
|
193
288
|
type: "loading",
|
|
194
289
|
message:
|
|
195
290
|
choice.kind === "adopt"
|
|
196
|
-
? `Creating "${choice.id}"…`
|
|
291
|
+
? `Creating "${choice.name ?? choice.id}"…`
|
|
197
292
|
: `Activating ${choice.id}…`,
|
|
198
293
|
},
|
|
199
294
|
});
|
|
@@ -212,29 +307,19 @@ export function useProfileStartupModal(
|
|
|
212
307
|
onDisplace: (artifact) => moved.push(artifact),
|
|
213
308
|
})
|
|
214
309
|
.then((result: ActivationResult) => {
|
|
310
|
+
// Answered here, on success — NOT when the question was shown.
|
|
311
|
+
// Clearing it on show let a screen's modal replace it and the
|
|
312
|
+
// question never came back.
|
|
313
|
+
answered();
|
|
215
314
|
onActivated?.();
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
displaced,
|
|
225
|
-
projectPath,
|
|
226
|
-
automatic,
|
|
227
|
-
activationNotes(result),
|
|
228
|
-
),
|
|
229
|
-
variant: "success",
|
|
230
|
-
onDismiss: () => {
|
|
231
|
-
dispatch({ type: "HIDE_MODAL" });
|
|
232
|
-
// The Profiles tab is where the result is visible, and
|
|
233
|
-
// where the user will want to look next.
|
|
234
|
-
navigateToScreen("profiles");
|
|
235
|
-
},
|
|
236
|
-
},
|
|
237
|
-
});
|
|
315
|
+
// No result modal and no jump to the Profiles tab: the user
|
|
316
|
+
// stays where they are, told in the notice banner. An automatic
|
|
317
|
+
// activation is reported the same way, so it is never
|
|
318
|
+
// invisible.
|
|
319
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
320
|
+
for (const line of activationNotices(result)) {
|
|
321
|
+
notify(line.text, line.tone);
|
|
322
|
+
}
|
|
238
323
|
})
|
|
239
324
|
.catch((error: unknown) => {
|
|
240
325
|
// The manifest stopped parsing between launch and this answer —
|
|
@@ -246,6 +331,9 @@ export function useProfileStartupModal(
|
|
|
246
331
|
error.path === getManifestPath(projectPath)
|
|
247
332
|
? unparseableNeed(error)
|
|
248
333
|
: current;
|
|
334
|
+
// Still unanswered, and `next` is what a lost error modal
|
|
335
|
+
// returns to.
|
|
336
|
+
flowRef.current = { need: next, working: false };
|
|
249
337
|
dispatch({
|
|
250
338
|
type: "SHOW_MODAL",
|
|
251
339
|
modal: {
|
|
@@ -277,6 +365,7 @@ export function useProfileStartupModal(
|
|
|
277
365
|
* say it is done.
|
|
278
366
|
*/
|
|
279
367
|
const updateFormat = (): void => {
|
|
368
|
+
flowRef.current = { need, working: true };
|
|
280
369
|
dispatch({
|
|
281
370
|
type: "SHOW_MODAL",
|
|
282
371
|
modal: { type: "loading", message: "Updating the profile format…" },
|
|
@@ -284,14 +373,30 @@ export function useProfileStartupModal(
|
|
|
284
373
|
void prepareProfileStartup(projectPath, { convert: true })
|
|
285
374
|
.then((result) => {
|
|
286
375
|
const outcome = formatUpdateOutcome(result);
|
|
376
|
+
// What the conversion's activation warns about — a plugin that
|
|
377
|
+
// will not load, one not downloaded yet, a skill that failed —
|
|
378
|
+
// in user terms. Posted whichever way it went, except a refusal,
|
|
379
|
+
// which converted nothing. Posting under the next question is
|
|
380
|
+
// safe: notices do not run down while a modal is open.
|
|
381
|
+
const warnings = (): void => {
|
|
382
|
+
for (const activation of result.activations) {
|
|
383
|
+
for (const line of activationWarnings(activation)) {
|
|
384
|
+
notify(line.text, line.tone);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
};
|
|
287
388
|
if (outcome.kind === "ask") {
|
|
389
|
+
// Converted, but no profile is active yet: a new question,
|
|
390
|
+
// still unanswered.
|
|
288
391
|
onActivated?.();
|
|
289
392
|
ask(outcome.need);
|
|
393
|
+
warnings();
|
|
290
394
|
return;
|
|
291
395
|
}
|
|
292
396
|
if (outcome.kind === "failed") {
|
|
293
397
|
// Refused, so still the old format: back to the question,
|
|
294
398
|
// as the CLI stops on the same lines.
|
|
399
|
+
flowRef.current = { need, working: false };
|
|
295
400
|
dispatch({
|
|
296
401
|
type: "SHOW_MODAL",
|
|
297
402
|
modal: {
|
|
@@ -304,22 +409,15 @@ export function useProfileStartupModal(
|
|
|
304
409
|
});
|
|
305
410
|
return;
|
|
306
411
|
}
|
|
412
|
+
// Nothing left to ask: the converted profile is active.
|
|
413
|
+
answered();
|
|
307
414
|
onActivated?.();
|
|
308
|
-
dispatch({
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
type: "message",
|
|
312
|
-
title: outcome.title,
|
|
313
|
-
message: outcome.message,
|
|
314
|
-
variant: "success",
|
|
315
|
-
onDismiss: () => {
|
|
316
|
-
dispatch({ type: "HIDE_MODAL" });
|
|
317
|
-
navigateToScreen("profiles");
|
|
318
|
-
},
|
|
319
|
-
},
|
|
320
|
-
});
|
|
415
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
416
|
+
notify(outcome.notice);
|
|
417
|
+
warnings();
|
|
321
418
|
})
|
|
322
419
|
.catch((error: unknown) => {
|
|
420
|
+
flowRef.current = { need, working: false };
|
|
323
421
|
dispatch({
|
|
324
422
|
type: "SHOW_MODAL",
|
|
325
423
|
modal: {
|
|
@@ -333,29 +431,78 @@ export function useProfileStartupModal(
|
|
|
333
431
|
});
|
|
334
432
|
};
|
|
335
433
|
|
|
434
|
+
/**
|
|
435
|
+
* "Save what you have" picked: ask what to call it, with the generated
|
|
436
|
+
* id as the suggestion. An invalid name is explained above the field and
|
|
437
|
+
* asked again with what was typed still in it; Escape creates nothing
|
|
438
|
+
* and goes back to the profile question.
|
|
439
|
+
*/
|
|
440
|
+
const askName = (
|
|
441
|
+
suggestion: string,
|
|
442
|
+
current: ProfileStartupNeed,
|
|
443
|
+
typed: string = suggestion,
|
|
444
|
+
reason?: string,
|
|
445
|
+
): void => {
|
|
446
|
+
dispatch({
|
|
447
|
+
type: "SHOW_MODAL",
|
|
448
|
+
modal: {
|
|
449
|
+
type: "input",
|
|
450
|
+
title: "Save as a new profile",
|
|
451
|
+
label: reason ? `${reason}\n\nProfile name` : "Profile name",
|
|
452
|
+
defaultValue: typed,
|
|
453
|
+
onSubmit: (value: string) => {
|
|
454
|
+
const check = profileIdForName(
|
|
455
|
+
value.trim() === "" ? suggestion : value,
|
|
456
|
+
current.manifest,
|
|
457
|
+
);
|
|
458
|
+
if (!check.ok) {
|
|
459
|
+
askName(suggestion, current, value, check.reason);
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
apply({ kind: "adopt", id: check.id, name: check.name }, current);
|
|
463
|
+
},
|
|
464
|
+
onCancel: () => ask(current),
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
};
|
|
468
|
+
|
|
336
469
|
function ask(current: ProfileStartupNeed = need): void {
|
|
470
|
+
flowRef.current = { need: current, working: false };
|
|
337
471
|
dispatch({
|
|
338
472
|
type: "SHOW_MODAL",
|
|
339
473
|
modal: buildProfileStartupModal({
|
|
340
474
|
need: current,
|
|
341
|
-
onChoose: (choice) =>
|
|
475
|
+
onChoose: (choice) =>
|
|
476
|
+
choice.kind === "adopt"
|
|
477
|
+
? askName(choice.id, current)
|
|
478
|
+
: apply(choice, current),
|
|
342
479
|
onUpdateFormat: updateFormat,
|
|
343
|
-
onQuit,
|
|
480
|
+
onQuit: quit,
|
|
344
481
|
}),
|
|
345
482
|
});
|
|
346
483
|
}
|
|
347
484
|
|
|
485
|
+
const flow = flowRef.current;
|
|
486
|
+
// Work in flight owns the slot's next state; see the doc comment above.
|
|
487
|
+
if (flow?.working) return;
|
|
488
|
+
// Shown before and lost: put back the question that was on screen, not
|
|
489
|
+
// the one magus opened with, and never re-run an automatic activation.
|
|
490
|
+
if (flow) {
|
|
491
|
+
ask(flow.need);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
|
|
348
495
|
// The repo already answered and there is nothing of the user's to
|
|
349
496
|
// replace: asking here would ask someone to re-decide what their team
|
|
350
|
-
// decided, with nothing at stake. Reported in the
|
|
497
|
+
// decided, with nothing at stake. Reported in the notice banner, so it
|
|
351
498
|
// is never invisible.
|
|
352
499
|
if (need.autoActivate && need.declaredDefault !== null) {
|
|
353
|
-
apply({ kind: "activate", id: need.declaredDefault }
|
|
500
|
+
apply({ kind: "activate", id: need.declaredDefault });
|
|
354
501
|
return;
|
|
355
502
|
}
|
|
356
503
|
ask();
|
|
357
504
|
},
|
|
358
|
-
[dispatch,
|
|
505
|
+
[dispatch, notify, projectPath, onQuit, onActivated, onAnswered],
|
|
359
506
|
);
|
|
360
507
|
|
|
361
508
|
return { show };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useCallback } from "react";
|
|
2
|
-
import { useApp, useNavigation } from "../state/AppContext.js";
|
|
3
|
-
import type { ModalState } from "../state/types.js";
|
|
2
|
+
import { useApp, useNavigation, useNotify } from "../state/AppContext.js";
|
|
3
|
+
import type { ModalState, NoticeLine } from "../state/types.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Startup broken-plugin modal: magus launches, finds that Claude Code will load
|
|
@@ -71,12 +71,13 @@ export function buildStaleLoadModal(args: BuildStaleLoadModalArgs): ModalState {
|
|
|
71
71
|
export function useStaleLoadModal() {
|
|
72
72
|
const { dispatch } = useApp();
|
|
73
73
|
const { navigateToScreen } = useNavigation();
|
|
74
|
+
const notify = useNotify();
|
|
74
75
|
|
|
75
76
|
const show = useCallback(
|
|
76
77
|
(args: {
|
|
77
78
|
brokenCount: number;
|
|
78
79
|
/** Runs the repair. Resolves to what to tell the user afterwards. */
|
|
79
|
-
onFix: () => Promise<
|
|
80
|
+
onFix: () => Promise<readonly NoticeLine[]> | readonly NoticeLine[];
|
|
80
81
|
}) => {
|
|
81
82
|
if (args.brokenCount === 0) return;
|
|
82
83
|
const dismiss = () => dispatch({ type: "HIDE_MODAL" });
|
|
@@ -91,18 +92,17 @@ export function useStaleLoadModal() {
|
|
|
91
92
|
// the loader after writing and can legitimately answer "still not
|
|
92
93
|
// loading the target"; swallowing that would leave the user believing
|
|
93
94
|
// a broken plugin was fixed.
|
|
94
|
-
|
|
95
|
+
//
|
|
96
|
+
// Called inside `.then` so a synchronous throw reaches `.catch`
|
|
97
|
+
// too; otherwise the loading modal stays up and swallows every key.
|
|
98
|
+
void Promise.resolve()
|
|
99
|
+
.then(() => args.onFix())
|
|
95
100
|
.then((summary) => {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
message: summary,
|
|
102
|
-
variant: "info",
|
|
103
|
-
onDismiss: dismiss,
|
|
104
|
-
},
|
|
105
|
-
});
|
|
101
|
+
// Hidden, not replaced: the loading modal swallows every key,
|
|
102
|
+
// and the result belongs in the notice banner, outside the
|
|
103
|
+
// modal slot the next startup question is waiting for.
|
|
104
|
+
dispatch({ type: "HIDE_MODAL" });
|
|
105
|
+
for (const line of summary) notify(line.text, line.tone);
|
|
106
106
|
})
|
|
107
107
|
.catch((err) => {
|
|
108
108
|
dispatch({
|
|
@@ -125,33 +125,47 @@ export function useStaleLoadModal() {
|
|
|
125
125
|
});
|
|
126
126
|
dispatch({ type: "SHOW_MODAL", modal });
|
|
127
127
|
},
|
|
128
|
-
[dispatch, navigateToScreen],
|
|
128
|
+
[dispatch, navigateToScreen, notify],
|
|
129
129
|
);
|
|
130
130
|
|
|
131
131
|
return { show };
|
|
132
132
|
}
|
|
133
133
|
|
|
134
134
|
/**
|
|
135
|
-
*
|
|
135
|
+
* What the repair did, as notices: one line for every plugin that now loads
|
|
136
|
+
* the version installed, and a warning for each that still does not.
|
|
136
137
|
*
|
|
137
|
-
* Exported and pure so the wording is tested rather than inspected by eye.
|
|
138
|
-
*
|
|
139
|
-
* the
|
|
138
|
+
* Exported and pure so the wording is tested rather than inspected by eye. The
|
|
139
|
+
* lines say what loads NOW, because that — not the row count — is the thing
|
|
140
|
+
* the user came here to change. A failure is never folded into the success
|
|
141
|
+
* line: `ok` is the loader re-asked after the write, and a repair that did not
|
|
142
|
+
* take must read as one.
|
|
140
143
|
*/
|
|
141
|
-
export function
|
|
144
|
+
export function staleLoadRepairNotices(
|
|
142
145
|
results: ReadonlyArray<{
|
|
143
146
|
pluginId: string;
|
|
144
147
|
target: string;
|
|
145
148
|
ok: boolean;
|
|
146
149
|
error?: string;
|
|
147
150
|
}>,
|
|
148
|
-
):
|
|
149
|
-
if (results.length === 0)
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
151
|
+
): NoticeLine[] {
|
|
152
|
+
if (results.length === 0) {
|
|
153
|
+
return [{ text: "Nothing needed repairing", tone: "success" }];
|
|
154
|
+
}
|
|
155
|
+
const lines: NoticeLine[] = [];
|
|
156
|
+
const fixed = results.filter((r) => r.ok);
|
|
157
|
+
if (fixed.length > 0) {
|
|
158
|
+
lines.push({
|
|
159
|
+
text: `Now loading ${fixed.map((r) => `${r.pluginId} v${r.target}`).join(", ")}`,
|
|
160
|
+
tone: "success",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
for (const r of results) {
|
|
164
|
+
if (r.ok) continue;
|
|
165
|
+
lines.push({
|
|
166
|
+
text: `${r.pluginId} not repaired: ${r.error ?? "repair did not take effect"}`,
|
|
167
|
+
tone: "warning",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
return lines;
|
|
157
171
|
}
|