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
@@ -1,15 +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
- import { useApp, useModal, useNavigation } from "../state/AppContext.js";
6
+ import {
7
+ useApp,
8
+ useFailurePopup,
9
+ useModal,
10
+ useNavigation,
11
+ } from "../state/AppContext.js";
6
12
  import { useDimensions } from "../state/DimensionsContext.js";
7
13
 
8
- import { addMcpServer, setAllowMcp } from "../../services/claude-settings.js";
14
+ import {
15
+ addMcpServer,
16
+ getEnabledMcpServers,
17
+ getInstalledMcpServers,
18
+ setAllowMcp,
19
+ } from "../../services/claude-settings.js";
9
20
  import { formatDate, searchMcpServers } from "../../services/mcp-registry.js";
10
21
  import type { McpRegistryServer, McpServerConfig } from "../../types/index.js";
11
22
  import { ScrollableList } from "../components/ScrollableList.js";
12
- import { rowLead } from "../components/primitives/index.js";
23
+ import {
24
+ type McpRegistryListItem,
25
+ renderMcpRegistryRow,
26
+ renderMcpRegistryStatus,
27
+ } from "../renderers/mcpRenderers.js";
13
28
  import { theme } from "../theme.js";
14
29
 
15
30
  /**
@@ -75,6 +90,7 @@ export function McpRegistryScreen() {
75
90
  const { state, dispatch } = useApp();
76
91
  const { mcpRegistry } = state;
77
92
  const modal = useModal();
93
+ const showFailures = useFailurePopup();
78
94
  const { navigateToScreen } = useNavigation();
79
95
  const dimensions = useDimensions();
80
96
 
@@ -88,6 +104,33 @@ export function McpRegistryScreen() {
88
104
 
89
105
  const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
90
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
+
91
134
  // Load servers
92
135
  const loadServers = useCallback(async (query: string) => {
93
136
  setIsLoading(true);
@@ -190,19 +233,28 @@ export function McpRegistryScreen() {
190
233
  await setAllowMcp(true, state.projectPath);
191
234
  await addMcpServer(server.name, config, state.projectPath);
192
235
  modal.hideModal();
193
- await modal.message(
194
- "Installed",
195
- `${server.name} has been configured.\n\nRestart Claude Code to activate.`,
196
- "success",
197
- );
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();
198
241
  } catch (error) {
199
242
  modal.hideModal();
200
- await modal.message("Error", `Failed to install: ${error}`, "error");
243
+ await showFailures(
244
+ stepFailureReport(`Could not add ${server.name}`, server.name, error),
245
+ );
201
246
  }
202
247
  };
203
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
+
204
255
  // Get selected server
205
- const selectedServer = servers[mcpRegistry.selectedIndex];
256
+ const selectedItem = listItems[mcpRegistry.selectedIndex];
257
+ const selectedServer = selectedItem?.server;
206
258
 
207
259
  const renderDetail = () => {
208
260
  if (isLoading) {
@@ -213,7 +265,7 @@ export function McpRegistryScreen() {
213
265
  return <text fg={theme.colors.danger}>Error: {error}</text>;
214
266
  }
215
267
 
216
- if (!selectedServer) {
268
+ if (!selectedServer || !selectedItem) {
217
269
  return (
218
270
  <text fg={theme.colors.muted}>Select a server to see details</text>
219
271
  );
@@ -235,6 +287,7 @@ export function McpRegistryScreen() {
235
287
  <box marginTop={1}>
236
288
  <text fg={theme.colors.text}>{selectedServer.short_description}</text>
237
289
  </box>
290
+ <box marginTop={1}>{renderMcpRegistryStatus(selectedItem)}</box>
238
291
  <box flexDirection="row" marginTop={1}>
239
292
  <text fg={theme.colors.text}>
240
293
  <strong>Version: </strong>
@@ -263,36 +316,15 @@ export function McpRegistryScreen() {
263
316
  </text>
264
317
  </box>
265
318
  )}
266
- <box marginTop={1}>
267
- <text fg={theme.colors.muted}>Press Enter to install</text>
268
- </box>
319
+ {selectedItem.isInstalled ? null : (
320
+ <box marginTop={1}>
321
+ <text fg={theme.colors.muted}>Press Enter to install</text>
322
+ </box>
323
+ )}
269
324
  </box>
270
325
  );
271
326
  };
272
327
 
273
- const renderListItem = (
274
- server: McpRegistryServer,
275
- _idx: number,
276
- isSelected: boolean,
277
- ) => {
278
- const version = server.version ? ` v${server.version}` : "";
279
- return isSelected ? (
280
- <text bg={theme.selection.bg} fg={theme.selection.fg}>
281
- {rowLead(true)}
282
- {server.name}
283
- {version}{" "}
284
- </text>
285
- ) : (
286
- <text fg={theme.colors.text}>
287
- <span>{rowLead(false)}</span>
288
- <span>
289
- <strong>{server.name}</strong>
290
- </span>
291
- <span fg={theme.colors.info}>{version}</span>
292
- </text>
293
- );
294
- };
295
-
296
328
  // Status for search placeholder
297
329
  const searchPlaceholder = `${servers.length} servers │ / to search`;
298
330
 
@@ -316,11 +348,11 @@ export function McpRegistryScreen() {
316
348
  <text fg={theme.colors.muted}>No servers found</text>
317
349
  ) : (
318
350
  <ScrollableList
319
- items={servers}
351
+ items={listItems}
320
352
  selectedIndex={mcpRegistry.selectedIndex}
321
- renderItem={renderListItem}
353
+ renderItem={renderMcpRegistryRow}
322
354
  maxHeight={dimensions.listPanelHeight}
323
- getKey={(server) => server.name}
355
+ getKey={(item) => item.server.name}
324
356
  />
325
357
  )
326
358
  }
@@ -1,8 +1,15 @@
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
- import { useApp, useModal, useNavigation } from "../state/AppContext.js";
7
+ import {
8
+ useApp,
9
+ useFailurePopup,
10
+ useModal,
11
+ useNavigation,
12
+ } from "../state/AppContext.js";
6
13
  import { useDimensions } from "../state/DimensionsContext.js";
7
14
 
8
15
  import {
@@ -30,6 +37,7 @@ export function McpScreen() {
30
37
  const { state, dispatch } = useApp();
31
38
  const { mcp } = state;
32
39
  const modal = useModal();
40
+ const showFailures = useFailurePopup();
33
41
  const { navigateToScreen } = useNavigation();
34
42
  const dimensions = useDimensions();
35
43
 
@@ -69,9 +77,12 @@ export function McpScreen() {
69
77
  }
70
78
  }, [dispatch, state.projectPath]);
71
79
 
80
+ // Load data on mount or when dataRefreshVersion changes — a profile
81
+ // activating changes what this screen shows.
82
+ // 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
72
83
  useEffect(() => {
73
84
  fetchData();
74
- }, [fetchData]);
85
+ }, [fetchData, state.dataRefreshVersion]);
75
86
 
76
87
  const allListItems = useMemo((): McpListItem[] => {
77
88
  if (mcp.servers.status !== "success") return [];
@@ -87,22 +98,34 @@ export function McpScreen() {
87
98
 
88
99
  for (const server of servers) {
89
100
  const isInstalled = mcp.installedServers[server.name] !== undefined;
90
- items.push({ kind: "server", server, isInstalled });
101
+ items.push({
102
+ kind: "server",
103
+ server,
104
+ isInstalled,
105
+ needsRestart: mcp.changedThisSession.includes(server.name),
106
+ });
91
107
  }
92
108
  }
93
109
 
94
110
  return items;
95
- }, [mcp.servers, mcp.installedServers]);
111
+ }, [mcp.servers, mcp.installedServers, mcp.changedThisSession]);
96
112
 
97
113
  useScreenKeys("mcp", {
98
114
  // Guarded: `installMcpServer` throws on a catalogue entry with no URL or
99
115
  // command, and a rejection nobody catches ends the app.
100
116
  toggle: () => {
117
+ const item = allListItems[mcp.selectedIndex];
118
+ if (item?.kind !== "server") return;
119
+ const { name } = item.server;
101
120
  handleSelect().catch((error: unknown) =>
102
- modal.message(
103
- "Error",
104
- error instanceof Error ? error.message : String(error),
105
- "error",
121
+ showFailures(
122
+ stepFailureReport(
123
+ item.isInstalled
124
+ ? `Could not remove ${name}`
125
+ : `Could not add ${name}`,
126
+ name,
127
+ error,
128
+ ),
106
129
  ),
107
130
  );
108
131
  },
@@ -138,15 +161,19 @@ export function McpScreen() {
138
161
  try {
139
162
  await removeMcpServer(server.name, state.projectPath);
140
163
  modal.hideModal();
141
- await modal.message(
142
- "Removed",
143
- `${server.name} has been removed.`,
144
- "success",
145
- );
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 });
146
167
  fetchData();
147
168
  } catch (error) {
148
169
  modal.hideModal();
149
- 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
+ );
150
177
  }
151
178
  }
152
179
  } else {
@@ -197,10 +224,14 @@ export function McpScreen() {
197
224
  if (value === null) return;
198
225
 
199
226
  if (field.required && !value) {
200
- await modal.message(
201
- "Required Field",
202
- `${field.label} is required.`,
203
- "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
+ ]),
204
235
  );
205
236
  return;
206
237
  }
@@ -216,15 +247,15 @@ export function McpScreen() {
216
247
  try {
217
248
  await addMcpServer(server.name, config, state.projectPath);
218
249
  modal.hideModal();
219
- await modal.message(
220
- "Installed",
221
- `${server.name} has been configured.\n\nRestart Claude Code to activate.`,
222
- "success",
223
- );
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 });
224
253
  fetchData();
225
254
  } catch (error) {
226
255
  modal.hideModal();
227
- await modal.message("Error", `Failed to install: ${error}`, "error");
256
+ await showFailures(
257
+ stepFailureReport(`Could not add ${server.name}`, server.name, error),
258
+ );
228
259
  }
229
260
  };
230
261
 
@@ -7,11 +7,13 @@ import {
7
7
  readModelsConfig,
8
8
  readModelsStatus,
9
9
  } from "../../services/models-manager.js";
10
+ import { failureReport } from "../adapters/failureReport.js";
10
11
  import {
11
12
  type ModelsBrowserItem,
12
13
  buildModelsItems,
13
14
  firstSelectableIndex,
14
15
  } from "../adapters/modelsAdapter.js";
16
+ import { stepFailureReport } from "../adapters/startupReports.js";
15
17
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
16
18
  import { ScrollableList } from "../components/ScrollableList.js";
17
19
  import { ScreenLayout } from "../components/layout/index.js";
@@ -23,7 +25,7 @@ import {
23
25
  renderModelRow,
24
26
  renderModelsSummary,
25
27
  } from "../renderers/modelRenderers.js";
26
- import { useApp } from "../state/AppContext.js";
28
+ import { useApp, useFailurePopup } from "../state/AppContext.js";
27
29
  import { useDimensions } from "../state/DimensionsContext.js";
28
30
  import { asyncValue } from "../state/types.js";
29
31
  import { theme } from "../theme.js";
@@ -134,22 +136,15 @@ export function ModelsScreen() {
134
136
  };
135
137
  }, [dispatch]);
136
138
 
137
- // ── Status line ───────────────────────────────────────────────────────────
139
+ // ── Feedback ──────────────────────────────────────────────────────────────
138
140
 
139
141
  /**
140
- * Set the status line.
141
- *
142
- * No timer, matching the Styles screen: a message that erased itself after a
143
- * few seconds was usually gone by the time anyone looked. It is cleared when
144
- * the NEXT action starts instead.
142
+ * No status line and no messages. A success shows as the screen's state —
143
+ * the reloaded list, "Model tiers: on", "writing…" while a write runs. What
144
+ * did not happen goes into the one failure popup, with why and, when there
145
+ * is one, what to do. A key pressed where it does nothing does nothing.
145
146
  */
