claudeup 4.35.1 → 4.36.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 (58) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/scope-squares.test.tsx +165 -0
  3. package/src/__tests__/theme-adaptive-colors.test.ts +307 -0
  4. package/src/__tests__/uppercase-keybindings.test.ts +101 -0
  5. package/src/main.tsx +21 -5
  6. package/src/opentui.d.ts +21 -12
  7. package/src/ui/App.tsx +19 -12
  8. package/src/ui/adapters/pluginsAdapter.ts +174 -168
  9. package/src/ui/adapters/settingsAdapter.ts +119 -116
  10. package/src/ui/adapters/skillsAdapter.ts +203 -196
  11. package/src/ui/components/CategoryHeader.tsx +9 -8
  12. package/src/ui/components/EmptyFilterState.tsx +10 -5
  13. package/src/ui/components/FlagDetailEditor.tsx +0 -0
  14. package/src/ui/components/ScopeIndicator.tsx +10 -6
  15. package/src/ui/components/ScrollableList.tsx +3 -2
  16. package/src/ui/components/SearchInput.tsx +2 -1
  17. package/src/ui/components/StyledText.tsx +5 -4
  18. package/src/ui/components/TabBar.tsx +4 -3
  19. package/src/ui/components/layout/FooterHints.tsx +37 -30
  20. package/src/ui/components/layout/Panel.tsx +6 -5
  21. package/src/ui/components/layout/ProgressBar.tsx +7 -6
  22. package/src/ui/components/layout/ScopeTabs.tsx +6 -5
  23. package/src/ui/components/layout/ScreenLayout.tsx +27 -24
  24. package/src/ui/components/layout/index.ts +3 -3
  25. package/src/ui/components/modals/ConfirmModal.tsx +12 -11
  26. package/src/ui/components/modals/InputModal.tsx +14 -6
  27. package/src/ui/components/modals/LoadingModal.tsx +6 -5
  28. package/src/ui/components/modals/MessageModal.tsx +9 -8
  29. package/src/ui/components/modals/SelectModal.tsx +11 -7
  30. package/src/ui/components/modals/VersionMismatchModal.tsx +14 -16
  31. package/src/ui/components/primitives/ActionHints.tsx +26 -26
  32. package/src/ui/components/primitives/DetailSection.tsx +13 -12
  33. package/src/ui/components/primitives/KeyValueLine.tsx +9 -8
  34. package/src/ui/components/primitives/ListCategoryRow.tsx +25 -27
  35. package/src/ui/components/primitives/MetaText.tsx +3 -3
  36. package/src/ui/components/primitives/ScopeDetail.tsx +48 -48
  37. package/src/ui/components/primitives/ScopeSquares.tsx +47 -22
  38. package/src/ui/components/primitives/SelectableRow.tsx +22 -16
  39. package/src/ui/hooks/useGitignoreModal.ts +78 -74
  40. package/src/ui/registry.ts +11 -11
  41. package/src/ui/renderers/cliToolRenderers.tsx +260 -203
  42. package/src/ui/renderers/gitignoreRenderers.tsx +43 -42
  43. package/src/ui/renderers/mcpRenderers.tsx +121 -117
  44. package/src/ui/renderers/pluginRenderers.tsx +528 -471
  45. package/src/ui/renderers/profileRenderers.tsx +346 -300
  46. package/src/ui/renderers/settingsRenderers.tsx +183 -176
  47. package/src/ui/renderers/skillRenderers.tsx +410 -326
  48. package/src/ui/screens/AliasScreen.tsx +1336 -1309
  49. package/src/ui/screens/CliToolsScreen.tsx +92 -40
  50. package/src/ui/screens/EnvVarsScreen.tsx +19 -13
  51. package/src/ui/screens/GitignoreScreen.tsx +510 -493
  52. package/src/ui/screens/McpRegistryScreen.tsx +28 -21
  53. package/src/ui/screens/McpScreen.tsx +12 -3
  54. package/src/ui/screens/PluginsScreen.tsx +17 -7
  55. package/src/ui/screens/ProfilesScreen.tsx +39 -23
  56. package/src/ui/screens/SkillsScreen.tsx +832 -688
  57. package/src/ui/theme-mode.ts +73 -0
  58. package/src/ui/theme.ts +147 -53
