magus-cli 7.7.0 → 7.8.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 (83) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/blackbox-parity.test.ts +29 -15
  3. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  4. package/src/__tests__/blind-780-cli-tools.test.ts +808 -0
  5. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  6. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  7. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  8. package/src/__tests__/blind-780-review2.test.ts +1203 -0
  9. package/src/__tests__/cli-update-view.test.ts +3 -3
  10. package/src/__tests__/deprecated-remove.test.ts +827 -0
  11. package/src/__tests__/doctor.test.ts +159 -1
  12. package/src/__tests__/failure-popup-review2.test.tsx +920 -0
  13. package/src/__tests__/failure-popup.test.tsx +419 -0
  14. package/src/__tests__/final-fixes-780.test.tsx +724 -0
  15. package/src/__tests__/format-update-outcome.test.ts +6 -32
  16. package/src/__tests__/gitignore-profile.test.ts +1 -9
  17. package/src/__tests__/models-screen-state.test.ts +18 -40
  18. package/src/__tests__/plugin-resolution.test.ts +52 -13
  19. package/src/__tests__/plugin-row-text.test.ts +30 -37
  20. package/src/__tests__/profile-dependencies.test.ts +7 -0
  21. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  22. package/src/__tests__/profile-startup.test.ts +40 -12
  23. package/src/__tests__/stale-load-repair.test.ts +31 -23
  24. package/src/__tests__/update-apply.test.ts +13 -29
  25. package/src/__tests__/update-plan.test.ts +23 -17
  26. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  27. package/src/__tests__/version-unknown-state.test.ts +15 -17
  28. package/src/cli/doctor.ts +151 -30
  29. package/src/cli/install.ts +5 -6
  30. package/src/cli/update-view.ts +58 -15
  31. package/src/cli/update.ts +27 -5
  32. package/src/services/catalog-cache-store.ts +8 -6
  33. package/src/services/claude-cli.ts +106 -19
  34. package/src/services/claude-settings.ts +84 -36
  35. package/src/services/plugin-env-needs.ts +69 -0
  36. package/src/services/plugin-manager.ts +31 -19
  37. package/src/services/plugin-resolution.ts +34 -42
  38. package/src/services/profile-activate.ts +1 -2
  39. package/src/services/profile-startup.ts +18 -43
  40. package/src/services/profile-state.ts +72 -2
  41. package/src/services/resolver.ts +95 -1
  42. package/src/services/update-engine.ts +326 -31
  43. package/src/services/update-plan.ts +293 -33
  44. package/src/types/index.ts +7 -0
  45. package/src/ui/App.tsx +90 -146
  46. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  47. package/src/ui/adapters/failureReport.ts +137 -0
  48. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  49. package/src/ui/adapters/startupReports.ts +282 -0
  50. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  51. package/src/ui/components/modals/MessageModal.tsx +7 -12
  52. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  53. package/src/ui/components/modals/index.ts +1 -0
  54. package/src/ui/hooks/startupQueue.ts +56 -0
  55. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  56. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  57. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  58. package/src/ui/keymap.ts +14 -1
  59. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  60. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  61. package/src/ui/renderers/pluginRowText.ts +25 -25
  62. package/src/ui/renderers/pluginStatusText.ts +18 -1
  63. package/src/ui/screens/AliasScreen.tsx +38 -45
  64. package/src/ui/screens/CliToolsScreen.tsx +65 -54
  65. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  66. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  67. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  68. package/src/ui/screens/McpScreen.tsx +46 -19
  69. package/src/ui/screens/ModelsScreen.tsx +46 -86
  70. package/src/ui/screens/PluginsScreen.tsx +313 -221
  71. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  72. package/src/ui/screens/SkillsScreen.tsx +83 -80
  73. package/src/ui/screens/StylesScreen.tsx +211 -275
  74. package/src/ui/state/AppContext.tsx +66 -44
  75. package/src/ui/state/DimensionsContext.tsx +3 -22
  76. package/src/ui/state/reducer.ts +12 -100
  77. package/src/ui/state/types.ts +48 -69
  78. package/src/utils/user-path.ts +60 -0
  79. package/src/__tests__/notices-rules.test.ts +0 -476
  80. package/src/__tests__/notices.test.tsx +0 -604
  81. package/src/__tests__/styles-status-line.test.ts +0 -72
  82. package/src/__tests__/version-snapshot.test.ts +0 -212
  83. package/src/services/version-snapshot.ts +0 -189
