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
@@ -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
- flushSync(() => {
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
- flushSync(() => {
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
- 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");
@@ -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 { 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 {
@@ -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
- await modal.message(
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
- await modal.message(
220
- "Installed",
221
- `${server.name} has been configured.\n\nRestart Claude Code to activate.`,
222
- "success",
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 { useApp, useModal, useProgress } from "../state/AppContext.js";
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
- let message =
562
- "Cache refreshed.\n\n" +
563
- "To update marketplaces from GitHub, run in Claude Code:\n" +
564
- " /plugin marketplace update\n\n" +
565
- "Then refresh magus again with 'r'.";
566
-
567
- if (result.repair.length > 0) {
568
- const totalRepaired = result.repair.reduce(
569
- (sum, r) => sum + r.repaired.length,
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
- await modal.message(
701
- "Skipped Configuration",
702
- `You can configure these variables later in the Settings tab (press ${tabKey("settings")}).`,
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
- await modal.message(
729
- "Configuration Incomplete",
730
- `Skipped remaining configuration.\nYou can configure these later in the Settings tab (press ${tabKey("settings")}).`,
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
- await modal.message(
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
- if (result.unchanged.length > 0) {
1051
- await modal.message(
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
- if (result.unchanged.length > 0) {
1125
- await modal.message(
1126
- "No change",
1127
- reasons.lines(result.unchanged),
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
- await modal.message(
1282
- "Saved",
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, exact pins are unknown here and every row is
1383
- // judged against "latest". Saying so is what keeps a row that disagrees with
1384
- // `magus update <profile>` from reading as a contradiction.
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
- (catalogNotice ??
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 (