@@ -1,399 +1,453 @@
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
+ }
185
+ }
186
+
187
+ if (isSelected) {
188
+ return (
189
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
190
+ {" "}
191
+ <ScopeSquares
192
+ user={hasUser}
193
+ project={hasProject}
194
+ local={hasLocal}
195
+ selected={true}
196
+ />{" "}
197
+ {plugin.name}
198
+ {versionStr}{" "}
199
+ </text>
200
+ );
201
+ }
202
+
203
+ // Fuzzy highlight
204
+ const segments = item.matches
205
+ ? highlightMatches(plugin.name, item.matches)
206
+ : null;
207
+ const displayName = segments
208
+ ? segments.map((seg) => seg.text).join("")
209
+ : plugin.name;
210
+
211
+ if (plugin.isOrphaned) {
212
+ const ver = isKnownVersion(plugin.installedVersion)
213
+ ? ` v${plugin.installedVersion}`
214
+ : "";
215
+ return (
216
+ <text fg={theme.colors.text}>
217
+ <span fg={theme.colors.danger}> ■■■ </span>
218
+ <span fg={theme.colors.muted}>{displayName}</span>
219
+ {ver ? <span fg={theme.colors.warning}>{ver}</span> : null}
220
+ <span fg={theme.colors.danger}> deprecated</span>
221
+ </text>
222
+ );
223
+ }
224
+
225
+ return (
226
+ <text fg={theme.colors.text}>
227
+ <span> </span>
228
+ <ScopeSquares user={hasUser} project={hasProject} local={hasLocal} />
229
+ <span> </span>
230
+ <span
231
+ fg={
232
+ hasAnyScope && !notInstalled ? theme.colors.text : theme.colors.muted
233
+ }
234
+ >
235
+ {displayName}
236
+ </span>
237
+ <MetaText
238
+ text={versionStr}
239
+ tone={
240
+ plugin.recentlyUpdatedFrom || plugin.recentlyInstalled
241
+ ? "success"
242
+ : // "not installed" is a broken state — enabled somewhere with
243
+ // nothing actually installed — while a pending update is
244
+ // routine. They shared `warning` and so read as the same
245
+ // severity; red vs amber is the distinction that matters.
246
+ notInstalled
247
+ ? "danger"
248
+ : plugin.hasUpdate
249
+ ? "warning"
250
+ : "muted"
251
+ }
252
+ />
253
+ </text>
254
+ );
226
255
  }
227
256
 
228
257
  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