@@ -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, useNotify } from "../state/AppContext.js";
7
+ import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
8
8
  import { useDimensions } from "../state/DimensionsContext.js";
9
9
 
10
10
  import { cliTools } from "../../data/cli-tools.js";
@@ -23,12 +23,18 @@ import {
23
23
  specForCatalogTool,
24
24
  } from "../../services/tool-probe.js";
25
25
  import type { Command } from "../../utils/command-utils.js";
26
- import { formatCommand } from "../../utils/command-utils.js";
27
26
  import { createRequestGate } from "../../utils/request-gate.js";
28
27
  import {
28
+ type CliToolCommandFailure,
29
29
  type CliToolRow,
30
30
  buildToolRow,
31
+ cliToolConflictPartialReport,
32
+ cliToolConflictStuckReport,
31
33
  cliToolEnterAction,
34
+ cliToolFailureReport,
35
+ cliToolNotDone,
36
+ cliToolsNotUpdatedReport,
37
+ commandFailureReason,
32
38
  countToolRows,
33
39
  } from "../adapters/cliToolsAdapter.js";
34
40
  import { ScrollableList } from "../components/ScrollableList.js";
@@ -39,6 +45,8 @@ import {
39
45
  import { theme } from "../theme.js";
40
46
 
41
47
  const execFileAsync = promisify(execFile);
48
+ /** How long one installer command may run before it is stopped. */
49
+ const COMMAND_TIMEOUT_MS = 60_000;
42
50
 
43
51
  // ─── Session cache ─────────────────────────────────────────────────────────────
44
52
  //
@@ -91,7 +99,7 @@ export function CliToolsScreen() {
91
99
  const { state, dispatch } = useApp();
92
100
  const { cliTools: cliToolsState } = state;
93
101
  const modal = useModal();
94
- const notify = useNotify();
102
+ const showFailures = useFailurePopup();
95
103
  const dimensions = useDimensions();
96
104
 
97
105
  const [rows, setRows] = useState<CliToolRow[]>(
@@ -209,23 +217,22 @@ export function CliToolsScreen() {
209
217
  };
210
218
 
211
219
  /**
212
- * Run one installer command and report success.
220
+ * Run one installer command: null when it succeeded, else why it failed.
213
221
  *
214
222
  * `execFile`, so there is no shell to re-parse a package name — the same
215
223
  * reason `utils/run.ts` exists. It does NOT call that shared `runCommand`:
216
224
  * that one inherits stdio, and a TUI must never hand the terminal to a
217
- * child. Output is captured and discarded here; the modal is the progress
218
- * indicator.
225
+ * child. Output is captured; the modal is the progress indicator, and on a
226
+ * failure the installer's own words become the popup's reason.
219
227
  */
220
- const runCommand = async (
221
- command: Command,
222
- ): Promise<{ ok: boolean; error?: string }> => {
228
+ const runCommand = async (command: Command): Promise<string | null> => {
223
229
  try {
224
- await execFileAsync(command.cmd, [...command.args], { timeout: 60000 });
225
- return { ok: true };
230
+ await execFileAsync(command.cmd, [...command.args], {
231
+ timeout: COMMAND_TIMEOUT_MS,
232
+ });
233
+ return null;
226
234
  } catch (err) {
227
- const msg = err instanceof Error ? err.message : String(err);
228
- return { ok: false, error: msg };
235
+ return commandFailureReason(command, err, COMMAND_TIMEOUT_MS);
229
236
  }
230
237
  };
231
238
 
@@ -238,18 +245,19 @@ export function CliToolsScreen() {
238
245
  const { command, progress: action } = enter;
239
246
 
240
247
  modal.loading(`${action} ${tool.displayName}...`);
241
- const result = await runCommand(command);
248
+ const reason = await runCommand(command);
242
249
  modal.hideModal();
243
250
 
244
- if (result.ok) {
251
+ if (reason === null) {
245
252
  handleRefresh();
246
- } else {
247
- await modal.message(
248
- "Error",
249
- `Failed to ${action.toLowerCase()} ${tool.displayName}.\n\nTry running manually:\n${formatCommand(command)}`,
250
- "error",
251
- );
253
+ return;
252
254
  }
255
+ await showFailures(
256
+ cliToolFailureReport(
257
+ `${tool.displayName} was not ${cliToolNotDone(enter.kind)}`,
258
+ [{ tool: tool.displayName, command, reason }],
259
+ ),
260
+ );
253
261
  };
254
262
 
255
263
  /**
@@ -285,6 +293,11 @@ export function CliToolsScreen() {
285
293
  methods
286
294
  .map((m) => `The ${m} install stays: ${blockers.get(m)}.`)
287
295
  .join("\n");
296
+ const blockerRows = (methods: readonly InstallMethod[]) =>
297
+ methods.map((method) => ({
298
+ method,
299
+ reason: blockers.get(method) ?? "magus cannot remove it",
300
+ }));
288
301
 
289
302
  // One choice per install that can be KEPT — but only where keeping it
290
303
  // removes something. "Keep npm" beside an unremovable native copy would
@@ -298,10 +311,8 @@ export function CliToolsScreen() {
298
311
  }));
299
312
 
300
313
  if (options.length === 0) {
301
- await modal.message(
302
- `${tool.displayName} conflict`,
303
- `Installed via ${allMethods.join(" + ")}, and magus can remove none of them.\n\n${stuckList(allMethods)}\n\nRemove one by hand, then press r.`,
304
- "error",
314
+ await showFailures(
315
+ cliToolConflictStuckReport(tool.displayName, blockerRows(allMethods)),
305
316
  );
306
317
  return;
307
318
  }
@@ -319,42 +330,37 @@ export function CliToolsScreen() {
319
330
  const leftBehind = stuck.filter((m) => m !== keep);
320
331
  modal.loading(`Removing ${toRemove.join(", ")} install(s)...`);
321
332
 
322
- const errors: string[] = [];
333
+ const failed: Array<{
334
+ method: InstallMethod;
335
+ command: Command;
336
+ reason: string;
337
+ }> = [];
338
+ const unremovable = blockerRows(leftBehind);
323
339
  for (const method of toRemove) {
324
340
  const cmd = cliToolUninstall(tool, method, install.brewFormula);
325
341
  // Unreachable: `removable` was built from this same call. Reported
326
342
  // rather than skipped, because a silent skip is the defect above.
327
343
  if (!cmd) {
328
- errors.push(`${method}: no uninstall command`);
344
+ unremovable.push({
345
+ method,
346
+ reason: "magus has no uninstall command for it",
347
+ });
329
348
  continue;
330
349
  }
331
- const result = await runCommand(cmd);
332
- if (!result.ok) errors.push(`${method}: ${formatCommand(cmd)}`);
350
+ const reason = await runCommand(cmd);
351
+ if (reason !== null) failed.push({ method, command: cmd, reason });
333
352
  }
334
353
 
335
354
  modal.hideModal();
336
355
 
337
- const stays =
338
- leftBehind.length > 0
339
- ? `${stuckList(leftBehind)}\nRemove it by hand if you want it gone.`
340
- : "";
341
-
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
- await modal.message(
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",
356
+ // A failed removal goes into the failure popup with the command to run,
357
+ // and so does an install left behind beside it. An install magus could
358
+ // never remove is not, on its own, a failure: nothing failed, and the row
359
+ // still shows the conflict until it is dealt with.
360
+ if (failed.length > 0 || unremovable.length > leftBehind.length) {
361
+ await showFailures(
362
+ cliToolConflictPartialReport(tool.displayName, failed, unremovable),
355
363
  );
356
- } else if (stays !== "") {
357
- notify(stays.replace(/\n/g, " "), "warning");
358
364
  }
359
365
 
360
366
  handleRefresh();
@@ -365,19 +371,24 @@ export function CliToolsScreen() {
365
371
  // swept up in a bulk action — that is exactly the "cannot tell, so run
366
372
  // it anyway" the CLI no longer does either.
367
373
  const updatable = rows.filter((r) => r.status?.state === "upgrade");
368
- if (updatable.length === 0) {
369
- notify("No tool is known to be behind");
370
- return;
371
- }
374
+ if (updatable.length === 0) return;
372
375
 
373
376
  modal.loading(`Updating ${updatable.length} tool(s)...`);
374
377
 
378
+ // Every failure is kept: the reload shows the tool still behind, and
379
+ // only this says why.
380
+ const failed: CliToolCommandFailure[] = [];
375
381
  for (const row of updatable) {
376
- await runCommand(row.updateCommand ?? row.installCommand);
382
+ const command = row.updateCommand ?? row.installCommand;
383
+ const reason = await runCommand(command);
384
+ if (reason !== null) {
385
+ failed.push({ tool: row.tool.displayName, command, reason });
386
+ }
377
387
  }
378
388
 
379
389
  modal.hideModal();
380
390
  handleRefresh();
391
+ await showFailures(cliToolsNotUpdatedReport(failed));
381
392
  };
382
393
 
383
394
  const selectedRow = rows[cliToolsState.selectedIndex];
@@ -9,6 +9,7 @@ import {
9
9
  type SettingsBrowserItem,
10
10
  buildSettingsBrowserItems,
11
11
  } from "../adapters/settingsAdapter.js";
12
+ import { stepFailureReport } from "../adapters/startupReports.js";
12
13
  import { ScrollableList } from "../components/ScrollableList.js";
13
14
  import { ScreenLayout } from "../components/layout/index.js";
14
15
  import { ErrorNotice } from "../components/primitives/index.js";
@@ -18,7 +19,7 @@ import {
18
19
  renderSettingDetail,
19
20
  renderSettingRow,
20
21
  } from "../renderers/settingsRenderers.js";
21
- import { useApp, useModal } from "../state/AppContext.js";
22
+ import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
22
23
  import { useDimensions } from "../state/DimensionsContext.js";
23
24
  import { theme } from "../theme.js";
24
25
 
@@ -26,8 +27,19 @@ export function SettingsScreen() {
26
27
  const { state, dispatch } = useApp();
27
28
  const { settings } = state;
28
29
  const modal = useModal();
30
+ const showFailures = useFailurePopup();
29
31
  const dimensions = useDimensions();
30
32
 
33
+ /** A setting that did not save, at the scope it was being saved to. */
34
+ const reportNotSaved = (
35
+ name: string,
36
+ scope: "user" | "project",
37
+ error: unknown,
38
+ ): Promise<void> =>
39
+ showFailures(
40
+ stepFailureReport(`${name} was not saved`, scope.toUpperCase(), error),
41
+ );
42
+
31
43
  const fetchData = useCallback(async () => {
32
44
  dispatch({ type: "SETTINGS_DATA_LOADING" });
33
45
  try {
@@ -84,7 +96,7 @@ export function SettingsScreen() {
84
96
  await writeSettingValue(setting, newValue, scope, state.projectPath);
85
97
  await fetchData();
86
98
  } catch (error) {
87
- await modal.message("Error", `Failed to update: ${error}`, "error");
99
+ await reportNotSaved(setting.name, scope, error);
88
100
  }
89
101
  } else if (setting.type === "select" && setting.options) {
90
102
  const options = setting.options.map((o) => ({
@@ -110,7 +122,7 @@ export function SettingsScreen() {
110
122
  await writeSettingValue(setting, val, scope, state.projectPath);
111
123
  await fetchData();
112
124
  } catch (error) {
113
- await modal.message("Error", `Failed to update: ${error}`, "error");
125
+ await reportNotSaved(setting.name, scope, error);
114
126
  }
115
127
  } else {
116
128
  // String type: if already set, clear it; if unset, show input modal
@@ -119,7 +131,7 @@ export function SettingsScreen() {
119
131
  await writeSettingValue(setting, undefined, scope, state.projectPath);
120
132
  await fetchData();
121
133
  } catch (error) {
122
- await modal.message("Error", `Failed to update: ${error}`, "error");
134
+ await reportNotSaved(setting.name, scope, error);
123
135
  }
124
136
  } else {
125
137
  const newValue = await modal.input(
@@ -137,7 +149,7 @@ export function SettingsScreen() {
137
149
  );
138
150
  await fetchData();
139
151
  } catch (error) {
140
- await modal.message("Error", `Failed to update: ${error}`, "error");
152
+ await reportNotSaved(setting.name, scope, error);
141
153
  }
142
154
  }
143
155
  }
@@ -12,6 +12,11 @@ import {
12
12
  loadGitignoreState,
13
13
  } from "../../services/gitignore-service.js";
14
14
  import type { ResolvedRule, Tier, Violation } from "../../types/gitignore.js";
15
+ import {
16
+ gitignoreFixReport,
17
+ gitignoreTemplateReport,
18
+ stepFailureReport,
19
+ } from "../adapters/startupReports.js";
15
20
  import { ScreenLayout } from "../components/layout/index.js";
16
21
  import { useScreenKeys } from "../input/KeyRouter.js";
17
22
  import { moveDirection } from "../keymap.js";
@@ -19,7 +24,8 @@ import {
19
24
  type RuleRow,
20
25
  renderRuleRow,
21
26
  } from "../renderers/gitignoreRenderers.js";
22
- import { useApp, useModal, useNotify } from "../state/AppContext.js";
27
+ import { useApp, useModal } from "../state/AppContext.js";
28
+ import type { FailureReport } from "../state/types.js";
23
29
  import { theme } from "../theme.js";
24
30
 
25
31
  // Clean rows are grouped by the manifest that defined them. Order = display
@@ -36,7 +42,6 @@ const SOURCE_LABELS: Record<Tier, string> = {
36
42
  export function GitignoreScreen() {
37
43
  const { state } = useApp();
38
44
  const modal = useModal();
39
- const notify = useNotify();
40
45
  const renderer = useRenderer();
41
46
  const cwd = state.projectPath;
42
47
 
@@ -139,109 +144,83 @@ export function GitignoreScreen() {
139
144
  const handleFixAll = useCallback(async () => {
140
145
  if (!data || toFixRows.length === 0) return;
141
146
  try {
142
- const messages: string[] = [];
143
- const failures: string[] = [];
147
+ const failures: Array<{ path: string; message: string }> = [];
144
148
  for (const { row } of toFixRows) {
145
149
  for (const v of row.violations) {
146
150
  const r = await applyDestructiveFix(cwd, v, row.rule.pattern);
147
- (r.applied ? messages : failures).push(r.message);
151
+ if (!r.applied) failures.push({ path: v.path, message: r.message });
148
152
  }
149
153
  }
150
154
  await reload();
151
- if (failures.length > 0) {
152
- await modal.message(
153
- "Some fixes didn't apply",
154
- [...failures, ...messages].join("\n"),
155
- "error",
156
- );
157
- }
155
+ // Only what did not apply, each with why; the reloaded rows show the rest.
156
+ const report = gitignoreFixReport(failures);
157
+ if (report) await modal.failures(report);
158
158
  } catch (err) {
159
- await modal.message(
160
- "Fix failed",
161
- err instanceof Error ? err.message : String(err),
162
- "error",
159
+ await modal.failures(
160
+ stepFailureReport("The fixes were not applied", ".gitignore", err),
163
161
  );
164
162
  }
165
163
  }, [cwd, data, toFixRows, reload, modal]);
166
164
 
167
165
  // Fix just the highlighted row.
168
166
  const handleFixOne = useCallback(async () => {
169
- if (!selectedRow) {
170
- notify("Highlight a row that needs fixing, then press f", "warning");
171
- return;
172
- }
173
- if (selectedRow.violations.length === 0) {
174
- notify(`${selectedRow.rule.pattern} already matches — nothing to fix`);
175
- return;
176
- }
167
+ // Nothing highlighted, or a row that already matches: nothing to fix.
168
+ if (!selectedRow || selectedRow.violations.length === 0) return;
177
169
 
178
170
  try {
179
- const results: Array<{ applied: boolean; message: string }> = [];
171
+ const failures: Array<{ path: string; message: string }> = [];
180
172
  for (const v of selectedRow.violations) {
181
- results.push(
182
- await applyDestructiveFix(cwd, v, selectedRow.rule.pattern),
183
- );
173
+ const r = await applyDestructiveFix(cwd, v, selectedRow.rule.pattern);
174
+ if (!r.applied) failures.push({ path: v.path, message: r.message });
184
175
  }
185
- const failed = results.filter((r) => !r.applied);
186
176
  await reload();
187
- // Success is silent — the reload removes the fixed row. Only surface a
188
- // modal when something didn't apply.
189
- if (failed.length > 0) {
190
- await modal.message(
191
- "Some fixes didn't apply",
192
- results.map((r) => r.message).join("\n"),
193
- "error",
194
- );
195
- }
177
+ // Success is silent — the reload removes the fixed row. Only what did
178
+ // not apply goes into the failure popup.
179
+ const report = gitignoreFixReport(failures);
180
+ if (report) await modal.failures(report);
196
181
  } catch (err) {
197
- await modal.message(
198
- "Fix failed",
199
- err instanceof Error ? err.message : String(err),
200
- "error",
182
+ await modal.failures(
183
+ stepFailureReport(
184
+ "The fix was not applied",
185
+ selectedRow.rule.pattern,
186
+ err,
187
+ ),
201
188
  );
202
189
  }
203
- }, [selectedRow, modal, notify, cwd, reload]);
190
+ }, [selectedRow, modal, cwd, reload]);
204
191
 
205
192
  const handleApplyTemplate = useCallback(async () => {
206
193
  const names = Object.keys(BUILTIN_TEMPLATES);
207
- if (names.length === 0) {
208
- notify("No built-in templates available", "warning");
209
- return;
210
- }
194
+ if (names.length === 0) return;
211
195
  const selected = await modal.select(
212
196
  "Apply template",
213
197
  "Merge the template into the project manifest at .claude/gitignore.json.",
214
198
  names.map((n) => ({ label: n, value: n })),
215
199
  );
216
200
  if (!selected) return;
201
+ let report: FailureReport | null = null;
217
202
  try {
203
+ // What was merged shows in the list. What was skipped does not, so it
204
+ // is the one thing said — in the failure popup.
218
205
  const r = await applyTemplate(cwd, selected);
219
- notify(
220
- `Applied "${selected}": ${r.added.ignore.length} ignore + ${r.added.track.length} track entries`,
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
- }
206
+ report = gitignoreTemplateReport(selected, r.conflicts);
229
207
  } catch (err) {
230
- await modal.message(
231
- "Template failed",
232
- err instanceof Error ? err.message : String(err),
233
- "error",
208
+ report = stepFailureReport(
209
+ `Template "${selected}" was not applied`,
210
+ ".claude/gitignore.json",
211
+ err,
234
212
  );
235
213
  }
236
214
  await reload();
237
- }, [cwd, modal, notify, reload]);
215
+ if (report) await modal.failures(report);
216
+ }, [cwd, modal, reload]);
238
217
 
239
218
  /**
240
219
  * Open the project manifest in $EDITOR, creating it first if needed.
241
220
  *
242
221
  * Every await sits inside the try: this runs fire-and-forget from a key, and a
243
222
  * rejection nobody catches ends the app. A manifest that cannot be read or
244
- * created is said in a dialog on this screen instead.
223
+ * created is said in the failure popup instead.
245
224
  */
246
225
  const handleEditManifest = useCallback(async () => {
247
226
  let projectManifest: string;
@@ -252,10 +231,12 @@ export function GitignoreScreen() {
252
231
  current.resolved,
253
232
  );
254
233
  } catch (err) {
255
- await modal.message(
256
- "Cannot open the manifest",
257
- err instanceof Error ? err.message : String(err),
258
- "error",
234
+ await modal.failures(
235
+ stepFailureReport(
236
+ "The project's rules were not opened for editing",
237
+ ".claude/gitignore.json",
238
+ err,
239
+ ),
259
240
  );
260
241
  return;
261
242
  }
@@ -1,20 +1,30 @@
1
1
  import React, { useEffect, useCallback, useState, useRef } from "react";
2
+ import { stepFailureReport } from "../adapters/startupReports.js";
2
3
  import { ScreenLayout } from "../components/layout/index.js";
3
4
  import { useScreenKeys, useSearchInput } from "../input/KeyRouter.js";
4
5
  import { moveDirection } from "../keymap.js";
5
6
  import {
6
7
  useApp,
8
+ useFailurePopup,
7
9
  useModal,
8
10
  useNavigation,
9
- useNotify,
10
11
  } from "../state/AppContext.js";
11
12
  import { useDimensions } from "../state/DimensionsContext.js";
12
13
 
13
- import { addMcpServer, setAllowMcp } from "../../services/claude-settings.js";
14
+ import {
15
+ addMcpServer,
16
+ getEnabledMcpServers,
17
+ getInstalledMcpServers,
18
+ setAllowMcp,
19
+ } from "../../services/claude-settings.js";
14
20
  import { formatDate, searchMcpServers } from "../../services/mcp-registry.js";
15
21
  import type { McpRegistryServer, McpServerConfig } from "../../types/index.js";
16
22
  import { ScrollableList } from "../components/ScrollableList.js";
17
- import { rowLead } from "../components/primitives/index.js";
23
+ import {
24
+ type McpRegistryListItem,
25
+ renderMcpRegistryRow,
26
+ renderMcpRegistryStatus,
27
+ } from "../renderers/mcpRenderers.js";
18
28
  import { theme } from "../theme.js";
19
29
 
20
30
  /**
@@ -80,7 +90,7 @@ export function McpRegistryScreen() {
80
90
  const { state, dispatch } = useApp();
81
91
  const { mcpRegistry } = state;
82
92
  const modal = useModal();
83
- const notify = useNotify();
93
+ const showFailures = useFailurePopup();
84
94
  const { navigateToScreen } = useNavigation();
85
95
  const dimensions = useDimensions();
86
96
 
@@ -94,6 +104,33 @@ export function McpRegistryScreen() {
94
104
 
95
105
  const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
96
106
 
107
+ /**
108
+ * The servers this project has configured, read from the same place the MCP
109
+ * screen reads. A row here shows installed from THIS, and the restart from
110
+ * `mcp.changedThisSession` — the same two states a curated row carries.
111
+ */
112
+ const [installedNames, setInstalledNames] = useState<ReadonlySet<string>>(
113
+ new Set(),
114
+ );
115
+ const loadInstalled = useCallback(async () => {
116
+ try {
117
+ const [installed, enabled] = await Promise.all([
118
+ getInstalledMcpServers(state.projectPath),
119
+ getEnabledMcpServers(state.projectPath),
120
+ ]);
121
+ setInstalledNames(
122
+ new Set([...Object.keys(installed), ...Object.keys(enabled)]),
123
+ );
124
+ } catch {
125
+ // A configuration that cannot be read is the MCP screen's to report,
126
+ // where it replaces the list; here the rows simply carry no marks.
127
+ setInstalledNames(new Set());
128
+ }
129
+ }, [state.projectPath]);
130
+ useEffect(() => {
131
+ void loadInstalled();
132
+ }, [loadInstalled]);
133
+
97
134
  // Load servers
98
135
  const loadServers = useCallback(async (query: string) => {
99
136
  setIsLoading(true);
@@ -196,16 +233,28 @@ export function McpRegistryScreen() {
196
233
  await setAllowMcp(true, state.projectPath);
197
234
  await addMcpServer(server.name, config, state.projectPath);
198
235
  modal.hideModal();
199
- // A warning: the server does nothing until the user restarts.
200
- notify(`Added ${server.name} — restart Claude Code to use it`, "warning");
236
+ // Shown on THIS row as its state — installed, and "↻ restart Claude
237
+ // Code" — not as a message. The MCP screen lists curated servers only,
238
+ // so this row is where the user can see it.
239
+ dispatch({ type: "MCP_SERVER_CHANGED", name: server.name });
240
+ await loadInstalled();
201
241
  } catch (error) {
202
242
  modal.hideModal();
203
- await modal.message("Error", `Failed to install: ${error}`, "error");
243
+ await showFailures(
244
+ stepFailureReport(`Could not add ${server.name}`, server.name, error),
245
+ );
204
246
  }
205
247
  };
206
248
 
249
+ const listItems: McpRegistryListItem[] = servers.map((server) => ({
250
+ server,
251
+ isInstalled: installedNames.has(server.name),
252
+ needsRestart: state.mcp.changedThisSession.includes(server.name),
253
+ }));
254
+
207
255
  // Get selected server
208
- const selectedServer = servers[mcpRegistry.selectedIndex];
256
+ const selectedItem = listItems[mcpRegistry.selectedIndex];
257
+ const selectedServer = selectedItem?.server;
209
258
 
210
259
  const renderDetail = () => {
211
260
  if (isLoading) {
@@ -216,7 +265,7 @@ export function McpRegistryScreen() {
216
265
  return <text fg={theme.colors.danger}>Error: {error}</text>;
217
266
  }
218
267
 
219
- if (!selectedServer) {
268
+ if (!selectedServer || !selectedItem) {
220
269
  return (
221
270
  <text fg={theme.colors.muted}>Select a server to see details</text>
222
271
  );
@@ -238,6 +287,7 @@ export function McpRegistryScreen() {
238
287
  <box marginTop={1}>
239
288
  <text fg={theme.colors.text}>{selectedServer.short_description}</text>
240
289
  </box>
290
+ <box marginTop={1}>{renderMcpRegistryStatus(selectedItem)}</box>
241
291
  <box flexDirection="row" marginTop={1}>
242
292
  <text fg={theme.colors.text}>
243
293
  <strong>Version: </strong>
@@ -266,36 +316,15 @@ export function McpRegistryScreen() {
266
316
  </text>
267
317
  </box>
268
318
  )}
269
- <box marginTop={1}>
270
- <text fg={theme.colors.muted}>Press Enter to install</text>
271
- </box>
319
+ {selectedItem.isInstalled ? null : (
320
+ <box marginTop={1}>
321
+ <text fg={theme.colors.muted}>Press Enter to install</text>
322
+ </box>
323
+ )}
272
324
  </box>
273
325
  );
274
326
  };
275
327
 
276
- const renderListItem = (
277
- server: McpRegistryServer,
278
- _idx: number,
279
- isSelected: boolean,
280
- ) => {
281
- const version = server.version ? ` v${server.version}` : "";
282
- return isSelected ? (
283
- <text bg={theme.selection.bg} fg={theme.selection.fg}>
284
- {rowLead(true)}
285
- {server.name}
286
- {version}{" "}
287
- </text>
288
- ) : (
289
- <text fg={theme.colors.text}>
290
- <span>{rowLead(false)}</span>
291
- <span>
292
- <strong>{server.name}</strong>
293
- </span>
294
- <span fg={theme.colors.info}>{version}</span>
295
- </text>
296
- );
297
- };
298
-
299
328
  // Status for search placeholder
300
329
  const searchPlaceholder = `${servers.length} servers │ / to search`;
301
330
 
@@ -319,11 +348,11 @@ export function McpRegistryScreen() {
319
348
  <text fg={theme.colors.muted}>No servers found</text>
320
349
  ) : (
321
350
  <ScrollableList
322
- items={servers}
351
+ items={listItems}
323
352
  selectedIndex={mcpRegistry.selectedIndex}
324
- renderItem={renderListItem}
353
+ renderItem={renderMcpRegistryRow}
325
354
  maxHeight={dimensions.listPanelHeight}
326
- getKey={(server) => server.name}
355
+ getKey={(item) => item.server.name}
327
356
  />
328
357
  )
329
358
  }