claudeup 3.16.0 → 4.0.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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/data/predefined-profiles.js +191 -0
  3. package/src/data/predefined-profiles.ts +205 -0
  4. package/src/ui/adapters/pluginsAdapter.js +139 -0
  5. package/src/ui/adapters/pluginsAdapter.ts +202 -0
  6. package/src/ui/adapters/settingsAdapter.js +111 -0
  7. package/src/ui/adapters/settingsAdapter.ts +165 -0
  8. package/src/ui/components/ScrollableList.js +4 -4
  9. package/src/ui/components/ScrollableList.tsx +4 -4
  10. package/src/ui/components/SearchInput.js +2 -2
  11. package/src/ui/components/SearchInput.tsx +3 -3
  12. package/src/ui/components/StyledText.js +1 -1
  13. package/src/ui/components/StyledText.tsx +5 -1
  14. package/src/ui/components/layout/ProgressBar.js +1 -1
  15. package/src/ui/components/layout/ProgressBar.tsx +1 -5
  16. package/src/ui/components/modals/InputModal.tsx +1 -6
  17. package/src/ui/components/modals/LoadingModal.js +1 -1
  18. package/src/ui/components/modals/LoadingModal.tsx +1 -3
  19. package/src/ui/hooks/index.js +3 -3
  20. package/src/ui/hooks/index.ts +3 -3
  21. package/src/ui/hooks/useKeyboard.ts +1 -3
  22. package/src/ui/hooks/useKeyboardHandler.js +9 -9
  23. package/src/ui/hooks/useKeyboardHandler.ts +9 -9
  24. package/src/ui/renderers/cliToolRenderers.js +33 -0
  25. package/src/ui/renderers/cliToolRenderers.tsx +153 -0
  26. package/src/ui/renderers/mcpRenderers.js +26 -0
  27. package/src/ui/renderers/mcpRenderers.tsx +145 -0
  28. package/src/ui/renderers/pluginRenderers.js +124 -0
  29. package/src/ui/renderers/pluginRenderers.tsx +362 -0
  30. package/src/ui/renderers/profileRenderers.js +172 -0
  31. package/src/ui/renderers/profileRenderers.tsx +410 -0
  32. package/src/ui/renderers/settingsRenderers.js +69 -0
  33. package/src/ui/renderers/settingsRenderers.tsx +205 -0
  34. package/src/ui/screens/CliToolsScreen.js +14 -58
  35. package/src/ui/screens/CliToolsScreen.tsx +36 -196
  36. package/src/ui/screens/EnvVarsScreen.js +12 -168
  37. package/src/ui/screens/EnvVarsScreen.tsx +16 -327
  38. package/src/ui/screens/McpScreen.js +12 -62
  39. package/src/ui/screens/McpScreen.tsx +21 -190
  40. package/src/ui/screens/PluginsScreen.js +52 -425
  41. package/src/ui/screens/PluginsScreen.tsx +70 -758
  42. package/src/ui/screens/ProfilesScreen.js +104 -68
  43. package/src/ui/screens/ProfilesScreen.tsx +147 -221
  44. package/src/ui/screens/SkillsScreen.js +16 -16
  45. package/src/ui/screens/SkillsScreen.tsx +20 -23
@@ -3,11 +3,9 @@ import { useApp, useModal, useProgress } from "../state/AppContext.js";
3
3
  import { useDimensions } from "../state/DimensionsContext.js";
4
4
  import { useKeyboard } from "../hooks/useKeyboard.js";
5
5
  import { ScreenLayout } from "../components/layout/index.js";
6
- import { CategoryHeader } from "../components/CategoryHeader.js";
7
6
  import { ScrollableList } from "../components/ScrollableList.js";
8
7
  import { EmptyFilterState } from "../components/EmptyFilterState.js";
9
- import { scopeIndicatorText } from "../components/ScopeIndicator.js";
10
- import { fuzzyFilter, highlightMatches } from "../../utils/fuzzy-search.js";
8
+ import { fuzzyFilter } from "../../utils/fuzzy-search.js";
11
9
  import { getAllMarketplaces } from "../../data/marketplaces.js";
