magus-cli 7.7.0 → 7.9.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 (140) hide show
  1. package/README.md +1 -1
  2. package/package.json +4 -4
  3. package/src/__tests__/blackbox-parity.test.ts +45 -26
  4. package/src/__tests__/blackbox-plugin-state.test.ts +25 -6
  5. package/src/__tests__/blind/plugin-deps/doctor.test.ts +622 -0
  6. package/src/__tests__/blind/plugin-deps/helpers.ts +340 -0
  7. package/src/__tests__/blind/plugin-deps/hook.test.ts +328 -0
  8. package/src/__tests__/blind/plugin-deps/probe.test.ts +694 -0
  9. package/src/__tests__/blind/plugin-deps/read-only.test.ts +96 -0
  10. package/src/__tests__/blind/plugin-deps/schema.test.ts +390 -0
  11. package/src/__tests__/blind-780-cli-tools.test.ts +796 -0
  12. package/src/__tests__/blind-780-deprecated.test.ts +631 -0
  13. package/src/__tests__/blind-780-failure-reasons.test.ts +1004 -0
  14. package/src/__tests__/blind-780-plugin-resolution.test.ts +431 -0
  15. package/src/__tests__/blind-780-review2.test.ts +1178 -0
  16. package/src/__tests__/cli-apply-seams.test.ts +2 -67
  17. package/src/__tests__/cli-tool-commands.test.ts +131 -6
  18. package/src/__tests__/cli-tools-adapter.test.ts +52 -2
  19. package/src/__tests__/cli-tools-plugin-managed.test.tsx +83 -0
  20. package/src/__tests__/cli-update-view.test.ts +3 -3
  21. package/src/__tests__/deprecated-remove.test.ts +827 -0
  22. package/src/__tests__/deps-adapter.test.ts +191 -0
  23. package/src/__tests__/deps-check-hook.test.ts +220 -0
  24. package/src/__tests__/deps-plan.test.ts +896 -0
  25. package/src/__tests__/deps-report.test.ts +197 -0
  26. package/src/__tests__/doctor-bins.test.ts +134 -36
  27. package/src/__tests__/doctor-json.test.ts +261 -0
  28. package/src/__tests__/doctor.test.ts +159 -1
  29. package/src/__tests__/failure-popup-review2.test.tsx +896 -0
  30. package/src/__tests__/failure-popup.test.tsx +419 -0
  31. package/src/__tests__/final-fixes-780.test.tsx +740 -0
  32. package/src/__tests__/fixtures/bins.ts +55 -0
  33. package/src/__tests__/format-update-outcome.test.ts +6 -32
  34. package/src/__tests__/gitignore-profile.test.ts +1 -9
  35. package/src/__tests__/install-plan.test.ts +15 -1
  36. package/src/__tests__/marketplace-refresh.test.ts +9 -3
  37. package/src/__tests__/models-screen-state.test.ts +18 -40
  38. package/src/__tests__/no-shell-spawn.test.ts +77 -0
  39. package/src/__tests__/npm-package-name.test.ts +6 -9
  40. package/src/__tests__/plugin-requires.test.ts +138 -153
  41. package/src/__tests__/plugin-resolution.test.ts +52 -13
  42. package/src/__tests__/plugin-row-text.test.ts +30 -37
  43. package/src/__tests__/profile-dependencies.test.ts +7 -0
  44. package/src/__tests__/profile-startup-old-format.test.ts +0 -3
  45. package/src/__tests__/profile-startup.test.ts +40 -12
  46. package/src/__tests__/requires-probe.test.ts +858 -0
  47. package/src/__tests__/resolver.test.ts +129 -52
  48. package/src/__tests__/run.test.ts +108 -0
  49. package/src/__tests__/stale-load-repair.test.ts +31 -23
  50. package/src/__tests__/tool-probe.test.ts +1 -1
  51. package/src/__tests__/toolchain.test.ts +123 -52
  52. package/src/__tests__/update-apply.test.ts +13 -29
  53. package/src/__tests__/update-plan.test.ts +24 -18
  54. package/src/__tests__/useStaleLoadModal.test.ts +1 -57
  55. package/src/__tests__/version-unknown-state.test.ts +15 -17
  56. package/src/cli/ansi.ts +2 -7
  57. package/src/cli/deps-view.ts +213 -0
  58. package/src/cli/doctor.ts +320 -54
  59. package/src/cli/install.ts +55 -71
  60. package/src/cli/profile.ts +20 -2
  61. package/src/cli/router.ts +6 -2
  62. package/src/cli/update-view.ts +58 -15
  63. package/src/cli/update.ts +55 -12
  64. package/src/data/cli-tools.ts +24 -9
  65. package/src/services/active-closure.ts +3 -3
  66. package/src/services/catalog-cache-store.ts +8 -6
  67. package/src/services/claude-cli.ts +106 -19
  68. package/src/services/claude-settings.ts +88 -384
  69. package/src/services/cli-tool-commands.ts +104 -3
  70. package/src/services/deps-plan.ts +962 -0
  71. package/src/services/deps-report.ts +265 -0
  72. package/src/services/doctor-bins.ts +115 -43
  73. package/src/services/install-plan.ts +11 -1
  74. package/src/services/local-marketplace.ts +12 -206
  75. package/src/services/marketplace-refresh.ts +4 -4
  76. package/src/services/plugin-env-needs.ts +69 -0
  77. package/src/services/plugin-manager.ts +35 -37
  78. package/src/services/plugin-requires.ts +93 -162
  79. package/src/services/plugin-resolution.ts +34 -42
  80. package/src/services/profile-activate.ts +1 -2
  81. package/src/services/profile-startup.ts +18 -43
  82. package/src/services/profile-state.ts +72 -2
  83. package/src/services/resolver.ts +263 -59
  84. package/src/services/tool-probe.ts +18 -25
  85. package/src/services/toolchain.ts +117 -111
  86. package/src/services/update-engine.ts +345 -45
  87. package/src/services/update-plan.ts +296 -36
  88. package/src/shared/plugin/hooks/bun-missing.sh +39 -0
  89. package/src/shared/plugin/hooks/deps-check.ts +224 -0
  90. package/src/shared/plugin/hooks/lib/requires-probe.ts +1183 -0
  91. package/src/types/index.ts +60 -16
  92. package/src/ui/App.tsx +81 -171
  93. package/src/ui/adapters/cliToolsAdapter.ts +252 -12
  94. package/src/ui/adapters/depsAdapter.ts +129 -0
  95. package/src/ui/adapters/failureReport.ts +137 -0
  96. package/src/ui/adapters/pluginsAdapter.ts +297 -45
  97. package/src/ui/adapters/startupReports.ts +260 -0
  98. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  99. package/src/ui/components/modals/MessageModal.tsx +7 -12
  100. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  101. package/src/ui/components/modals/index.ts +1 -0
  102. package/src/ui/hooks/startupQueue.ts +56 -0
  103. package/src/ui/hooks/useGitignoreModal.ts +30 -52
  104. package/src/ui/hooks/useProfileStartupModal.ts +119 -142
  105. package/src/ui/hooks/useStaleLoadModal.ts +35 -65
  106. package/src/ui/keymap.ts +14 -1
  107. package/src/ui/renderers/cliToolRenderers.tsx +135 -2
  108. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  109. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  110. package/src/ui/renderers/pluginRowText.ts +25 -25
  111. package/src/ui/renderers/pluginStatusText.ts +18 -1
  112. package/src/ui/screens/AliasScreen.tsx +38 -45
  113. package/src/ui/screens/CliToolsScreen.tsx +199 -98
  114. package/src/ui/screens/EnvVarsScreen.tsx +17 -5
  115. package/src/ui/screens/GitignoreScreen.tsx +48 -67
  116. package/src/ui/screens/McpRegistryScreen.tsx +67 -38
  117. package/src/ui/screens/McpScreen.tsx +46 -19
  118. package/src/ui/screens/ModelsScreen.tsx +46 -86
  119. package/src/ui/screens/PluginsScreen.tsx +315 -259
  120. package/src/ui/screens/ProfilesScreen.tsx +79 -40
  121. package/src/ui/screens/SkillsScreen.tsx +83 -80
  122. package/src/ui/screens/StylesScreen.tsx +211 -275
  123. package/src/ui/state/AppContext.tsx +66 -44
  124. package/src/ui/state/DimensionsContext.tsx +3 -22
  125. package/src/ui/state/reducer.ts +12 -100
  126. package/src/ui/state/types.ts +48 -69
  127. package/src/utils/command-utils.ts +4 -5
  128. package/src/utils/run.ts +123 -52
  129. package/src/utils/string-utils.ts +9 -0
  130. package/src/utils/user-path.ts +60 -0
  131. package/src/__tests__/go-module.test.ts +0 -43
  132. package/src/__tests__/notices-rules.test.ts +0 -476
  133. package/src/__tests__/notices.test.tsx +0 -604
  134. package/src/__tests__/plugin-setup.test.ts +0 -1016
  135. package/src/__tests__/shell-script-callers.test.ts +0 -92
  136. package/src/__tests__/styles-status-line.test.ts +0 -72
  137. package/src/__tests__/version-snapshot.test.ts +0 -212
  138. package/src/services/plugin-setup.ts +0 -965
  139. package/src/services/version-snapshot.ts +0 -189
  140. package/src/utils/go-module.ts +0 -36
