magus-cli 7.6.0 → 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.
@@ -17,10 +17,9 @@
17
17
 
18
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 {
@@ -37,8 +36,8 @@ import {
37
36
  } from "../../services/profile-startup.js";
38
37
  import { applyProfileChoice } from "../../services/profile-state.js";
39
38
  import { SettingsParseError } from "../../services/settings-file.js";
40
- import { useApp, useNavigation } from "../state/AppContext.js";
41
- import type { ModalState } from "../state/types.js";
39
+ import { useApp, useNotify } from "../state/AppContext.js";
40
+ import type { ModalState, NoticeLine } from "../state/types.js";
42
41
 
43
42
  export interface BuildProfileModalArgs {
44
43
  need: ProfileStartupNeed;
@@ -85,38 +84,79 @@ export function buildProfileStartupModal(
85
84
  };
86
85
  }
87
86
 
88
- /** What changed, phrased for a message modal rather than a terminal. */
89
- export function describeOutcome(
90
- profileId: string,
91
- displaced: DisplacedArtifact[],
92
- projectPath: string,
93
- /** True when this happened without asking, following the committed default. */
94
- automatic = false,
95
- /** Anything else activation reported (see `activationNotes`). */
96
- notes: string[] = [],
97
- ): string {
98
- const lines = [
99
- automatic
100
- ? `This project declares "${profileId}" as its profile, so magus activated it.`
101
- : `"${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),
102
108
  ];
103
- if (displaced.length > 0) {
104
- lines.push(
105
- "",
106
- "These were moved aside to make room for the profile's files, and are kept:",
107
- ...displaced.map((d) => {
108
- const from = d.link.startsWith(projectPath)
109
- ? d.link.slice(projectPath.length + 1)
110
- : d.link;
111
- const to = d.savedTo.startsWith(projectPath)
112
- ? d.savedTo.slice(projectPath.length + 1)
113
- : d.savedTo;
114
- return ` ${from} → ${to}`;
115
- }),
116
- );
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
+ });
117
135
  }
118
- if (notes.length > 0) lines.push("", ...notes);
119
- return lines.join("\n");
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
+ });
150
+ }
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;
120
160
  }
121
161
 
122
162
  /** Project-relative, so a path does not wrap over two lines. */
@@ -170,7 +210,10 @@ export function describeFailure(
170
210
  export interface ProfileStartupModalCallbacks {
171
211
  /** Leave magus: the modal's only non-action. */
172
212
  onQuit: () => void;
173
- /** Called once a choice has activated a profile — see App's gitignore re-check. */
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
+ */
174
217
  onActivated?: () => void;
175
218
  /**
176
219
  * The question is ANSWERED: a profile is active, or the user chose to quit.
@@ -185,7 +228,8 @@ export interface ProfileStartupModalCallbacks {
185
228
  * question on screen NOW, which is not always the one magus opened with: "Update
186
229
  * it now" asks a new one, and a manifest that stopped parsing turns the question
187
230
  * into the unparseable one. `working` is true while an activation or a format
188
- * update is running — its result modal takes the slot when it settles.
231
+ * update is running — when it settles it empties the slot (success, reported as
232
+ * a notice) or puts its error modal there.
189
233
  */
190
234
  interface ProfileFlow {
191
235
  need: ProfileStartupNeed;
@@ -198,7 +242,7 @@ export function useProfileStartupModal({
198
242
  onAnswered,
199
243
  }: ProfileStartupModalCallbacks) {
200
244
  const { state, dispatch } = useApp();
201
- const { navigateToScreen } = useNavigation();
245
+ const notify = useNotify();
202
246
  const projectPath = state.projectPath;
203
247
  // A ref, not state: `show` is recreated on every render, and the flow must
204
248
  // survive that — a re-show has to know what was last asked, and that work is
@@ -235,7 +279,6 @@ export function useProfileStartupModal({
235
279
  */
236
280
  const apply = (
237
281
  choice: ProfileChoice,
238
- automatic: boolean,
239
282
  current: ProfileStartupNeed = need,
240
283
  ): void => {
241
284
  flowRef.current = { need: current, working: true };
@@ -269,28 +312,14 @@ export function useProfileStartupModal({
269
312
  // question never came back.
270
313
  answered();
271
314
  onActivated?.();
272
- const { profileId, displaced } = result;
273
- dispatch({
274
- type: "SHOW_MODAL",
275
- modal: {
276
- type: "message",
277
- title: "Profile active",
278
- message: describeOutcome(
279
- profileId,
280
- displaced,
281
- projectPath,
282
- automatic,
283
- activationNotes(result),
284
- ),
285
- variant: "success",
286
- onDismiss: () => {
287
- dispatch({ type: "HIDE_MODAL" });
288
- // The Profiles tab is where the result is visible, and
289
- // where the user will want to look next.
290
- navigateToScreen("profiles");
291
- },
292
- },
293
- });
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
+ }
294
323
  })
295
324
  .catch((error: unknown) => {
296
325
  // The manifest stopped parsing between launch and this answer —
@@ -344,11 +373,24 @@ export function useProfileStartupModal({
344
373
  void prepareProfileStartup(projectPath, { convert: true })
345
374
  .then((result) => {
346
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
+ };
347
388
  if (outcome.kind === "ask") {
348
389
  // Converted, but no profile is active yet: a new question,
349
390
  // still unanswered.
350
391
  onActivated?.();
351
392
  ask(outcome.need);
393
+ warnings();
352
394
  return;
353
395
  }
354
396
  if (outcome.kind === "failed") {
@@ -370,19 +412,9 @@ export function useProfileStartupModal({
370
412
  // Nothing left to ask: the converted profile is active.
371
413
  answered();
372
414
  onActivated?.();
373
- dispatch({
374
- type: "SHOW_MODAL",
375
- modal: {
376
- type: "message",
377
- title: outcome.title,
378
- message: outcome.message,
379
- variant: "success",
380
- onDismiss: () => {
381
- dispatch({ type: "HIDE_MODAL" });
382
- navigateToScreen("profiles");
383
- },
384
- },
385
- });
415
+ dispatch({ type: "HIDE_MODAL" });
416
+ notify(outcome.notice);
417
+ warnings();
386
418
  })
387
419
  .catch((error: unknown) => {
388
420
  flowRef.current = { need, working: false };
@@ -427,11 +459,7 @@ export function useProfileStartupModal({
427
459
  askName(suggestion, current, value, check.reason);
428
460
  return;
429
461
  }
430
- apply(
431
- { kind: "adopt", id: check.id, name: check.name },
432
- false,
433
- current,
434
- );
462
+ apply({ kind: "adopt", id: check.id, name: check.name }, current);
435
463
  },
436
464
  onCancel: () => ask(current),
437
465
  },
@@ -447,7 +475,7 @@ export function useProfileStartupModal({
447
475
  onChoose: (choice) =>
448
476
  choice.kind === "adopt"
449
477
  ? askName(choice.id, current)
450
- : apply(choice, false, current),
478
+ : apply(choice, current),
451
479
  onUpdateFormat: updateFormat,
452
480
  onQuit: quit,
453
481
  }),
@@ -466,15 +494,15 @@ export function useProfileStartupModal({
466
494
 
467
495
  // The repo already answered and there is nothing of the user's to
468
496
  // replace: asking here would ask someone to re-decide what their team
469
- // 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
470
498
  // is never invisible.
471
499
  if (need.autoActivate && need.declaredDefault !== null) {
472
- apply({ kind: "activate", id: need.declaredDefault }, true);
500
+ apply({ kind: "activate", id: need.declaredDefault });
473
501
  return;
474
502
  }
475
503
  ask();
476
504
  },
477
- [dispatch, navigateToScreen, projectPath, onQuit, onActivated, onAnswered],
505
+ [dispatch, notify, projectPath, onQuit, onActivated, onAnswered],
478
506
  );
479
507
 
480
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" });
@@ -97,16 +98,11 @@ export function useStaleLoadModal() {
97
98
  void Promise.resolve()
98
99
  .then(() => args.onFix())
99
100
  .then((summary) => {
100
- dispatch({
101
- type: "SHOW_MODAL",
102
- modal: {
103
- type: "message",
104
- title: "Plugin repair",
105
- message: summary,
106
- variant: "info",
107
- onDismiss: dismiss,
108
- },
109
- });
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);
110
106
  })
111
107
  .catch((err) => {
112
108
  dispatch({
@@ -129,33 +125,47 @@ export function useStaleLoadModal() {
129
125
  });
130
126
  dispatch({ type: "SHOW_MODAL", modal });
131
127
  },
132
- [dispatch, navigateToScreen],
128
+ [dispatch, navigateToScreen, notify],
133
129
  );
134
130
 
135
131
  return { show };
136
132
  }
137
133
 
138
134
  /**
139
- * 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.
140
137
  *
141
- * Exported and pure so the wording is tested rather than inspected by eye. Each
142
- * line says what the loader resolves NOW, because that — not the row count — is
143
- * 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.
144
143
  */
145
- export function staleLoadRepairSummary(
144
+ export function staleLoadRepairNotices(
146
145
  results: ReadonlyArray<{
147
146
  pluginId: string;
148
147
  target: string;
149
148
  ok: boolean;
150
149
  error?: string;
151
150
  }>,
152
- ): string {
153
- if (results.length === 0) return "Nothing to repair.";
154
- return results
155
- .map((r) =>
156
- r.ok
157
- ? `✓ ${r.pluginId} now loads v${r.target}`
158
- : `✗ ${r.pluginId} — ${r.error ?? "repair did not take effect"}`,
159
- )
160
- .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;
161
171
  }
@@ -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
- await modal.message(
569
- "No shells detected",
570
- "No supported rc files found. Create ~/.zshrc, ~/.bashrc, or ~/.config/fish/config.fish first.",
571
- "info",
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 report = [
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
- if (report.length > 0) {
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
- errors.length > 0 ? "Partly removed" : "One install stays",
348
- report.join("\n\n"),
349
- errors.length > 0 ? "error" : "info",
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
- await modal.message(
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
- await modal.message(
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
- await modal.message(
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
- await modal.message(
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
- const conflictNote =
228
- r.conflicts.length > 0
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 { useApp, useModal, useNavigation } from "../state/AppContext.js";
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
- await modal.message(
194
- "Installed",
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");