12
10
  import {
13
11
  getAvailablePlugins,
@@ -40,25 +38,11 @@ import {
40
38
  checkMissingDeps,
41
39
  installPluginDeps,
42
40
  } from "../../services/plugin-setup.js";
43
- import type { Marketplace } from "../../types/index.js";
44
-
45
- // Virtual marketplace name for the community sub-section of claude-plugins-official
46
- const COMMUNITY_VIRTUAL_MARKETPLACE = "claude-plugins-official:community";
47
- // The marketplace that gets split into Anthropic Official + Community sections
48
- const SPLIT_MARKETPLACE = "claude-plugins-official";
49
-
50
- interface ListItem {
51
- id: string;
52
- type: "category" | "plugin";
53
- label: string;
54
- marketplace?: Marketplace;
55
- marketplaceEnabled?: boolean;
56
- plugin?: PluginInfo;
57
- pluginCount?: number;
58
- isExpanded?: boolean;
59
- /** True for the virtual Community sub-section derived from claude-plugins-official */
60
- isCommunitySection?: boolean;
61
- }
41
+ import {
42
+ buildPluginBrowserItems,
43
+ type PluginBrowserItem,
44
+ } from "../adapters/pluginsAdapter.js";
45
+ import { renderPluginRow, renderPluginDetail } from "../renderers/pluginRenderers.js";
62
46
 
63
47
  export function PluginsScreen() {
64
48
  const { state, dispatch } = useApp();
@@ -78,10 +62,7 @@ export function PluginsScreen() {
78
62
  try {
79
63
  const localMarketplaces = await getLocalMarketplacesInfo();
80
64
  const allMarketplaces = getAllMarketplaces(localMarketplaces);
81
-
82
- // Always use getAvailablePlugins which fetches all scope data
83
65
  const pluginData = await getAvailablePlugins(state.projectPath);
84
-
85
66
  dispatch({
86
67
  type: "PLUGINS_DATA_SUCCESS",
87
68
  marketplaces: allMarketplaces,
@@ -101,132 +82,18 @@ export function PluginsScreen() {
101
82
  }, [fetchData, state.dataRefreshVersion]);
102
83
 
103
84
  // Build list items (categories + plugins)
104
- const allItems = useMemo((): ListItem[] => {
85
+ const allItems = useMemo((): PluginBrowserItem[] => {
105
86
  if (
106
87
  pluginsState.marketplaces.status !== "success" ||
107
88
  pluginsState.plugins.status !== "success"
108
89
  ) {
109
90
  return [];
110
91
  }
111
-
112
- const marketplaces = pluginsState.marketplaces.data;
113
- const plugins = pluginsState.plugins.data;
114
- const collapsed = pluginsState.collapsedMarketplaces;
115
-
116
- const pluginsByMarketplace = new Map<string, PluginInfo[]>();
117
- for (const plugin of plugins) {
118
- const existing = pluginsByMarketplace.get(plugin.marketplace) || [];
119
- existing.push(plugin);
120
- pluginsByMarketplace.set(plugin.marketplace, existing);
121
- }
122
-
123
- // Sort marketplaces: deprecated ones go to the bottom
124
- const sortedMarketplaces = [...marketplaces].sort((a, b) => {
125
- const aDeprecated = a.name === "claude-code-plugins" ? 1 : 0;
126
- const bDeprecated = b.name === "claude-code-plugins" ? 1 : 0;
127
- return aDeprecated - bDeprecated;
92
+ return buildPluginBrowserItems({
93
+ marketplaces: pluginsState.marketplaces.data,
94
+ plugins: pluginsState.plugins.data,
95
+ collapsedMarketplaces: pluginsState.collapsedMarketplaces,
128
96
  });
129
-
130
- const items: ListItem[] = [];
131
-
132
- for (const marketplace of sortedMarketplaces) {
133
- const marketplacePlugins =
134
- pluginsByMarketplace.get(marketplace.name) || [];
135
- const isCollapsed = collapsed.has(marketplace.name);
136
- const isEnabled = marketplacePlugins.length > 0 || marketplace.official;
137
- const hasPlugins = marketplacePlugins.length > 0;
138
-
139
- // Special handling: split claude-plugins-official into two sub-sections
140
- if (marketplace.name === SPLIT_MARKETPLACE && hasPlugins) {
141
- const anthropicPlugins = marketplacePlugins.filter(
142
- (p) => p.author?.name?.toLowerCase() === "anthropic",
143
- );
144
- const communityPlugins = marketplacePlugins.filter(
145
- (p) => p.author?.name?.toLowerCase() !== "anthropic",
146
- );
147
-
148
- // Sub-section 1: Anthropic Official (plugins by Anthropic)
149
- const anthropicCollapsed = collapsed.has(marketplace.name);
150
- const anthropicHasPlugins = anthropicPlugins.length > 0;
151
- items.push({
152
- id: `mp:${marketplace.name}`,
153
- type: "category",
154
- label: marketplace.displayName,
155
- marketplace,
156
- marketplaceEnabled: isEnabled,
157
- pluginCount: anthropicPlugins.length,
158
- isExpanded: !anthropicCollapsed && anthropicHasPlugins,
159
- });
160
- if (isEnabled && anthropicHasPlugins && !anthropicCollapsed) {
161
- for (const plugin of anthropicPlugins) {
162
- items.push({
163
- id: `pl:${plugin.id}`,
164
- type: "plugin",
165
- label: plugin.name,
166
- plugin,
167
- });
168
- }
169
- }
170
-
171
- // Sub-section 2: Community (third-party plugins in same marketplace)
172
- if (communityPlugins.length > 0) {
173
- const communityVirtualMp: Marketplace = {
174
- name: COMMUNITY_VIRTUAL_MARKETPLACE,
175
- displayName: "Anthropic Official — 3rd Party",
176
- source: marketplace.source,
177
- description: "Third-party plugins in the Anthropic Official marketplace",
178
- };
179
- const communityCollapsed = collapsed.has(COMMUNITY_VIRTUAL_MARKETPLACE);
180
- items.push({
181
- id: `mp:${COMMUNITY_VIRTUAL_MARKETPLACE}`,
182
- type: "category",
183
- label: "Anthropic Official — 3rd Party",
184
- marketplace: communityVirtualMp,
185
- marketplaceEnabled: true,
186
- pluginCount: communityPlugins.length,
187
- isExpanded: !communityCollapsed,
188
- isCommunitySection: true,
189
- });
190
- if (!communityCollapsed) {
191
- for (const plugin of communityPlugins) {
192
- items.push({
193
- id: `pl:${plugin.id}`,
194
- type: "plugin",
195
- label: plugin.name,
196
- plugin,
197
- });
198
- }
199
- }
200
- }
201
-
202
- continue;
203
- }
204
-
205
- // Category header (marketplace)
206
- items.push({
207
- id: `mp:${marketplace.name}`,
208
- type: "category",
209
- label: marketplace.displayName,
210
- marketplace,
211
- marketplaceEnabled: isEnabled,
212
- pluginCount: marketplacePlugins.length,
213
- isExpanded: !isCollapsed && hasPlugins,
214
- });
215
-
216
- // Plugins under this marketplace (if expanded)
217
- if (isEnabled && hasPlugins && !isCollapsed) {
218
- for (const plugin of marketplacePlugins) {
219
- items.push({
220
- id: `pl:${plugin.id}`,
221
- type: "plugin",
222
- label: plugin.name,
223
- plugin,
224
- });
225
- }
226
- }
227
- }
228
-
229
- return items;
230
97
  }, [
231
98
  pluginsState.marketplaces,
232
99
  pluginsState.plugins,
@@ -239,50 +106,43 @@ export function PluginsScreen() {
239
106
  if (!query) return allItems;
240
107
 
241
108
  // Only search plugins, not categories
242
- const pluginItems = allItems.filter((item) => item.type === "plugin");
109
+ const pluginItems = allItems.filter((item) => item.kind === "plugin");
243
110
  const fuzzyResults = fuzzyFilter(pluginItems, query, (item) => item.label);
244
111
 
245
- // Build a set of matched plugin item ids for O(1) lookup
246
112
  const matchedPluginIds = new Set<string>();
247
113
  for (const result of fuzzyResults) {
248
114
  matchedPluginIds.add(result.item.id);
249
115
  }
250
116
 
251
- // Walk allItems sequentially: track the current category section.
252
- // For each category, include it only if any plugin under it matched.
253
- // We build a map from category item id -> whether any plugin below matched.
117
+ // Walk allItems sequentially: include a category only if any plugin below matched
254
118
  const categoryHasMatch = new Map<string, boolean>();
255
119
  let currentCategoryId: string | null = null;
256
120
  for (const item of allItems) {
257
- if (item.type === "category") {
121
+ if (item.kind === "category") {
258
122
  currentCategoryId = item.id;
259
123
  if (!categoryHasMatch.has(item.id)) {
260
124
  categoryHasMatch.set(item.id, false);
261
125
  }
262
- } else if (item.type === "plugin" && currentCategoryId) {
126
+ } else if (item.kind === "plugin" && currentCategoryId) {
263
127
  if (matchedPluginIds.has(item.id)) {
264
128
  categoryHasMatch.set(currentCategoryId, true);
265
129
  }
266
130
  }
267
131
  }
268
132
 
269
- const result: ListItem[] = [];
133
+ const result: PluginBrowserItem[] = [];
270
134
  let currentCatIncluded = false;
271
135
  currentCategoryId = null;
272
136
 
273
137
  for (const item of allItems) {
274
- if (item.type === "category") {
138
+ if (item.kind === "category") {
275
139
  currentCategoryId = item.id;
276
140
  currentCatIncluded = categoryHasMatch.get(item.id) === true;
277
- if (currentCatIncluded) {
278
- result.push(item);
279
- }
280
- } else if (item.type === "plugin" && currentCatIncluded) {
141
+ if (currentCatIncluded) result.push(item);
142
+ } else if (item.kind === "plugin" && currentCatIncluded) {
281
143
  if (matchedPluginIds.has(item.id)) {
282
144
  const matched = fuzzyResults.find((r) => r.item.id === item.id);
283
- result.push({ ...item, _matches: matched?.matches } as ListItem & {
284
- _matches?: number[];
285
- });
145
+ result.push({ ...item, matches: matched?.matches });
286
146
  }
287
147
  }
288
148
  }
@@ -290,37 +150,28 @@ export function PluginsScreen() {
290
150
  return result;
291
151
  }, [allItems, pluginsState.searchQuery]);
292
152
 
293
- // Only selectable items (plugins, not categories)
294
- const selectableItems = useMemo(() => {
295
- return filteredItems.filter(
296
- (item) => item.type === "plugin" || item.type === "category",
297
- );
298
- }, [filteredItems]);
153
+ const selectableItems = useMemo(() => filteredItems, [filteredItems]);
299
154
 
300
- // Keyboard handling — inline search with live filtering
155
+ // Keyboard handling
301
156
  useKeyboard((event) => {
302
157
  if (state.modal) return;
303
158
 
304
159
  const hasQuery = pluginsState.searchQuery.length > 0;
305
160
 
306
- // Escape: always clear search state fully
307
161
  if (event.name === "escape") {
308
162
  if (hasQuery || isSearchActive) {
309
163
  dispatch({ type: "PLUGINS_SET_SEARCH", query: "" });
310
164
  dispatch({ type: "SET_SEARCHING", isSearching: false });
311
165
  dispatch({ type: "PLUGINS_SELECT", index: 0 });
312
166
  }
313
- // Don't return — let GlobalKeyHandler handle Escape too (for quit)
314
167
  return;
315
168
  }
316
169
 
317
- // Backspace: remove last char from search query
318
170
  if (event.name === "backspace" || event.name === "delete") {
319
171
  if (hasQuery) {
320
172
  const newQuery = pluginsState.searchQuery.slice(0, -1);
321
173
  dispatch({ type: "PLUGINS_SET_SEARCH", query: newQuery });
322
174
  dispatch({ type: "PLUGINS_SELECT", index: 0 });
323
- // If query becomes empty, exit search mode
324
175
  if (newQuery.length === 0) {
325
176
  dispatch({ type: "SET_SEARCHING", isSearching: false });
326
177
  }
@@ -328,54 +179,43 @@ export function PluginsScreen() {
328
179
  return;
329
180
  }
330
181
 
331
- // Navigation — always works; exits search mode on navigate
332
182
  if (event.name === "up" || event.name === "k") {
333
183
  if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
334
- const newIndex = Math.max(0, pluginsState.selectedIndex - 1);
335
- dispatch({ type: "PLUGINS_SELECT", index: newIndex });
184
+ dispatch({ type: "PLUGINS_SELECT", index: Math.max(0, pluginsState.selectedIndex - 1) });
336
185
  return;
337
186
  }
338
187
  if (event.name === "down" || event.name === "j") {
339
188
  if (isSearchActive) dispatch({ type: "SET_SEARCHING", isSearching: false });
340
- const newIndex = Math.min(
341
- selectableItems.length - 1,
342
- pluginsState.selectedIndex + 1,
343
- );
344
- dispatch({ type: "PLUGINS_SELECT", index: newIndex });
189
+ dispatch({
190
+ type: "PLUGINS_SELECT",
191
+ index: Math.min(selectableItems.length - 1, pluginsState.selectedIndex + 1),
192
+ });
345
193
  return;
346
194
  }
347
195
 
348
- // Enter exit search mode (keep filter active) + select/install
349
- if (event.name === "enter") {
196
+ if (event.name === "enter" || event.name === "return") {
350
197
  if (isSearchActive) {
351
198
  dispatch({ type: "SET_SEARCHING", isSearching: false });
352
- // Keep the query — filter stays active, shortcuts resume
353
199
  return;
354
200
  }
355
201
  handleSelect();
356
202
  return;
357
203
  }
358
204
 
359
- // Collapse/expand marketplace — always works
360
205
  if (
361
206
  (event.name === "left" ||
362
207
  event.name === "right" ||
363
208
  event.name === "<" ||
364
209
  event.name === ">") &&
365
- selectableItems[pluginsState.selectedIndex]?.marketplace
210
+ selectableItems[pluginsState.selectedIndex]?.kind === "category"
366
211
  ) {
367
212
  const item = selectableItems[pluginsState.selectedIndex];
368
- if (item?.marketplace) {
369
- dispatch({
370
- type: "PLUGINS_TOGGLE_MARKETPLACE",
371
- name: item.marketplace.name,
372
- });
213
+ if (item?.kind === "category") {
214
+ dispatch({ type: "PLUGINS_TOGGLE_MARKETPLACE", name: item.marketplace.name });
373
215
  }
374
216
  return;
375
217
  }
376
218
 
377
- // When actively typing in search, letters go to the query
378
- // After Enter (isSearchActive=false, hasQuery=true), shortcuts resume
379
219
  if (isSearchActive) {
380
220
  if (event.name.length === 1 && !event.ctrl && !event.meta && !/[0-9]/.test(event.name)) {
381
221
  dispatch({
@@ -387,15 +227,11 @@ export function PluginsScreen() {
387
227
  return;
388
228
  }
389
229
 
390
- // Action shortcuts work when not actively typing (even with filter visible)
391
-
392
- // Start explicit search mode with /
393
230
  if (event.name === "/") {
394
231
  dispatch({ type: "SET_SEARCHING", isSearching: true });
395
232
  return;
396
233
  }
397
234
 
398
- // Action shortcuts (only when query is empty)
399
235
  if (event.name === "r") handleRefresh();
400
236
  else if (event.name === "n") handleShowAddMarketplaceInstructions();
401
237
  else if (event.name === "t") handleShowTeamConfigHelp();
@@ -405,13 +241,10 @@ export function PluginsScreen() {
405
241
  else if (event.name === "U") handleUpdate();
406
242
  else if (event.name === "a") handleUpdateAll();
407
243
  else if (event.name === "s") handleSaveAsProfile();
408
- // "/" to enter search mode
409
- else if (event.name === "/") {
410
- dispatch({ type: "SET_SEARCHING", isSearching: true });
411
- }
412
244
  });
413
245
 
414
- // Handle actions
246
+ // ── Action handlers ────────────────────────────────────────────────────────
247
+
415
248
  const handleRefresh = async () => {
416
249
  progress.show("Refreshing cache...");
417
250
  try {
@@ -421,7 +254,6 @@ export function PluginsScreen() {
421
254
  clearMarketplaceCache();
422
255
  progress.hide();
423
256
 
424
- // Build message
425
257
  let message =
426
258
  "Cache refreshed.\n\n" +
427
259
  "To update marketplaces from GitHub, run in Claude Code:\n" +
@@ -487,34 +319,26 @@ export function PluginsScreen() {
487
319
  };
488
320
 
489
321
  /**
490
- * Collect environment variables required by a plugin's MCP servers
491
- * Prompts user for missing values and saves to local settings
322
+ * Collect environment variables required by a plugin's MCP servers.
492
323
  */
493
324
  const collectPluginEnvVars = async (
494
325
  pluginName: string,
495
326
  marketplace: string,
496
327
  ): Promise<boolean> => {
497
328
  try {
498
- // Get plugin source path from marketplace manifest
499
329
  const pluginSource = await getPluginSourcePath(marketplace, pluginName);
500
- if (!pluginSource) return true; // No source path, nothing to configure
330
+ if (!pluginSource) return true;
501
331
 
502
- // Get env var requirements from plugin's MCP config
503
- const requirements = await getPluginEnvRequirements(
504
- marketplace,
505
- pluginSource,
506
- );
507
- if (requirements.length === 0) return true; // No env vars needed
332
+ const requirements = await getPluginEnvRequirements(marketplace, pluginSource);
333
+ if (requirements.length === 0) return true;
508
334
 
509
- // Get existing env vars
510
335
  const existingEnvVars = await getMcpEnvVars(state.projectPath);
511
336
  const missingVars = requirements.filter(
512
337
  (req) => !existingEnvVars[req.name] && !process.env[req.name],
513
338
  );
514
339
 
515
- if (missingVars.length === 0) return true; // All vars already configured
340
+ if (missingVars.length === 0) return true;
516
341
 
517
- // Ask user if they want to configure MCP server env vars now
518
342
  const serverNames = [...new Set(missingVars.map((v) => v.serverName))];
519
343
  const wantToConfigure = await modal.confirm(
520
344
  "Configure MCP Servers?",
@@ -527,12 +351,10 @@ export function PluginsScreen() {
527
351
  "You can configure these variables later in the Environment Variables screen (press 4).",
528
352
  "info",
529
353
  );
530
- return true; // Still installed, just not configured
354
+ return true;
531
355
  }
532
356
 
533
- // Collect each missing env var
534
357
  for (const req of missingVars) {
535
- // Check if value exists in process.env
536
358
  const existingProcessEnv = process.env[req.name];
537
359
  if (existingProcessEnv) {
538
360
  const useExisting = await modal.confirm(
@@ -540,13 +362,11 @@ export function PluginsScreen() {
540
362
  `${req.name} is already set in your environment.\n\nUse the existing value?`,
541
363
  );
542
364
  if (useExisting) {
543
- // Store reference to env var instead of literal value
544
365
  await setMcpEnvVar(req.name, `\${${req.name}}`, state.projectPath);
545
366
  continue;
546
367
  }
547
368
  }
548
369
 
549
- // Prompt for value
550
370
  const value = await modal.input(
551
371
  `Configure ${req.serverName}`,
552
372
  `${req.label} (required):`,
@@ -554,13 +374,12 @@ export function PluginsScreen() {
554
374
  );
555
375
 
556
376
  if (value === null) {
557
- // User cancelled
558
377
  await modal.message(
559
378
  "Configuration Incomplete",
560
379
  `Skipped remaining configuration.\nYou can configure these later in Environment Variables (press 4).`,
561
380
  "info",
562
381
  );
563
- return true; // Still installed
382
+ return true;
564
383
  }
565
384
 
566
385
  if (value) {
@@ -571,13 +390,12 @@ export function PluginsScreen() {
571
390
  return true;
572
391
  } catch (error) {
573
392
  console.error("Error collecting plugin env vars:", error);
574
- return true; // Don't block installation on config errors
393
+ return true;
575
394
  }
576
395
  };
577
396
 
578
397
  /**
579
- * Install system dependencies required by a plugin's MCP servers
580
- * Auto-detects available package managers (uv/pip, brew, npm, cargo)
398
+ * Install system dependencies required by a plugin's MCP servers.
581
399
  */
582
400
  const installPluginSystemDeps = async (
583
401
  pluginName: string,
@@ -597,13 +415,11 @@ export function PluginsScreen() {
597
415
 
598
416
  if (!hasMissing) return;
599
417
 
600
- // Build description of what will be installed
601
418
  const parts: string[] = [];
602
419
  if (missing.pip?.length) parts.push(`pip: ${missing.pip.join(", ")}`);
603
420
  if (missing.brew?.length) parts.push(`brew: ${missing.brew.join(", ")}`);
604
421
  if (missing.npm?.length) parts.push(`npm: ${missing.npm.join(", ")}`);
605
- if (missing.cargo?.length)
606
- parts.push(`cargo: ${missing.cargo.join(", ")}`);
422
+ if (missing.cargo?.length) parts.push(`cargo: ${missing.cargo.join(", ")}`);
607
423
 
608
424
  const wantInstall = await modal.confirm(
609
425
  "Install Dependencies?",
@@ -617,9 +433,7 @@ export function PluginsScreen() {
617
433
  modal.hideModal();
618
434
 
619
435
  if (result.failed.length > 0) {
620
- const failMsg = result.failed
621
- .map((f) => `${f.pkg}: ${f.error}`)
622
- .join("\n");
436
+ const failMsg = result.failed.map((f) => `${f.pkg}: ${f.error}`).join("\n");
623
437
  await modal.message(
624
438
  "Partial Install",
625
439
  `Installed: ${result.installed.length}\nFailed:\n${failMsg}`,
@@ -638,9 +452,7 @@ export function PluginsScreen() {
638
452
  };
639
453
 
640
454
  /**
641
- * Save the installed version to settings after CLI install/update.
642
- * Claude CLI doesn't update installedPluginVersions in settings.json,
643
- * so we do it ourselves to keep the TUI version display accurate.
455
+ * Save installed plugin version to settings after CLI install/update.
644
456
  */
645
457
  const saveVersionAfterInstall = async (
646
458
  pluginId: string,
@@ -651,11 +463,7 @@ export function PluginsScreen() {
651
463
  if (scope === "user") {
652
464
  await saveGlobalInstalledPluginVersion(pluginId, version);
653
465
  } else if (scope === "local") {
654
- await saveLocalInstalledPluginVersion(
655
- pluginId,
656
- version,
657
- state.projectPath,
658
- );
466
+ await saveLocalInstalledPluginVersion(pluginId, version, state.projectPath);
659
467
  } else {
660
468
  await saveInstalledPluginVersion(pluginId, version, state.projectPath);
661
469
  }
@@ -668,20 +476,16 @@ export function PluginsScreen() {
668
476
  const item = selectableItems[pluginsState.selectedIndex];
669
477
  if (!item) return;
670
478
 
671
- if (item.type === "category" && item.marketplace) {
479
+ if (item.kind === "category") {
672
480
  const mp = item.marketplace;
673
481
 
674
482
  if (item.marketplaceEnabled) {
675
483
  const isCollapsed = pluginsState.collapsedMarketplaces.has(mp.name);
676
-
677
- // If collapsed, expand first (even if no plugins - they might load)
678
484
  if (isCollapsed) {
679
485
  dispatch({ type: "PLUGINS_TOGGLE_MARKETPLACE", name: mp.name });
680
- } else if (item.pluginCount && item.pluginCount > 0) {
681
- // If expanded with plugins, collapse
486
+ } else if (item.pluginCount > 0) {
682
487
  dispatch({ type: "PLUGINS_TOGGLE_MARKETPLACE", name: mp.name });
683
488
  } else {
684
- // If expanded with no plugins, show removal instructions
685
489
  await modal.message(
686
490
  `Remove ${mp.displayName}?`,
687
491
  `To remove this marketplace, run in Claude Code:\n\n` +
@@ -691,7 +495,6 @@ export function PluginsScreen() {
691
495
  );
692
496
  }
693
497
  } else {
694
- // Show add marketplace instructions
695
498
  await modal.message(
696
499
  `Add ${mp.displayName}?`,
697
500
  `To add this marketplace, run in your terminal:\n\n` +
@@ -701,11 +504,10 @@ export function PluginsScreen() {
701
504
  "info",
702
505
  );
703
506
  }
704
- } else if (item.type === "plugin" && item.plugin) {
507
+ } else if (item.kind === "plugin") {
705
508
  const plugin = item.plugin;
706
509
  const latestVersion = plugin.version || "0.0.0";
707
510
 
708
- // Build scope options with status info
709
511
  const buildScopeLabel = (
710
512
  name: string,
711
513
  scope: { enabled?: boolean; version?: string } | undefined,
@@ -713,12 +515,7 @@ export function PluginsScreen() {
713
515
  ) => {
714
516
  const installed = scope?.enabled;
715
517
  const ver = scope?.version;
716
- const hasUpdate =
717
- ver &&
718
- latestVersion &&
719
- ver !== latestVersion &&
720
- latestVersion !== "0.0.0";
721
-
518
+ const hasUpdate = ver && latestVersion && ver !== latestVersion && latestVersion !== "0.0.0";
722
519
  let label = installed ? `● ${name}` : `○ ${name}`;
723
520
  label += ` (${desc})`;
724
521
  if (ver) label += ` v${ver}`;
@@ -727,29 +524,14 @@ export function PluginsScreen() {
727
524
  };
728
525
 
729
526
  const scopeOptions = [
730
- {
731
- label: buildScopeLabel("User", plugin.userScope, "global"),
732
- value: "user",
733
- },
734
- {
735
- label: buildScopeLabel("Project", plugin.projectScope, "team"),
736
- value: "project",
737
- },
738
- {
739
- label: buildScopeLabel("Local", plugin.localScope, "private"),
740
- value: "local",
741
- },
527
+ { label: buildScopeLabel("User", plugin.userScope, "global"), value: "user" },
528
+ { label: buildScopeLabel("Project", plugin.projectScope, "team"), value: "project" },
529
+ { label: buildScopeLabel("Local", plugin.localScope, "private"), value: "local" },
742
530
  ];
743
531
 
744
- const scopeValue = await modal.select(
745
- plugin.name,
746
- `Select scope to toggle:`,
747
- scopeOptions,
748
- );
749
-
750
- if (scopeValue === null) return; // Cancelled
532
+ const scopeValue = await modal.select(plugin.name, `Select scope to toggle:`, scopeOptions);
533
+ if (scopeValue === null) return;
751
534
 
752
- // Determine action based on selected scope's current state
753
535
  const selectedScope =
754
536
  scopeValue === "user"
755
537
  ? plugin.userScope
@@ -759,20 +541,14 @@ export function PluginsScreen() {
759
541
  const isInstalledInScope = selectedScope?.enabled;
760
542
  const installedVersion = selectedScope?.version;
761
543
  const scopeLabel =
762
- scopeValue === "user"
763
- ? "User"
764
- : scopeValue === "project"
765
- ? "Project"
766
- : "Local";
544
+ scopeValue === "user" ? "User" : scopeValue === "project" ? "Project" : "Local";
767
545
 
768
- // Check if this scope has an update available
769
546
  const hasUpdateInScope =
770
547
  isInstalledInScope &&
771
548
  installedVersion &&
772
549
  latestVersion !== "0.0.0" &&
773
550
  installedVersion !== latestVersion;
774
551
 
775
- // Determine action: update if available, otherwise toggle
776
552
  let action: "update" | "install" | "uninstall";
777
553
  if (isInstalledInScope && hasUpdateInScope) {
778
554
  action = "update";
@@ -800,8 +576,6 @@ export function PluginsScreen() {
800
576
  } else {
801
577
  await cliInstallPlugin(plugin.id, scope);
802
578
  await saveVersionAfterInstall(plugin.id, latestVersion, scope);
803
-
804
- // On fresh install, configure env vars and install system deps
805
579
  modal.hideModal();
806
580
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
807
581
  await installPluginSystemDeps(plugin.name, plugin.marketplace);
@@ -819,20 +593,15 @@ export function PluginsScreen() {
819
593
 
820
594
  const handleUpdate = async () => {
821
595
  const item = selectableItems[pluginsState.selectedIndex];
822
- if (!item || item.type !== "plugin" || !item.plugin?.hasUpdate) return;
596
+ if (!item || item.kind !== "plugin" || !item.plugin.hasUpdate) return;
823
597
 
824
598
  const plugin = item.plugin;
825
- const scope: PluginScope =
826
- pluginsState.scope === "global" ? "user" : "project";
599
+ const scope: PluginScope = pluginsState.scope === "global" ? "user" : "project";
827
600
 
828
601
  modal.loading(`Updating ${plugin.name}...`);
829
602
  try {
830
603
  await cliUpdatePlugin(plugin.id, scope);
831
- await saveVersionAfterInstall(
832
- plugin.id,
833
- plugin.version || "0.0.0",
834
- scope,
835
- );
604
+ await saveVersionAfterInstall(plugin.id, plugin.version || "0.0.0", scope);
836
605
  modal.hideModal();
837
606
  fetchData();
838
607
  } catch (error) {
@@ -847,18 +616,13 @@ export function PluginsScreen() {
847
616
  const updatable = pluginsState.plugins.data.filter((p) => p.hasUpdate);
848
617
  if (updatable.length === 0) return;
849
618
 
850
- const scope: PluginScope =
851
- pluginsState.scope === "global" ? "user" : "project";
619
+ const scope: PluginScope = pluginsState.scope === "global" ? "user" : "project";
852
620
  modal.loading(`Updating ${updatable.length} plugin(s)...`);
853
621
 
854
622
  try {
855
623
  for (const plugin of updatable) {
856
624
  await cliUpdatePlugin(plugin.id, scope);
857
- await saveVersionAfterInstall(
858
- plugin.id,
859
- plugin.version || "0.0.0",
860
- scope,
861
- );
625
+ await saveVersionAfterInstall(plugin.id, plugin.version || "0.0.0", scope);
862
626
  }
863
627
  modal.hideModal();
864
628
  fetchData();
@@ -868,17 +632,15 @@ export function PluginsScreen() {
868
632
  }
869
633
  };
870
634
 
871
- // Scope-specific toggle (install if not installed, uninstall if installed)
872
635
  const handleScopeToggle = async (scope: "user" | "project" | "local") => {
873
636
  const item = selectableItems[pluginsState.selectedIndex];
874
- if (!item || item.type !== "plugin" || !item.plugin) return;
637
+ if (!item || item.kind !== "plugin") return;
875
638
 
876
639
  const plugin = item.plugin;
877
640
  const latestVersion = plugin.version || "0.0.0";
878
641
  const scopeLabel =
879
642
  scope === "user" ? "User" : scope === "project" ? "Project" : "Local";
880
643
 
881
- // Check if installed in this specific scope
882
644
  const scopeData =
883
645
  scope === "user"
884
646
  ? plugin.userScope
@@ -888,20 +650,12 @@ export function PluginsScreen() {
888
650
  const isInstalledInScope = scopeData?.enabled;
889
651
  const installedVersion = scopeData?.version;
890
652
 
891
- // Also check if installed in ANY scope (for the toggle behavior)
892
- const isInstalledAnywhere = plugin.userScope?.enabled || plugin.projectScope?.enabled || plugin.localScope?.enabled;
893
-
894
- // Check if this scope has an update available
895
653
  const hasUpdateInScope =
896
654
  isInstalledInScope &&
897
655
  installedVersion &&
898
656
  latestVersion !== "0.0.0" &&
899
657
  installedVersion !== latestVersion;
900
658
 
901
- // Determine action for THIS scope:
902
- // - installed in this scope + has update → update
903
- // - installed in this scope → uninstall from this scope
904
- // - not installed in this scope → install to this scope
905
659
  let action: "update" | "install" | "uninstall";
906
660
  if (isInstalledInScope && hasUpdateInScope) {
907
661
  action = "update";
@@ -928,8 +682,6 @@ export function PluginsScreen() {
928
682
  } else {
929
683
  await cliInstallPlugin(plugin.id, scope);
930
684
  await saveVersionAfterInstall(plugin.id, latestVersion, scope);
931
-
932
- // On fresh install, configure env vars and install system deps
933
685
  modal.hideModal();
934
686
  await collectPluginEnvVars(plugin.name, plugin.marketplace);
935
687
  await installPluginSystemDeps(plugin.name, plugin.marketplace);
@@ -945,7 +697,6 @@ export function PluginsScreen() {
945
697
  };
946
698
 
947
699
  const handleSaveAsProfile = async () => {
948
- // Read current enabledPlugins from project settings
949
700
  const settings = await readSettings(state.projectPath);
950
701
  const enabledPlugins = settings.enabledPlugins ?? {};
951
702
 
@@ -956,14 +707,8 @@ export function PluginsScreen() {
956
707
  "Save to scope",
957
708
  "Where should this profile be saved?",
958
709
  [
959
- {
960
- label: "User~/.claude/profiles.json (available everywhere)",
961
- value: "user",
962
- },
963
- {
964
- label: "Project — .claude/profiles.json (shared with team via git)",
965
- value: "project",
966
- },
710
+ { label: "User — ~/.claude/profiles.json (available everywhere)", value: "user" },
711
+ { label: "Project.claude/profiles.json (shared with team via git)", value: "project" },
967
712
  ],
968
713
  );
969
714
  if (scopeChoice === null) return;
@@ -985,67 +730,8 @@ export function PluginsScreen() {
985
730
  }
986
731
  };
987
732
 
988
- const handleUninstall = async () => {
989
- const item = selectableItems[pluginsState.selectedIndex];
990
- if (!item || item.type !== "plugin" || !item.plugin) return;
991
-
992
- const plugin = item.plugin;
993
-
994
- // Build list of scopes where plugin is installed
995
- const installedScopes: { label: string; value: string }[] = [];
996
- if (plugin.userScope?.enabled) {
997
- const ver = plugin.userScope.version
998
- ? ` v${plugin.userScope.version}`
999
- : "";
1000
- installedScopes.push({ label: `User (global)${ver}`, value: "user" });
1001
- }
1002
- if (plugin.projectScope?.enabled) {
1003
- const ver = plugin.projectScope.version
1004
- ? ` v${plugin.projectScope.version}`
1005
- : "";
1006
- installedScopes.push({ label: `Project${ver}`, value: "project" });
1007
- }
1008
- if (plugin.localScope?.enabled) {
1009
- const ver = plugin.localScope.version
1010
- ? ` v${plugin.localScope.version}`
1011
- : "";
1012
- installedScopes.push({ label: `Local${ver}`, value: "local" });
1013
- }
1014
-
1015
- if (installedScopes.length === 0) {
1016
- await modal.message(
1017
- "Not Installed",
1018
- `${plugin.name} is not installed in any scope.`,
1019
- "info",
1020
- );
1021
- return;
1022
- }
1023
-
1024
- const scopeValue = await modal.select(
1025
- `Uninstall ${plugin.name}`,
1026
- `Installed in ${installedScopes.length} scope(s):`,
1027
- installedScopes,
1028
- );
1029
-
1030
- if (scopeValue === null) return; // Cancelled
1031
-
1032
- modal.loading(`Uninstalling ${plugin.name}...`);
1033
-
1034
- try {
1035
- await cliUninstallPlugin(
1036
- plugin.id,
1037
- scopeValue as PluginScope,
1038
- state.projectPath,
1039
- );
1040
- modal.hideModal();
1041
- fetchData();
1042
- } catch (error) {
1043
- modal.hideModal();
1044
- await modal.message("Error", `Failed to uninstall: ${error}`, "error");
1045
- }
1046
- };
733
+ // ── Render ─────────────────────────────────────────────────────────────────
1047
734
 
1048
- // Render loading state
1049
735
  if (
1050
736
  pluginsState.marketplaces.status === "loading" ||
1051
737
  pluginsState.plugins.status === "loading"
@@ -1060,7 +746,6 @@ export function PluginsScreen() {
1060
746
  );
1061
747
  }
1062
748
 
1063
- // Render error state
1064
749
  if (
1065
750
  pluginsState.marketplaces.status === "error" ||
1066
751
  pluginsState.plugins.status === "error"
@@ -1075,391 +760,18 @@ export function PluginsScreen() {
1075
760
  );
1076
761
  }
1077
762
 
1078
- // Get selected item for detail panel
1079
763
  const selectedItem = selectableItems[pluginsState.selectedIndex];
1080
764
 
1081
- // Render item with fuzzy highlight support
1082
- const renderListItem = (
1083
- item: ListItem,
1084
- _idx: number,
1085
- isSelected: boolean,
1086
- ) => {
1087
- if (item.type === "category" && item.marketplace) {
1088
- const mp = item.marketplace;
1089
- // Differentiate marketplace types with appropriate badges
1090
- let statusText = "";
1091
- let statusColor = "green";
1092
- if (item.marketplaceEnabled) {
1093
- if (item.isCommunitySection) {
1094
- statusText = "3rd Party";
1095
- statusColor = "gray";
1096
- } else if (mp.name === "claude-plugins-official") {
1097
- statusText = "★ Official";
1098
- statusColor = "yellow";
1099
- } else if (mp.name === "claude-code-plugins") {
1100
- statusText = "⚠ Deprecated";
1101
- statusColor = "gray";
1102
- } else if (mp.official) {
1103
- statusText = "★ Official";
1104
- statusColor = "yellow";
1105
- } else {
1106
- statusText = "✓ Added";
1107
- statusColor = "green";
1108
- }
1109
- }
1110
-
1111
- if (isSelected) {
1112
- const arrow = item.isExpanded ? "▼" : "▶";
1113
- const count =
1114
- item.pluginCount !== undefined && item.pluginCount > 0
1115
- ? ` (${item.pluginCount})`
1116
- : "";
1117
- return (
1118
- <text bg="magenta" fg="white">
1119
- <strong>
1120
- {" "}
1121
- {arrow} {mp.displayName}
1122
- {count}{" "}
1123
- </strong>
1124
- </text>
1125
- );
1126
- }
1127
-
1128
- return (
1129
- <CategoryHeader
1130
- title={mp.displayName}
1131
- expanded={item.isExpanded}
1132
- count={item.pluginCount}
1133
- status={statusText}
1134
- statusColor={statusColor}
1135
- />
1136
- );
1137
- }
1138
-
1139
- if (item.type === "plugin" && item.plugin) {
1140
- const plugin = item.plugin;
1141
- const isAnyScope = plugin.userScope?.enabled || plugin.projectScope?.enabled || plugin.localScope?.enabled;
1142
-
1143
- // Build scope parts for colored rendering
1144
- const hasUser = plugin.userScope?.enabled;
1145
- const hasProject = plugin.projectScope?.enabled;
1146
- const hasLocal = plugin.localScope?.enabled;
1147
- const hasAnyScope = hasUser || hasProject || hasLocal;
1148
-
1149
- // Build version string
1150
- let versionStr = "";
1151
- if (plugin.isOrphaned) {
1152
- versionStr = " deprecated";
1153
- } else if (plugin.installedVersion && plugin.installedVersion !== "0.0.0") {
1154
- versionStr = ` v${plugin.installedVersion}`;
1155
- if (plugin.hasUpdate && plugin.version) {
1156
- versionStr += ` → v${plugin.version}`;
1157
- }
1158
- }
1159
-
1160
- // Get fuzzy match highlights if available
1161
- const matches = (item as ListItem & { _matches?: number[] })._matches;
1162
- const segments = matches ? highlightMatches(plugin.name, matches) : null;
1163
-
1164
- if (isSelected) {
1165
- return (
1166
- <text bg="magenta" fg="white">
1167
- {" "}
1168
- <span>{hasUser ? "■" : "□"}</span>
1169
- <span>{hasProject ? "■" : "□"}</span>
1170
- <span>{hasLocal ? "■" : "□"}</span>
1171
- {" "}{plugin.name}{versionStr}{" "}
1172
- </text>
1173
- );
1174
- }
1175
-
1176
- const displayName = segments
1177
- ? segments.map((seg) => seg.text).join("")
1178
- : plugin.name;
1179
-
1180
- if (plugin.isOrphaned) {
1181
- const ver = plugin.installedVersion && plugin.installedVersion !== "0.0.0"
1182
- ? ` v${plugin.installedVersion}` : "";
1183
- return (
1184
- <text>
1185
- <span fg="red"> ■■■ </span>
1186
- <span fg="gray">{displayName}</span>
1187
- {ver && <span fg="yellow">{ver}</span>}
1188
- <span fg="red"> deprecated</span>
1189
- </text>
1190
- );
1191
- }
1192
-
1193
- return (
1194
- <text>
1195
- <span> </span>
1196
- <span fg={hasUser ? "cyan" : "#333333"}>■</span>
1197
- <span fg={hasProject ? "green" : "#333333"}>■</span>
1198
- <span fg={hasLocal ? "yellow" : "#333333"}>■</span>
1199
- <span> </span>
1200
- <span fg={hasAnyScope ? "white" : "gray"}>{displayName}</span>
1201
- <span fg={plugin.hasUpdate ? "yellow" : "gray"}>{versionStr}</span>
1202
- </text>
1203
- );
1204
- }
1205
-
1206
- return <text fg="gray">{item.label}</text>;
1207
- };
1208
-
1209
- // Render detail content - compact to fit in available space
1210
- const renderDetail = () => {
1211
- if (!selectedItem) {
1212
- return <text fg="gray">Select an item</text>;
1213
- }
1214
-
1215
- if (selectedItem.type === "category" && selectedItem.marketplace) {
1216
- const mp = selectedItem.marketplace;
1217
- const isEnabled = selectedItem.marketplaceEnabled;
1218
-
1219
- // Get appropriate badge for marketplace type
1220
- const getBadge = () => {
1221
- if (selectedItem.isCommunitySection) return " 3rd Party";
1222
- if (mp.name === "claude-plugins-official") return " ★";
1223
- if (mp.name === "claude-code-plugins") return " ⚠";
1224
- if (mp.official) return " ★";
1225
- return "";
1226
- };
1227
-
1228
- // Determine action hint based on state
1229
- const isCollapsed = pluginsState.collapsedMarketplaces.has(mp.name);
1230
- const hasPlugins = (selectedItem.pluginCount || 0) > 0;
1231
- let actionHint = "Add";
1232
- if (isEnabled) {
1233
- if (isCollapsed) {
1234
- actionHint = "Expand";
1235
- } else if (hasPlugins) {
1236
- actionHint = "Collapse";
1237
- } else {
1238
- actionHint = "Remove";
1239
- }
1240
- }
1241
-
1242
- return (
1243
- <box flexDirection="column">
1244
- <text fg="cyan">
1245
- <strong>
1246
- {mp.displayName}
1247
- {getBadge()}
1248
- </strong>
1249
- </text>
1250
- <text fg="gray">{mp.description || "No description"}</text>
1251
- <text fg={isEnabled ? "green" : "gray"}>
1252
- {isEnabled ? "● Added" : "○ Not added"}
1253
- </text>
1254
- <text fg="#5c9aff">github.com/{mp.source.repo}</text>
1255
- <text>Plugins: {selectedItem.pluginCount || 0}</text>
1256
- <box marginTop={1}>
1257
- <text bg={isEnabled ? "cyan" : "green"} fg="black">
1258
- {" "}
1259
- Enter{" "}
1260
- </text>
1261
- <text fg="gray"> {actionHint}</text>
1262
- </box>
1263
- {isEnabled && (
1264
- <box>
1265
- <text fg="gray">← → to expand/collapse</text>
1266
- </box>
1267
- )}
1268
- </box>
1269
- );
1270
- }
1271
-
1272
- if (selectedItem.type === "plugin" && selectedItem.plugin) {
1273
- const plugin = selectedItem.plugin;
1274
- const isInstalled = plugin.userScope?.enabled || plugin.projectScope?.enabled || plugin.localScope?.enabled;
1275
-
1276
- // Orphaned/deprecated plugin
1277
- if (plugin.isOrphaned) {
1278
- return (
1279
- <box flexDirection="column">
1280
- <box justifyContent="center">
1281
- <text bg="yellow" fg="black"><strong> {plugin.name} — DEPRECATED </strong></text>
1282
- </box>
1283
- <box marginTop={1}>
1284
- <text fg="yellow">This plugin is no longer in the marketplace.</text>
1285
- </box>
1286
- <box marginTop={1}>
1287
- <text fg="gray">It was removed from the marketplace but still referenced in your settings. Press d to uninstall and clean up.</text>
1288
- </box>
1289
- {isInstalled && (
1290
- <box flexDirection="column" marginTop={2}>
1291
- <box>
1292
- <text bg="red" fg="white"> d </text>
1293
- <text> Uninstall (recommended)</text>
1294
- </box>
1295
- </box>
1296
- )}
1297
- </box>
1298
- );
1299
- }
1300
-
1301
- // Build component counts
1302
- const components: string[] = [];
1303
- if (plugin.agents?.length)
1304
- components.push(`${plugin.agents.length} agents`);
1305
- if (plugin.commands?.length)
1306
- components.push(`${plugin.commands.length} commands`);
1307
- if (plugin.skills?.length)
1308
- components.push(`${plugin.skills.length} skills`);
1309
- if (plugin.mcpServers?.length)
1310
- components.push(`${plugin.mcpServers.length} MCP`);
1311
- if (plugin.lspServers && Object.keys(plugin.lspServers).length) {
1312
- components.push(`${Object.keys(plugin.lspServers).length} LSP`);
1313
- }
1314
-
1315
- // Show version only if valid (not null, not 0.0.0)
1316
- const showVersion = plugin.version && plugin.version !== "0.0.0";
1317
- const showInstalledVersion =
1318
- plugin.installedVersion && plugin.installedVersion !== "0.0.0";
1319
-
1320
- return (
1321
- <box flexDirection="column">
1322
- {/* Plugin name header - centered */}
1323
- <box justifyContent="center">
1324
- <text bg="magenta" fg="white">
1325
- <strong>
1326
- {" "}
1327
- {plugin.name}
1328
- {plugin.hasUpdate ? " ⬆" : ""}{" "}
1329
- </strong>
1330
- </text>
1331
- </box>
1332
-
1333
- {/* Status line */}
1334
- <box marginTop={1}>
1335
- {isInstalled ? (
1336
- <text fg="green">● Installed</text>
1337
- ) : (
1338
- <text fg="gray">○ Not installed</text>
1339
- )}
1340
- </box>
1341
-
1342
- {/* Description */}
1343
- <box marginTop={1} marginBottom={1}>
1344
- <text fg="white">{plugin.description}</text>
1345
- </box>
1346
-
1347
- {/* Metadata */}
1348
- {showVersion && (
1349
- <text>
1350
- <span>Version </span>
1351
- <span fg="#5c9aff">v{plugin.version}</span>
1352
- {showInstalledVersion &&
1353
- plugin.installedVersion !== plugin.version && (
1354
- <span> (v{plugin.installedVersion} installed)</span>
1355
- )}
1356
- </text>
1357
- )}
1358
- {plugin.category && (
1359
- <text>
1360
- <span>Category </span>
1361
- <span fg="magenta">{plugin.category}</span>
1362
- </text>
1363
- )}
1364
- {plugin.author && (
1365
- <text>
1366
- <span>Author </span>
1367
- <span>{plugin.author.name}</span>
1368
- </text>
1369
- )}
1370
- {components.length > 0 && (
1371
- <text>
1372
- <span>Contains </span>
1373
- <span fg="yellow">{components.join(" · ")}</span>
1374
- </text>
1375
- )}
1376
-
1377
- {/* Scope Status with shortcuts - each scope has its own color */}
1378
- <box flexDirection="column" marginTop={1}>
1379
- <text>────────────────────────</text>
1380
- <text>
1381
- <strong>Scopes:</strong>
1382
- </text>
1383
- <box marginTop={1} flexDirection="column">
1384
- <text>
1385
- <span bg="cyan" fg="black">
1386
- {" "}
1387
- u{" "}
1388
- </span>
1389
- <span fg={plugin.userScope?.enabled ? "cyan" : "gray"}>
1390
- {plugin.userScope?.enabled ? " ● " : " ○ "}
1391
- </span>
1392
- <span fg="cyan">User</span>
1393
- <span> global</span>
1394
- {plugin.userScope?.version && (
1395
- <span fg="cyan"> v{plugin.userScope.version}</span>
1396
- )}
1397
- </text>
1398
- <text>
1399
- <span bg="green" fg="black">
1400
- {" "}
1401
- p{" "}
1402
- </span>
1403
- <span fg={plugin.projectScope?.enabled ? "green" : "gray"}>
1404
- {plugin.projectScope?.enabled ? " ● " : " ○ "}
1405
- </span>
1406
- <span fg="green">Project</span>
1407
- <span> team</span>
1408
- {plugin.projectScope?.version && (
1409
- <span fg="green"> v{plugin.projectScope.version}</span>
1410
- )}
1411
- </text>
1412
- <text>
1413
- <span bg="yellow" fg="black">
1414
- {" "}
1415
- l{" "}
1416
- </span>
1417
- <span fg={plugin.localScope?.enabled ? "yellow" : "gray"}>
1418
- {plugin.localScope?.enabled ? " ● " : " ○ "}
1419
- </span>
1420
- <span fg="yellow">Local</span>
1421
- <span> private</span>
1422
- {plugin.localScope?.version && (
1423
- <span fg="yellow"> v{plugin.localScope.version}</span>
1424
- )}
1425
- </text>
1426
- </box>
1427
- </box>
1428
-
1429
- {/* Additional actions */}
1430
- {isInstalled && (
1431
- <box flexDirection="column" marginTop={1}>
1432
- {plugin.hasUpdate && (
1433
- <box>
1434
- <text bg="magenta" fg="white">
1435
- {" "}
1436
- U{" "}
1437
- </text>
1438
- <text> Update to v{plugin.version}</text>
1439
- </box>
1440
- )}
1441
- </box>
1442
- )}
1443
- </box>
1444
- );
1445
- }
1446
-
1447
- return null;
1448
- };
1449
-
1450
765
  const footerHints = isSearchActive
1451
- ? "type to filter │ Enter:done │ Esc:clear"
1452
- : "u/p/l:toggle │ U:update │ a:all │ s:profile │ /:search";
766
+ ? "type to filter │ Enter:done │ Esc:clear"
767
+ : "u/p/l:toggle │ U:update │ a:all │ s:profile │ /:search";
1453
768
 
1454
- // Calculate status for subtitle
1455
769
  const scopeLabel = pluginsState.scope === "global" ? "Global" : "Project";
1456
- const plugins =
770
+ const plugins: PluginInfo[] =
1457
771
  pluginsState.plugins.status === "success" ? pluginsState.plugins.data : [];
1458
772
  const installedCount = plugins.filter((p) => p.enabled).length;
1459
773
  const updateCount = plugins.filter((p) => p.hasUpdate).length;
1460
774
  const subtitle = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} updates` : ""}`;
1461
-
1462
- // Search placeholder shows status when not searching
1463
775
  const searchPlaceholder = `${scopeLabel} │ ${installedCount} installed${updateCount > 0 ? ` │ ${updateCount} ⬆` : ""} │ / to search`;
1464
776
 
1465
777
  return (
@@ -1478,7 +790,7 @@ export function PluginsScreen() {
1478
790
  <ScrollableList
1479
791
  items={selectableItems}
1480
792
  selectedIndex={pluginsState.selectedIndex}
1481
- renderItem={renderListItem}
793
+ renderItem={renderPluginRow}
1482
794
  maxHeight={dimensions.listPanelHeight}
1483
795
  />
1484
796
  {pluginsState.searchQuery && selectableItems.length === 0 && (
@@ -1486,7 +798,7 @@ export function PluginsScreen() {
1486
798
  )}
1487
799
  </box>
1488
800
  }
1489
- detailPanel={renderDetail()}
801
+ detailPanel={renderPluginDetail(selectedItem, pluginsState.collapsedMarketplaces)}
1490
802
  />
1491
803
  );
1492
804
  }