@@ -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
  }
@@ -1,12 +1,14 @@
1
1
  import React, { useEffect, useCallback, useMemo } from "react";
2
+ import { failureReport } from "../adapters/failureReport.js";
3
+ import { stepFailureReport } from "../adapters/startupReports.js";
2
4
  import { ScreenLayout } from "../components/layout/index.js";
3
5
  import { useScreenKeys } from "../input/KeyRouter.js";
4
6
  import { moveDirection } from "../keymap.js";
5
7
  import {
6
8
  useApp,
9
+ useFailurePopup,
7
10
  useModal,
8
11
  useNavigation,
9
- useNotify,
10
12
  } from "../state/AppContext.js";
11
13
  import { useDimensions } from "../state/DimensionsContext.js";
12
14
 
@@ -35,7 +37,7 @@ export function McpScreen() {
35
37
  const { state, dispatch } = useApp();
36
38
  const { mcp } = state;
37
39
  const modal = useModal();
38
- const notify = useNotify();
40
+ const showFailures = useFailurePopup();
39
41
  const { navigateToScreen } = useNavigation();
40
42
  const dimensions = useDimensions();
41
43
 
@@ -96,22 +98,34 @@ export function McpScreen() {
96
98
 
97
99
  for (const server of servers) {
98
100
  const isInstalled = mcp.installedServers[server.name] !== undefined;
99
- items.push({ kind: "server", server, isInstalled });
101
+ items.push({
102
+ kind: "server",
103
+ server,
104
+ isInstalled,
105
+ needsRestart: mcp.changedThisSession.includes(server.name),
106
+ });
100
107
  }
101
108
  }
102
109
 
103
110
  return items;
104
- }, [mcp.servers, mcp.installedServers]);
111
+ }, [mcp.servers, mcp.installedServers, mcp.changedThisSession]);
105
112
 
