magus-cli 7.6.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 +9 -34
  16. package/src/__tests__/gitignore-profile.test.ts +0 -4
  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 -42
  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/gitignore-service.ts +0 -19
  36. package/src/services/plugin-env-needs.ts +69 -0
  37. package/src/services/plugin-manager.ts +31 -19
  38. package/src/services/plugin-resolution.ts +34 -42
  39. package/src/services/profile-absorb.ts +9 -0
  40. package/src/services/profile-activate.ts +1 -1
  41. package/src/services/profile-startup.ts +30 -27
  42. package/src/services/profile-state.ts +81 -2
  43. package/src/services/resolver.ts +95 -1
  44. package/src/services/update-engine.ts +326 -31
  45. package/src/services/update-plan.ts +293 -33
  46. package/src/types/index.ts +7 -0
  47. package/src/ui/App.tsx +186 -123
  48. package/src/ui/adapters/cliToolsAdapter.ts +203 -1
  49. package/src/ui/adapters/failureReport.ts +137 -0
  50. package/src/ui/adapters/pluginsAdapter.ts +299 -28
  51. package/src/ui/adapters/startupReports.ts +282 -0
  52. package/src/ui/components/modals/FailureListModal.tsx +231 -0
  53. package/src/ui/components/modals/MessageModal.tsx +7 -13
  54. package/src/ui/components/modals/ModalContainer.tsx +10 -5
  55. package/src/ui/components/modals/index.ts +1 -0
  56. package/src/ui/hooks/startupQueue.ts +56 -0
  57. package/src/ui/hooks/useGitignoreModal.ts +31 -31
  58. package/src/ui/hooks/useProfileStartupModal.ts +127 -122
  59. package/src/ui/hooks/useStaleLoadModal.ts +33 -53
  60. package/src/ui/keymap.ts +14 -1
  61. package/src/ui/renderers/mcpRenderers.tsx +110 -3
  62. package/src/ui/renderers/pluginRenderers.tsx +77 -6
  63. package/src/ui/renderers/pluginRowText.ts +25 -25
  64. package/src/ui/renderers/pluginStatusText.ts +18 -1
  65. package/src/ui/screens/AliasScreen.tsx +38 -44
  66. package/src/ui/screens/CliToolsScreen.tsx +65 -51
  67. package/src/ui/screens/EnvVarsScreen.tsx +21 -6
  68. package/src/ui/screens/GitignoreScreen.tsx +49 -75
  69. package/src/ui/screens/McpRegistryScreen.tsx +72 -40
  70. package/src/ui/screens/McpScreen.tsx +55 -24
  71. package/src/ui/screens/ModelsScreen.tsx +46 -86
  72. package/src/ui/screens/PluginsScreen.tsx +316 -245
  73. package/src/ui/screens/ProfilesScreen.tsx +88 -73
  74. package/src/ui/screens/SkillsScreen.tsx +83 -80
  75. package/src/ui/screens/StylesScreen.tsx +211 -275
  76. package/src/ui/state/AppContext.tsx +72 -7
  77. package/src/ui/state/DimensionsContext.tsx +3 -22
  78. package/src/ui/state/reducer.ts +25 -14
  79. package/src/ui/state/types.ts +61 -31
  80. package/src/utils/user-path.ts +60 -0
  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 } 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,6 +99,7 @@ export function CliToolsScreen() {
91
99
  const { state, dispatch } = useApp();
92
100
  const { cliTools: cliToolsState } = state;
93
101
  const modal = useModal();
102
+ const showFailures = useFailurePopup();
94
103
  const dimensions = useDimensions();
95
104
 
96
105
  const [rows, setRows] = useState<CliToolRow[]>(
@@ -208,23 +217,22 @@ export function CliToolsScreen() {
208
217
  };
209
218
 
210
219
  /**
211
- * Run one installer command and report success.
220
+ * Run one installer command: null when it succeeded, else why it failed.
212
221
  *
213
222
  * `execFile`, so there is no shell to re-parse a package name — the same
214
223
  * reason `utils/run.ts` exists. It does NOT call that shared `runCommand`:
215
224
  * that one inherits stdio, and a TUI must never hand the terminal to a
216
- * child. Output is captured and discarded here; the modal is the progress
217
- * 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.
218
227
  */
219
- const runCommand = async (
220
- command: Command,
221
- ): Promise<{ ok: boolean; error?: string }> => {
228
+ const runCommand = async (command: Command): Promise<string | null> => {
222
229
  try {
223
- await execFileAsync(command.cmd, [...command.args], { timeout: 60000 });
224
- return { ok: true };
230
+ await execFileAsync(command.cmd, [...command.args], {
231
+ timeout: COMMAND_TIMEOUT_MS,
232
+ });
233
+ return null;
225
234
  } catch (err) {
226
- const msg = err instanceof Error ? err.message : String(err);
227
- return { ok: false, error: msg };
235
+ return commandFailureReason(command, err, COMMAND_TIMEOUT_MS);
228
236
  }
229
237
  };
230
238
 
@@ -237,18 +245,19 @@ export function CliToolsScreen() {
237
245
  const { command, progress: action } = enter;
238
246
 
239
247
  modal.loading(`${action} ${tool.displayName}...`);
240
- const result = await runCommand(command);
248
+ const reason = await runCommand(command);
241
249
  modal.hideModal();
242
250
 
243
- if (result.ok) {
251
+ if (reason === null) {
244
252
  handleRefresh();
245
- } else {
246
- await modal.message(
247
- "Error",
248
- `Failed to ${action.toLowerCase()} ${tool.displayName}.\n\nTry running manually:\n${formatCommand(command)}`,
249
- "error",
250
- );
253
+ return;
251
254
  }
255
+ await showFailures(
256
+ cliToolFailureReport(
257
+ `${tool.displayName} was not ${cliToolNotDone(enter.kind)}`,
258
+ [{ tool: tool.displayName, command, reason }],
259
+ ),
260
+ );
252
261
  };
253
262
 
254
263
  /**
@@ -284,6 +293,11 @@ export function CliToolsScreen() {
284
293
  methods
285
294
  .map((m) => `The ${m} install stays: ${blockers.get(m)}.`)
286
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
+ }));
287
301
 
288
302
  // One choice per install that can be KEPT — but only where keeping it
289
303
  // removes something. "Keep npm" beside an unremovable native copy would
@@ -297,10 +311,8 @@ export function CliToolsScreen() {
297
311
  }));
298
312
 
299
313
  if (options.length === 0) {
300
- await modal.message(
301
- `${tool.displayName} conflict`,
302
- `Installed via ${allMethods.join(" + ")}, and magus can remove none of them.\n\n${stuckList(allMethods)}\n\nRemove one by hand, then press r.`,
303
- "error",
314
+ await showFailures(
315
+ cliToolConflictStuckReport(tool.displayName, blockerRows(allMethods)),
304
316
  );
305
317
  return;
306
318
  }
@@ -318,35 +330,36 @@ export function CliToolsScreen() {
318
330
  const leftBehind = stuck.filter((m) => m !== keep);
319
331
  modal.loading(`Removing ${toRemove.join(", ")} install(s)...`);
320
332
 
321
- const errors: string[] = [];
333
+ const failed: Array<{
334
+ method: InstallMethod;
335
+ command: Command;
336
+ reason: string;
337
+ }> = [];
338
+ const unremovable = blockerRows(leftBehind);
322
339
  for (const method of toRemove) {
323
340
  const cmd = cliToolUninstall(tool, method, install.brewFormula);
324
341
  // Unreachable: `removable` was built from this same call. Reported
325
342
  // rather than skipped, because a silent skip is the defect above.
326
343
  if (!cmd) {
327
- errors.push(`${method}: no uninstall command`);
344
+ unremovable.push({
345
+ method,
346
+ reason: "magus has no uninstall command for it",
347
+ });
328
348
  continue;
329
349
  }
330
- const result = await runCommand(cmd);
331
- if (!result.ok) errors.push(`${method}: ${formatCommand(cmd)}`);
350
+ const reason = await runCommand(cmd);
351
+ if (reason !== null) failed.push({ method, command: cmd, reason });
332
352
  }
333
353
 
334
354
  modal.hideModal();
335
355
 
336
- const report = [
337
- errors.length > 0
338
- ? `These removals failed. Run them by hand:\n${errors.join("\n")}`
339
- : "",
340
- leftBehind.length > 0
341
- ? `${stuckList(leftBehind)}\nRemove it by hand if you want it gone.`
342
- : "",
343
- ].filter((part) => part !== "");
344
-
345
- if (report.length > 0) {
346
- await modal.message(
347
- errors.length > 0 ? "Partly removed" : "One install stays",
348
- report.join("\n\n"),
349
- errors.length > 0 ? "error" : "info",
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),
350
363
  );
351
364
  }
352
365
 
@@ -358,23 +371,24 @@ export function CliToolsScreen() {
358
371
  // swept up in a bulk action — that is exactly the "cannot tell, so run
359
372
  // it anyway" the CLI no longer does either.
360
373
  const updatable = rows.filter((r) => r.status?.state === "upgrade");
361
- if (updatable.length === 0) {
362
- await modal.message(
363
- "Up to Date",
364
- "No tool is known to be behind.",
365
- "info",
366
- );
367
- return;
368
- }
374
+ if (updatable.length === 0) return;
369
375
 
370
376
  modal.loading(`Updating ${updatable.length} tool(s)...`);
371
377
 
378
+ // Every failure is kept: the reload shows the tool still behind, and
379
+ // only this says why.
380
+ const failed: CliToolCommandFailure[] = [];
372
381
  for (const row of updatable) {
373
- 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
+ }
374
387
  }
375
388
 
376
389
  modal.hideModal();
377
390
  handleRefresh();
391
+ await showFailures(cliToolsNotUpdatedReport(failed));
378
392
  };
379
393
 
380
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 {
@@ -54,9 +66,12 @@ export function SettingsScreen() {
54
66
  }
55
67
  }, [dispatch, state.projectPath]);
56
68
 
69
+ // Load data on mount or when dataRefreshVersion changes — a profile
70
+ // activating changes what this screen shows.
71
+ // 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
72
  useEffect(() => {
58
73
  fetchData();
59
- }, [fetchData]);
74
+ }, [fetchData, state.dataRefreshVersion]);
60
75
 
61
76
  const listItems = useMemo((): SettingsBrowserItem[] => {
62
77
  if (settings.values.status !== "success") return [];
@@ -81,7 +96,7 @@ export function SettingsScreen() {
81
96
  await writeSettingValue(setting, newValue, scope, state.projectPath);
82
97
  await fetchData();
83
98
  } catch (error) {
84
- await modal.message("Error", `Failed to update: ${error}`, "error");
99
+ await reportNotSaved(setting.name, scope, error);
85
100
  }
86
101
  } else if (setting.type === "select" && setting.options) {
87
102
  const options = setting.options.map((o) => ({
@@ -107,7 +122,7 @@ export function SettingsScreen() {
107
122
  await writeSettingValue(setting, val, scope, state.projectPath);
108
123
  await fetchData();
109
124
  } catch (error) {
110
- await modal.message("Error", `Failed to update: ${error}`, "error");
125
+ await reportNotSaved(setting.name, scope, error);
111
126
  }
112
127
  } else {
113
128
  // String type: if already set, clear it; if unset, show input modal
@@ -116,7 +131,7 @@ export function SettingsScreen() {
116
131
  await writeSettingValue(setting, undefined, scope, state.projectPath);
117
132
  await fetchData();
118
133
  } catch (error) {
119
- await modal.message("Error", `Failed to update: ${error}`, "error");
134
+ await reportNotSaved(setting.name, scope, error);
120
135
  }
121
136
  } else {
122
137
  const newValue = await modal.input(
@@ -134,7 +149,7 @@ export function SettingsScreen() {
134
149
  );
135
150
  await fetchData();
136
151
  } catch (error) {
137
- await modal.message("Error", `Failed to update: ${error}`, "error");
152
+ await reportNotSaved(setting.name, scope, error);
138
153
  }
139
154
  }
140
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";
@@ -20,6 +25,7 @@ import {
20
25
  renderRuleRow,
21
26
  } from "../renderers/gitignoreRenderers.js";
22
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
@@ -57,9 +63,12 @@ export function GitignoreScreen() {
57
63
  }
58
64
  }, [cwd]);
59
65
 
66
+ // Load on mount or when dataRefreshVersion changes — a profile activating
67
+ // can turn committed files into generated ones this screen lists.
68
+ // 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
69
  useEffect(() => {
61
70
  void reload();
62
- }, [reload]);
71
+ }, [reload, state.dataRefreshVersion]);
63
72
 
64
73
  // Build the managed list: every desired gitignore rule, with all matching
65
74
  // violations grouped under the rule that owns them.
@@ -135,112 +144,75 @@ export function GitignoreScreen() {
135
144
  const handleFixAll = useCallback(async () => {
136
145
  if (!data || toFixRows.length === 0) return;
137
146
  try {
138
- const messages: string[] = [];
139
- const failures: string[] = [];
147
+ const failures: Array<{ path: string; message: string }> = [];
140
148
  for (const { row } of toFixRows) {
141
149
  for (const v of row.violations) {
142
150
  const r = await applyDestructiveFix(cwd, v, row.rule.pattern);
143
- (r.applied ? messages : failures).push(r.message);
151
+ if (!r.applied) failures.push({ path: v.path, message: r.message });
144
152
  }
145
153
  }
146
154
  await reload();
147
- if (failures.length > 0) {
148
- await modal.message(
149
- "Some fixes didn't apply",
150
- [...failures, ...messages].join("\n"),
151
- "error",
152
- );
153
- }
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);
154
158
  } catch (err) {
155
- await modal.message(
156
- "Fix failed",
157
- err instanceof Error ? err.message : String(err),
158
- "error",
159
+ await modal.failures(
160
+ stepFailureReport("The fixes were not applied", ".gitignore", err),
159
161
  );
160
162
  }
161
163
  }, [cwd, data, toFixRows, reload, modal]);
162
164
 
163
165
  // Fix just the highlighted row.
164
166
  const handleFixOne = useCallback(async () => {
165
- 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
- );
171
- return;
172
- }
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
- );
179
- return;
180
- }
167
+ // Nothing highlighted, or a row that already matches: nothing to fix.
168
+ if (!selectedRow || selectedRow.violations.length === 0) return;
181
169
 
182
170
  try {
183
- const results: Array<{ applied: boolean; message: string }> = [];
171
+ const failures: Array<{ path: string; message: string }> = [];
184
172
  for (const v of selectedRow.violations) {
185
- results.push(
186
- await applyDestructiveFix(cwd, v, selectedRow.rule.pattern),
187
- );
173
+ const r = await applyDestructiveFix(cwd, v, selectedRow.rule.pattern);
174
+ if (!r.applied) failures.push({ path: v.path, message: r.message });
188
175
  }
189
- const failed = results.filter((r) => !r.applied);
190
176
  await reload();
191
- // Success is silent — the reload removes the fixed row. Only surface a
192
- // modal when something didn't apply.
193
- if (failed.length > 0) {
194
- await modal.message(
195
- "Some fixes didn't apply",
196
- results.map((r) => r.message).join("\n"),
197
- "error",
198
- );
199
- }
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);
200
181
  } catch (err) {
201
- await modal.message(
202
- "Fix failed",
203
- err instanceof Error ? err.message : String(err),
204
- "error",
182
+ await modal.failures(
183
+ stepFailureReport(
184
+ "The fix was not applied",
185
+ selectedRow.rule.pattern,
186
+ err,
187
+ ),
205
188
  );
206
189
  }
207
190
  }, [selectedRow, modal, cwd, reload]);
208
191
 
209
192
  const handleApplyTemplate = useCallback(async () => {
210
193
  const names = Object.keys(BUILTIN_TEMPLATES);
211
- if (names.length === 0) {
212
- await modal.message(
213
- "No templates",
214
- "No built-in templates available.",
215
- "info",
216
- );
217
- return;
218
- }
194
+ if (names.length === 0) return;
219
195
  const selected = await modal.select(
220
196
  "Apply template",
221
197
  "Merge the template into the project manifest at .claude/gitignore.json.",
222
198
  names.map((n) => ({ label: n, value: n })),
223
199
  );
224
200
  if (!selected) return;
201
+ let report: FailureReport | null = null;
225
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.
226
205
  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",
235
- );
206
+ report = gitignoreTemplateReport(selected, r.conflicts);
236
207
  } catch (err) {
237
- await modal.message(
238
- "Template failed",
239
- err instanceof Error ? err.message : String(err),
240
- "error",
208
+ report = stepFailureReport(
209
+ `Template "${selected}" was not applied`,
210
+ ".claude/gitignore.json",
211
+ err,
241
212
  );
242
213
  }
243
214
  await reload();
215
+ if (report) await modal.failures(report);
244
216
  }, [cwd, modal, reload]);
245
217
 
246
218
  /**
@@ -248,7 +220,7 @@ export function GitignoreScreen() {
248
220
  *
249
221
  * Every await sits inside the try: this runs fire-and-forget from a key, and a
250
222
  * rejection nobody catches ends the app. A manifest that cannot be read or
251
- * created is said in a dialog on this screen instead.
223
+ * created is said in the failure popup instead.
252
224
  */
253
225
  const handleEditManifest = useCallback(async () => {
254
226
  let projectManifest: string;
@@ -259,10 +231,12 @@ export function GitignoreScreen() {
259
231
  current.resolved,
260
232
  );
261
233
  } catch (err) {
262
- await modal.message(
263
- "Cannot open the manifest",
264
- err instanceof Error ? err.message : String(err),
265
- "error",
234
+ await modal.failures(
235
+ stepFailureReport(
236
+ "The project's rules were not opened for editing",
237
+ ".claude/gitignore.json",
238
+ err,
239
+ ),
266
240
  );
267
241
  return;
268
242
  }