claudeup 4.35.1 → 4.37.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 (72) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/catalog-cache-store.test.ts +271 -0
  3. package/src/__tests__/catalog-notice.test.ts +155 -0
  4. package/src/__tests__/github-budget.test.ts +200 -0
  5. package/src/__tests__/plugin-manager-fallback.test.ts +200 -8
  6. package/src/__tests__/scope-squares.test.tsx +165 -0
  7. package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
  8. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  9. package/src/main.tsx +21 -5
  10. package/src/opentui.d.ts +21 -12
  11. package/src/services/catalog-cache-store.ts +218 -0
  12. package/src/services/github-budget.ts +274 -0
  13. package/src/services/marketplace-catalog-git.ts +170 -0
  14. package/src/services/marketplace-catalog.ts +95 -0
  15. package/src/services/marketplace-fetcher.ts +310 -87
  16. package/src/services/plugin-manager.ts +103 -92
  17. package/src/ui/App.tsx +19 -12
  18. package/src/ui/adapters/catalogNotice.ts +122 -0
  19. package/src/ui/adapters/pluginsAdapter.ts +174 -168
  20. package/src/ui/adapters/settingsAdapter.ts +119 -116
  21. package/src/ui/adapters/skillsAdapter.ts +203 -196
  22. package/src/ui/components/CategoryHeader.tsx +9 -8
  23. package/src/ui/components/EmptyFilterState.tsx +10 -5
  24. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  25. package/src/ui/components/ScopeIndicator.tsx +10 -6
  26. package/src/ui/components/ScrollableList.tsx +3 -2
  27. package/src/ui/components/SearchInput.tsx +2 -1
  28. package/src/ui/components/StyledText.tsx +5 -4
  29. package/src/ui/components/TabBar.tsx +4 -3
  30. package/src/ui/components/layout/FooterHints.tsx +37 -30
  31. package/src/ui/components/layout/Panel.tsx +6 -5
  32. package/src/ui/components/layout/ProgressBar.tsx +7 -6
  33. package/src/ui/components/layout/ScopeTabs.tsx +6 -5
  34. package/src/ui/components/layout/ScreenLayout.tsx +46 -26
  35. package/src/ui/components/layout/index.ts +3 -3
  36. package/src/ui/components/modals/ConfirmModal.tsx +12 -11
  37. package/src/ui/components/modals/InputModal.tsx +14 -6
  38. package/src/ui/components/modals/LoadingModal.tsx +6 -5
  39. package/src/ui/components/modals/MessageModal.tsx +9 -8
  40. package/src/ui/components/modals/SelectModal.tsx +11 -7
  41. package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
  42. package/src/ui/components/primitives/ActionHints.tsx +26 -26
  43. package/src/ui/components/primitives/DetailSection.tsx +13 -12
  44. package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
  45. package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
  46. package/src/ui/components/primitives/MetaText.tsx +3 -3
  47. package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
  48. package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
  49. package/src/ui/components/primitives/SelectableRow.tsx +22 -16
  50. package/src/ui/hooks/useGitignoreModal.ts +78 -74
  51. package/src/ui/registry.ts +11 -11
  52. package/src/ui/renderers/cliToolRenderers.tsx +260 -203
  53. package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
  54. package/src/ui/renderers/mcpRenderers.tsx +121 -117
  55. package/src/ui/renderers/pluginRenderers.tsx +566 -471
  56. package/src/ui/renderers/profileRenderers.tsx +346 -300
  57. package/src/ui/renderers/settingsRenderers.tsx +183 -176
  58. package/src/ui/renderers/skillRenderers.tsx +410 -326
  59. package/src/ui/screens/AliasScreen.tsx +1336 -1309
  60. package/src/ui/screens/CliToolsScreen.tsx +92 -40
  61. package/src/ui/screens/EnvVarsScreen.tsx +19 -13
  62. package/src/ui/screens/GitignoreScreen.tsx +510 -493
  63. package/src/ui/screens/McpRegistryScreen.tsx +28 -21
  64. package/src/ui/screens/McpScreen.tsx +12 -3
  65. package/src/ui/screens/PluginsScreen.tsx +152 -33
  66. package/src/ui/screens/ProfilesScreen.tsx +39 -23
  67. package/src/ui/screens/SkillsScreen.tsx +832 -688
  68. package/src/ui/state/reducer.ts +11 -2
  69. package/src/ui/state/types.ts +16 -1
  70. package/src/ui/theme-mode.ts +73 -0
  71. package/src/ui/theme.ts +147 -53
  72. package/src/utils/config-dir.ts +47 -0
