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
@@ -16,7 +16,6 @@ import {
16
16
  acceptPendingUpdate,
17
17
  checkAllSources,
18
18
  checkSourceForUpdates,
19
- describeCommunityFailure,
20
19
  fetchCommunityStyles,
21
20
  readCommunityStatuses,
22
21
  } from "../../services/community-styles.js";
@@ -33,6 +32,9 @@ import {
33
32
  } from "../../services/styles-manager.js";
34
33
  import { fillTerminology } from "../../services/terminology-filler.js";
35
34
  import { openInDefaultApp } from "../../utils/open-file.js";
35
+ import { counted, failureReport } from "../adapters/failureReport.js";
36
+ import { exceptionReason } from "../adapters/pluginsAdapter.js";
37
+ import { stepFailureReport } from "../adapters/startupReports.js";
36
38
  import {
37
39
  type StyleBrowserItem,
38
40
  buildStyleBrowserItems,
@@ -47,8 +49,9 @@ import {
47
49
  renderStyleDetail,
48
50
  renderStyleRow,
49
51
  } from "../renderers/styleRenderers.js";
50
- import { useApp, useModal } from "../state/AppContext.js";
52
+ import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
51
53
  import { useDimensions } from "../state/DimensionsContext.js";
54
+ import type { FailureRow } from "../state/types.js";
52
55
  import { theme } from "../theme.js";
53
56
 
54
57
  export function StylesScreen() {
@@ -132,24 +135,25 @@ export function StylesScreen() {
132
135
  [snapshot],
133
136
  );
134
137
 
135
- // ── Status line (auto-clearing) ───────────────────────────────────────────
138
+ // ── Failures ──────────────────────────────────────────────────────────────
136
139
 
137
140
  /**
138
- * Set the status line.
139
- *
140
- * No timer. A message used to erase itself after 5s, which meant the last
141
- * thing you did was usually gone by the time you looked — and made the line
142
- * untestable by anything that samples the screen a moment later. It is
143
- * cleared when the NEXT action starts instead, so the screen always reports
144
- * the most recent thing that happened.
141
+ * No status line and no messages. A success shows as the screen's state —
142
+ * the active style, a row's upstream badge, "talking to GitHub…" while a
143
+ * request runs. What did not happen goes into the one failure popup, with
144
+ * why and, when there is one, what to do. A key pressed on a row it does
145
+ * nothing on (the footer does not offer it there) does nothing.
145
146
  */
146
- const showStatus = useCallback(
147
- (text: string, tone: "success" | "error" = "success") => {
148
- dispatch({ type: "STYLES_STATUS_SET", status: { text, tone } });
149
- },
150
- [dispatch],
147
+ const showFailures = useFailurePopup();
148
+ const refuse = useCallback(
149
+ (headline: string, subject: string, reason: string, fix?: string) =>
150
+ showFailures(
151
+ failureReport(headline, [
152
+ fix ? { subject, reason, fix } : { subject, reason },
153
+ ]),
154
+ ),
155
+ [showFailures],
151
156
  );
152
- const statusMsg = stylesState.status;
153
157
 
154
158
  // ── Derived ───────────────────────────────────────────────────────────────
155
159
 
@@ -226,14 +230,14 @@ export function StylesScreen() {
226
230
  * the right section.
227
231
  */
228
232
  const handleFetch = useCallback(async () => {
229
- if (isFetching) {
230
- showStatus("Already talking to GitHub — one request at a time", "error");
231
- return;
232
- }
233
+ // One request at a time; the header already says "talking to GitHub…".
234
+ if (isFetching) return;
233
235
  if (!cacheDir) {
234
- showStatus(
235
- "magus could not resolve its cache directory — set CLAUDE_CONFIG_DIR",
236
- "error",
236
+ await refuse(
237
+ "Nothing was fetched",
238
+ "styles cache",
239
+ "magus could not find its cache directory",
240
+ "Set CLAUDE_CONFIG_DIR",
237
241
  );
238
242
  return;
239
243
  }
@@ -249,9 +253,10 @@ export function StylesScreen() {
249
253
  selectedItem.source.origin === "community"
250
254
  ) {
251
255
  if (!selectedItem.source.managed) {
252
- showStatus(
253
- `${selectedItem.label} is your own copy — magus will not overwrite it`,
254
- "error",
256
+ await refuse(
257
+ `${selectedItem.label} was not fetched`,
258
+ selectedItem.label,
259
+ "it is your own copy, and magus will not overwrite it",
255
260
  );
256
261
  return;
257
262
  }
@@ -267,10 +272,7 @@ export function StylesScreen() {
267
272
  : id,
268
273
  );
269
274
  } else {
270
- showStatus(
271
- "Nothing to fetch here — move to a Community row and press f",
272
- "error",
273
- );
275
+ // Not a row `f` acts on, and the footer does not offer it here.
274
276
  return;
275
277
  }
276
278
 
@@ -280,19 +282,17 @@ export function StylesScreen() {
280
282
  const result = await acceptPendingUpdate({ cacheDir, id: accept });
281
283
  await fetchData();
282
284
  await refreshUpstream();
283
- showStatus(
284
- result.outcome === "accepted"
285
- ? `Updated ${accept} — press a to re-apply it`
286
- : `No downloaded update for ${accept} — press u to check`,
287
- result.outcome === "accepted" ? "success" : "error",
288
- );
285
+ if (result.outcome !== "accepted") {
286
+ await refuse(
287
+ `${accept} was not updated`,
288
+ accept,
289
+ "no update has been downloaded for it",
290
+ "Press u to check for one",
291
+ );
292
+ }
289
293
  return;
290
294
  }
291
295
 
292
- showStatus(
293
- `Fetching ${ids.length} style${ids.length === 1 ? "" : "s"} from GitHub…`,
294
- "success",
295
- );
296
296
  const result = await fetchCommunityStyles({
297
297
  ids,
298
298
  fetcher: githubFetcher,
@@ -301,33 +301,33 @@ export function StylesScreen() {
301
301
  await fetchData();
302
302
  await refreshUpstream();
303
303
 
304
- if (result.failed.length > 0) {
305
- // The FIRST failure verbatim, not a count. Each kind carries its own
306
- // diagnosis — a 404 blames our registry, a DNS failure names Tailscale
307
- // — and "3 styles failed" throws all of that away.
308
- const [first] = result.failed;
309
- showStatus(
310
- describeCommunityFailure(first.failure, first.label),
311
- "error",
312
- );
313
- return;
314
- }
315
- if (result.unknown.length > 0) {
316
- showStatus(
317
- `magus has no registry entry for ${result.unknown.join(", ")} — please report it`,
318
- "error",
319
- );
320
- return;
321
- }
322
- showStatus(
323
- `Fetched ${result.written.length} style${
324
- result.written.length === 1 ? "" : "s"
325
- } — read it, then press Space to select and a to apply`,
304
+ // Every failure with its own diagnosis — a 404 blames our registry, a
305
+ // DNS failure names Tailscale — never "3 styles failed".
306
+ const rows: FailureRow[] = [
307
+ ...result.failed.map(({ failure, label }) =>
308
+ failure.kind === "rate-limited"
309
+ ? {
310
+ subject: label,
311
+ reason: failure.detail,
312
+ fix: "Set GITHUB_TOKEN to raise the limit",
313
+ }
314
+ : { subject: label, reason: failure.detail },
315
+ ),
316
+ ...result.unknown.map((id) => ({
317
+ subject: id,
318
+ reason: "magus does not know where to fetch this style from",
319
+ fix: "Please report it",
320
+ })),
321
+ ];
322
+ await showFailures(
323
+ failureReport(
324
+ `${counted(rows.length, "style")} ${rows.length === 1 ? "was" : "were"} not fetched`,
325
+ rows,
326
+ ),
326
327
  );
327
328
  } catch (error) {
328
- showStatus(
329
- error instanceof Error ? error.message : String(error),
330
- "error",
329
+ await showFailures(
330
+ stepFailureReport("Nothing was fetched", "styles", error),
331
331
  );
332
332
  } finally {
333
333
  setIsFetching(false);
@@ -339,7 +339,8 @@ export function StylesScreen() {
339
339
  snapshot,
340
340
  fetchData,
341
341
  refreshUpstream,
342
- showStatus,
342
+ refuse,
343
+ showFailures,
343
344
  ]);
344
345
 
345
346
  /**
@@ -352,17 +353,10 @@ export function StylesScreen() {
352
353
  */
353
354
  const handleCheckUpdates = useCallback(
354
355
  async (all: boolean) => {
355
- if (isFetching) {
356
- showStatus(
357
- "Already talking to GitHub — one request at a time",
358
- "error",
359
- );
360
- return;
361
- }
362
- if (!cacheDir || cachedCommunity.length === 0) {
363
- showStatus("No fetched community styles to check", "error");
364
- return;
365
- }
356
+ // One request at a time; the header already says "talking to GitHub…".
357
+ if (isFetching) return;
358
+ // Nothing fetched, nothing to check: the Community rows say so.
359
+ if (!cacheDir || cachedCommunity.length === 0) return;
366
360
 
367
361
  let cached = cachedCommunity;
368
362
  let only: CommunityStyleSource | null = null;
@@ -374,13 +368,8 @@ export function StylesScreen() {
374
368
  ? selectedItem.source.name
375
369
  : null;
376
370
  const resolved = name ? resolveCommunityStyle(name) : null;
377
- if (!resolved) {
378
- showStatus(
379
- "Move to a fetched community style and press u, or U to check all",
380
- "error",
381
- );
382
- return;
383
- }
371
+ // Not a row `u` acts on, and the footer does not offer it here.
372
+ if (!resolved) return;
384
373
  only = resolved.source;
385
374
  // Every style of this REPO, not just the selected row — one API call
386
375
  // answers for all of them, so narrowing it further would pay the same
@@ -389,16 +378,16 @@ export function StylesScreen() {
389
378
  (entry) => resolveCommunityStyle(entry.id)?.source.id === only?.id,
390
379
  );
391
380
  if (cached.length === 0) {
392
- showStatus(
393
- `${name} is not a copy magus manages — nothing to check`,
394
- "error",
381
+ await refuse(
382
+ `${name} was not checked`,
383
+ name ?? "style",
384
+ "it is not a copy magus manages",
395
385
  );
396
386
  return;
397
387
  }
398
388
  }
399
389
 
400
390
  setIsFetching(true);
401
- showStatus("Checking GitHub for newer versions…");
402
391
  try {
403
392
  const statuses = only
404
393
  ? (
@@ -420,29 +409,26 @@ export function StylesScreen() {
420
409
  ).statuses;
421
410
 
422
411
  await refreshUpstream();
423
- const values = Object.values(statuses);
424
- const updates = values.filter(
425
- (s) => s.state === "update-available",
426
- ).length;
427
- const unknown = values.filter((s) => s.state === "unknown");
428
- if (updates > 0) {
429
- showStatus(
430
- `${updates} update${updates === 1 ? "" : "s"} downloaded to .pending — press f on a row to accept`,
431
- );
432
- } else if (unknown.length === values.length && unknown.length > 0) {
433
- // Every answer was "we could not look". Say the reason, not a
434
- // reassuring summary — a rate-limited check must never read as
435
- // up to date.
436
- showStatus(`Could not check — ${unknown[0].detail}`, "error");
437
- } else {
438
- showStatus(
439
- `Up to date${unknown.length > 0 ? ` (${unknown.length} could not be checked)` : ""}`,
440
- );
441
- }
412
+ // An update found shows on its row. A style that could not be
413
+ // checked is a failure, said with its reason — a rate-limited check
414
+ // must never read as up to date.
415
+ const unknown = Object.entries(statuses).filter(
416
+ ([, s]) => s.state === "unknown",
417
+ );
418
+ await showFailures(
419
+ failureReport(
420
+ `${counted(unknown.length, "style")} could not be checked for updates`,
421
+ unknown.map(([id, s]) => ({
422
+ subject: id.startsWith(COMMUNITY_ID_PREFIX)
423
+ ? id.slice(COMMUNITY_ID_PREFIX.length)
424
+ : id,
425
+ reason: s.detail || "GitHub did not answer",
426
+ })),
427
+ ),
428
+ );
442
429
  } catch (error) {
443
- showStatus(
444
- error instanceof Error ? error.message : String(error),
445
- "error",
430
+ await showFailures(
431
+ stepFailureReport("Nothing was checked for updates", "styles", error),
446
432
  );
447
433
  } finally {
448
434
  setIsFetching(false);
@@ -454,7 +440,8 @@ export function StylesScreen() {
454
440
  cachedCommunity,
455
441
  selectedItem,
456
442
  refreshUpstream,
457
- showStatus,
443
+ refuse,
444
+ showFailures,
458
445
  ],
459
446
  );
460
447
 
@@ -477,9 +464,11 @@ export function StylesScreen() {
477
464
  const { source } = selectedItem;
478
465
 
479
466
  if (selectedItem.disabled) {
480
- showStatus(
481
- `"${source.name}" is a template — fill it in and import it instead`,
482
- "error",
467
+ void refuse(
468
+ `${source.name} was not selected`,
469
+ source.name,
470
+ "it is a template",
471
+ "Fill it in and import it instead",
483
472
  );
484
473
  return;
485
474
  }
@@ -506,7 +495,7 @@ export function StylesScreen() {
506
495
  stylesState.selected,
507
496
  snapshot,
508
497
  dispatch,
509
- showStatus,
498
+ refuse,
510
499
  handleFetch,
511
500
  ]);
512
501
 
@@ -515,11 +504,20 @@ export function StylesScreen() {
515
504
  const handleApply = useCallback(async () => {
516
505
  if (isApplying) return;
517
506
  if (errors.length > 0) {
518
- showStatus(errors[0], "error");
507
+ await refuse(
508
+ "No style was applied",
509
+ "selection",
510
+ errors[0] ?? "the selection is not valid",
511
+ );
519
512
  return;
520
513
  }
521
514
  if (chosen.presets.length === 0 && chosen.imports.length === 0) {
522
- showStatus("Nothing selected — press Space to tick a style", "error");
515
+ await refuse(
516
+ "No style was applied",
517
+ "selection",
518
+ "nothing is ticked",
519
+ "Press Space to tick a style",
520
+ );
523
521
  return;
524
522
  }
525
523
 
@@ -531,43 +529,48 @@ export function StylesScreen() {
531
529
  imports: chosen.imports as ImportedStyle[],
532
530
  });
533
531
  await fetchData();
534
- const where = result.profile
535
- ? `profile "${result.profile}"`
536
- : "this project";
537
- // Say plainly when the manifest was NOT updated: without that record a
538
- // the next activation renders settings.json from the manifest and
539
- // drops the style.
540
- const durability =
541
- result.profile && !result.recordedInManifest
542
- ? " — not in profiles.json, so `magus install` will drop it"
543
- : "";
544
- showStatus(
545
- `Applied ${result.styleName} to ${where} — commit .claude/style.json to share it${durability}`,
546
- result.profile && !result.recordedInManifest ? "error" : "success",
547
- );
532
+ // Applied: the header shows the active style. Said only when the
533
+ // profile did NOT record it — the next activation renders the live
534
+ // settings from the profile and drops the style.
535
+ if (result.profile && !result.recordedInManifest) {
536
+ await refuse(
537
+ `${result.styleName} is active, but not saved in the profile`,
538
+ `profile ${result.profile}`,
539
+ "the style is not recorded in it, so the next magus install drops it",
540
+ );
541
+ }
548
542
  } catch (error) {
549
- showStatus(
550
- error instanceof Error ? error.message : String(error),
551
- "error",
543
+ await showFailures(
544
+ stepFailureReport("No style was applied", "output style", error),
552
545
  );
553
546
  } finally {
554
547
  setIsApplying(false);
555
548
  }
556
- }, [isApplying, errors, chosen, state.projectPath, fetchData, showStatus]);
549
+ }, [
550
+ isApplying,
551
+ errors,
552
+ chosen,
553
+ state.projectPath,
554
+ fetchData,
555
+ refuse,
556
+ showFailures,
557
+ ]);
557
558
 
558
559
  const handleClear = useCallback(async () => {
559
560
  try {
560
561
  await clearStyle(state.projectPath);
561
562
  await fetchData();
562
563
  dispatch({ type: "STYLES_SET_SELECTION", ids: [] });
563
- showStatus("Cleared the active output style");
564
564
  } catch (error) {
565
- showStatus(
566
- error instanceof Error ? error.message : String(error),
567
- "error",
565
+ await showFailures(
566
+ stepFailureReport(
567
+ "The output style was not cleared",
568
+ "output style",
569
+ error,
570
+ ),
568
571
  );
569
572
  }
570
- }, [state.projectPath, fetchData, dispatch, showStatus]);
573
+ }, [state.projectPath, fetchData, dispatch, showFailures]);
571
574
 
572
575
  const handleReset = useCallback(() => {
573
576
  dispatch({
@@ -577,8 +580,7 @@ export function StylesScreen() {
577
580
  ...(snapshot?.applied?.imports ?? []),
578
581
  ],
579
582
  });
580
- showStatus("Selection reset to the live style");
581
- }, [snapshot, dispatch, showStatus]);
583
+ }, [snapshot, dispatch]);
582
584
 
583
585
  /**
584
586
  * Start a new team style: scaffold a file in the project's
@@ -586,7 +588,7 @@ export function StylesScreen() {
586
588
  *
587
589
  * The file is created rather than opened in an editor — magus owns the
588
590
  * TTY, and handing it to `$EDITOR` from inside OpenTUI is a good way to
589
- * leave the terminal wedged. The status line reports the path; `r` reloads.
591
+ * leave the terminal wedged. It opens in the default app; `r` reloads.
590
592
  */
591
593
  const handleNewTeamStyle = useCallback(async () => {
592
594
  const name = await modal.input(
@@ -597,35 +599,36 @@ export function StylesScreen() {
597
599
  try {
598
600
  const created = await createTeamStyle(state.projectPath, name);
599
601
  await fetchData();
600
- // Project-relative: the status line is one row, and an absolute path
601
- // under a long temp or worktree prefix is truncated before it reaches
602
- // the filename — the only part the reader needs.
602
+ // Project-relative: an absolute path under a long temp or worktree
603
+ // prefix is cut before it reaches the filename — the part that matters.
603
604
  const shown = created.path.startsWith(`${state.projectPath}/`)
604
605
  ? created.path.slice(state.projectPath.length + 1)
605
606
  : created.path;
606
- // Open it straight away: a scaffold nobody edits is worse than no
607
- // scaffold, and the whole point of creating one is to write in it.
608
- let opened = true;
607
+ // Open it straight away — an existing one too, which is opened rather
608
+ // than overwritten: a scaffold nobody edits is worse than no scaffold,
609
+ // and the whole point of creating one is to write in it. The new row
610
+ // is the confirmation; only a file that would not open is said.
609
611
  try {
610
612
  await openInDefaultApp(created.path);
611
- } catch {
612
- opened = false;
613
+ } catch (error) {
614
+ await showFailures(
615
+ failureReport(`${shown} was not opened`, [
616
+ {
617
+ subject: shown,
618
+ reason: exceptionReason(
619
+ error instanceof Error ? error.message : String(error),
620
+ ).reason,
621
+ fix: "Open it to edit, then press r",
622
+ },
623
+ ]),
624
+ );
613
625
  }
614
- showStatus(
615
- created.existed
616
- ? `${shown} already exists — opened it instead of overwriting`
617
- : opened
618
- ? `Created and opened ${shown} — press r after saving`
619
- : `Created ${shown} — open it to edit, then press r`,
620
- created.existed ? "error" : "success",
621
- );
622
626
  } catch (error) {
623
- showStatus(
624
- error instanceof Error ? error.message : String(error),
625
- "error",
627
+ await showFailures(
628
+ stepFailureReport("The team style was not created", name, error),
626
629
  );
627
630
  }
628
- }, [modal, state.projectPath, fetchData, showStatus]);
631
+ }, [modal, state.projectPath, fetchData, showFailures]);
629
632
 
630
633
  /**
631
634
  * Open the selected style's file in the system's default application.
@@ -636,9 +639,11 @@ export function StylesScreen() {
636
639
  */
637
640
  const handleOpen = useCallback(async () => {
638
641
  if (selectedItem?.kind === "offer") {
639
- showStatus(
640
- `${selectedItem.label} is not on this machine yet — press f to fetch it`,
641
- "error",
642
+ await refuse(
643
+ `${selectedItem.label} was not opened`,
644
+ selectedItem.label,
645
+ "it is not on this machine yet",
646
+ "Press f to fetch it",
642
647
  );
643
648
  return;
644
649
  }
@@ -646,29 +651,29 @@ export function StylesScreen() {
646
651
  const { source } = selectedItem;
647
652
 
648
653
  if (source.kind === "preset") {
649
- showStatus(
650
- `${source.name} is built into magus and is replaced on update — press n to make a team style instead`,
651
- "error",
654
+ await refuse(
655
+ `${source.name} was not opened`,
656
+ source.name,
657
+ "it is built into magus and is replaced on update",
658
+ "Press n to make a team style instead",
652
659
  );
653
660
  return;
654
661
  }
655
662
 
663
+ // Opened in the default app, which is the confirmation. Whether a later
664
+ // re-fetch or re-capture overwrites it is on the style's detail panel.
656
665
  try {
657
666
  await openInDefaultApp(source.path);
658
- showStatus(
659
- source.origin === "anthropic"
660
- ? `Opened ${source.name}.md — note re-capturing overwrites it`
661
- : source.origin === "community"
662
- ? `Opened ${source.name}.md — a re-fetch overwrites it, so edit a copy`
663
- : `Opened ${source.name}.md — press r after saving`,
664
- );
665
667
  } catch (error) {
666
- showStatus(
667
- error instanceof Error ? error.message : String(error),
668
- "error",
668
+ await showFailures(
669
+ stepFailureReport(
670
+ `${source.name} was not opened`,
671
+ `${source.name}.md`,
672
+ error,
673
+ ),
669
674
  );
670
675
  }
671
- }, [selectedItem, showStatus]);
676
+ }, [selectedItem, refuse, showFailures]);
672
677
 
673
678
  const isFilling = stylesState.isFilling;
674
679
 
@@ -680,44 +685,30 @@ export function StylesScreen() {
680
685
  * for. It runs read-only and returns table rows; magus writes the file.
681
686
  */
682
687
  const handleFillTemplate = useCallback(async () => {
683
- // Both of these used to return silently. A fill runs for minutes, so the
684
- // natural thing a user does is press t again — and got no acknowledgement
685
- // that the key had even been received, which reads as the key being dead.
686
- if (isFilling) {
687
- showStatus(
688
- "Already reading the codebase — this takes a few minutes",
689
- "error",
690
- );
691
- return;
692
- }
693
- if (!selectedItem || selectedItem.kind !== "style") {
694
- showStatus("Select a style row first", "error");
695
- return;
696
- }
688
+ // A fill runs for minutes; the header says "reading the codebase…" for
689
+ // all of it, so a second `t` is acknowledged by that state, not ignored.
690
+ if (isFilling) return;
691
+ // `t` is offered only on a template preset (the footer hides it
692
+ // elsewhere), and does nothing anywhere else.
693
+ if (!selectedItem || selectedItem.kind !== "style") return;
697
694
  const { source } = selectedItem;
698
- if (source.kind !== "preset" || !source.template) {
699
- showStatus("Only a template preset can be filled in", "error");
700
- return;
701
- }
695
+ if (source.kind !== "preset" || !source.template) return;
702
696
 
703
697
  dispatch({ type: "STYLES_FILL_START" });
704
- showStatus(
705
- `Reading the codebase to fill ${source.name}… this can take a few minutes`,
706
- "success",
707
- );
708
698
  try {
709
- const result = await fillTerminology({
699
+ // Filled: the new style appears under Team.
700
+ await fillTerminology({
710
701
  projectPath: state.projectPath,
711
702
  templateBody: source.body,
712
703
  });
713
704
  await fetchData();
714
- showStatus(
715
- `Filled ${source.name} with ${result.rows.length} terms — it is now under Team`,
716
- );
717
705
  } catch (error) {
718
- showStatus(
719
- error instanceof Error ? error.message : String(error),
720
- "error",
706
+ await showFailures(
707
+ stepFailureReport(
708
+ `${source.name} was not filled in`,
709
+ source.name,
710
+ error,
711
+ ),
721
712
  );
722
713
  } finally {
723
714
  dispatch({ type: "STYLES_FILL_END" });
@@ -727,7 +718,7 @@ export function StylesScreen() {
727
718
  selectedItem,
728
719
  state.projectPath,
729
720
  fetchData,
730
- showStatus,
721
+ showFailures,
731
722
  dispatch,
732
723
  ]);
733
724
 
@@ -763,112 +754,56 @@ export function StylesScreen() {
763
754
  blur: () => dispatch({ type: "SET_SEARCHING", isSearching: false }),
764
755
  });
765
756
 
766
- /**
767
- * The previous message belongs to the previous ACTION, so every key this
768
- * screen acts on clears it first. Keys another layer takes — a digit or Tab
769
- * leaving the tab, text typed into the search box — leave it alone, so a
770
- * round trip through another tab keeps it.
771
- */
772
- const clearStatus = () => {
773
- if (stylesState.status) dispatch({ type: "STYLES_STATUS_CLEAR" });
774
- };
775
-
776
757
  useScreenKeys("styles", {
777
- toggle: () => {
778
- clearStatus();
779
- handleToggle();
780
- },
758
+ toggle: () => handleToggle(),
781
759
  apply: () => {
782
- clearStatus();
783
760
  void handleApply();
784
761
  },
785
762
  fill: () => {
786
- clearStatus();
787
763
  void handleFillTemplate();
788
764
  },
789
765
  clear: () => {
790
- clearStatus();
791
766
  void handleClear();
792
767
  },
793
768
  // Enter opens the style for editing; Space is the ticking key. Enter
794
769
  // reads as "go into this thing", and ticking already has a key that
795
770
  // means exactly that and nothing else.
796
771
  edit: () => {
797
- clearStatus();
798
772
  void handleOpen();
799
773
  },
800
774
  fetch: () => {
801
- clearStatus();
802
775
  void handleFetch();
803
776
  },
804
777
  new: () => {
805
- clearStatus();
806
778
  void handleNewTeamStyle();
807
779
  },
808
780
  check: () => {
809
- clearStatus();
810
781
  void handleCheckUpdates(false);
811
782
  },
812
783
  "check-all": () => {
813
- clearStatus();
814
784
  void handleCheckUpdates(true);
815
785
  },
816
- reset: () => {
817
- clearStatus();
818
- handleReset();
819
- },
786
+ reset: () => handleReset(),
820
787
  refresh: () => {
821
- clearStatus();
822
788
  void fetchData();
823
789
  },
824
- search: () => {
825
- clearStatus();
826
- dispatch({ type: "SET_SEARCHING", isSearching: true });
827
- },
790
+ search: () => dispatch({ type: "SET_SEARCHING", isSearching: true }),
828
791
  // Only while a committed filter is set; otherwise Esc is the global back.
829
792
  "clear-filter": () => {
830
793
  if (!stylesState.searchQuery) return false;
831
- clearStatus();
832
794
  dispatch({ type: "STYLES_SET_SEARCH", query: "" });
833
795
  dispatch({ type: "STYLES_SELECT", index: 0 });
834
796
  return true;
835
797
  },
836
- move: (key) => {
837
- clearStatus();
838
- move(moveDirection(key));
839
- },
798
+ move: (key) => move(moveDirection(key)),
840
799
  });
841
800
 
842
801
  // ── Status line ───────────────────────────────────────────────────────────
843
802
 
844
- // Transient feedback takes the full-width notice row under the header, not
845
- // the header itself: a long message beside the title used to shrink the
846
- // title away, and the two ran together as one garbled sentence ("magus
847
- // Styleevidence-first ships with…"). Order matters inside the row. A
848
- // message wins while it lasts, so pressing a key always acknowledges the
849
- // press — otherwise the fill banner below would shadow "already reading"
850
- // and the second press would look ignored again, which is the bug this
851
- // path exists to fix. The banner is the fallback because `statusMsg` is
852
- // local and dies when the screen unmounts, leaving it the only thing that
853
- // still reports work in flight after a tab switch.
854
- const noticeContent = statusMsg ? (
855
- <text
856
- fg={
857
- statusMsg.tone === "success"
858
- ? theme.colors.success
859
- : theme.colors.danger
860
- }
861
- >
862
- {statusMsg.text}
863
- </text>
864
- ) : isFilling ? (
865
- <text fg={theme.colors.muted}>
866
- Reading the codebase to fill a template… this can take a few minutes
867
- </text>
868
- ) : undefined;
869
-
870
- // The header keeps only the short ambient state — it shares its row with
871
- // the title and must never crowd it.
803
+ // The header carries state only — it shares its row with the title and must
804
+ // never crowd it. Work in flight is state too: `isFilling` lives in app
805
+ // state, so "reading the codebase…" survives a tab switch while a fill that
806
+ // takes minutes runs.
872
807
  const statusContent = (
873
808
  <text fg={theme.colors.text}>
874
809
  <span fg={theme.colors.muted}>Active: </span>
@@ -886,6 +821,8 @@ export function StylesScreen() {
886
821
  ) : null}
887
822
  {isApplying ? (
888
823
  <span fg={theme.colors.muted}> │ applying…</span>
824
+ ) : isFilling ? (
825
+ <span fg={theme.colors.muted}> │ reading the codebase…</span>
889
826
  ) : isFetching ? (
890
827
  <span fg={theme.colors.muted}> │ talking to GitHub…</span>
891
828
  ) : errors.length > 0 ? (
@@ -949,7 +886,6 @@ export function StylesScreen() {
949
886
  title="magus Styles"
950
887
  currentScreen="styles"
951
888
  statusLine={statusContent}
952
- notice={noticeContent}
953
889
  search={
954
890
  stylesState.searchQuery || isSearchActive
955
891
  ? {