146
- const showStatus = useCallback(
147
- (text: string, tone: "success" | "error" = "success") => {
148
- dispatch({ type: "MODELS_STATUS_SET", status: { text, tone } });
149
- },
150
- [dispatch],
151
- );
152
- const statusMsg = modelsState.status;
147
+ const showFailures = useFailurePopup();
153
148
 
154
149
  // ── Derived ───────────────────────────────────────────────────────────────
155
150
 
@@ -189,18 +184,17 @@ export function ModelsScreen() {
189
184
  *
190
185
  * No `ensureManifest` is passed. That helper prompts on the terminal, and
191
186
  * this process's terminal belongs to OpenTUI — a project with no profiles
192
- * gets a plain `.claude/models.json`, and the status line says so.
187
+ * gets a plain `.claude/models.json`.
188
+ *
189
+ * A press while a write is in flight does nothing: the header already says
190
+ * "writing…". Neither does one with no preset under the cursor — the only
191
+ * other row is the status header, which the cursor never lands on, so that
192
+ * is an empty filtered list.
193
193
  */
194
194
  const handleApply = useCallback(async () => {
195
- if (modelsState.isApplying) {
196
- showStatus("Already writing the routing — one at a time", "error");
197
- return;
198
- }
199
- if (!selectedItem || selectedItem.kind !== "preset") {
200
- showStatus("Move to a preset row and press a", "error");
201
- return;
202
- }
203
- const { config, custom } = selectedItem;
195
+ if (modelsState.isApplying) return;
196
+ if (!selectedItem || selectedItem.kind !== "preset") return;
197
+ const { config, custom, label } = selectedItem;
204
198
 
205
199
  dispatch({ type: "MODELS_APPLY_START" });
206
200
  try {
@@ -213,24 +207,24 @@ export function ModelsScreen() {
213
207
  projectPath: state.projectPath,
214
208
  preset: custom ? config : config.preset,
215
209
  });
210
+ // Applied: the reloaded list and the header show it. Said only when
211
+ // the profile did NOT record it — the next magus install renders the
212
+ // live settings from the profile and drops the routing.
216
213
  await fetchData();
217
- const where = result.profile
218
- ? `profile "${result.profile}"`
219
- : "this project";
220
- // Say plainly when the manifest was NOT updated: without that record a
221
- // `magus install` rewrites the profile and the routing disappears.
222
- const durability =
223
- result.profile && !result.recordedInManifest
224
- ? " — not in profiles.json, so `magus install` will drop it"
225
- : "";
226
- showStatus(
227
- `Applied ${selectedItem.label} in ${where}, hook ${result.hook}${durability}`,
228
- result.profile && !result.recordedInManifest ? "error" : "success",
229
- );
214
+ if (result.profile && !result.recordedInManifest) {
215
+ await showFailures(
216
+ failureReport(`${label} is active, but not saved in the profile`, [
217
+ {
218
+ subject: `profile ${result.profile}`,
219
+ reason:
220
+ "the routing is not recorded in it, so the next magus install drops it",
221
+ },
222
+ ]),
223
+ );
224
+ }
230
225
  } catch (error) {
231
- showStatus(
232
- error instanceof Error ? error.message : String(error),
233
- "error",
226
+ await showFailures(
227
+ stepFailureReport("No model routing was applied", label, error),
234
228
  );
235
229
  } finally {
236
230
  dispatch({ type: "MODELS_APPLY_END" });
@@ -240,26 +234,24 @@ export function ModelsScreen() {
240
234
  selectedItem,
241
235
  state.projectPath,
242
236
  fetchData,
243
- showStatus,
237
+ showFailures,
244
238
  dispatch,
245
239
  ]);
246
240
 
241
+ // Off: the reloaded header reads "Model tiers: off".
247
242
  const handleClear = useCallback(async () => {
248
- if (modelsState.isApplying) {
249
- showStatus("Already writing the routing — one at a time", "error");
250
- return;
251
- }
243
+ if (modelsState.isApplying) return;
252
244
  dispatch({ type: "MODELS_APPLY_START" });
253
245
  try {
254
246
  await clearModels(state.projectPath);
255
247
  await fetchData();
256
- showStatus(
257
- "Model tiers off — subagents inherit the session model again. The hook stays registered and is a no-op without a config.",
258
- );
259
248
  } catch (error) {
260
- showStatus(
261
- error instanceof Error ? error.message : String(error),
262
- "error",
249
+ await showFailures(
250
+ stepFailureReport(
251
+ "Model tiers were not turned off",
252
+ "model routing",
253
+ error,
254
+ ),
263
255
  );
264
256
  } finally {
265
257
  dispatch({ type: "MODELS_APPLY_END" });
@@ -268,7 +260,7 @@ export function ModelsScreen() {
268
260
  modelsState.isApplying,
269
261
  state.projectPath,
270
262
  fetchData,
271
- showStatus,
263
+ showFailures,
272
264
  dispatch,
273
265
  ]);
274
266
 
@@ -301,67 +293,36 @@ export function ModelsScreen() {
301
293
  blur: () => dispatch({ type: "SET_SEARCHING", isSearching: false }),
302
294
  });
303
295
 
304
- /**
305
- * The previous message belongs to the previous ACTION, so every key this
306
- * screen acts on clears it first. Keys another layer takes — a digit or Tab
307
- * leaving the tab, text typed into the search box — leave it alone, so a
308
- * round trip through another tab keeps it.
309
- */
310
- const clearStatus = () => {
311
- if (modelsState.status) dispatch({ type: "MODELS_STATUS_CLEAR" });
312
- };
313
-
314
296
  useScreenKeys("models", {
315
297
  // Enter and `a` are the same action here. There is nothing to "open" on
316
298
  // this screen — a preset row has one thing it can do — so binding Enter
317
299
  // to anything else would leave the most obvious key doing nothing.
318
300
  apply: () => {
319
- clearStatus();
320
301
  void handleApply();
321
302
  },
322
303
  off: () => {
323
- clearStatus();
324
304
  void handleClear();
325
305
  },
326
306
  reload: () => {
327
- clearStatus();
328
307
  void fetchData();
329
308
  },
330
309
  search: () => {
331
- clearStatus();
332
310
  dispatch({ type: "SET_SEARCHING", isSearching: true });
333
311
  },
334
312
  // Only while a committed filter is set; otherwise Esc is the global back.
335
313
  "clear-filter": () => {
336
314
  if (!modelsState.searchQuery) return false;
337
- clearStatus();
338
315
  dispatch({ type: "MODELS_SET_SEARCH", query: "" });
339
316
  dispatch({ type: "MODELS_SELECT", index: 0 });
340
317
  return true;
341
318
  },
342
319
  move: (key) => {
343
- clearStatus();
344
320
  move(moveDirection(key));
345
321
  },
346
322
  });
347
323
 
348
324
  // ── Chrome ────────────────────────────────────────────────────────────────
349
325
 
350
- // Transient feedback takes the full-width notice row under the header, never
351
- // the header itself: a long message beside the title shrinks the title away
352
- // and the two read as one garbled sentence.
353
- const noticeContent = statusMsg ? (
354
- <text
355
- fg={
356
- statusMsg.tone === "success"
357
- ? theme.colors.success
358
- : theme.colors.danger
359
- }
360
- >
361
- {statusMsg.text}
362
- </text>
363
- ) : undefined;
364
-
365
326
  // The file's `preset` string is deliberately not shown beside the state: it records what
366
327
  // was last APPLIED, so a config edited afterwards would wear a built-in's name here while
367
328
  // the list correctly calls it `Custom`.
@@ -398,7 +359,6 @@ export function ModelsScreen() {
398
359
  title="magus Models"
399
360
  currentScreen="models"
400
361
  statusLine={statusContent}
401
- notice={noticeContent}
402
362
  search={
403
363
  modelsState.searchQuery || isSearchActive
404
364
  ? {
@@ -469,8 +429,8 @@ export function ModelsScreen() {
469
429
  configPath: snapshot?.path ?? null,
470
430
  width: listWidth,
471
431
  // Rows the list did not use. One held back because the
472
- // search bar and the notice each take a row from the panel
473
- // without taking one from `listPanelHeight` — and an
432
+ // search bar takes a row from the panel without taking
433
+ // one from `listPanelHeight` — and an
474
434
  // over-tall panel overprints rather than clipping.
475
435
  available:
476
436
  dimensions.listPanelHeight -