- );
258
+ const { plugin } = item;
259
+ // "Installed" has to mean the same thing here as in the list row. Reading the
260
+ // `enabledPlugins` flag alone made this panel print "● Installed" for the
261
+ // exact plugin the row beside it was labelling "not installed".
262
+ const isInstalled =
263
+ isInstalledInScope(plugin.userScope) ||
264
+ isInstalledInScope(plugin.projectScope) ||
265
+ isInstalledInScope(plugin.localScope);
266
+ const brokenInstall = isEnabledButNotInstalled(plugin);
267
+
268
+ // Orphaned/deprecated plugin
269
+ if (plugin.isOrphaned) {
270
+ return (
271
+ <box flexDirection="column">
272
+ <box justifyContent="center">
273
+ <text bg={theme.colors.warning} fg={theme.hints.fg}>
274
+ <strong> {plugin.name} — DEPRECATED </strong>
275
+ </text>
276
+ </box>
277
+ <box marginTop={1}>
278
+ <text fg={theme.colors.warning}>
279
+ This plugin is no longer in the marketplace.
280
+ </text>
281
+ </box>
282
+ <box marginTop={1}>
283
+ <text fg={theme.colors.muted}>
284
+ It was removed from the marketplace but still referenced in your
285
+ settings. Press d to uninstall and clean up.
286
+ </text>
287
+ </box>
288
+ <ActionHints
289
+ hints={[
290
+ {
291
+ key: "d",
292
+ label: isInstalled
293
+ ? "Remove from all scopes"
294
+ : "Clean up stale reference",
295
+ tone: "danger",
296
+ },
297
+ ]}
298
+ />
299
+ </box>
300
+ );
301
+ }
302
+
303
+ // Build component counts
304
+ const components: string[] = [];
305
+ if (plugin.agents?.length) components.push(`${plugin.agents.length} agents`);
306
+ if (plugin.commands?.length)
307
+ components.push(`${plugin.commands.length} commands`);
308
+ if (plugin.skills?.length) components.push(`${plugin.skills.length} skills`);
309
+ if (plugin.mcpServers?.length)
310
+ components.push(`${plugin.mcpServers.length} MCP`);
311
+ if (plugin.lspServers && Object.keys(plugin.lspServers).length) {
312
+ components.push(`${Object.keys(plugin.lspServers).length} LSP`);
313
+ }
314
+
315
+ const showVersion = isKnownVersion(plugin.version ?? undefined);
316
+ const showInstalledVersion =
317
+ isInstalled && isKnownVersion(plugin.installedVersion);
318
+
319
+ return (
320
+ <box flexDirection="column">
321
+ {/* Plugin name header */}
322
+ <box justifyContent="center">
323
+ <text bg={theme.selection.bg} fg={theme.selection.fg}>
324
+ <strong>
325
+ {" "}
326
+ {plugin.name}
327
+ {isInstalled && plugin.hasUpdate ? " ⬆" : ""}{" "}
328
+ </strong>
329
+ </text>
330
+ </box>
331
+
332
+ {/* Status line */}
333
+ <box marginTop={1}>
334
+ <text
335
+ fg={
336
+ isInstalled
337
+ ? theme.colors.success
338
+ : brokenInstall
339
+ ? theme.colors.warning
340
+ : theme.colors.muted
341
+ }
342
+ >
343
+ {isInstalled
344
+ ? "● Installed"
345
+ : brokenInstall
346
+ ? "○ Not installed — enabled in settings, no files on disk"
347
+ : "○ Not installed"}
348
+ </text>
349
+ </box>
350
+ {brokenInstall ? (
351
+ <box>
352
+ <text fg={theme.colors.muted}>
353
+ Claude Code cannot load it in this state. Press the scope key to
354
+ repair.
355
+ </text>
356
+ </box>
357
+ ) : null}
358
+
359
+ {/* Description */}
360
+ <box marginTop={1} marginBottom={1}>
361
+ <text fg={theme.colors.text}>{plugin.description}</text>
362
+ </box>
363
+
364
+ {/* Metadata */}
365
+ {showVersion ? (
366
+ <KeyValueLine
367
+ label="Version"
368
+ value={
369
+ <span>
370
+ <span fg={theme.colors.link}>v{plugin.version}</span>
371
+ {showInstalledVersion &&
372
+ plugin.installedVersion !== plugin.version ? (
373
+ <span> (v{plugin.installedVersion} installed)</span>
374
+ ) : null}
375
+ </span>
376
+ }
377
+ />
378
+ ) : null}
379
+ {plugin.category ? (
380
+ <KeyValueLine
381
+ label="Category"
382
+ value={<span fg={theme.colors.accent}>{plugin.category}</span>}
383
+ />
384
+ ) : null}
385
+ {plugin.author ? (
386
+ <KeyValueLine
387
+ label="Author"
388
+ value={<span>{plugin.author.name}</span>}
389
+ />
390
+ ) : null}
391
+ {components.length > 0 ? (
392
+ <KeyValueLine
393
+ label="Contains"
394
+ value={
395
+ <span fg={theme.colors.warning}>{components.join(" · ")}</span>
396
+ }
397
+ />
398
+ ) : null}
399
+
400
+ {/* Scope Status */}
401
+ <DetailSection>
402
+ <text fg={theme.colors.text}>{"─".repeat(24)}</text>
403
+ <text fg={theme.colors.text}>
404
+ <strong>Scopes:</strong>
405
+ </text>
406
+ <box marginTop={1} flexDirection="column">
407
+ <ScopeLine
408
+ keyHint="u"
409
+ name="User"
410
+ qualifier="global"
411
+ color={theme.scopes.user}
412
+ scope={plugin.userScope}
413
+ />
414
+ <ScopeLine
415
+ keyHint="p"
416
+ name="Project"
417
+ qualifier="team"
418
+ color={theme.scopes.project}
419
+ scope={plugin.projectScope}
420
+ />
421
+ <ScopeLine
422
+ keyHint="l"
423
+ name="Local"
424
+ qualifier="private"
425
+ color={theme.scopes.local}
426
+ scope={plugin.localScope}
427
+ />
428
+ </box>
429
+ </DetailSection>
430
+
431
+ {/* Recent releases */}
432
+ <ReleaseHistory
433
+ releases={plugin.releases}
434
+ installedVersion={plugin.installedVersion}
435
+ />
436
+
437
+ {/* Update action */}
438
+ {isInstalled && plugin.hasUpdate ? (
439
+ <ActionHints
440
+ hints={[
441
+ {
442
+ key: "U",
443
+ label: `Update to v${plugin.version}`,
444
+ tone: "primary",
445
+ },
446
+ ]}
447
+ />
448
+ ) : null}
449
+ </box>
450
+ );
397
451
  }
398
452
 
399
453
  /**
@@ -405,36 +459,36 @@ function pluginDetail(item: PluginPluginItem): React.ReactNode {
405
459
  * different actions from the user and used to look identical.
406
460
  */
407
461
  function ScopeLine({
408
- keyHint,
409
- name,
410
- qualifier,
411
- color,
412
- scope,
462
+ keyHint,
463
+ name,
464
+ qualifier,
465
+ color,
466
+ scope,
413
467
  }: {
414
- keyHint: string;
415
- name: string;
416
- qualifier: string;
417
- color: string;
418
- scope?: ScopeStatus;
468
+ keyHint: string;
469
+ name: string;
470
+ qualifier: string;
471
+ color: UiColor;
472
+ scope?: ScopeStatus;
419
473
  }): 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
