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
@@ -1,399 +1,491 @@
1
- import React from "react";
2
- import type {
3
- PluginBrowserItem,
4
- PluginCategoryItem,
5
- PluginPluginItem,
6
- } from "../adapters/pluginsAdapter.js";
7
- import { CategoryHeader } from "../components/CategoryHeader.js";
8
- import {
9
- SelectableRow,
10
- ScopeSquares,
11
- ActionHints,
12
- MetaText,
13
- KeyValueLine,
14
- DetailSection,
15
- } from "../components/primitives/index.js";
16
- import { theme } from "../theme.js";
17
- import { highlightMatches } from "../../utils/fuzzy-search.js";
1
+ import type React from "react";
18
2
  import { getMarketplaceVersion } from "../../services/marketplace-fetcher.js";
19
3
  import {
20
- isEnabledButNotInstalled,
21
- isInstalledInScope,
4
+ isEnabledButNotInstalled,
5
+ isInstalledInScope,
22
6
  } from "../../services/plugin-manager.js";
7
+ import type { ScopeStatus } from "../../services/plugin-manager.js";
23
8
  import { isKnownVersion } from "../../services/version-snapshot.js";
24
9
  import type { PluginRelease } from "../../types/index.js";
25
- import type { ScopeStatus } from "../../services/plugin-manager.js";
10
+ import { highlightMatches } from "../../utils/fuzzy-search.js";
11
+ import type {
12
+ PluginBrowserItem,
13
+ PluginCategoryItem,
14
+ PluginPluginItem,
15
+ } from "../adapters/pluginsAdapter.js";
16
+ import { CategoryHeader } from "../components/CategoryHeader.js";
17
+ import {
18
+ ActionHints,
19
+ DetailSection,
20
+ KeyValueLine,
21
+ MetaText,
22
+ ScopeSquares,
23
+ SelectableRow,
24
+ } from "../components/primitives/index.js";
25
+ import { type UiColor, theme } from "../theme.js";
26
26
 
27
27
  // ─── Category renderers ───────────────────────────────────────────────────────
28
28
 