@@ -8,6 +8,7 @@ import { searchMcpServers, formatDate } from "../../services/mcp-registry.js";
8
8
  import { addMcpServer, setAllowMcp } from "../../services/claude-settings.js";
9
9
  import type { McpRegistryServer, McpServerConfig } from "../../types/index.js";
10
10
  import { ScrollableList } from "../components/ScrollableList.js";
11
+ import { theme } from "../theme.js";
11
12
 
12
13
  /**
13
14
  * Deduplicate servers by name, keeping only the latest version.
@@ -182,7 +183,9 @@ export function McpRegistryScreen() {
182
183
  dispatch({ type: "MCPREGISTRY_SELECT", index: newIndex });
183
184
  } else if (event.name === "l") {
184
185
  navigateToScreen("mcp");
185
- } else if (event.name === "R") {
186
+ } else if (event.name === "r" && event.shift) {
187
+ // `name` is always the unshifted key, so `=== "R"` never matched and
188
+ // refresh silently did nothing.
186
189
  loadServers(searchQuery);
187
190
  } else if (event.name === "enter") {
188
191
  handleInstall();
@@ -219,15 +222,17 @@ export function McpRegistryScreen() {
219
222
 
220
223
  const renderDetail = () => {
221
224
  if (isLoading) {
222
- return <text fg="gray">Loading...</text>;
225
+ return <text fg={theme.colors.muted}>Loading...</text>;
223
226
  }
224
227
 
225
228
  if (error) {
226
- return <text fg="red">Error: {error}</text>;
229
+ return <text fg={theme.colors.danger}>Error: {error}</text>;
227
230
  }
228
231
 
229
232
  if (!selectedServer) {
230
- return <text fg="gray">Select a server to see details</text>;
233
+ return (
234
+ <text fg={theme.colors.muted}>Select a server to see details</text>
235
+ );
231
236
  }
232
237
 
233
238
  const dateDisplay = selectedServer.published_at
@@ -240,40 +245,42 @@ export function McpRegistryScreen() {
240
245
 
241
246
  return (
242
247
  <box flexDirection="column">
243
- <text fg="magenta">
248
+ <text fg={theme.colors.accent}>
244
249
  <strong>{selectedServer.name}</strong>
245
250
  </text>
246
251
  <box marginTop={1}>
247
- <text>{selectedServer.short_description}</text>
252
+ <text fg={theme.colors.text}>{selectedServer.short_description}</text>
248
253
  </box>
249
254
  <box marginTop={1}>
250
- <text>
255
+ <text fg={theme.colors.text}>
251
256
  <strong>Version: </strong>
252
257
  </text>
253
- <text fg="green">{versionDisplay}</text>
258
+ <text fg={theme.colors.success}>{versionDisplay}</text>
254
259
  </box>
255
260
  <box>
256
- <text>
261
+ <text fg={theme.colors.text}>
257
262
  <strong>Published: </strong>
258
263
  </text>
259
- <text fg="cyan">{dateDisplay}</text>
264
+ <text fg={theme.colors.info}>{dateDisplay}</text>
260
265
  </box>
261
266
  <box marginTop={1} flexDirection="column">
262
- <text>
267
+ <text fg={theme.colors.text}>
263
268
  <strong>URL:</strong>
264
269
  </text>
265
- <text fg="cyan">{selectedServer.url}</text>
270
+ <text fg={theme.colors.info}>{selectedServer.url}</text>
266
271
  </box>
267
272
  {selectedServer.source_code_url && (
268
273
  <box marginTop={1} flexDirection="column">
269
- <text>
274
+ <text fg={theme.colors.text}>
270
275
  <strong>Source:</strong>
271
276
  </text>
272
- <text fg="gray">{selectedServer.source_code_url}</text>
277
+ <text fg={theme.colors.muted}>
278
+ {selectedServer.source_code_url}
279
+ </text>
273
280
  </box>
274
281
  )}
275
282
  <box marginTop={1}>
276
- <text fg="green">Press Enter to install</text>
283
+ <text fg={theme.colors.success}>Press Enter to install</text>
277
284
  </box>
278
285
  </box>
279
286
  );
@@ -286,17 +293,17 @@ export function McpRegistryScreen() {
286
293
  ) => {
287
294
  const version = server.version ? ` v${server.version}` : "";
288
295
  return isSelected ? (
289
- <text bg="magenta" fg="white">
296
+ <text bg={theme.colors.accent} fg={theme.hints.fg}>
290
297
  {" "}
291
298
  {server.name}
292
299
  {version}{" "}
293
300
  </text>
294
301
  ) : (
295
- <text>
302
+ <text fg={theme.colors.text}>
296
303
  <span>
297
304
  <strong>{server.name}</strong>
298
305
  </span>
299
- <span fg="green">{version}</span>
306
+ <span fg={theme.colors.success}>{version}</span>
300
307
  </text>
301
308
  );
302
309
  };
@@ -333,11 +340,11 @@ export function McpRegistryScreen() {
333
340
  footerHints={footerHints}
334
341
  listPanel={
335
342
  isLoading ? (
336
- <text fg="gray">Loading...</text>
343
+ <text fg={theme.colors.muted}>Loading...</text>
337
344
  ) : error ? (
338
- <text fg="red">Error: {error}</text>
345
+ <text fg={theme.colors.danger}>Error: {error}</text>
339
346
  ) : servers.length === 0 ? (
340
- <text fg="gray">No servers found</text>
347
+ <text fg={theme.colors.muted}>No servers found</text>
341
348
  ) : (
342
349
  <ScrollableList
343
350
  items={servers}
@@ -130,7 +130,11 @@ export function McpScreen() {
130
130
  try {
131
131
  await removeMcpServer(server.name, state.projectPath);
132
132
  modal.hideModal();
133
- await modal.message("Removed", `${server.name} has been removed.`, "success");
133
+ await modal.message(
134
+ "Removed",
135
+ `${server.name} has been removed.`,
136
+ "success",
137
+ );
134
138
  fetchData();
135
139
  } catch (error) {
136
140
  modal.hideModal();
@@ -146,7 +150,8 @@ export function McpScreen() {
146
150
  let config: McpServerConfig;
147
151
 
148
152
  if (server.type === "http") {
149
- if (!server.url) throw new Error(`${server.name} is missing its HTTP URL`);
153
+ if (!server.url)
154
+ throw new Error(`${server.name} is missing its HTTP URL`);
150
155
  config = { type: "http", url: server.url };
151
156
  } else {
152
157
  if (!server.command) {
@@ -184,7 +189,11 @@ export function McpScreen() {
184
189
  if (value === null) return;
185
190
 
186
191
  if (field.required && !value) {
187
- await modal.message("Required Field", `${field.label} is required.`, "error");
192
+ await modal.message(
193
+ "Required Field",
194
+ `${field.label} is required.`,
195
+ "error",
196
+ );
188
197
  return;
189
198
  }
190
199
 
@@ -1,12 +1,16 @@
1
- import React, { useEffect, useCallback, useMemo } from "react";
1
+ import React, { useEffect, useCallback, useMemo, useState } from "react";
2
2
  import { useApp, useModal, useProgress } from "../state/AppContext.js";
3
+ import { asyncValue } from "../state/types.js";
3
4
  import { useDimensions } from "../state/DimensionsContext.js";
4
5
  import { useKeyboard } from "../hooks/useKeyboard.js";
5
6
  import { ScreenLayout } from "../components/layout/index.js";
6
7
  import { ScrollableList } from "../components/ScrollableList.js";
7
8
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
8
9
  import { fuzzyFilter } from "../../utils/fuzzy-search.js";
9
- import { defaultMarketplaces, getAllMarketplaces } from "../../data/marketplaces.js";
10
+ import {
11
+ defaultMarketplaces,
12
+ getAllMarketplaces,
13
+ } from "../../data/marketplaces.js";
10
14
  import { clearContentDriftCache } from "../../services/content-drift.js";
11
15
  import {
12
16
  diffVersions,
@@ -15,12 +19,14 @@ import {
15
19
  } from "../../services/version-snapshot.js";
16
20
  import {
17
21
  getAvailablePlugins,
22
+ getMarketplaceFetchFailures,
18
23
  refreshAllMarketplaces,
19
24
  clearMarketplaceCache,
20
25
  getLocalMarketplacesInfo,
21
26
  isInstalledInScope,
22
27
  resolveScopeAction,
23
28
  saveInstalledPluginVersion,
29
+ type MarketplaceFetchFailure,
24
30
  type PluginInfo,
25
31
  type ScopeStatus,
26
32
  } from "../../services/plugin-manager.js";
@@ -63,6 +69,17 @@ import {
63
69
  renderPluginRow,
64
70
  renderPluginDetail,
65
71
  } from "../renderers/pluginRenderers.js";
72
+ import { buildCatalogNoticeText } from "../adapters/catalogNotice.js";
73
+ import { theme } from "../theme.js";
74
+
75
+ /** Wraps the notice text in the warning-coloured row ScreenLayout renders. */
76
+ function buildCatalogNotice(
77
+ input: Parameters<typeof buildCatalogNoticeText>[0],
78
+ ): React.ReactNode {
79
+ const text = buildCatalogNoticeText(input);
80
+ if (!text) return null;
81
+ return <text fg={theme.colors.warning}>{text}</text>;
82
+ }
66
83
 