- );
474
+ const installed = isInstalledInScope(scope);
475
+ return (
476
+ <text fg={theme.colors.text}>
477
+ <span bg={color} fg={theme.hints.fg}>
478
+ {" "}
479
+ {keyHint}{" "}
480
+ </span>
481
+ <span fg={installed ? color : theme.colors.muted}>
482
+ {installed ? " ● " : " ○ "}
483
+ </span>
484
+ <span fg={color}>{name}</span>
485
+ <span> {qualifier}</span>
486
+ {scope?.version ? <span fg={color}> v{scope.version}</span> : null}
487
+ {!installed && scope?.enabled ? (
488
+ <span fg={theme.colors.warning}> enabled, not installed</span>
489
+ ) : null}
490
+ </text>
491
+ );
438
492
  }
439
493
 
440
494
  /**
@@ -448,60 +502,63 @@ function ScopeLine({
448
502
  * releases published), everything renders neutrally rather than guessing.
449
503
  */
450
504
  function ReleaseHistory({
451
- releases,
452
- installedVersion,
505
+ releases,
506
+ installedVersion,
453
507
  }: {
454
- releases?: PluginRelease[];
455
- installedVersion?: string;
508
+ releases?: PluginRelease[];
509
+ installedVersion?: string;
456
510
  }): 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
- );
511
+ if (!releases?.length) return null;
512
+
513
+ const installedAt = installedVersion
514
+ ? releases.findIndex((r) => r.version === installedVersion)
515
+ : -1;
516
+
517
+ return (
518
+ <DetailSection>
519
+ <text fg={theme.colors.text}>{"─".repeat(24)}</text>
520
+ <text fg={theme.colors.text}>
521
+ <strong>Recent releases:</strong>
522
+ </text>
523
+ <box marginTop={1} flexDirection="column">
524
+ {releases.map((release, i) => {
525
+ const isNewer = installedAt > 0 && i < installedAt;
526
+ const isInstalled = installedAt >= 0 && i === installedAt;
527
+ const versionColor = isNewer
528
+ ? theme.colors.warning
529
+ : isInstalled
530
+ ? theme.colors.success
531
+ : theme.colors.link;
532
+
533
+ return (
534
+ <box key={release.version} flexDirection="column" marginBottom={1}>
535
+ <text fg={theme.colors.text}>
536
+ <span fg={versionColor}>
537
+ {isNewer ? "▲ " : isInstalled ? "● " : " "}v{release.version}
538
+ </span>
539
+ {release.date ? (
540
+ <span fg={theme.colors.muted}> {release.date}</span>
541
+ ) : null}
542
+ {release.kinds?.length ? (
543
+ <span fg={theme.colors.dim}>
544
+ {" "}
545
+ {release.kinds.join(" · ")}
546
+ </span>
547
+ ) : null}
548
+ {isInstalled ? (
549
+ <span fg={theme.colors.success}> installed</span>
550
+ ) : null}
551
+ </text>
552
+ <text fg={isNewer ? theme.colors.text : theme.colors.muted}>
553
+ {" "}
554
+ {release.summary}
555
+ </text>
556
+ </box>
557
+ );
558
+ })}
559
+ </box>
560
+ </DetailSection>
561
+ );
505
562
  }
506
563
 
507
564
  // ─── Public dispatch functions ────────────────────────────────────────────────
@@ -510,26 +567,26 @@ function ReleaseHistory({
510
567
  * Render a plugin browser list row by item kind.
511
568
  */
512
569
  export function renderPluginRow(
513
- item: PluginBrowserItem,
514
- _index: number,
515
- isSelected: boolean,
570
+ item: PluginBrowserItem,
571
+ _index: number,
572
+ isSelected: boolean,
516
573
  ): React.ReactNode {
517
- if (item.kind === "category") {
518
- return categoryRow(item, isSelected);
519
- }
520
- return pluginRow(item, isSelected);
574
+ if (item.kind === "category") {
575
+ return categoryRow(item, isSelected);
576
+ }
577
+ return pluginRow(item, isSelected);
521
578
  }
522
579
 
523
580
  /**
524
581
  * Render the detail panel for a plugin browser item.
525
582
  */
526
583
  export function renderPluginDetail(
527
- item: PluginBrowserItem | undefined,
528
- collapsedMarketplaces: Set<string>,
584
+ item: PluginBrowserItem | undefined,
585
+ collapsedMarketplaces: Set<string>,
529
586
  ): 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);
587
+ if (!item) return <text fg={theme.colors.muted}>Select an item</text>;
588
+ if (item.kind === "category") {
589
+ return categoryDetail(item, collapsedMarketplaces);
590
+ }
591
+ return pluginDetail(item);
535
592
  }