29
- function categoryRow(item: PluginCategoryItem, isSelected: boolean): React.ReactNode {
30
- const mp = item.marketplace;
31
- // Marketplaces that declare a `metadata.version` render "Name v8.0.0 (15)";
32
- // those without keep the plain "Name (15)" form.
33
- const version = getMarketplaceVersion(mp.name);
34
-
35
- if (isSelected) {
36
- const arrow = item.isExpanded ? "▼" : "▶";
37
- const versionLabel = version ? ` v${version}` : "";
38
- const count = item.pluginCount > 0 ? ` (${item.pluginCount})` : "";
39
- return (
40
- <SelectableRow selected={true}>
41
- <strong>
42
- {" "}
43
- {arrow} {mp.displayName}
44
- {versionLabel}
45
- {count}{" "}
46
- </strong>
47
- </SelectableRow>
48
- );
49
- }
50
-
51
- // Map tone to a statusColor for CategoryHeader (legacy component)
52
- const statusColorMap: Record<PluginCategoryItem["tone"], string> = {
53
- yellow: "yellow",
54
- gray: "gray",
55
- green: "green",
56
- red: "red",
57
- purple: theme.colors.accent,
58
- teal: "cyan",
59
- byline: theme.colors.byline,
60
- };
61
-
62
- return (
63
- <CategoryHeader
64
- title={mp.displayName}
65
- version={version}
66
- expanded={item.isExpanded}
67
- count={item.pluginCount}
68
- status={item.badge}
69
- statusColor={statusColorMap[item.tone]}
70
- />
71
- );
29
+ function categoryRow(
30
+ item: PluginCategoryItem,
31
+ isSelected: boolean,
32
+ ): React.ReactNode {
33
+ const mp = item.marketplace;
34
+ // Marketplaces that declare a `metadata.version` render "Name v8.0.0 (15)";
35
+ // those without keep the plain "Name (15)" form.
36
+ const version = getMarketplaceVersion(mp.name);
37
+
38
+ if (isSelected) {
39
+ const arrow = item.isExpanded ? "▼" : "▶";
40
+ const versionLabel = version ? ` v${version}` : "";
41
+ const count = item.pluginCount > 0 ? ` (${item.pluginCount})` : "";
42
+ return (
43
+ <SelectableRow selected={true}>
44
+ <strong>
45
+ {" "}
46
+ {arrow} {mp.displayName}
47
+ {versionLabel}
48
+ {count}{" "}
49
+ </strong>
50
+ </SelectableRow>
51
+ );
52
+ }
53
+
54
+ // Map tone to a statusColor for CategoryHeader (legacy component)
55
+ const statusColorMap: Record<PluginCategoryItem["tone"], UiColor> = {
56
+ yellow: theme.colors.warning,
57
+ gray: theme.colors.muted,
58
+ green: theme.colors.success,
59
+ red: theme.colors.danger,
60
+ purple: theme.colors.accent,
61
+ teal: theme.colors.info,
62
+ byline: theme.colors.byline,
63
+ };
64
+
65
+ return (
66
+ <CategoryHeader
67
+ title={mp.displayName}
68
+ version={version}
69
+ expanded={item.isExpanded}
70
+ count={item.pluginCount}
71
+ status={item.badge}
72
+ statusColor={statusColorMap[item.tone]}
73
+ />
74
+ );
72
75
  }
73
76
 
74
77
  function categoryDetail(
75
- item: PluginCategoryItem,
76
- collapsedMarketplaces: Set<string>,
78
+ item: PluginCategoryItem,
79
+ collapsedMarketplaces: Set<string>,
77
80
  ): React.ReactNode {
78
- const mp = item.marketplace;
79
- const isEnabled = item.marketplaceEnabled;
80
- const isCollapsed = collapsedMarketplaces.has(mp.name);
81
- const hasPlugins = item.pluginCount > 0;
82
-
83
- let actionHint = "Add";
84
- if (isEnabled) {
85
- if (isCollapsed) {
86
- actionHint = "Expand";
87
- } else if (hasPlugins) {
88
- actionHint = "Collapse";
89
- } else {
90
- actionHint = "Remove";
91
- }
92
- }
93
-
94
- return (
95
- <box flexDirection="column">
96
- <text fg={theme.colors.info}>
97
- <strong>
98
- {mp.displayName}
99
- {item.badge ? ` ${item.badge}` : ""}
100
- </strong>
101
- </text>
102
- <text fg={theme.colors.muted}>{mp.description || "No description"}</text>
103
- <text fg={isEnabled ? theme.colors.success : theme.colors.muted}>
104
- {isEnabled ? "● Added" : "○ Not added"}
105
- </text>
106
- <text fg={theme.colors.link}>github.com/{mp.source.repo}</text>
107
- <text>Plugins: {item.pluginCount}</text>
108
- <ActionHints
109
- hints={[
110
- { key: "Enter", label: actionHint, tone: isEnabled ? "primary" : "default" },
111
- ]}
112
- />
113
- {isEnabled ? (
114
- <box>
115
- <text fg={theme.colors.muted}>← → to expand/collapse</text>
116
- </box>
117
- ) : null}
118
- </box>
119
- );
81
+ const mp = item.marketplace;
82
+ const isEnabled = item.marketplaceEnabled;
83
+ const isCollapsed = collapsedMarketplaces.has(mp.name);
84
+ const hasPlugins = item.pluginCount > 0;
85
+
86
+ let actionHint = "Add";
87
+ if (isEnabled) {
88
+ if (isCollapsed) {
89
+ actionHint = "Expand";
90
+ } else if (hasPlugins) {
91
+ actionHint = "Collapse";
92
+ } else {
93
+ actionHint = "Remove";
94
+ }
95
+ }
96
+
97
+ return (
98
+ <box flexDirection="column">
99
+ <text fg={theme.colors.info}>
100
+ <strong>
101
+ {mp.displayName}
102
+ {item.badge ? ` ${item.badge}` : ""}
103
+ </strong>
104
+ </text>
105
+ <text fg={theme.colors.muted}>{mp.description || "No description"}</text>
106
+ <text fg={isEnabled ? theme.colors.success : theme.colors.muted}>
107
+ {isEnabled ? "● Added" : "○ Not added"}
108
+ </text>
109
+ <text fg={theme.colors.link}>github.com/{mp.source.repo}</text>
110
+ <text fg={theme.colors.text}>Plugins: {item.pluginCount}</text>
111
+ <ActionHints
112
+ hints={[
113
+ {
114
+ key: "Enter",
115
+ label: actionHint,
116
+ tone: isEnabled ? "primary" : "default",
117
+ },
118
+ ]}
119
+ />
120
+ {isEnabled ? (
121
+ <box>
122
+ <text fg={theme.colors.muted}>← → to expand/collapse</text>
123
+ </box>
124
+ ) : null}
125
+ </box>
126
+ );
120
127
  }
121
128
 
122
129
  // ─── Plugin renderers ─────────────────────────────────────────────────────────
123
130
 
124
- function pluginRow(item: PluginPluginItem, isSelected: boolean): React.ReactNode {
125
- const { plugin } = item;
126
- const hasUser = !!plugin.userScope?.enabled;
127
- const hasProject = !!plugin.projectScope?.enabled;
128
- const hasLocal = !!plugin.localScope?.enabled;
129
- const hasAnyScope = hasUser || hasProject || hasLocal;
130
-
131
- // Enabled somewhere but nothing actually installed — a broken state that used
132
- // to render exactly like a healthy install.
133
- const notInstalled = isEnabledButNotInstalled(plugin);
134
-
135
- // A known version is one we can actually show. "0.0.0" means installed with an
136
- // unknown version (every official Anthropic plugin records it) — never render it.
137
- const shownVersion = isKnownVersion(plugin.installedVersion)
138
- ? plugin.installedVersion
139
- : undefined;
140
-
141
- let versionStr = "";
142
- if (plugin.isOrphaned) {
143
- // "deprecated" alone reads as "delete me" — but a plugin that merely moved
144
- // marketplace is still published, and deleting it loses it for no reason.
145
- versionStr = plugin.movedTo
146
- ? ` moved ${plugin.movedTo}`
147
- : " deprecated";
148
- } else if (notInstalled) {
149
- versionStr = " not installed";
150
- } else if (hasAnyScope) {
151
- // "what just changed" and "what is still available" are independent facts,
152
- // and both can be true at once: the prerunner can move a plugin 2.9.0→3.0.1
153
- // while the marketplace already offers 3.3.1. These used to share one
154
- // if/else chain, so the "updated" badge silently swallowed the pending
155
- // update and the update only reappeared on the next refresh, which read
156
- // as the state spontaneously changing. Render them as separate clauses.
157
- if (plugin.recentlyUpdatedFrom && shownVersion) {
158
- // Show what actually happened, not just where it landed — the update may
159
- // have come from Claude Code or the prerunner without ever telling us.
160
- versionStr = ` v${plugin.recentlyUpdatedFrom} v${shownVersion} updated`;
161
- } else if (plugin.recentlyInstalled) {
162
- // Newly seen. Version is optional here: a plugin can be genuinely
163
- // installed with no version we can name.
164
- versionStr = shownVersion ? ` v${shownVersion} new` : " new";
165
- } else if (shownVersion) {
166
- versionStr = ` v${shownVersion}`;
167
- }
168
-
169
- // Pending update, regardless of which badge above applied.
170
- if (plugin.hasUpdate && plugin.version && versionStr) {
171
- versionStr += ` → v${plugin.version} available`;
172
- } else if (plugin.contentStale && versionStr) {
173
- // Same version, different files. The version compare says "up to date"
174
- // and is wrong only a reinstall delivers the current content.
175
- versionStr += " stale — reinstall";
176
- }
177
- }
178
-
179
- if (isSelected) {
180
- return (
181
- <text bg={theme.selection.bg} fg={theme.selection.fg}>
182
- {" "}
183
- <ScopeSquares user={hasUser} project={hasProject} local={hasLocal} selected={true} />
184
- {" "}{plugin.name}{versionStr}{" "}
185
- </text>
186
- );
187
- }
188
-
189
- // Fuzzy highlight
190
- const segments = item.matches ? highlightMatches(plugin.name, item.matches) : null;
191
- const displayName = segments ? segments.map((seg) => seg.text).join("") : plugin.name;
192
-
193
- if (plugin.isOrphaned) {
194
- const ver =
195
- isKnownVersion(plugin.installedVersion) ? ` v${plugin.installedVersion}` : "";
196
- return (
197
- <text>
198
- <span fg={theme.colors.danger}> ■■■ </span>
199
- <span fg={theme.colors.muted}>{displayName}</span>
200
- {ver ? <span fg={theme.colors.warning}>{ver}</span> : null}
201
- <span fg={theme.colors.danger}> deprecated</span>
202
- </text>
203
- );
204
- }
205
-
206
- return (
207
- <text>
208
- <span> </span>
209
- <ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
210
- <span> </span>
211
- <span fg={hasAnyScope && !notInstalled ? theme.colors.text : theme.colors.muted}>
212
- {displayName}
213
- </span>
214
- <MetaText
215
- text={versionStr}
216
- tone={
217
- plugin.recentlyUpdatedFrom || plugin.recentlyInstalled
218
- ? "success"
219
- : notInstalled || plugin.hasUpdate
220
- ? "warning"
221
- : "muted"
222
- }
223
- />
224
- </text>
225
- );
131
+ function pluginRow(
132
+ item: PluginPluginItem,
133
+ isSelected: boolean,
134
+ ): React.ReactNode {
135
+ const { plugin } = item;
136
+ const hasUser = !!plugin.userScope?.enabled;
137
+ const hasProject = !!plugin.projectScope?.enabled;
138
+ const hasLocal = !!plugin.localScope?.enabled;
139
+ const hasAnyScope = hasUser || hasProject || hasLocal;
140
+
141
+ // Enabled somewhere but nothing actually installed — a broken state that used
142
+ // to render exactly like a healthy install.
143
+ const notInstalled = isEnabledButNotInstalled(plugin);
144
+
145
+ // A known version is one we can actually show. "0.0.0" means installed with an
146
+ // unknown version (every official Anthropic plugin records it) — never render it.
147
+ const shownVersion = isKnownVersion(plugin.installedVersion)
148
+ ? plugin.installedVersion
149
+ : undefined;
150
+
151
+ let versionStr = "";
152
+ if (plugin.isOrphaned) {
153
+ // "deprecated" alone reads as "delete me" — but a plugin that merely moved
154
+ // marketplace is still published, and deleting it loses it for no reason.
155
+ versionStr = plugin.movedTo ? ` moved → ${plugin.movedTo}` : " deprecated";
156
+ } else if (notInstalled) {
157
+ versionStr = " not installed";
158
+ } else if (hasAnyScope) {
159
+ // "what just changed" and "what is still available" are independent facts,
160
+ // and both can be true at once: the prerunner can move a plugin 2.9.0→3.0.1
161
+ // while the marketplace already offers 3.3.1. These used to share one
162
+ // if/else chain, so the "updated" badge silently swallowed the pending
163
+ // update — and the update only reappeared on the next refresh, which read
164
+ // as the state spontaneously changing. Render them as separate clauses.
165
+ if (plugin.recentlyUpdatedFrom && shownVersion) {
166
+ // Show what actually happened, not just where it landed the update may
167
+ // have come from Claude Code or the prerunner without ever telling us.
168
+ versionStr = ` v${plugin.recentlyUpdatedFrom} → v${shownVersion} updated`;
169
+ } else if (plugin.recentlyInstalled) {
170
+ // Newly seen. Version is optional here: a plugin can be genuinely
171
+ // installed with no version we can name.
172
+ versionStr = shownVersion ? ` v${shownVersion} new` : " new";
173
+ } else if (shownVersion) {
174
+ versionStr = ` v${shownVersion}`;
175
+ }
176
+
177
+ // Pending update, regardless of which badge above applied.
178
+ if (plugin.hasUpdate && plugin.version && versionStr) {
179
+ versionStr += ` → v${plugin.version} available`;
180
+ } else if (plugin.contentStale && versionStr) {
181
+ // Same version, different files. The version compare says "up to date"
182
+ // and is wrongonly a reinstall delivers the current content.
183
+ versionStr += " stale — reinstall";
184
+ } else if (plugin.updateCheckFailed && versionStr) {
185
+ // No update to show — but we never confirmed there wasn't one. Neither
186
+ // upstream source could be read, so the compare ran against the pinned
187
+ // clone, which by policy never advances. A bare version here reads as
188
+ // "current", which is the exact claim we cannot make.
189
+ versionStr += " ? unverified";
190
+ }
191
+ }
192
+
193
+ if (isSelected) {
194
+ return (
195
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
196
+ {" "}
197
+ <ScopeSquares
198
+ user={hasUser}
199
+ project={hasProject}
200
+ local={hasLocal}
201
+ selected={true}
202
+ />{" "}
203
+ {plugin.name}
204
+ {versionStr}{" "}
205
+ </text>
206
+ );
207
+ }
208
+
209
+ // Fuzzy highlight
210
+ const segments = item.matches
211
+ ? highlightMatches(plugin.name, item.matches)
212
+ : null;
213
+ const displayName = segments
214
+ ? segments.map((seg) => seg.text).join("")
215
+ : plugin.name;
216
+
217
+ if (plugin.isOrphaned) {
218
+ const ver = isKnownVersion(plugin.installedVersion)
219
+ ? ` v${plugin.installedVersion}`
220
+ : "";
221
+ return (
222
+ <text fg={theme.colors.text}>
223
+ <span fg={theme.colors.danger}> ■■■ </span>
224
+ <span fg={theme.colors.muted}>{displayName}</span>
225
+ {ver ? <span fg={theme.colors.warning}>{ver}</span> : null}
226
+ <span fg={theme.colors.danger}> deprecated</span>
227
+ </text>
228
+ );
229
+ }
230
+
231
+ return (
232
+ <text fg={theme.colors.text}>
233
+ <span> </span>
234
+ <ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
235
+ <span> </span>
236
+ <span
237
+ fg={
238
+ hasAnyScope && !notInstalled ? theme.colors.text : theme.colors.muted
239
+ }
240
+ >
241
+ {displayName}
242
+ </span>
243
+ <MetaText
244
+ text={versionStr}
245
+ tone={
246
+ plugin.recentlyUpdatedFrom || plugin.recentlyInstalled
247
+ ? "success"
248
+ : // "not installed" is a broken state — enabled somewhere with
249
+ // nothing actually installed — while a pending update is
250
+ // routine. They shared `warning` and so read as the same
251
+ // severity; red vs amber is the distinction that matters.
252
+ notInstalled
253
+ ? "danger"
254
+ : plugin.hasUpdate
255
+ ? "warning"
256
+ : plugin.updateCheckFailed
257
+ ? // Dimmer than a real update: nothing is known to be
258
+ // wrong. But not `muted` either, which is the colour
259
+ // of a verified-current plugin.
260
+ "warning"
261
+ : "muted"
262
+ }
263
+ />
264
+ </text>
265
+ );
226
266
  }
227
267
 
228
268
  function pluginDetail(item: PluginPluginItem): React.ReactNode {
229
- const { plugin } = item;
230
- // "Installed" has to mean the same thing here as in the list row. Reading the
231
- // `enabledPlugins` flag alone made this panel print "● Installed" for the
232
- // exact plugin the row beside it was labelling "not installed".
233
- const isInstalled =
234
- isInstalledInScope(plugin.userScope) ||
235
- isInstalledInScope(plugin.projectScope) ||
236
- isInstalledInScope(plugin.localScope);
237
- const brokenInstall = isEnabledButNotInstalled(plugin);
238
-
239
- // Orphaned/deprecated plugin
240
- if (plugin.isOrphaned) {
241
- return (
242
- <box flexDirection="column">
243
- <box justifyContent="center">
244
- <text bg={theme.colors.warning} fg="black">
245
- <strong> {plugin.name} — DEPRECATED </strong>
246
- </text>
247
- </box>
248
- <box marginTop={1}>
249
- <text fg={theme.colors.warning}>
250
- This plugin is no longer in the marketplace.
251
- </text>
252
- </box>
253
- <box marginTop={1}>
254
- <text fg={theme.colors.muted}>
255
- It was removed from the marketplace but still referenced in your settings. Press d to
256
- uninstall and clean up.
257
- </text>
258
- </box>
259
- <ActionHints
260
- hints={[{ key: "d", label: isInstalled ? "Remove from all scopes" : "Clean up stale reference", tone: "danger" }]}
261
- />
262
- </box>
263
- );
264
- }
265
-
266
- // Build component counts
267
- const components: string[] = [];
268
- if (plugin.agents?.length) components.push(`${plugin.agents.length} agents`);
269
- if (plugin.commands?.length) components.push(`${plugin.commands.length} commands`);
270
- if (plugin.skills?.length) components.push(`${plugin.skills.length} skills`);
271
- if (plugin.mcpServers?.length) components.push(`${plugin.mcpServers.length} MCP`);
272
- if (plugin.lspServers && Object.keys(plugin.lspServers).length) {
273
- components.push(`${Object.keys(plugin.lspServers).length} LSP`);
274
- }
275
-
276
- const showVersion = isKnownVersion(plugin.version ?? undefined);
277
- const showInstalledVersion =
278
- isInstalled && isKnownVersion(plugin.installedVersion);
279
-
280
- return (
281
- <box flexDirection="column">
282
- {/* Plugin name header */}
283
- <box justifyContent="center">
284
- <text bg={theme.selection.bg} fg={theme.selection.fg}>
285
- <strong>
286
- {" "}
287
- {plugin.name}
288
- {isInstalled && plugin.hasUpdate ? " ⬆" : ""}{" "}
289
- </strong>
290
- </text>
291
- </box>
292
-
293
- {/* Status line */}
294
- <box marginTop={1}>
295
- <text
296
- fg={
297
- isInstalled
298
- ? theme.colors.success
299
- : brokenInstall
300
- ? theme.colors.warning
301
- : theme.colors.muted
302
- }
303
- >
304
- {isInstalled
305
- ? "● Installed"
306
- : brokenInstall
307
- ? "○ Not installed — enabled in settings, no files on disk"
308
- : "○ Not installed"}
309
- </text>
310
- </box>
311
- {brokenInstall ? (
312
- <box>
313
- <text fg={theme.colors.muted}>
314
- Claude Code cannot load it in this state. Press the scope key to
315
- repair.
316
- </text>
317
- </box>
318
- ) : null}
319
-
320
- {/* Description */}
321
- <box marginTop={1} marginBottom={1}>
322
- <text fg={theme.colors.text}>{plugin.description}</text>
323
- </box>
324
-
325
- {/* Metadata */}
326
- {showVersion ? (
327
- <KeyValueLine
328
- label="Version"
329
- value={
330
- <span>
331
- <span fg={theme.colors.link}>v{plugin.version}</span>
332
- {showInstalledVersion && plugin.installedVersion !== plugin.version ? (
333
- <span> (v{plugin.installedVersion} installed)</span>
334
- ) : null}
335
- </span>
336
- }
337
- />
338
- ) : null}
339
- {plugin.category ? (
340
- <KeyValueLine
341
- label="Category"
342
- value={<span fg={theme.colors.accent}>{plugin.category}</span>}
343
- />
344
- ) : null}
345
- {plugin.author ? (
346
- <KeyValueLine label="Author" value={<span>{plugin.author.name}</span>} />
347
- ) : null}
348
- {components.length > 0 ? (
349
- <KeyValueLine
350
- label="Contains"
351
- value={<span fg={theme.colors.warning}>{components.join(" · ")}</span>}
352
- />
353
- ) : null}
354
-
355
- {/* Scope Status */}
356
- <DetailSection>
357
- <text>{"─".repeat(24)}</text>
358
- <text>
359
- <strong>Scopes:</strong>
360
- </text>
361
- <box marginTop={1} flexDirection="column">
362
- <ScopeLine
363
- keyHint="u"
364
- name="User"
365
- qualifier="global"
366
- color={theme.scopes.user}
367
- scope={plugin.userScope}
368
- />
369
- <ScopeLine
370
- keyHint="p"
371
- name="Project"
372
- qualifier="team"
373
- color={theme.scopes.project}
374
- scope={plugin.projectScope}
375
- />
376
- <ScopeLine
377
- keyHint="l"
378
- name="Local"
379
- qualifier="private"
380
- color={theme.scopes.local}
381
- scope={plugin.localScope}
382
- />
383
- </box>
384
- </DetailSection>
385
-
386
- {/* Recent releases */}
387
- <ReleaseHistory releases={plugin.releases} installedVersion={plugin.installedVersion} />
388
-
389
- {/* Update action */}
390
- {isInstalled && plugin.hasUpdate ? (
391
- <ActionHints
392
- hints={[{ key: "U", label: `Update to v${plugin.version}`, tone: "primary" }]}
393
- />
394
- ) : null}
395
- </box>
396
- );
269
+ const { plugin } = item;
270
+ // "Installed" has to mean the same thing here as in the list row. Reading the
271
+ // `enabledPlugins` flag alone made this panel print "● Installed" for the
272
+ // exact plugin the row beside it was labelling "not installed".
273
+ const isInstalled =
274
+ isInstalledInScope(plugin.userScope) ||
275
+ isInstalledInScope(plugin.projectScope) ||
276
+ isInstalledInScope(plugin.localScope);
277
+ const brokenInstall = isEnabledButNotInstalled(plugin);
278
+
279
+ // Orphaned/deprecated plugin
280
+ if (plugin.isOrphaned) {
281
+ return (
282
+ <box flexDirection="column">
283
+ <box justifyContent="center">
284
+ <text bg={theme.colors.warning} fg={theme.hints.fg}>
285
+ <strong> {plugin.name} — DEPRECATED </strong>
286
+ </text>
287
+ </box>
288
+ <box marginTop={1}>
289
+ <text fg={theme.colors.warning}>
290
+ This plugin is no longer in the marketplace.
291
+ </text>
292
+ </box>
293
+ <box marginTop={1}>
294
+ <text fg={theme.colors.muted}>
295
+ It was removed from the marketplace but still referenced in your
296
+ settings. Press d to uninstall and clean up.
297
+ </text>
298
+ </box>
299
+ <ActionHints
300
+ hints={[
301
+ {
302
+ key: "d",
303
+ label: isInstalled
304
+ ? "Remove from all scopes"
305
+ : "Clean up stale reference",
306
+ tone: "danger",
307
+ },
308
+ ]}
309
+ />
310
+ </box>
311
+ );
312
+ }
313
+
314
+ // Build component counts
315
+ const components: string[] = [];
316
+ if (plugin.agents?.length) components.push(`${plugin.agents.length} agents`);
317
+ if (plugin.commands?.length)
318
+ components.push(`${plugin.commands.length} commands`);
319
+ if (plugin.skills?.length) components.push(`${plugin.skills.length} skills`);
320
+ if (plugin.mcpServers?.length)
321
+ components.push(`${plugin.mcpServers.length} MCP`);
322
+ if (plugin.lspServers && Object.keys(plugin.lspServers).length) {
323
+ components.push(`${Object.keys(plugin.lspServers).length} LSP`);
324
+ }
325
+
326
+ const showVersion = isKnownVersion(plugin.version ?? undefined);
327
+ const showInstalledVersion =
328
+ isInstalled && isKnownVersion(plugin.installedVersion);
329
+
330
+ return (
331
+ <box flexDirection="column">
332
+ {/* Plugin name header */}
333
+ <box justifyContent="center">
334
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
335
+ <strong>
336
+ {" "}
337
+ {plugin.name}
338
+ {isInstalled && plugin.hasUpdate ? " ⬆" : ""}{" "}
339
+ </strong>
340
+ </text>
341
+ </box>
342
+
343
+ {/* Status line */}
344
+ <box marginTop={1}>
345
+ <text
346
+ fg={
347
+ isInstalled
348
+ ? theme.colors.success
349
+ : brokenInstall
350
+ ? theme.colors.warning
351
+ : theme.colors.muted
352
+ }
353
+ >
354
+ {isInstalled
355
+ ? "● Installed"
356
+ : brokenInstall
357
+ ? "○ Not installed — enabled in settings, no files on disk"
358
+ : "○ Not installed"}
359
+ </text>
360
+ </box>
361
+ {brokenInstall ? (
362
+ <box>
363
+ <text fg={theme.colors.muted}>
364
+ Claude Code cannot load it in this state. Press the scope key to
365
+ repair.
366
+ </text>
367
+ </box>
368
+ ) : null}
369
+
370
+ {/* Description */}
371
+ <box marginTop={1} marginBottom={1}>
372
+ <text fg={theme.colors.text}>{plugin.description}</text>
373
+ </box>
374
+
375
+ {/* Metadata */}
376
+ {showVersion ? (
377
+ <KeyValueLine
378
+ label="Version"
379
+ value={
380
+ <span>
381
+ <span fg={theme.colors.link}>v{plugin.version}</span>
382
+ {showInstalledVersion &&
383
+ plugin.installedVersion !== plugin.version ? (
384
+ <span> (v{plugin.installedVersion} installed)</span>
385
+ ) : null}
386
+ </span>
387
+ }
388
+ />
389
+ ) : null}
390
+ {/*
391
+ * Name the source of the version above whenever it is not authoritative.
392
+ * Without this the panel showed a version and an installed version that
393
+ * happened to match, and the user reasonably read that as "current" —
394
+ * when in fact GitHub was never reached and the number came off a clone
395
+ * that had not moved in days.
396
+ */}
397
+ {plugin.updateCheckFailed ? (
398
+ <>
399
+ <KeyValueLine
400
+ label="Latest"
401
+ value={
402
+ <span fg={theme.colors.warning}>unverified — not checked</span>
403
+ }
404
+ />
405
+ <box>
406
+ <text fg={theme.colors.muted}>
407
+ {plugin.updateCheckFailure
408
+ ? ` ${plugin.updateCheckFailure.detail}. `
409
+ : " "}
410
+ {plugin.catalogSource === "local-clone"
411
+ ? "Version shown is from the pinned marketplace clone, so a newer release may exist. Press r to retry."
412
+ : "Press r to retry."}
413
+ </text>
414
+ </box>
415
+ </>
416
+ ) : null}
417
+ {plugin.category ? (
418
+ <KeyValueLine
419
+ label="Category"
420
+ value={<span fg={theme.colors.accent}>{plugin.category}</span>}
421
+ />
422
+ ) : null}
423
+ {plugin.author ? (
424
+ <KeyValueLine
425
+ label="Author"
426
+ value={<span>{plugin.author.name}</span>}
427
+ />
428
+ ) : null}
429
+ {components.length > 0 ? (
430
+ <KeyValueLine
431
+ label="Contains"
432
+ value={
433
+ <span fg={theme.colors.warning}>{components.join(" · ")}</span>
434
+ }
435
+ />
436
+ ) : null}
437
+
438
+ {/* Scope Status */}
439
+ <DetailSection>
440
+ <text fg={theme.colors.text}>{"─".repeat(24)}</text>
441
+ <text fg={theme.colors.text}>
442
+ <strong>Scopes:</strong>
443
+ </text>
444
+ <box marginTop={1} flexDirection="column">
445
+ <ScopeLine
446
+ keyHint="u"
447
+ name="User"
448
+ qualifier="global"
449
+ color={theme.scopes.user}
450
+ scope={plugin.userScope}
451
+ />
452
+ <ScopeLine
453
+ keyHint="p"
454
+ name="Project"
455
+ qualifier="team"
456
+ color={theme.scopes.project}
457
+ scope={plugin.projectScope}
458
+ />
459
+ <ScopeLine
460
+ keyHint="l"
461
+ name="Local"
462
+ qualifier="private"
463
+ color={theme.scopes.local}
464
+ scope={plugin.localScope}
465
+ />
466
+ </box>
467
+ </DetailSection>
468
+
469
+ {/* Recent releases */}
470
+ <ReleaseHistory
471
+ releases={plugin.releases}
472
+ installedVersion={plugin.installedVersion}
473
+ />
474
+
475
+ {/* Update action */}
476
+ {isInstalled && plugin.hasUpdate ? (
477
+ <ActionHints
478
+ hints={[
479
+ {
480
+ key: "U",
481
+ label: `Update to v${plugin.version}`,
482
+ tone: "primary",
483
+ },
484
+ ]}
485
+ />
486
+ ) : null}
487
+ </box>
488
+ );
397
489
  }
398
490
 
399
491
  /**
@@ -405,36 +497,36 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
405
497
  * different actions from the user and used to look identical.
406
498
  */
407
499
  function ScopeLine({
408
- keyHint,
409
- name,
410
- qualifier,
411
- color,
412
- scope,
500
+ keyHint,
501
+ name,
502
+ qualifier,
503
+ color,
504
+ scope,
413
505
  }: {
414
- keyHint: string;
415
- name: string;
416
- qualifier: string;
417
- color: string;
418
- scope?: ScopeStatus;
506
+ keyHint: string;
507
+ name: string;
508
+ qualifier: string;
509
+ color: UiColor;
510
+ scope?: ScopeStatus;
419
511
  }): React.ReactNode {
420
- const installed = isInstalledInScope(scope);
421
- return (
422
- <text>
423
- <span bg={color} fg="black">
424
- {" "}
425
- {keyHint}{" "}
426
- </span>
427
- <span fg={installed ? color : theme.colors.muted}>
428
- {installed ? " ● " : " ○ "}
429
- </span>
430
- <span fg={color}>{name}</span>
431
- <span> {qualifier}</span>
432
- {scope?.version ? <span fg={color}> v{scope.version}</span> : null}
433
- {!installed && scope?.enabled ? (
434
- <span fg={theme.colors.warning}> enabled, not installed</span>
435
- ) : null}
436
- </text>
437
- );
512
+ const installed = isInstalledInScope(scope);
513
+ return (
514
+ <text fg={theme.colors.text}>
515
+ <span bg={color} fg={theme.hints.fg}>
516
+ {" "}
517
+ {keyHint}{" "}
518
+ </span>
519
+ <span fg={installed ? color : theme.colors.muted}>
520
+ {installed ? " ● " : " ○ "}
521
+ </span>
522
+ <span fg={color}>{name}</span>
523
+ <span> {qualifier}</span>
524
+ {scope?.version ? <span fg={color}> v{scope.version}</span> : null}
525
+ {!installed && scope?.enabled ? (
526
+ <span fg={theme.colors.warning}> enabled, not installed</span>
527
+ ) : null}
528
+ </text>
529
+ );
438
530
  }
439
531
 
440
532
  /**
@@ -448,60 +540,63 @@ function ScopeLine({
448
540
  * releases published), everything renders neutrally rather than guessing.
449
541
  */
450
542
  function ReleaseHistory({
451
- releases,
452
- installedVersion,
543
+ releases,
544
+ installedVersion,
453
545
  }: {
454
- releases?: PluginRelease[];
455
- installedVersion?: string;
546
+ releases?: PluginRelease[];
547
+ installedVersion?: string;
456
548
  }): React.ReactNode {
457
- if (!releases?.length) return null;
458
-
459
- const installedAt = installedVersion
460
- ? releases.findIndex((r) => r.version === installedVersion)
461
- : -1;
462
-
463
- return (
464
- <DetailSection>
465
- <text>{"─".repeat(24)}</text>
466
- <text>
467
- <strong>Recent releases:</strong>
468
- </text>
469
- <box marginTop={1} flexDirection="column">
470
- {releases.map((release, i) => {
471
- const isNewer = installedAt > 0 && i < installedAt;
472
- const isInstalled = installedAt >= 0 && i === installedAt;
473
- const versionColor = isNewer
474
- ? theme.colors.warning
475
- : isInstalled
476
- ? theme.colors.success
477
- : theme.colors.link;
478
-
479
- return (
480
- <box key={release.version} flexDirection="column" marginBottom={1}>
481
- <text>
482
- <span fg={versionColor}>
483
- {isNewer ? "▲ " : isInstalled ? "● " : " "}v{release.version}
484
- </span>
485
- {release.date ? (
486
- <span fg={theme.colors.muted}> {release.date}</span>
487
- ) : null}
488
- {release.kinds?.length ? (
489
- <span fg={theme.colors.dim}> {release.kinds.join(" · ")}</span>
490
- ) : null}
491
- {isInstalled ? (
492
- <span fg={theme.colors.success}> installed</span>
493
- ) : null}
494
- </text>
495
- <text fg={isNewer ? theme.colors.text : theme.colors.muted}>
496
- {" "}
497
- {release.summary}
498
- </text>
499
- </box>
500
- );
501
- })}
502
- </box>
503
- </DetailSection>
504
- );
549
+ if (!releases?.length) return null;
550
+
551
+ const installedAt = installedVersion
552
+ ? releases.findIndex((r) => r.version === installedVersion)
553
+ : -1;
554
+
555
+ return (
556
+ <DetailSection>
557
+ <text fg={theme.colors.text}>{"─".repeat(24)}</text>
558
+ <text fg={theme.colors.text}>
559
+ <strong>Recent releases:</strong>
560
+ </text>
561
+ <box marginTop={1} flexDirection="column">
562
+ {releases.map((release, i) => {
563
+ const isNewer = installedAt > 0 && i < installedAt;
564
+ const isInstalled = installedAt >= 0 && i === installedAt;
565
+ const versionColor = isNewer
566
+ ? theme.colors.warning
567
+ : isInstalled
568
+ ? theme.colors.success
569
+ : theme.colors.link;
570
+
571
+ return (
572
+ <box key={release.version} flexDirection="column" marginBottom={1}>
573
+ <text fg={theme.colors.text}>
574
+ <span fg={versionColor}>
575
+ {isNewer ? "▲ " : isInstalled ? "● " : " "}v{release.version}
576
+ </span>
577
+ {release.date ? (
578
+ <span fg={theme.colors.muted}> {release.date}</span>
579
+ ) : null}
580
+ {release.kinds?.length ? (
581
+ <span fg={theme.colors.dim}>
582
+ {" "}
583
+ {release.kinds.join(" · ")}
584
+ </span>
585
+ ) : null}
586
+ {isInstalled ? (
587
+ <span fg={theme.colors.success}> installed</span>
588
+ ) : null}
589
+ </text>
590
+ <text fg={isNewer ? theme.colors.text : theme.colors.muted}>
591
+ {" "}
592
+ {release.summary}
593
+ </text>
594
+ </box>
595
+ );
596
+ })}
597
+ </box>
598
+ </DetailSection>
599
+ );
505
600
  }
506
601
 
507
602
  // ─── Public dispatch functions ────────────────────────────────────────────────
@@ -510,26 +605,26 @@ function ReleaseHistory({
510
605
  * Render a plugin browser list row by item kind.
511
606
  */
512
607
  export function renderPluginRow(
513
- item: PluginBrowserItem,
514
- _index: number,
515
- isSelected: boolean,
608
+ item: PluginBrowserItem,
609
+ _index: number,
610
+ isSelected: boolean,
516
611
  ): React.ReactNode {
517
- if (item.kind === "category") {
518
- return categoryRow(item, isSelected);
519
- }
520
- return pluginRow(item, isSelected);
612
+ if (item.kind === "category") {
613
+ return categoryRow(item, isSelected);
614
+ }
615
+ return pluginRow(item, isSelected);
521
616
  }
522
617
 
523
618
  /**
524
619
  * Render the detail panel for a plugin browser item.
525
620
  */
526
621
  export function renderPluginDetail(
527
- item: PluginBrowserItem | undefined,
528
- collapsedMarketplaces: Set<string>,
622
+ item: PluginBrowserItem | undefined,
623
+ collapsedMarketplaces: Set<string>,
529
624
  ): React.ReactNode {
530
- if (!item) return <text fg={theme.colors.muted}>Select an item</text>;
531
- if (item.kind === "category") {
532
- return categoryDetail(item, collapsedMarketplaces);
533
- }
534
- return pluginDetail(item);
625
+ if (!item) return <text fg={theme.colors.muted}>Select an item</text>;
626
+ if (item.kind === "category") {
627
+ return categoryDetail(item, collapsedMarketplaces);
628
+ }
629
+ return pluginDetail(item);
535
630
  }