67
84
  export function PluginsScreen() {
68
85
  const { state, dispatch } = useApp();
@@ -77,6 +94,20 @@ export function PluginsScreen() {
77
94
  state.currentRoute.screen === "plugins" &&
78
95
  !state.modal;
79
96
 
97
+ /**
98
+ * Catalogs that could not be read from any authoritative source.
99
+ *
100
+ * Held in state rather than derived at render time because it is an answer
101
+ * from the last fetch and must survive until the next one — a failure the user
102
+ * cannot see is the bug this screen shipped with.
103
+ */
104
+ const [catalogFailures, setCatalogFailures] = useState<
105
+ MarketplaceFetchFailure[]
106
+ >([]);
107
+
108
+ /** Re-render tick so the retry countdown in the notice actually counts down. */
109
+ const [nowTick, setNowTick] = useState(() => Date.now());
110
+
80
111
  // Fetch data (always fetches all scopes)
81
112
  const fetchData = useCallback(async () => {
82
113
  dispatch({ type: "PLUGINS_DATA_LOADING" });
@@ -85,6 +116,24 @@ export function PluginsScreen() {
85
116
  const allMarketplaces = getAllMarketplaces(localMarketplaces);
86
117
  const pluginData = await getAvailablePlugins(state.projectPath);
87
118
 
119
+ // Which catalogs answered, and which only appeared to. Read straight
120
+ // after the fetch, while the record still describes this attempt.
121
+ //
122
+ // Only failures that survived every authoritative source are worth
123
+ // reporting: a rate-limited HTTP fetch that the git remote ref then
124
+ // answered is a non-event for the user, and reporting it would fire a
125
+ // warning about intended, working behaviour.
126
+ const unresolved = new Set(
127
+ pluginData
128
+ .filter((p) => p.updateCheckFailed)
129
+ .map((p) => p.marketplace),
130
+ );
131
+ setCatalogFailures(
132
+ getMarketplaceFetchFailures().filter((f) =>
133
+ unresolved.has(f.marketplace),
134
+ ),
135
+ );
136
+
88
137
  // Surface version changes made outside claudeup (Claude Code's own
89
138
  // plugin update, the prerunner, a manual CLI call). Diffing against
90
139
  // what we last rendered catches all of them; logging only what
@@ -127,24 +176,60 @@ export function PluginsScreen() {
127
176
  fetchData();
128
177
  }, [fetchData, state.dataRefreshVersion]);
129
178
 
179
+ /**
180
+ * Tick the countdown, and refetch by itself once the rate-limit window reopens.
181
+ *
182
+ * The user asked "when can I try again" — so the screen answers it and then
183
+ * acts on it. Leaving them to press `r` and guess is what makes a rate limit
184
+ * feel like a dead end, and a mistimed guess spends another request against the
185
+ * limit for nothing.
186
+ *
187
+ * Only armed while a retry is actually pending, so an untroubled screen runs no
188
+ * timer at all.
189
+ */
190
+ const nextRetryAt = useMemo(() => {
191
+ const times = catalogFailures
192
+ .map((f) => f.retryAt)
193
+ .filter((t): t is number => typeof t === "number");
194
+ return times.length > 0 ? Math.min(...times) : undefined;
195
+ }, [catalogFailures]);
196
+
197
+ useEffect(() => {
198
+ if (nextRetryAt === undefined) return;
199
+
200
+ const tick = setInterval(() => {
201
+ const now = Date.now();
202
+ setNowTick(now);
203
+ if (now >= nextRetryAt) {
204
+ clearInterval(tick);
205
+ // The cooldown has expired, so the fetcher will really try again
206
+ // rather than short-circuit on its own negative cache.
207
+ clearMarketplaceCache();
208
+ fetchData();
209
+ }
210
+ }, 1000);
211
+
212
+ return () => clearInterval(tick);
213
+ }, [nextRetryAt, fetchData]);
214
+
215
+ // The data to render: fresh when loaded, else the previous list while a
216
+ // reload is in flight. Reading `status === "success"` alone is what made a
217
+ // tab switch blank the screen — the router remounts this component, which
218
+ // refetches, and every intermediate frame had nothing to show.
219
+ const shownMarketplaces = asyncValue(pluginsState.marketplaces);
220
+ const shownPlugins = asyncValue(pluginsState.plugins);
221
+ const isReloading =
222
+ pluginsState.plugins.status === "loading" && shownPlugins !== undefined;
223
+
130
224
  // Build list items (categories + plugins)
131
225
  const allItems = useMemo((): PluginBrowserItem[] => {
132
- if (
133
- pluginsState.marketplaces.status !== "success" ||
134
- pluginsState.plugins.status !== "success"
135
- ) {
136
- return [];
137
- }
226
+ if (!shownMarketplaces || !shownPlugins) return [];
138
227
  return buildPluginBrowserItems({
139
- marketplaces: pluginsState.marketplaces.data,
140
- plugins: pluginsState.plugins.data,
228
+ marketplaces: shownMarketplaces,
229
+ plugins: shownPlugins,
141
230
  collapsedMarketplaces: pluginsState.collapsedMarketplaces,
142
231
  });
143
- }, [
144
- pluginsState.marketplaces,
145
- pluginsState.plugins,
146
- pluginsState.collapsedMarketplaces,
147
- ]);
232
+ }, [shownMarketplaces, shownPlugins, pluginsState.collapsedMarketplaces]);
148
233
 
149
234
  // Filter items by search query
150
235
  const filteredItems = useMemo(() => {
@@ -297,11 +382,15 @@ export function PluginsScreen() {
297
382
  if (event.name === "r") handleRefresh();
298
383
  else if (event.name === "n") handleShowAddMarketplaceInstructions();
299
384
  else if (event.name === "t") handleShowTeamConfigHelp();
385
+ // `name` is ALWAYS the unshifted key — Shift+U parses as
386
+ // {name:"u", shift:true, sequence:"U"}, so `name === "U"` can never be
387
+ // true. It sat below the plain "u" branch, so pressing Shift+U silently
388
+ // installed at user scope instead of updating.
389
+ else if (event.name === "u" && event.shift) handleUpdate();
300
390
  else if (event.name === "u") handleScopeToggle("user");
301
391
  else if (event.name === "p") handleScopeToggle("project");
302
392
  else if (event.name === "l") handleScopeToggle("local");
303
393
  else if (event.name === "d") handleRemoveDeprecated();
304
- else if (event.name === "U") handleUpdate();
305
394
  else if (event.name === "a") handleUpdateAll();
306
395
  else if (event.name === "m") handleCheckMismatches();
307
396
  else if (event.name === "s") handleSaveAsProfile();
@@ -993,7 +1082,9 @@ export function PluginsScreen() {
993
1082
  // Reinstall under the new namespace in every scope it occupied, so
994
1083
  // the migration does not quietly change where the plugin is enabled.
995
1084
  const newId = `${plugin.name}@${migrateTo}`;
996
- for (const scope of removedFrom.length > 0 ? removedFrom : ["project" as PluginScope]) {
1085
+ for (const scope of removedFrom.length > 0
1086
+ ? removedFrom
1087
+ : ["project" as PluginScope]) {
997
1088
  modal.loading(
998
1089
  `Installing ${newId} (${scope})…\nclaude plugin install ${newId} --scope ${scope}`,
999
1090
  );
@@ -1051,30 +1142,47 @@ export function PluginsScreen() {
1051
1142
 
1052
1143
  // ── Render ─────────────────────────────────────────────────────────────────
1053
1144
 
1054
- if (
1055
- pluginsState.marketplaces.status === "loading" ||
1056
- pluginsState.plugins.status === "loading"
1057
- ) {
1058
- return (
1059
- <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1060
- <text fg="#7e57c2">
1061
- <strong>claudeup Plugins</strong>
1062
- </text>
1063
- <text fg="gray">Loading...</text>
1064
- </box>
1065
- );
1145
+ // Only a genuine first load takes over the screen. A reload keeps the list on
1146
+ // screen and says so in the notice line instead.
1147
+ if (!shownMarketplaces || !shownPlugins) {
1148
+ if (
1149
+ pluginsState.marketplaces.status === "loading" ||
1150
+ pluginsState.plugins.status === "loading"
1151
+ ) {
1152
+ return (
1153
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1154
+ <text fg={theme.colors.accent}>
1155
+ <strong>claudeup Plugins</strong>
1156
+ </text>
1157
+ <text fg={theme.colors.muted}>
1158
+ Loading plugin catalogs from GitHub…
1159
+ </text>
1160
+ </box>
1161
+ );
1162
+ }
1066
1163
  }
1067
1164
 
1068
1165
  if (
1069
1166
  pluginsState.marketplaces.status === "error" ||
1070
1167
  pluginsState.plugins.status === "error"
1071
1168
  ) {
1169
+ // Name the error. "Error loading data" told the user nothing and sent them
1170
+ // to the debug pane to find out what claudeup already knew.
1171
+ const failure =
1172
+ pluginsState.plugins.status === "error"
1173
+ ? pluginsState.plugins.error
1174
+ : pluginsState.marketplaces.status === "error"
1175
+ ? pluginsState.marketplaces.error
1176
+ : undefined;
1072
1177
  return (
1073
1178
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
1074
- <text fg="#7e57c2">
1179
+ <text fg={theme.colors.accent}>
1075
1180
  <strong>claudeup Plugins</strong>
1076
1181
  </text>
1077
- <text fg="red">Error loading data</text>
1182
+ <text fg={theme.colors.danger}>
1183
+ Could not load plugins: {failure?.message ?? "unknown error"}
1184
+ </text>
1185
+ <text fg={theme.colors.muted}>Press r to retry.</text>
1078
1186
  </box>
1079
1187
  );
1080
1188
  }
@@ -1097,8 +1205,7 @@ export function PluginsScreen() {
1097
1205
  ];
1098
1206
 
1099
1207
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
1100
- const plugins: PluginInfo[] =
1101
- pluginsState.plugins.status === "success" ? pluginsState.plugins.data : [];
1208
+ const plugins: PluginInfo[] = shownPlugins ?? [];
1102
1209
  const installedCount = plugins.filter((p) => p.enabled).length;
1103
1210
  const updateCount = plugins.filter(
1104
1211
  (p) =>
@@ -1110,10 +1217,22 @@ export function PluginsScreen() {
1110
1217
  const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}`;
1111
1218
  const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""} │ / to search`;
1112
1219
 
1220
+ const notice = isReloading ? (
1221
+ <text fg={theme.colors.muted}>⟳ Refreshing catalogs from GitHub…</text>
1222
+ ) : (
1223
+ buildCatalogNotice({
1224
+ failures: catalogFailures,
1225
+ unverifiedPlugins: plugins.filter((p) => p.updateCheckFailed).length,
1226
+ width: dimensions.terminalWidth,
1227
+ now: nowTick,
1228
+ })
1229
+ );
1230
+
1113
1231
  return (
1114
1232
  <ScreenLayout
1115
1233
  title="claudeup Plugins"
1116
1234
  subtitle={subtitle}
1235
+ notice={notice}
1117
1236
  currentScreen="plugins"
1118
1237
  search={{
1119
1238
  isActive: isSearchActive,
@@ -12,10 +12,7 @@ import {
12
12
  exportProfileToJson,
13
13
  importProfileFromJson,
14
14
  } from "../../services/profiles.js";
15
- import {
16
- readSettings,
17
- writeSettings,
18
- } from "../../services/claude-settings.js";
15
+ import { readSettings, writeSettings } from "../../services/claude-settings.js";
19
16
  import {
20
17
  writeClipboard,
21
18
  readClipboard,
@@ -32,6 +29,7 @@ import {
32
29
  type ProfileListItem,
33
30
  } from "../renderers/profileRenderers.js";
34
31
  import { ScrollableList } from "../components/ScrollableList.js";
32
+ import { theme } from "../theme.js";
35
33
 
36
34
  export function ProfilesScreen() {
37
35
  const { state, dispatch } = useApp();
@@ -111,9 +109,7 @@ export function ProfilesScreen() {
111
109
  const handleApplyPredefined = async (profile: PredefinedProfile) => {
112
110
  const allPlugins = [
113
111
  ...profile.magusPlugins.map((p) => `${p}@magus`),
114
- ...profile.anthropicPlugins.map(
115
- (p) => `${p}@claude-plugins-official`,
116
- ),
112
+ ...profile.anthropicPlugins.map((p) => `${p}@claude-plugins-official`),
117
113
  ];
118
114
  const settingsCount = Object.keys(profile.settings).length;
119
115
 
@@ -161,7 +157,11 @@ export function ProfilesScreen() {
161
157
  );
162
158
  } catch (error) {
163
159
  modal.hideModal();
164
- await modal.message("Error", `Failed to apply profile: ${error}`, "error");
160
+ await modal.message(
161
+ "Error",
162
+ `Failed to apply profile: ${error}`,
163
+ "error",
164
+ );
165
165
  }
166
166
  };
167
167
 
@@ -217,7 +217,11 @@ export function ProfilesScreen() {
217
217
  );
218
218
  } catch (error) {
219
219
  modal.hideModal();
220
- await modal.message("Error", `Failed to apply profile: ${error}`, "error");
220
+ await modal.message(
221
+ "Error",
222
+ `Failed to apply profile: ${error}`,
223
+ "error",
224
+ );
221
225
  }
222
226
  };
223
227
 
@@ -242,7 +246,11 @@ export function ProfilesScreen() {
242
246
  );
243
247
  modal.hideModal();
244
248
  await fetchData();
245
- await modal.message("Renamed", `Profile renamed to "${newName.trim()}".`, "success");
249
+ await modal.message(
250
+ "Renamed",
251
+ `Profile renamed to "${newName.trim()}".`,
252
+ "success",
253
+ );
246
254
  } catch (error) {
247
255
  modal.hideModal();
248
256
  await modal.message("Error", `Failed to rename: ${error}`, "error");
@@ -345,10 +353,18 @@ export function ProfilesScreen() {
345
353
 
346
354
  modal.loading("Importing...");
347
355
  try {
348
- const id = await importProfileFromJson(json, targetScope, state.projectPath);
356
+ const id = await importProfileFromJson(
357
+ json,
358
+ targetScope,
359
+ state.projectPath,
360
+ );
349
361
  modal.hideModal();
350
362
  await fetchData();
351
- await modal.message("Imported", `Profile imported successfully (id: ${id}).`, "success");
363
+ await modal.message(
364
+ "Imported",
365
+ `Profile imported successfully (id: ${id}).`,
366
+ "success",
367
+ );
352
368
  } catch (error) {
353
369
  modal.hideModal();
354
370
  await modal.message("Error", `Failed to import: ${error}`, "error");
@@ -362,14 +378,16 @@ export function ProfilesScreen() {
362
378
  const projCount = profileList.filter((p) => p.scope === "project").length;
363
379
 
364
380
  const statusContent = (
365
- <text>
366
- <span fg="blue">{PREDEFINED_PROFILES.length} presets</span>
367
- <span fg="gray"> + </span>
368
- <span fg="cyan">{userCount} user</span>
369
- <span fg="gray"> + </span>
370
- <span fg="green">{projCount} project</span>
371
- <span fg="gray"> = </span>
372
- <span fg="white">{PREDEFINED_PROFILES.length + profileCount} total</span>
381
+ <text fg={theme.colors.text}>
382
+ <span fg={theme.colors.link}>{PREDEFINED_PROFILES.length} presets</span>
383
+ <span fg={theme.colors.muted}> + </span>
384
+ <span fg={theme.colors.info}>{userCount} user</span>
385
+ <span fg={theme.colors.muted}> + </span>
386
+ <span fg={theme.colors.success}>{projCount} project</span>
387
+ <span fg={theme.colors.muted}> = </span>
388
+ <span fg={theme.colors.text}>
389
+ {PREDEFINED_PROFILES.length + profileCount} total
390
+ </span>
373
391
  </text>
374
392
  );
375
393
 
@@ -380,9 +398,7 @@ export function ProfilesScreen() {
380
398
  : profilesState.selectedIndex;
381
399
 
382
400
  const loadingStatus =
383
- profilesState.profiles.status === "loading"
384
- ? true
385
- : false;
401
+ profilesState.profiles.status === "loading" ? true : false;
386
402
  const errorMessage =
387
403
  profilesState.profiles.status === "error"
388
404
  ? profilesState.profiles.error.message