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
@@ -1,5 +1,5 @@
1
1
  import type React from "react";
2
- import type { McpServer } from "../../types/index.js";
2
+ import type { McpRegistryServer, McpServer } from "../../types/index.js";
3
3
  import { rowLead } from "../components/primitives/index.js";
4
4
  import { theme } from "../theme.js";
5
5
 
@@ -10,11 +10,27 @@ import { theme } from "../theme.js";
10
10
  */
11
11
  const SERVER_ICON = "⬢";
12
12
 
13
+ /**
14
+ * A server added or removed this magus session, on its row: a running Claude
15
+ * Code picks the change up only after it restarts. One string for the curated
16
+ * and the registry rows alike.
17
+ */
18
+ const RESTART_MARK = " ↻ restart Claude Code";
19
+
13
20
  // ─── List item types ───────────────────────────────────────────────────────────
14
21
 
15
22
  export type McpListItem =
16
23
  | { kind: "category"; label: string }
17
- | { kind: "server"; server: McpServer; isInstalled: boolean };
24
+ | {
25
+ kind: "server";
26
+ server: McpServer;
27
+ isInstalled: boolean;
28
+ /**
29
+ * Added or removed in this magus session: a running Claude Code picks
30
+ * it up only after it restarts. Absent means false.
31
+ */
32
+ needsRestart?: boolean;
33
+ };
18
34
 
19
35
  // ─── Row renderers ─────────────────────────────────────────────────────────────
20
36
 
@@ -39,13 +55,15 @@ export function renderMcpRow(
39
55
  const { server, isInstalled } = item;
40
56
  const icon = isInstalled ? "●" : "○";
41
57
  const iconColor = isInstalled ? theme.colors.success : theme.colors.muted;
58
+ const restart = item.needsRestart ? RESTART_MARK : "";
42
59
 
43
60
  if (isSelected) {
44
61
  return (
45
62
  <text bg={theme.selection.bg} fg={theme.selection.fg}>
46
63
  {rowLead(true)}
47
64
  {icon} {server.name}
48
- {server.requiresConfig ? " ⚙" : ""}{" "}
65
+ {server.requiresConfig ? " ⚙" : ""}
66
+ {restart}{" "}
49
67
  </text>
50
68
  );
51
69
  }
@@ -58,10 +76,91 @@ export function renderMcpRow(
58
76
  </span>
59
77
  <span fg={theme.colors.text}> {server.name}</span>
60
78
  {server.requiresConfig ? <span fg={theme.colors.warning}> ⚙</span> : null}
79
+ {restart ? <span fg={theme.colors.warning}>{restart}</span> : null}
61
80
  </text>
62
81
  );
63
82
  }
64
83
 