106
113
  useScreenKeys("mcp", {
107
114
  // Guarded: `installMcpServer` throws on a catalogue entry with no URL or
108
115
  // command, and a rejection nobody catches ends the app.
109
116
  toggle: () => {
117
+ const item = allListItems[mcp.selectedIndex];
118
+ if (item?.kind !== "server") return;
119
+ const { name } = item.server;
110
120
  handleSelect().catch((error: unknown) =>
111
- modal.message(
112
- "Error",
113
- error instanceof Error ? error.message : String(error),
114
- "error",
121
+ showFailures(
122
+ stepFailureReport(
123
+ item.isInstalled
124
+ ? `Could not remove ${name}`
125
+ : `Could not add ${name}`,
126
+ name,
127
+ error,
128
+ ),
115
129
  ),
116
130
  );
117
131
  },
@@ -147,11 +161,19 @@ export function McpScreen() {
147
161
  try {
148
162
  await removeMcpServer(server.name, state.projectPath);
149
163
  modal.hideModal();
150
- // The row turns uninstalled; nothing else to say.
164
+ // The row turns uninstalled, and says a running Claude Code still
165
+ // has it until it restarts.
166
+ dispatch({ type: "MCP_SERVER_CHANGED", name: server.name });
151
167
  fetchData();
152
168
  } catch (error) {
153
169
  modal.hideModal();
154
- await modal.message("Error", `Failed to remove: ${error}`, "error");
170
+ await showFailures(
171
+ stepFailureReport(
172
+ `Could not remove ${server.name}`,
173
+ server.name,
174
+ error,
175
+ ),
176
+ );
155
177
  }
156
178
  }
157
179
  } else {
@@ -202,10 +224,14 @@ export function McpScreen() {
202
224
  if (value === null) return;
203
225
 
204
226
  if (field.required && !value) {
205
- await modal.message(
206
- "Required Field",
207
- `${field.label} is required.`,
208
- "error",
227
+ await showFailures(
228
+ failureReport(`Could not add ${server.name}`, [
229
+ {
230
+ subject: field.label,
231
+ reason: "is required, and was left empty",
232
+ fix: `Press Enter on ${server.name} again and fill it in`,
233
+ },
234
+ ]),
209
235
  );
210
236
  return;
211
237
  }
@@ -221,14 +247,15 @@ export function McpScreen() {
221
247
  try {
222
248
  await addMcpServer(server.name, config, state.projectPath);
223
249
  modal.hideModal();
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");
250
+ // The row shows it installed AND that it needs a restart to be used —
251
+ // state on the server, not a message that scrolls away.
252
+ dispatch({ type: "MCP_SERVER_CHANGED", name: server.name });
228
253
  fetchData();
229
254
  } catch (error) {
230
255
  modal.hideModal();
231
- await modal.message("Error", `Failed to install: ${error}`, "error");
256
+ await showFailures(
257
+ stepFailureReport(`Could not add ${server.name}`, server.name, error),
258
+ );
232
259
  }
233
260
  };
234
261