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.
Files changed (39) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/format-update-outcome.test.ts +9 -8
  3. package/src/__tests__/gitignore-profile.test.ts +9 -5
  4. package/src/__tests__/key-focus-guard.test.tsx +260 -0
  5. package/src/__tests__/notices-rules.test.ts +476 -0
  6. package/src/__tests__/notices.test.tsx +604 -0
  7. package/src/__tests__/profile-name-on-save.test.ts +443 -0
  8. package/src/__tests__/profile-startup.test.ts +1 -1
  9. package/src/__tests__/prompt-eof.test.ts +245 -0
  10. package/src/__tests__/update-apply.test.ts +3 -3
  11. package/src/__tests__/useStaleLoadModal.test.ts +27 -12
  12. package/src/cli/bootstrap.ts +34 -2
  13. package/src/cli/prompt.ts +12 -2
  14. package/src/cli/update-view.ts +6 -4
  15. package/src/services/gitignore-service.ts +0 -19
  16. package/src/services/profile-absorb.ts +9 -0
  17. package/src/services/profile-activate.ts +4 -2
  18. package/src/services/profile-adopt.ts +9 -2
  19. package/src/services/profile-startup.ts +91 -18
  20. package/src/services/profile-state.ts +10 -1
  21. package/src/ui/App.tsx +295 -141
  22. package/src/ui/adapters/pluginsAdapter.ts +28 -9
  23. package/src/ui/components/modals/MessageModal.tsx +1 -2
  24. package/src/ui/components/modals/ModalContainer.tsx +3 -0
  25. package/src/ui/hooks/useGitignoreModal.ts +58 -18
  26. package/src/ui/hooks/useProfileStartupModal.ts +232 -85
  27. package/src/ui/hooks/useStaleLoadModal.ts +43 -29
  28. package/src/ui/input/KeyRouter.tsx +43 -2
  29. package/src/ui/screens/AliasScreen.tsx +7 -6
  30. package/src/ui/screens/CliToolsScreen.tsx +19 -16
  31. package/src/ui/screens/EnvVarsScreen.tsx +4 -1
  32. package/src/ui/screens/GitignoreScreen.tsx +20 -27
  33. package/src/ui/screens/McpRegistryScreen.tsx +9 -6
  34. package/src/ui/screens/McpScreen.tsx +16 -12
  35. package/src/ui/screens/PluginsScreen.tsx +35 -56
  36. package/src/ui/screens/ProfilesScreen.tsx +27 -51
  37. package/src/ui/state/AppContext.tsx +46 -3
  38. package/src/ui/state/reducer.ts +100 -1
  39. package/src/ui/state/types.ts +53 -2
@@ -1,6 +1,7 @@
1
1
  import { useCallback } from "react";
2
- import { useApp, useNavigation } from "../state/AppContext.js";
3
- import type { ModalState } from "../state/types.js";
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, shown before returning to the app. */
92
- onFixSafe: () => Promise<string | undefined> | string | undefined;
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
- dispatch({ type: "HIDE_MODAL" });
102
- void Promise.resolve(args.onFixSafe())
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
- if (!report) return;
105
- dispatch({
106
- type: "SHOW_MODAL",
107
- modal: {
108
- type: "message",
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
- type ActivationResult,
22
- type ProfileChoice,
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, useNavigation } from "../state/AppContext.js";
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
- /** What changed, phrased for a message modal rather than a terminal. */
88
- export function describeOutcome(
89
- profileId: string,
90
- displaced: DisplacedArtifact[],
91
- projectPath: string,
92
- /** True when this happened without asking, following the committed default. */
93
- automatic = false,
94
- /** Anything else activation reported (see `activationNotes`). */
95
- notes: string[] = [],
96
- ): string {
97
- const lines = [
98
- automatic
99
- ? `This project declares "${profileId}" as its profile, so magus activated it.`
100
- : `"${profileId}" is now the active profile.`,
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
- if (displaced.length > 0) {
103
- lines.push(
104
- "",
105
- "These were moved aside to make room for the profile's files, and are kept:",
106
- ...displaced.map((d) => {
107
- const from = d.link.startsWith(projectPath)
108
- ? d.link.slice(projectPath.length + 1)
109
- : d.link;
110
- const to = d.savedTo.startsWith(projectPath)
111
- ? d.savedTo.slice(projectPath.length + 1)
112
- : d.savedTo;
113
- return ` ${from} → ${to}`;
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
- if (notes.length > 0) lines.push("", ...notes);
118
- return lines.join("\n");
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 function useProfileStartupModal(
170
- onQuit: () => void,
171
- /** Called once a choice has activated a profile — see App's gitignore re-check. */
172
- onActivated?: () => void,
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 { navigateToScreen } = useNavigation();
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
- const { profileId, displaced } = result;
217
- dispatch({
218
- type: "SHOW_MODAL",
219
- modal: {
220
- type: "message",
221
- title: "Profile active",
222
- message: describeOutcome(
223
- profileId,
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
- type: "SHOW_MODAL",
310
- modal: {
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) => apply(choice, false, current),
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 outcome modal, so it
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 }, true);
500
+ apply({ kind: "activate", id: need.declaredDefault });
354
501
  return;
355
502
  }
356
503
  ask();
357
504
  },
358
- [dispatch, navigateToScreen, projectPath, onQuit, onActivated],
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<string> | string;
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
- void Promise.resolve(args.onFix())
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
- dispatch({
97
- type: "SHOW_MODAL",
98
- modal: {
99
- type: "message",
100
- title: "Plugin repair",
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
- * One line per repair attempt, for the modal that follows the fix.
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. Each
138
- * line says what the loader resolves NOW, because that — not the row count — is
139
- * the thing the user came here to change.
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 staleLoadRepairSummary(
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
- ): string {
149
- if (results.length === 0) return "Nothing to repair.";
150
- return results
151
- .map((r) =>
152
- r.ok
153
- ? `✓ ${r.pluginId} now loads v${r.target}`
154
- : `✗ ${r.pluginId} — ${r.error ?? "repair did not take effect"}`,
155
- )
156
- .join("\n");
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
  }