84
+ // ─── Registry rows ─────────────────────────────────────────────────────────────
85
+
86
+ /**
87
+ * One server on the MCP Registry screen, with the same two states a curated
88
+ * row on the MCP screen carries: installed in this project, and — when it was
89
+ * added this magus session — that a running Claude Code picks it up only after
90
+ * it restarts.
91
+ *
92
+ * Adding from the registry used to show nothing at all: the restart state was
93
+ * drawn only on curated rows, and a registry server is not one, so the promise
94
+ * "shown on the MCP screen" had no row to be shown on.
95
+ */
96
+ export interface McpRegistryListItem {
97
+ server: McpRegistryServer;
98
+ isInstalled: boolean;
99
+ needsRestart: boolean;
100
+ }
101
+
102
+ export function renderMcpRegistryRow(
103
+ item: McpRegistryListItem,
104
+ _index: number,
105
+ isSelected: boolean,
106
+ ): React.ReactNode {
107
+ const { server, isInstalled, needsRestart } = item;
108
+ const version = server.version ? ` v${server.version}` : "";
109
+ const icon = isInstalled ? "● " : "";
110
+ const restart = needsRestart ? RESTART_MARK : "";
111
+ if (isSelected) {
112
+ return (
113
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
114
+ {rowLead(true)}
115
+ {icon}
116
+ {server.name}
117
+ {version}
118
+ {restart}{" "}
119
+ </text>
120
+ );
121
+ }
122
+ return (
123
+ <text fg={theme.colors.text}>
124
+ <span>{rowLead(false)}</span>
125
+ {icon ? <span fg={theme.colors.success}>{icon}</span> : null}
126
+ <span>
127
+ <strong>{server.name}</strong>
128
+ </span>
129
+ <span fg={theme.colors.info}>{version}</span>
130
+ {restart ? <span fg={theme.colors.warning}>{restart}</span> : null}
131
+ </text>
132
+ );
133
+ }
134
+
135
+ /**
136
+ * The registry detail panel's state lines — installed or not, and the
137
+ * restart — in the same words and colours as a curated server's detail.
138
+ */
139
+ export function renderMcpRegistryStatus(
140
+ item: McpRegistryListItem,
141
+ ): React.ReactNode {
142
+ return (
143
+ <box flexDirection="column">
144
+ <box flexDirection="row">
145
+ <text fg={theme.colors.muted}>{"Status "}</text>
146
+ {item.isInstalled ? (
147
+ <text fg={theme.colors.success}>{"● Installed"}</text>
148
+ ) : (
149
+ <text fg={theme.colors.muted}>{"○ Not installed"}</text>
150
+ )}
151
+ </box>
152
+ {item.needsRestart ? (
153
+ <box flexDirection="row">
154
+ <text fg={theme.colors.muted}>{"Takes effect "}</text>
155
+ <text fg={theme.colors.warning} wrapMode="word">
156
+ {"↻ after restarting Claude Code"}
157
+ </text>
158
+ </box>
159
+ ) : null}
160
+ </box>
161
+ );
162
+ }
163
+
65
164
  // ─── Detail renderer ───────────────────────────────────────────────────────────
66
165
 
67
166
  export function renderMcpDetail(
@@ -112,6 +211,14 @@ export function renderMcpDetail(
112
211
  <text fg={theme.colors.muted}>{"○ Not installed"}</text>
113
212
  )}
114
213
  </box>
214
+ {item.needsRestart ? (
215
+ <box flexDirection="row">
216
+ <text fg={theme.colors.muted}>{"Takes effect "}</text>
217
+ <text fg={theme.colors.warning} wrapMode="word">
218
+ {"↻ after restarting Claude Code"}
219
+ </text>
220
+ </box>
221
+ ) : null}
115
222
  <box flexDirection="row">
116
223
  <text fg={theme.colors.muted}>{"Type "}</text>
117
224
  <text fg={theme.colors.text}>
@@ -1,5 +1,6 @@
1
1
  import type React from "react";
2
2
  import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
3
+ import { mcpSettingsNeededText } from "../../services/plugin-env-needs.js";
3
4
  import {
4
5
  compareVersions,
5
6
  hasMissingFiles,
@@ -10,11 +11,11 @@ import {
10
11
  } from "../../services/plugin-manager.js";
11
12
  import type { ScopeStatus } from "../../services/plugin-manager.js";
12
13
  import type { PluginComponentKind } from "../../services/skills-manager.js";
13
- import type {
14
- PluginUpdateAction,
15
- PluginUpdateItem,
14
+ import {
15
+ type PluginUpdateAction,
16
+ type PluginUpdateItem,
17
+ isKeptDeprecated,
16
18
  } from "../../services/update-plan.js";
17
- import { isKnownVersion } from "../../services/version-snapshot.js";
18
19
  import type { PluginRelease } from "../../types/index.js";
19
20
  import { highlightMatches } from "../../utils/fuzzy-search.js";
20
21
  import type {
@@ -43,7 +44,11 @@ import type {
43
44
  import { componentChip } from "../theme-mode.js";
44
45
  import { type UiColor, theme } from "../theme.js";
45
46
  import { renderMarkdown } from "./markdownRenderer.js";
46
- import { type PluginRowSegment, pluginRowText } from "./pluginRowText.js";
47
+ import {
48
+ type PluginRowSegment,
49
+ isKnownVersion,
50
+ pluginRowText,
51
+ } from "./pluginRowText.js";
47
52
  import { type StatusTone, pluginStatusText } from "./pluginStatusText.js";
48
53
 
49
54
  // ─── Category renderers ───────────────────────────────────────────────────────
@@ -285,6 +290,7 @@ const ACTION_LABEL: Record<PluginUpdateAction, string> = {
285
290
  // The DETAIL panel's label, not the row's — this is where the mechanism is
286
291
  // allowed to be named, and the note beside it spells out both versions.
287
292
  "stale-load": "BROKEN — OLDER VERSION LOADS",
293
+ remove: "DEPRECATED",
288
294
  unknown: "UNKNOWN",
289
295
  };
290
296
 
@@ -321,12 +327,14 @@ function StatusLine({
321
327
  : status.action === "update" && status.target
322
328
  ? `→ v${status.target}`
323
329
  : "";
330
+ // Moved or still required: kept on purpose, which is known — never UNKNOWN.
331
+ const label = isKeptDeprecated(status) ? "KEPT" : ACTION_LABEL[status.action];
324
332
  return (
325
333
  <KeyValueLine
326
334
  label="Status"
327
335
  value={
328
336
  <span>
329
- <span fg={toneColor(tone)}>{ACTION_LABEL[status.action]}</span>
337
+ <span fg={toneColor(tone)}>{label}</span>
330
338
  {detail ? <span fg={theme.colors.text}> {detail}</span> : null}
331
339
  </span>
332
340
  }
@@ -349,6 +357,57 @@ function pluginDetail(
349
357
 
350
358
  // Published by no marketplace we read. The status line says which of the
351
359
  // three things that means — moved, withdrawn, or its marketplace unread.
360
+ //
361
+ // Deprecated — a marketplace we read no longer lists it — is the one of the
362
+ // three that is a fact, and gets one plain line in the user's terms. The row
363
+ // already says `a` removes it; `d` removes just this one.
364
+ if (plugin.isOrphaned && status?.action === "remove") {
365
+ return (
366
+ <box flexDirection="column">
367
+ <box justifyContent="center">
368
+ <text bg={theme.colors.danger} fg={theme.hints.fg}>
369
+ <strong> {plugin.name} — DEPRECATED </strong>
370
+ </text>
371
+ </box>
372
+ {/* No Status line: the heading says DEPRECATED and the line below
373
+ says why, so the note would be the same fact a third time. */}
374
+ <box marginTop={1}>
375
+ <text fg={theme.colors.text}>
376
+ No longer published by{" "}
377
+ {plugin.marketplaceDisplay || plugin.marketplace}. Claude Code does
378
+ not load it.
379
+ </text>
380
+ </box>
381
+ <ActionHints hints={[{ key: "d", label: "Remove", tone: "danger" }]} />
382
+ </box>
383
+ );
384
+ }
385
+ // Kept on purpose: it moved to another marketplace, or a plugin that stays
386
+ // still requires it. The status line's note says which; nothing is wrong
387
+ // enough for amber, and `d` would only undo a deliberate keep.
388
+ if (plugin.isOrphaned && status && isKeptDeprecated(status)) {
389
+ return (
390
+ <box flexDirection="column">
391
+ <box justifyContent="center">
392
+ <text bg={theme.colors.muted} fg={theme.hints.fg}>
393
+ <strong> {plugin.name} — KEPT </strong>
394
+ </text>
395
+ </box>
396
+ <box marginTop={1}>
397
+ <StatusLine status={status} />
398
+ </box>
399
+ <box marginTop={1}>
400
+ <text fg={theme.colors.muted}>
401
+ No longer published by{" "}
402
+ {plugin.marketplaceDisplay || plugin.marketplace}.{" "}
403
+ {status.reason === "moved"
404
+ ? "magus leaves this copy installed; the plugin is still published elsewhere."
405
+ : "magus keeps it installed while a plugin that needs it stays."}
406
+ </text>
407
+ </box>
408
+ </box>
409
+ );
410
+ }
352
411
  if (plugin.isOrphaned) {
353
412
  return (
354
413
  <box flexDirection="column">
@@ -466,6 +525,18 @@ function pluginDetail(
466
525
  </text>
467
526
  </box>
468
527
  ) : null}
528
+ {/*
529
+ * A lasting state, not a message: derived from the current config each
530
+ * load, so it is here for as long as the variables are unset — however
531
+ * the install question was answered — and gone once they are set.
532
+ */}
533
+ {item.mcpSettingsNeeded ? (
534
+ <box>
535
+ <text fg={theme.colors.warning}>
536
+ ⚙ {mcpSettingsNeededText(item.mcpSettingsNeeded)}
537
+ </text>
538
+ </box>
539
+ ) : null}
469
540
  {/*
470
541
  * THE panel where the mechanism is spelled out in full. The row above
471
542
  * says only "BROKEN — press a to fix", on purpose; a reader who wants to
@@ -12,27 +12,43 @@
12
12
  * - an orphan's text depended on the cursor — the selected row said
13
13
  * "moved → X", the unselected one always said "deprecated".
14
14
  *
15
+ * A row shows the current version and the state, nothing about history. It
16
+ * used to badge what changed since magus last looked ("v1.8.0 → v1.7.4
17
+ * updated"), from a snapshot of versions seen, and rendered a downgrade as
18
+ * "updated" beside an offer to update again.
19
+ *
15
20
  * Pure and `.ts`, so a test can assert on exactly what the row prints.
16
21
  */
17
22
 
18
- import { isKnownVersion } from "../../services/version-snapshot.js";
19
23
  import type { PluginPluginItem } from "../adapters/pluginsAdapter.js";
20
24
  import { type StatusTone, pluginStatusText } from "./pluginStatusText.js";
21
25
 
26
+ /**
27
+ * Sentinel recorded by Claude Code for a plugin that is installed but whose
28
+ * version is unknown — every plugin in the official Anthropic marketplace uses
29
+ * it. It is not a real version and must never be rendered as one.
30
+ */
31
+ export const UNKNOWN_VERSION = "0.0.0";
32
+
33
+ /** True when a version string carries information worth printing. */
34
+ export function isKnownVersion(v: string | undefined): v is string {
35
+ return !!v && v !== UNKNOWN_VERSION;
36
+ }
37
+
22
38
  export interface PluginRowSegment {
23
39
  text: string;
24
40
  tone: StatusTone | "text";
25
41
  }
26
42
 
27
43
  export interface PluginRowText {
28
- /** After the name: the version, a history badge, then the status suffix. */
44
+ /** After the name: the version, then the status suffix. */
29
45
  segments: PluginRowSegment[];
30
46
  /** The segments joined — exactly what the row renders after the name. */
31
47
  text: string;
32
48
  }
33
49
 
34
50
  export function pluginRowText(item: PluginPluginItem): PluginRowText {
35
- const { plugin, status } = item;
51
+ const { status } = item;
36
52
  const segments: PluginRowSegment[] = [];
37
53
 
38
54
  // A version is shown only when it carries information. "0.0.0" means
@@ -43,32 +59,16 @@ export function pluginRowText(item: PluginPluginItem): PluginRowText {
43
59
  // `plugin.installedVersion` printed a version where the classifier had none —
44
60
  // "v0.8.1 files missing", with 0.8.1 read off a scope not installed here.
45
61
  const installed = status?.installed ?? undefined;
46
- const shown = isKnownVersion(installed) ? installed : undefined;
47
-
48
- // "What just changed" and "what state it is in now" are independent facts
49
- // and both can be true at once: an external update can move a plugin
50
- // 2.9.0 → 3.0.1 while the marketplace already offers 3.3.1. So the badge
51
- // replaces only the version, and the status suffix is always appended.
52
- if (plugin.recentlyUpdatedFrom && shown) {
53
- // Show what actually happened, not just where it landed — the update may
54
- // have come from Claude Code or a direct CLI call without telling us.
55
- segments.push({
56
- text: ` v${plugin.recentlyUpdatedFrom} → v${shown} updated`,
57
- tone: "success",
58
- });
59
- } else if (plugin.recentlyInstalled) {
60
- // Newly seen. A plugin can be genuinely installed with no version we can
61
- // name, so the version is optional here.
62
- segments.push({
63
- text: shown ? ` v${shown} new` : " new",
64
- tone: "success",
65
- });
66
- } else if (shown) {
67
- segments.push({ text: ` v${shown}`, tone: "muted" });
62
+ if (isKnownVersion(installed)) {
63
+ segments.push({ text: ` v${installed}`, tone: "muted" });
68
64
  }
69
65
 
70
66
  const suffix = pluginStatusText(status);
71
67
  if (suffix.suffix) segments.push({ text: suffix.suffix, tone: suffix.tone });
72
68
 
69
+ // Its MCP server lacks variables. The same ⚙ the MCP screen puts on a
70
+ // server that needs configuring; the detail panel says how many, and the key.
71
+ if (item.mcpSettingsNeeded) segments.push({ text: " ⚙", tone: "warning" });
72
+
73
73
  return { segments, text: segments.map((s) => s.text).join("") };
74
74
  }
@@ -11,7 +11,10 @@
11
11
  * which is what makes the two front ends say the same thing.
12
12
  */
13
13
 
14
- import type { PluginUpdateItem } from "../../services/update-plan.js";
14
+ import {
15
+ type PluginUpdateItem,
16
+ isKeptDeprecated,
17
+ } from "../../services/update-plan.js";
15
18
 
16
19
  export type StatusTone = "muted" | "warning" | "danger" | "success" | "info";
17
20
 
@@ -27,6 +30,16 @@ export function pluginStatusText(
27
30
  if (!status) return { suffix: "", tone: "muted" };
28
31
  const withNote = status.note ? ` (${status.note})` : "";
29
32
 
33
+ // No longer published here, and kept on purpose — it moved, or a plugin
34
+ // that stays still requires it. A fact, not "cannot tell": muted, with the
35
+ // note ("moved to Magus Marketing"), never the amber "?".
36
+ if (isKeptDeprecated(status)) {
37
+ return {
38
+ suffix: ` ${status.note ?? "no longer published"}`,
39
+ tone: "muted",
40
+ };
41
+ }
42
+
30
43
  switch (status.action) {
31
44
  case "unknown":
32
45
  // Amber, not red: "cannot tell" is not a failure.
@@ -55,6 +68,10 @@ export function pluginStatusText(
55
68
  // whose session runs a version nobody installed is broken, and there is
56
69
  // now a key that fixes it.
57
70
  return { suffix: " BROKEN — press a to fix", tone: "danger" };
71
+ case "remove":
72
+ // No longer published. Red, like BROKEN: Claude Code refuses to load it,
73
+ // and `a` removes it. Which marketplace dropped it is the detail panel's.
74
+ return { suffix: " deprecated — press a to remove", tone: "danger" };
58
75
  case "install":
59
76
  // `absent` only ever comes from a profile pin (see `pinForRow`), so it
60
77
  // may say so. An undeclared row whose files are missing must not.
@@ -32,6 +32,8 @@ import {
32
32
  validateAliasName,
33
33
  withoutDerivedChannel,
34
34
  } from "../../services/alias-store.js";
35
+ import { failureReport } from "../adapters/failureReport.js";
36
+ import { stepFailureReport } from "../adapters/startupReports.js";
35
37
  import {
36
38
  FlagDetailEditor,
37
39
  type FlagDetailEditorHandle,
@@ -39,7 +41,7 @@ import {
39
41
  import { ScreenLayout } from "../components/layout/index.js";
40
42
  import { useScreenKeys } from "../input/KeyRouter.js";
41
43
  import { moveDirection } from "../keymap.js";
42
- import { useApp, useModal, useNotify } from "../state/AppContext.js";
44
+ import { useApp, useFailurePopup, useModal } from "../state/AppContext.js";
43
45
  import { displayWidth, truncate } from "../text-width.js";
44
46
  import { theme } from "../theme.js";
45
47
 
@@ -114,7 +116,7 @@ type ViewMode = { kind: "main" } | { kind: "flag-detail"; flagId: string };
114
116
  export function AliasScreen() {
115
117
  const { state } = useApp();
116
118
  const modal = useModal();
117
- const notify = useNotify();
119
+ const showFailures = useFailurePopup();
118
120
 
119
121
  const [config, setConfig] = useState<AliasConfig | null>(null);
120
122
  const [shells, setShells] = useState<ShellTarget[]>([]);
@@ -127,11 +129,6 @@ export function AliasScreen() {
127
129
  string,
128
130
  FlagValue
129
131
  > | null>(null);
130
- // Transient write feedback shown on the header status line (not a blocking
131
- // modal). Auto-clears after a few seconds. Errors still use a modal since
132
- // they need to be read and acknowledged.
133
- const [writeStatus, setWriteStatus] = useState<string | null>(null);
134
-
135
132
  // A hand-written `claude` alias found OUTSIDE any managed block on mount.
136
133
  // Set when the rc file has no managed block but a stray alias we could
137
134
  // adopt — drives the one-time "import it?" offer below. Cleared once the
@@ -144,13 +141,6 @@ export function AliasScreen() {
144
141
  [number, number] | null
145
142
  >(null);
146
143
 
147
- // Clear the write status line a few seconds after it appears.
148
- useEffect(() => {
149
- if (!writeStatus) return;
150
- const t = setTimeout(() => setWriteStatus(null), 4000);
151
- return () => clearTimeout(t);
152
- }, [writeStatus]);
153
-
154
144
  // One-time "adopt a hand-written alias?" offer. Fires when the mount load
155
145
  // found a stray `claude` alias outside any managed block. We import only the
156
146
  // flags we recognized; whether we also TAKE OWNERSHIP (remove the original
@@ -421,14 +411,12 @@ export function AliasScreen() {
421
411
  try {
422
412
  await saveAliasName(name);
423
413
  } catch (err) {
424
- await modal.message(
425
- "Save failed",
426
- err instanceof Error ? err.message : String(err),
427
- "error",
414
+ await showFailures(
415
+ stepFailureReport("The alias name was not saved", name, err),
428
416
  );
429
417
  }
430
418
  },
431
- [modal],
419
+ [showFailures],
432
420
  );
433
421
 
434
422
  // Cascade dependencies on every mutation:
@@ -500,12 +488,16 @@ export function AliasScreen() {
500
488
  if (v === null) return;
501
489
  const reason = validateAliasName(v);
502
490
  if (reason) {
503
- await modal.message("Invalid alias name", reason, "error");
491
+ await showFailures(
492
+ failureReport("The alias name was not changed", [
493
+ { subject: v === "" ? "(empty)" : v, reason },
494
+ ]),
495
+ );
504
496
  return;
505
497
  }
506
498
  setConfig((prev) => (prev ? { ...prev, aliasName: v } : prev));
507
499
  void persistAliasName(v);
508
- }, [config, modal, persistAliasName]);
500
+ }, [config, modal, persistAliasName, showFailures]);
509
501
 
510
502
  const editSelected = useCallback(async () => {
511
503
  if (!selectedRow) return;
@@ -566,10 +558,16 @@ export function AliasScreen() {
566
558
  shells.find((s) => s.exists);
567
559
 
568
560
  if (!target) {
569
- // Nothing was written. Named files, because creating one is the fix.
570
- notify(
571
- "No shell config to write to — create ~/.zshrc, ~/.bashrc or ~/.config/fish/config.fish first",
572
- "warning",
561
+ // Nothing was written — a failure, like the write failing below. Named
562
+ // files, because creating one is the fix.
563
+ await showFailures(
564
+ failureReport("The alias was not written", [
565
+ {
566
+ subject: "shell config",
567
+ reason: "there is no shell config file to write it to",
568
+ fix: "Create ~/.zshrc, ~/.bashrc or ~/.config/fish/config.fish, then press w again",
569
+ },
570
+ ]),
573
571
  );
574
572
  return;
575
573
  }
@@ -579,7 +577,7 @@ export function AliasScreen() {
579
577
  // the FIRST write replaces that line in place (absorbing it into the
580
578
  // managed block) rather than appending a second alias. Once the block
581
579
  // exists, the writer ignores the range and splices by markers.
582
- const r = await writeAliasToShell(
580
+ await writeAliasToShell(
583
581
  config,
584
582
  target,
585
583
  pendingAdoptRange ? { adoptLineRange: pendingAdoptRange } : {},
@@ -587,23 +585,20 @@ export function AliasScreen() {
587
585
  // The adopted line is now gone (replaced by the block); drop the range
588
586
  // so subsequent writes don't try to re-target a line that moved.
589
587
  if (pendingAdoptRange) setPendingAdoptRange(null);
590
- // Non-blocking confirmation on the status line — no "press any key".
591
- const name = r.path.split("/").pop() || r.path;
592
- setWriteStatus(
593
- `✓ ${r.action === "created" ? "Created" : "Updated"} ${name} — source it or open a new shell`,
594
- );
588
+ // Written: no message. The header shows it — the snapshot now matches
589
+ // the in-memory flags, so "unsaved, w writes …" disappears.
595
590
  setShells(await detectShells());
596
- // We just wrote the rc file with current in-memory flag values, so
597
- // the snapshot now matches in-memory state. Dirty indicator clears.
598
591
  setRcSnapshot(config.flags);
599
592
  } catch (err) {
600
- await modal.message(
601
- "Write failed",
602
- err instanceof Error ? err.message : String(err),
603
- "error",
593
+ await showFailures(
594
+ stepFailureReport(
595
+ "The alias was not written",
596
+ `${target.kind} config`,
597
+ err,
598
+ ),
604
599
  );
605
600
  }
606
- }, [config, shells, modal, notify, pendingAdoptRange]);
601
+ }, [config, shells, pendingAdoptRange, showFailures]);
607
602
 
608
603
  // ── Keyboard ──────────────────────────────────────────────────────────
609
604
  // j/k navigate the selectable subset, jumping over headers/legend lines.
@@ -830,13 +825,11 @@ export function AliasScreen() {
830
825
  : 0;
831
826
  const subtitle = inDetail
832
827
  ? `editing ${detailFlag.flag}`
833
- : writeStatus
834
- ? writeStatus
835
- : config
836
- ? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled${
837
- dirty ? ` │ * unsaved, w writes ${writeLabel}` : ""
838
- }`
839
- : "";
828
+ : config
829
+ ? `${enabledCount} flag${enabledCount === 1 ? "" : "s"} enabled${
830
+ dirty ? ` │ * unsaved, w writes ${writeLabel}` : ""
831
+ }`
832
+ : "";
840
833
 
841
834
  return (
842
835
  <ScreenLayout