@laststance/claude-plugin-dashboard 0.3.0 → 0.3.2

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 (47) hide show
  1. package/README.md +1 -0
  2. package/dist/app.js +346 -211
  3. package/dist/cli.js +3 -1
  4. package/dist/components/ComponentBadges.d.ts +0 -9
  5. package/dist/components/ComponentBadges.js +0 -33
  6. package/dist/components/ComponentDetail.d.ts +32 -0
  7. package/dist/components/ComponentDetail.js +106 -0
  8. package/dist/components/ComponentList.d.ts +36 -2
  9. package/dist/components/ComponentList.js +105 -11
  10. package/dist/components/HelpOverlay.js +1 -0
  11. package/dist/components/KeyHints.d.ts +1 -0
  12. package/dist/components/KeyHints.js +8 -1
  13. package/dist/components/PluginDetail.d.ts +16 -3
  14. package/dist/components/PluginDetail.js +29 -3
  15. package/dist/services/componentService.d.ts +10 -42
  16. package/dist/services/componentService.js +19 -412
  17. package/dist/services/components/hookService.d.ts +17 -0
  18. package/dist/services/components/hookService.js +45 -0
  19. package/dist/services/components/index.d.ts +41 -0
  20. package/dist/services/components/index.js +126 -0
  21. package/dist/services/components/markdownService.d.ts +39 -0
  22. package/dist/services/components/markdownService.js +147 -0
  23. package/dist/services/components/serverService.d.ts +28 -0
  24. package/dist/services/components/serverService.js +69 -0
  25. package/dist/services/components/skillService.d.ts +48 -0
  26. package/dist/services/components/skillService.js +164 -0
  27. package/dist/services/components/utils.d.ts +23 -0
  28. package/dist/services/components/utils.js +42 -0
  29. package/dist/services/pluginActionsService.d.ts +31 -2
  30. package/dist/services/pluginActionsService.js +65 -6
  31. package/dist/store/index.d.ts +46 -0
  32. package/dist/store/index.js +47 -0
  33. package/dist/store/slices/marketplaceSlice.d.ts +344 -0
  34. package/dist/store/slices/marketplaceSlice.js +152 -0
  35. package/dist/store/slices/pluginSlice.d.ts +1544 -0
  36. package/dist/store/slices/pluginSlice.js +191 -0
  37. package/dist/store/slices/uiSlice.d.ts +147 -0
  38. package/dist/store/slices/uiSlice.js +126 -0
  39. package/dist/tabs/DiscoverTab.d.ts +8 -2
  40. package/dist/tabs/DiscoverTab.js +2 -2
  41. package/dist/tabs/EnabledTab.d.ts +8 -2
  42. package/dist/tabs/EnabledTab.js +2 -2
  43. package/dist/tabs/ErrorsTab.js +1 -1
  44. package/dist/tabs/InstalledTab.d.ts +8 -2
  45. package/dist/tabs/InstalledTab.js +2 -2
  46. package/dist/types/index.d.ts +47 -4
  47. package/package.json +7 -2
package/dist/cli.js CHANGED
@@ -16,7 +16,9 @@ import { jsx as _jsx } from "react/jsx-runtime";
16
16
  */
17
17
  import { withFullScreen } from 'fullscreen-ink';
18
18
  import { match, P } from 'ts-pattern';
19
+ import { Provider } from 'react-redux';
19
20
  import App from './app.js';
21
+ import { store } from './store/index.js';
20
22
  import { loadAllPlugins, loadInstalledPlugins, loadMarketplaces, getPluginStatistics, getPluginById, } from './services/pluginService.js';
21
23
  import { enablePlugin, disablePlugin, togglePlugin, } from './services/settingsService.js';
22
24
  import { fileExists } from './services/fileService.js';
@@ -307,7 +309,7 @@ else {
307
309
  }
308
310
  // Use fullscreen-ink for alternate screen buffer management
309
311
  // This prevents rendering artifacts when switching tabs
310
- const ink = withFullScreen(_jsx(App, {}));
312
+ const ink = withFullScreen(_jsx(Provider, { store: store, children: _jsx(App, {}) }));
311
313
  ink.start();
312
314
  ink.waitUntilExit();
313
315
  }
@@ -21,12 +21,3 @@ export interface ComponentBadgesProps {
21
21
  * // Renders: Skills:5 Slash:2
22
22
  */
23
23
  export default function ComponentBadges({ components, }: ComponentBadgesProps): React.ReactNode;
24
- /**
25
- * Get a human-readable description of component types
26
- * @param components - PluginComponents object
27
- * @returns Formatted string describing components
28
- * @example
29
- * getComponentsDescription({ skills: 3, mcpServers: 1 })
30
- * // => "3 skills, 1 MCP server"
31
- */
32
- export declare function getComponentsDescription(components: PluginComponents | undefined): string;
@@ -48,36 +48,3 @@ export default function ComponentBadges({ components, }) {
48
48
  function Badge({ label, count, color, }) {
49
49
  return (_jsxs(Text, { children: [_jsx(Text, { color: color, bold: true, children: label }), count !== undefined && _jsxs(Text, { dimColor: true, children: [":", count] })] }));
50
50
  }
51
- /**
52
- * Get a human-readable description of component types
53
- * @param components - PluginComponents object
54
- * @returns Formatted string describing components
55
- * @example
56
- * getComponentsDescription({ skills: 3, mcpServers: 1 })
57
- * // => "3 skills, 1 MCP server"
58
- */
59
- export function getComponentsDescription(components) {
60
- if (!components) {
61
- return '';
62
- }
63
- const parts = [];
64
- if (components.skills) {
65
- parts.push(`${components.skills} skill${components.skills > 1 ? 's' : ''}`);
66
- }
67
- if (components.commands) {
68
- parts.push(`${components.commands} command${components.commands > 1 ? 's' : ''}`);
69
- }
70
- if (components.agents) {
71
- parts.push(`${components.agents} agent${components.agents > 1 ? 's' : ''}`);
72
- }
73
- if (components.hooks) {
74
- parts.push('hooks');
75
- }
76
- if (components.mcpServers) {
77
- parts.push(`${components.mcpServers} MCP server${components.mcpServers > 1 ? 's' : ''}`);
78
- }
79
- if (components.lspServers) {
80
- parts.push(`${components.lspServers} LSP server${components.lspServers > 1 ? 's' : ''}`);
81
- }
82
- return parts.join(', ');
83
- }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * ComponentDetail component
3
+ * Displays detailed information about a selected component
4
+ * Shows name, type, description, allowed tools, and full content
5
+ */
6
+ import type { ComponentDetailedInfo } from '../types/index.js';
7
+ /**
8
+ * Props for ComponentDetail
9
+ */
10
+ export interface ComponentDetailProps {
11
+ /** Detailed component info to display */
12
+ component: ComponentDetailedInfo | null;
13
+ /** Maximum height for the detail panel */
14
+ maxHeight?: number;
15
+ }
16
+ /**
17
+ * Displays detailed component information in a panel
18
+ * Used when user selects a component from the ComponentList
19
+ * Supports compact mode when maxHeight <= 4 (shows only name, type, description)
20
+ * @param props - ComponentDetailProps
21
+ * @returns React node
22
+ * @example
23
+ * <ComponentDetail
24
+ * component={{
25
+ * name: "sentry-code-review",
26
+ * type: "skill",
27
+ * description: "Analyze Sentry comments",
28
+ * allowedTools: ["Read", "Edit"]
29
+ * }}
30
+ * />
31
+ */
32
+ export default function ComponentDetail({ component, maxHeight, }: ComponentDetailProps): React.ReactNode;
@@ -0,0 +1,106 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * ComponentDetail component
4
+ * Displays detailed information about a selected component
5
+ * Shows name, type, description, allowed tools, and full content
6
+ */
7
+ import * as path from 'node:path';
8
+ import { Box, Text } from 'ink';
9
+ /**
10
+ * Type badge colors for visual distinction
11
+ */
12
+ const TYPE_COLORS = {
13
+ skill: 'magenta',
14
+ command: 'cyan',
15
+ agent: 'blue',
16
+ hook: 'yellow',
17
+ mcp: 'green',
18
+ lsp: 'blueBright',
19
+ };
20
+ /**
21
+ * Type labels for display
22
+ */
23
+ const TYPE_LABELS = {
24
+ skill: 'Skill',
25
+ command: 'Slash Command',
26
+ agent: 'Agent',
27
+ hook: 'Hook',
28
+ mcp: 'MCP Server',
29
+ lsp: 'LSP Server',
30
+ };
31
+ /**
32
+ * Compact mode threshold - show minimal info when height is small
33
+ */
34
+ const COMPACT_MODE_THRESHOLD = 4;
35
+ /**
36
+ * Displays detailed component information in a panel
37
+ * Used when user selects a component from the ComponentList
38
+ * Supports compact mode when maxHeight <= 4 (shows only name, type, description)
39
+ * @param props - ComponentDetailProps
40
+ * @returns React node
41
+ * @example
42
+ * <ComponentDetail
43
+ * component={{
44
+ * name: "sentry-code-review",
45
+ * type: "skill",
46
+ * description: "Analyze Sentry comments",
47
+ * allowedTools: ["Read", "Edit"]
48
+ * }}
49
+ * />
50
+ */
51
+ export default function ComponentDetail({ component, maxHeight = 10, }) {
52
+ // Safe content height calculation (at least 1 line)
53
+ const contentHeight = Math.max(1, maxHeight - 6);
54
+ const isCompact = maxHeight <= COMPACT_MODE_THRESHOLD;
55
+ if (!component) {
56
+ return (_jsx(Box, { flexDirection: "column", borderStyle: "single", borderColor: "gray", paddingX: 1, height: maxHeight, children: _jsx(Text, { dimColor: true, children: "Select a component to view details" }) }));
57
+ }
58
+ const typeColor = TYPE_COLORS[component.type] || 'white';
59
+ const typeLabel = TYPE_LABELS[component.type] || component.type;
60
+ // Compact mode: minimal info only (name, type, description)
61
+ if (isCompact) {
62
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, height: maxHeight, overflow: "hidden", children: [_jsxs(Box, { height: 1, children: [_jsxs(Text, { bold: true, color: "white", children: ["\uD83D\uDCE6 ", truncateString(component.name, 20)] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: typeColor, bold: true, children: ["[", typeLabel, "]"] })] }), _jsx(Box, { height: 1, children: _jsx(Text, { wrap: "truncate", dimColor: true, children: component.description || 'No description' }) })] }));
63
+ }
64
+ // Full mode: show all details
65
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "single", borderColor: "cyan", paddingX: 1, height: maxHeight, overflow: "hidden", children: [_jsxs(Box, { height: 1, children: [_jsxs(Text, { bold: true, color: "white", children: ["\uD83D\uDCE6 ", component.name] }), _jsx(Text, { children: " " }), _jsxs(Text, { color: typeColor, bold: true, children: ["[", typeLabel, "]"] })] }), component.description && (_jsx(Box, { height: 1, children: _jsx(Text, { wrap: "truncate", children: component.description }) })), component.allowedTools && component.allowedTools.length > 0 && (_jsxs(Box, { height: 1, children: [_jsxs(Text, { color: "yellow", bold: true, children: ["Tools:", ' '] }), _jsx(Text, { dimColor: true, wrap: "truncate", children: component.allowedTools.join(', ') })] })), component.filePath && (_jsx(Box, { height: 1, children: _jsxs(Text, { color: "gray", children: ["\uD83D\uDCC4 ", shortenPath(component.filePath)] }) })), component.fullDescription && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "\u2500\u2500 Content \u2500\u2500" }), _jsx(Box, { height: contentHeight, overflow: "hidden", children: _jsx(Text, { dimColor: true, wrap: "truncate", children: truncateContent(component.fullDescription, contentHeight) }) })] }))] }));
66
+ }
67
+ /**
68
+ * Truncate string to max length with ellipsis
69
+ * @param str - String to truncate
70
+ * @param maxLength - Maximum length
71
+ * @returns Truncated string
72
+ */
73
+ function truncateString(str, maxLength) {
74
+ if (str.length <= maxLength) {
75
+ return str;
76
+ }
77
+ return str.slice(0, maxLength - 1) + '…';
78
+ }
79
+ /**
80
+ * Shorten file path for display (cross-platform)
81
+ * Shows only the last few path components
82
+ * @param filePath - Full file path
83
+ * @returns Shortened path with forward slashes for consistent display
84
+ */
85
+ function shortenPath(filePath) {
86
+ const normalized = path.normalize(filePath);
87
+ const parts = normalized.split(path.sep);
88
+ // Show last 4 components: .../skills/component-name/SKILL.md
89
+ if (parts.length > 4) {
90
+ return '.../' + parts.slice(-4).join('/');
91
+ }
92
+ return parts.join('/');
93
+ }
94
+ /**
95
+ * Truncate multi-line content to fit within height
96
+ * @param content - Full content string
97
+ * @param maxLines - Maximum number of lines
98
+ * @returns Truncated content
99
+ */
100
+ function truncateContent(content, maxLines) {
101
+ const lines = content.split('\n');
102
+ if (lines.length <= maxLines) {
103
+ return content;
104
+ }
105
+ return lines.slice(0, maxLines).join('\n') + '\n...';
106
+ }
@@ -8,7 +8,19 @@
8
8
  * - Not installed: Names only from marketplace JSON (if available)
9
9
  * - Fallback: Count-only display from PluginComponents
10
10
  */
11
- import type { PluginComponents, PluginComponentsDetailed } from '../types/index.js';
11
+ import type { ComponentInfo, PluginComponents, PluginComponentsDetailed } from '../types/index.js';
12
+ /**
13
+ * Flattened component item for selection tracking
14
+ * Combines type and info for easy navigation
15
+ */
16
+ export interface FlatComponentItem {
17
+ /** Component info */
18
+ info: ComponentInfo;
19
+ /** Category label */
20
+ category: string;
21
+ /** Category color */
22
+ color: string;
23
+ }
12
24
  /**
13
25
  * Props for ComponentList
14
26
  */
@@ -19,19 +31,41 @@ export interface ComponentListProps {
19
31
  componentsDetailed?: PluginComponentsDetailed;
20
32
  /** Maximum visible items per category (default: 3) */
21
33
  maxItems?: number;
34
+ /** Whether this list is focused for selection */
35
+ isFocused?: boolean;
36
+ /** Currently selected index in flattened list */
37
+ selectedIndex?: number;
38
+ /** Callback when selection changes */
39
+ onSelect?: (item: FlatComponentItem, index: number) => void;
40
+ /** Number of visible items in virtual scroll viewport (default: 5) */
41
+ visibleCount?: number;
22
42
  }
43
+ /**
44
+ * Flatten all components from detailed info into a single array for selection
45
+ * @param componentsDetailed - Detailed component info
46
+ * @returns Array of FlatComponentItem for navigation
47
+ * @example
48
+ * flattenComponents({ skills: [{ name: 'xlsx', type: 'skill' }] })
49
+ * // => [{ info: { name: 'xlsx', type: 'skill' }, category: 'Skills', color: 'magenta' }]
50
+ */
51
+ export declare function flattenComponents(componentsDetailed?: PluginComponentsDetailed): FlatComponentItem[];
23
52
  /**
24
53
  * Displays component details in a collapsible list
25
54
  * Shows names when available, falls back to counts
55
+ * Supports selection mode when isFocused is true
56
+ * Uses virtual scrolling when focused to prevent layout overflow
26
57
  * @param props - ComponentListProps
27
58
  * @returns React node or null if no components
28
59
  * @example
29
60
  * <ComponentList
30
61
  * componentsDetailed={{ skills: [{ name: 'xlsx', type: 'skill' }] }}
31
62
  * maxItems={3}
63
+ * isFocused={true}
64
+ * selectedIndex={0}
65
+ * visibleCount={5}
32
66
  * />
33
67
  */
34
- export default function ComponentList({ components, componentsDetailed, maxItems, }: ComponentListProps): React.ReactNode;
68
+ export default function ComponentList({ components, componentsDetailed, maxItems, isFocused, selectedIndex, visibleCount, }: ComponentListProps): React.ReactNode;
35
69
  /**
36
70
  * Check if PluginComponentsDetailed has any data
37
71
  * @param detailed - Detailed components object
@@ -61,18 +61,62 @@ const CATEGORY_CONFIGS = [
61
61
  * Default maximum visible items per category
62
62
  */
63
63
  const DEFAULT_MAX_ITEMS = 3;
64
+ /**
65
+ * Flatten all components from detailed info into a single array for selection
66
+ * @param componentsDetailed - Detailed component info
67
+ * @returns Array of FlatComponentItem for navigation
68
+ * @example
69
+ * flattenComponents({ skills: [{ name: 'xlsx', type: 'skill' }] })
70
+ * // => [{ info: { name: 'xlsx', type: 'skill' }, category: 'Skills', color: 'magenta' }]
71
+ */
72
+ export function flattenComponents(componentsDetailed) {
73
+ if (!componentsDetailed)
74
+ return [];
75
+ const items = [];
76
+ // Process each category in order
77
+ for (const config of CATEGORY_CONFIGS) {
78
+ const detailedItems = componentsDetailed[config.detailedKey];
79
+ if (!detailedItems?.length)
80
+ continue;
81
+ if (isComponentInfoArray(detailedItems)) {
82
+ for (const info of detailedItems) {
83
+ items.push({ info, category: config.label, color: config.color });
84
+ }
85
+ }
86
+ else {
87
+ // String array (mcpServers, lspServers, hooks) - convert to ComponentInfo
88
+ for (const name of detailedItems) {
89
+ items.push({
90
+ info: { name, type: config.type },
91
+ category: config.label,
92
+ color: config.color,
93
+ });
94
+ }
95
+ }
96
+ }
97
+ return items;
98
+ }
99
+ /**
100
+ * Default visible count for virtual scroll
101
+ */
102
+ const DEFAULT_VISIBLE_COUNT = 5;
64
103
  /**
65
104
  * Displays component details in a collapsible list
66
105
  * Shows names when available, falls back to counts
106
+ * Supports selection mode when isFocused is true
107
+ * Uses virtual scrolling when focused to prevent layout overflow
67
108
  * @param props - ComponentListProps
68
109
  * @returns React node or null if no components
69
110
  * @example
70
111
  * <ComponentList
71
112
  * componentsDetailed={{ skills: [{ name: 'xlsx', type: 'skill' }] }}
72
113
  * maxItems={3}
114
+ * isFocused={true}
115
+ * selectedIndex={0}
116
+ * visibleCount={5}
73
117
  * />
74
118
  */
75
- export default function ComponentList({ components, componentsDetailed, maxItems = DEFAULT_MAX_ITEMS, }) {
119
+ export default function ComponentList({ components, componentsDetailed, maxItems = DEFAULT_MAX_ITEMS, isFocused = false, selectedIndex = 0, visibleCount = DEFAULT_VISIBLE_COUNT, }) {
76
120
  // No data at all
77
121
  if (!components && !componentsDetailed) {
78
122
  return null;
@@ -83,6 +127,14 @@ export default function ComponentList({ components, componentsDetailed, maxItems
83
127
  if (!hasDetailedData && !hasCountData) {
84
128
  return null;
85
129
  }
130
+ // When focused, use flat virtual scroll approach
131
+ if (isFocused && hasDetailedData) {
132
+ const flatItems = flattenComponents(componentsDetailed);
133
+ return (_jsx(VirtualScrollList, { items: flatItems, selectedIndex: selectedIndex, visibleCount: visibleCount }));
134
+ }
135
+ // Normal mode: show collapsed categories
136
+ // Track current index across all categories
137
+ let currentFlatIndex = 0;
86
138
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 0, children: _jsx(Text, { color: "cyan", bold: true, children: "\u2500\u2500 Components \u2500\u2500" }) }), CATEGORY_CONFIGS.map((config) => {
87
139
  const detailedItems = componentsDetailed?.[config.detailedKey];
88
140
  const count = components?.[config.countKey];
@@ -94,11 +146,15 @@ export default function ComponentList({ components, componentsDetailed, maxItems
94
146
  if (detailedItems && detailedItems.length > 0) {
95
147
  // Type guard: detailedItems could be ComponentInfo[] or string[]
96
148
  if (isComponentInfoArray(detailedItems)) {
97
- return (_jsx(CategorySection, { label: config.label, color: config.color, items: detailedItems, maxItems: maxItems }, config.detailedKey));
149
+ const startIndex = currentFlatIndex;
150
+ currentFlatIndex += detailedItems.length;
151
+ return (_jsx(CategorySection, { label: config.label, color: config.color, items: detailedItems, maxItems: maxItems, isFocused: false, selectedIndex: selectedIndex, startIndex: startIndex }, config.detailedKey));
98
152
  }
99
153
  else {
100
154
  // String array (mcpServers, lspServers, hooks)
101
- return (_jsx(CategorySectionSimple, { label: config.label, color: config.color, items: detailedItems, maxItems: maxItems }, config.detailedKey));
155
+ const startIndex = currentFlatIndex;
156
+ currentFlatIndex += detailedItems.length;
157
+ return (_jsx(CategorySectionSimple, { label: config.label, color: config.color, items: detailedItems, maxItems: maxItems, isFocused: false, selectedIndex: selectedIndex, startIndex: startIndex }, config.detailedKey));
102
158
  }
103
159
  }
104
160
  // Count-only fallback (number) or boolean hooks fallback
@@ -112,24 +168,62 @@ export default function ComponentList({ components, componentsDetailed, maxItems
112
168
  return null;
113
169
  })] }));
114
170
  }
171
+ /**
172
+ * Virtual scroll list for focused mode
173
+ * Shows a fixed viewport with scroll indicators
174
+ * @param props - VirtualScrollListProps
175
+ * @returns React node
176
+ */
177
+ function VirtualScrollList({ items, selectedIndex, visibleCount, }) {
178
+ const totalItems = items.length;
179
+ // Calculate scroll window centered on selection
180
+ const halfVisible = Math.floor(visibleCount / 2);
181
+ let startIndex = Math.max(0, selectedIndex - halfVisible);
182
+ const endIndex = Math.min(totalItems, startIndex + visibleCount);
183
+ // Adjust start if we hit the end
184
+ startIndex = Math.max(0, endIndex - visibleCount);
185
+ const visibleItems = items.slice(startIndex, endIndex);
186
+ const itemsAbove = startIndex;
187
+ const itemsBelow = totalItems - endIndex;
188
+ // Height: 1 header + visibleCount items + 1 indicator (always show space for indicator)
189
+ const listHeight = 1 + visibleCount + 1;
190
+ return (_jsxs(Box, { flexDirection: "column", height: listHeight, children: [_jsxs(Box, { height: 1, children: [_jsx(Text, { color: "cyan", bold: true, children: "\u2500\u2500 Components \u2500\u2500" }), _jsx(Text, { dimColor: true, children: " (\u2191\u2193 select, \u2190 back)" })] }), visibleItems.map((item, idx) => {
191
+ const actualIndex = startIndex + idx;
192
+ const isSelected = actualIndex === selectedIndex;
193
+ return (_jsx(Box, { height: 1, children: _jsxs(Text, { inverse: isSelected, children: [isSelected ? ' ▶ ' : ' ', _jsxs(Text, { color: item.color, children: ["[", item.category.charAt(0), "]"] }), ' ', item.info.name] }) }, `${item.category}-${item.info.name}`));
194
+ }), _jsx(Box, { height: 1, children: itemsAbove > 0 && itemsBelow > 0 ? (_jsxs(Text, { dimColor: true, children: [' ', "\u2191", itemsAbove, " more \u00B7 \u2193", itemsBelow, " more"] })) : itemsAbove > 0 ? (_jsxs(Text, { dimColor: true, children: [' ', "\u2191", itemsAbove, " more"] })) : itemsBelow > 0 ? (_jsxs(Text, { dimColor: true, children: [' ', "\u2193", itemsBelow, " more"] })) : (_jsx(Text, { children: " " })) })] }));
195
+ }
115
196
  /**
116
197
  * Single category section with collapsible ComponentInfo items
117
198
  * Shows first N items, then "+M more..." for overflow
199
+ * Supports selection highlighting when focused
118
200
  * @returns React node for the category section
119
201
  */
120
- function CategorySection({ label, color, items, maxItems, }) {
121
- const visibleItems = items.slice(0, maxItems);
122
- const remainingCount = items.length - maxItems;
123
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: label }), _jsxs(Text, { dimColor: true, children: [" (", items.length, ")"] })] }), visibleItems.map((item) => (_jsxs(Text, { children: [' ', "\u2022 ", item.name, item.description && (_jsxs(Text, { dimColor: true, children: [" - ", truncate(item.description, 30)] }))] }, item.name))), remainingCount > 0 && (_jsxs(Text, { dimColor: true, children: [' ', "\u2514\u2500 +", remainingCount, " more..."] }))] }));
202
+ function CategorySection({ label, color, items, maxItems, isFocused = false, selectedIndex = 0, startIndex = 0, }) {
203
+ // When focused, show all items to allow selection
204
+ // When not focused, limit to maxItems
205
+ const visibleItems = isFocused ? items : items.slice(0, maxItems);
206
+ const remainingCount = isFocused ? 0 : items.length - maxItems;
207
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: label }), _jsxs(Text, { dimColor: true, children: [" (", items.length, ")"] })] }), visibleItems.map((item, index) => {
208
+ const flatIndex = startIndex + index;
209
+ const isSelected = isFocused && flatIndex === selectedIndex;
210
+ return (_jsx(Box, { height: 1, children: _jsxs(Text, { inverse: isSelected, children: [isSelected ? ' ▶ ' : ' ', item.name, item.description && !isSelected && (_jsxs(Text, { dimColor: true, children: [" - ", truncate(item.description, 25)] }))] }) }, item.name));
211
+ }), remainingCount > 0 && (_jsxs(Text, { dimColor: true, children: [' ', "\u2514\u2500 +", remainingCount, " more..."] }))] }));
124
212
  }
125
213
  /**
126
214
  * Category section for simple string arrays (mcpServers, lspServers, hooks)
215
+ * Supports selection highlighting when focused
127
216
  * @returns React node for the category section
128
217
  */
129
- function CategorySectionSimple({ label, color, items, maxItems, }) {
130
- const visibleItems = items.slice(0, maxItems);
131
- const remainingCount = items.length - maxItems;
132
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: label }), _jsxs(Text, { dimColor: true, children: [" (", items.length, ")"] })] }), visibleItems.map((item) => (_jsxs(Text, { children: [' ', "\u2022 ", item] }, item))), remainingCount > 0 && (_jsxs(Text, { dimColor: true, children: [' ', "\u2514\u2500 +", remainingCount, " more..."] }))] }));
218
+ function CategorySectionSimple({ label, color, items, maxItems, isFocused = false, selectedIndex = 0, startIndex = 0, }) {
219
+ // When focused, show all items to allow selection
220
+ const visibleItems = isFocused ? items : items.slice(0, maxItems);
221
+ const remainingCount = isFocused ? 0 : items.length - maxItems;
222
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: label }), _jsxs(Text, { dimColor: true, children: [" (", items.length, ")"] })] }), visibleItems.map((item, index) => {
223
+ const flatIndex = startIndex + index;
224
+ const isSelected = isFocused && flatIndex === selectedIndex;
225
+ return (_jsx(Box, { height: 1, children: _jsxs(Text, { inverse: isSelected, children: [isSelected ? ' ▶ ' : ' ', item] }) }, item));
226
+ }), remainingCount > 0 && (_jsxs(Text, { dimColor: true, children: [' ', "\u2514\u2500 +", remainingCount, " more..."] }))] }));
133
227
  }
134
228
  /**
135
229
  * Category section showing only count (fallback when no detailed info)
@@ -18,6 +18,7 @@ const helpSections = [
18
18
  items: [
19
19
  { key: 'i, Enter', description: 'Install / Toggle plugin' },
20
20
  { key: 'u', description: 'Uninstall plugin' },
21
+ { key: 'U', description: 'Update all installed plugins' },
21
22
  { key: 'Space', description: 'Toggle enable/disable' },
22
23
  { key: 's/S', description: 'Sort options / order' },
23
24
  ],
@@ -17,6 +17,7 @@ interface KeyHintsProps {
17
17
  * @example
18
18
  * <KeyHints focusZone="list" />
19
19
  * <KeyHints focusZone="search" extraHints={[{ key: 'i', action: 'install' }]} />
20
+ * <KeyHints focusZone="components" />
20
21
  */
21
22
  export default function KeyHints({ extraHints, focusZone, }: KeyHintsProps): import("react/jsx-runtime").JSX.Element;
22
23
  export {};
@@ -33,6 +33,12 @@ function getBaseHints(focusZone) {
33
33
  { key: 'Tab', action: 'next tab' },
34
34
  { key: 'h', action: 'help' },
35
35
  { key: 'q or ^C', action: 'quit' },
36
+ ])
37
+ .with('components', () => [
38
+ { key: '↑/↓', action: 'select component' },
39
+ { key: '←', action: 'back to plugin' },
40
+ { key: 'h', action: 'help' },
41
+ { key: 'q or ^C', action: 'quit' },
36
42
  ])
37
43
  .exhaustive();
38
44
  }
@@ -41,9 +47,10 @@ function getBaseHints(focusZone) {
41
47
  * @example
42
48
  * <KeyHints focusZone="list" />
43
49
  * <KeyHints focusZone="search" extraHints={[{ key: 'i', action: 'install' }]} />
50
+ * <KeyHints focusZone="components" />
44
51
  */
45
52
  export default function KeyHints({ extraHints, focusZone = 'list', }) {
46
53
  const baseHints = getBaseHints(focusZone);
47
54
  const allHints = extraHints ? [...baseHints, ...extraHints] : baseHints;
48
- return (_jsx(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: _jsx(Box, { gap: 2, flexWrap: "wrap", children: allHints.map((hint, index) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, color: "white", children: hint.key }), _jsx(Text, { dimColor: true, children: hint.action })] }, index))) }) }));
55
+ return (_jsx(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, marginTop: 1, children: _jsx(Box, { gap: 2, flexWrap: "wrap", children: allHints.map((hint) => (_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, color: "white", children: hint.key }), _jsx(Text, { dimColor: true, children: hint.action })] }, `${hint.key}-${hint.action}`))) }) }));
49
56
  }
@@ -4,15 +4,28 @@
4
4
  *
5
5
  * Uses fixed height to prevent layout jumping when switching between plugins
6
6
  * with different amounts of content (e.g., varying component counts).
7
+ *
8
+ * Supports component focus mode for navigating and viewing component details
7
9
  */
8
- import type { Plugin } from '../types/index.js';
10
+ import type { ComponentDetailedInfo, Plugin } from '../types/index.js';
9
11
  interface PluginDetailProps {
10
12
  plugin: Plugin | null;
13
+ /** Whether component focus mode is active */
14
+ componentFocusMode?: boolean;
15
+ /** Currently selected component index */
16
+ selectedComponentIndex?: number;
17
+ /** Selected component's detailed info (fetched from service) */
18
+ selectedComponentDetail?: ComponentDetailedInfo | null;
11
19
  }
12
20
  /**
13
21
  * Displays detailed information about a selected plugin
22
+ * Supports component focus mode for drilling into component details
14
23
  * @example
15
- * <PluginDetail plugin={selectedPlugin} />
24
+ * <PluginDetail
25
+ * plugin={selectedPlugin}
26
+ * componentFocusMode={true}
27
+ * selectedComponentIndex={0}
28
+ * />
16
29
  */
17
- export default function PluginDetail({ plugin }: PluginDetailProps): import("react/jsx-runtime").JSX.Element;
30
+ export default function PluginDetail({ plugin, componentFocusMode, selectedComponentIndex, selectedComponentDetail, }: PluginDetailProps): import("react/jsx-runtime").JSX.Element;
18
31
  export {};
@@ -5,29 +5,55 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
5
5
  *
6
6
  * Uses fixed height to prevent layout jumping when switching between plugins
7
7
  * with different amounts of content (e.g., varying component counts).
8
+ *
9
+ * Supports component focus mode for navigating and viewing component details
8
10
  */
9
11
  import { Box, Text } from 'ink';
10
12
  import StatusIcon from './StatusIcon.js';
11
13
  import ComponentBadges from './ComponentBadges.js';
12
14
  import ComponentList, { hasAnyCountComponents, hasAnyDetailedComponents, } from './ComponentList.js';
15
+ import ComponentDetail from './ComponentDetail.js';
13
16
  /**
14
17
  * Fixed height for PluginDetail panel (in terminal lines)
15
18
  * Matches PluginList's visibleCount * 2 + 2 = 26 lines
16
19
  * This ensures both panels have consistent height regardless of content
17
20
  */
18
21
  const DETAIL_PANEL_HEIGHT = 26;
22
+ /**
23
+ * Layout height constants for consistent rendering
24
+ * Total budget: 26 lines (DETAIL_PANEL_HEIGHT)
25
+ *
26
+ * Normal mode:
27
+ * Border/Padding: 4 | Header: 2 | Description: 2 | Metadata: 7
28
+ * Separator: 1 | ComponentList: 4 | Status: 3 | Actions: 3
29
+ *
30
+ * Focused mode (hide Status/Actions to maximize component browsing):
31
+ * Border/Padding: 4 | Header: 2 | Description: 2 | Metadata: 7
32
+ * Separator: 1 | ComponentList: 7 | ComponentDetail: 3
33
+ */
34
+ const COMPONENT_LIST_HEIGHT_NORMAL = 4;
35
+ const COMPONENT_LIST_HEIGHT_FOCUSED = 7;
36
+ const COMPONENT_DETAIL_HEIGHT = 3;
37
+ const COMPONENT_LIST_VISIBLE_COUNT = 5;
19
38
  /**
20
39
  * Displays detailed information about a selected plugin
40
+ * Supports component focus mode for drilling into component details
21
41
  * @example
22
- * <PluginDetail plugin={selectedPlugin} />
42
+ * <PluginDetail
43
+ * plugin={selectedPlugin}
44
+ * componentFocusMode={true}
45
+ * selectedComponentIndex={0}
46
+ * />
23
47
  */
24
- export default function PluginDetail({ plugin }) {
48
+ export default function PluginDetail({ plugin, componentFocusMode = false, selectedComponentIndex = 0, selectedComponentDetail = null, }) {
25
49
  if (!plugin) {
26
50
  return (_jsx(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "gray", height: DETAIL_PANEL_HEIGHT, children: _jsx(Text, { dimColor: true, children: "Select a plugin to view details" }) }));
27
51
  }
28
52
  return (_jsxs(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "cyan", height: DETAIL_PANEL_HEIGHT, overflow: "hidden", children: [_jsxs(Box, { marginBottom: 1, gap: 1, children: [_jsx(StatusIcon, { isInstalled: plugin.isInstalled, isEnabled: plugin.isEnabled }), _jsx(Text, { bold: true, color: "cyan", children: plugin.name })] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { wrap: "wrap", children: plugin.description || 'No description available' }) }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(DetailRow, { label: "Marketplace", value: plugin.marketplace }), _jsx(DetailRow, { label: "Version", value: plugin.version || '-' }), _jsx(DetailRow, { label: "Installs", value: formatCount(plugin.installCount) }), _jsx(DetailRow, { label: "Category", value: plugin.category || '-' }), _jsx(DetailRow, { label: "Author", value: plugin.author?.name || '-' }), _jsx(DetailRow, { label: "Homepage", value: plugin.homepage || '-' }), _jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "gray", children: 'Components:'.padEnd(12) }), plugin.components ? (_jsx(ComponentBadges, { components: plugin.components })) : (_jsx(Text, { color: "gray", children: "-" }))] })] }), ((plugin.components && hasAnyCountComponents(plugin.components)) ||
29
53
  (plugin.componentsDetailed &&
30
- hasAnyDetailedComponents(plugin.componentsDetailed))) && (_jsxs(_Fragment, { children: [_jsx(Box, { marginY: 1, children: _jsx(Text, { dimColor: true, children: '─'.repeat(36) }) }), _jsx(ComponentList, { components: plugin.components, componentsDetailed: plugin.componentsDetailed, maxItems: 3 })] })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { children: "Status:" }), plugin.isInstalled ? (plugin.isEnabled ? (_jsx(Text, { color: "green", bold: true, wrap: "truncate", children: "Enabled" })) : (_jsx(Text, { color: "yellow", bold: true, wrap: "truncate", children: "Disabled" }))) : (_jsx(Text, { color: "gray", wrap: "truncate", children: "Not Installed" }))] }), plugin.installedAt && (_jsxs(Text, { dimColor: true, wrap: "truncate", children: ["Installed: ", formatDate(plugin.installedAt)] }))] }), _jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "column", children: plugin.isInstalled ? (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, color: "white", children: "Space" }), ' ', plugin.isEnabled ? 'disable' : 'enable', " |", ' ', _jsx(Text, { bold: true, color: "white", children: "u" }), ' ', "uninstall"] }) })) : (_jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, color: "white", children: "i" }), ' ', "install"] })) })] }));
54
+ hasAnyDetailedComponents(plugin.componentsDetailed))) && (_jsxs(_Fragment, { children: [_jsx(Box, { marginY: 1, height: 1, children: _jsx(Text, { dimColor: true, children: ''.repeat(36) }) }), _jsx(Box, { height: componentFocusMode
55
+ ? COMPONENT_LIST_HEIGHT_FOCUSED
56
+ : COMPONENT_LIST_HEIGHT_NORMAL, overflow: "hidden", children: _jsx(ComponentList, { components: plugin.components, componentsDetailed: plugin.componentsDetailed, maxItems: 3, isFocused: componentFocusMode, selectedIndex: selectedComponentIndex, visibleCount: COMPONENT_LIST_VISIBLE_COUNT }) })] })), componentFocusMode && selectedComponentDetail && (_jsx(Box, { height: COMPONENT_DETAIL_HEIGHT, overflow: "hidden", children: _jsx(ComponentDetail, { component: selectedComponentDetail, maxHeight: COMPONENT_DETAIL_HEIGHT }) })), !componentFocusMode && (_jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { children: "Status:" }), plugin.isInstalled ? (plugin.isEnabled ? (_jsx(Text, { color: "green", bold: true, wrap: "truncate", children: "Enabled" })) : (_jsx(Text, { color: "yellow", bold: true, wrap: "truncate", children: "Disabled" }))) : (_jsx(Text, { color: "gray", wrap: "truncate", children: "Not Installed" }))] }), plugin.installedAt && (_jsxs(Text, { dimColor: true, wrap: "truncate", children: ["Installed: ", formatDate(plugin.installedAt)] }))] })), !componentFocusMode && (_jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, flexDirection: "column", children: plugin.isInstalled ? (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, color: "white", children: "Space" }), ' ', plugin.isEnabled ? 'disable' : 'enable', " |", ' ', _jsx(Text, { bold: true, color: "white", children: "u" }), ' ', "uninstall |", ' ', _jsx(Text, { bold: true, color: "white", children: "\u2192" }), ' ', "components"] }) })) : (_jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, color: "white", children: "i" }), ' ', "install |", ' ', _jsx(Text, { bold: true, color: "white", children: "\u2192" }), ' ', "components"] })) }))] }));
31
57
  }
32
58
  /**
33
59
  * Single detail row with label and value
@@ -2,45 +2,13 @@
2
2
  * Component service for detecting plugin component types
3
3
  * Parses plugin.json and scans plugin directory structure to identify
4
4
  * skills, commands, agents, hooks, MCP servers, and LSP servers
5
- */
6
- import type { PluginComponents, PluginComponentsDetailed } from '../types/index.js';
7
- /**
8
- * Detect all component types for a plugin at the given install path
9
- * @param installPath - Absolute path to the installed plugin directory
10
- * @returns PluginComponents object with detected component counts
11
- * - Returns undefined values for components that are not present
12
- * - Returns counts > 0 for components that exist
13
- * @example
14
- * const components = detectPluginComponents('/path/to/plugin')
15
- * // => { skills: 5, commands: 2, mcpServers: 1 }
16
- */
17
- export declare function detectPluginComponents(installPath: string): PluginComponents | undefined;
18
- /**
19
- * Detect detailed components for an installed plugin
20
- * Reads skills/, commands/, agents/ directories and parses plugin.json
21
- * @param installPath - Absolute path to installed plugin directory
22
- * @returns Detailed component info with names and descriptions
23
- * - Returns undefined if path doesn't exist or has no components
24
- * @example
25
- * detectComponentsDetailed('/path/to/plugin')
26
- * // => { skills: [{ name: 'xlsx', description: '...', type: 'skill' }] }
27
- */
28
- export declare function detectComponentsDetailed(installPath: string): PluginComponentsDetailed | undefined;
29
- /**
30
- * Check if a plugin has any components
31
- * @param components - PluginComponents object
32
- * @returns true if at least one component type is present
33
- * @example
34
- * hasAnyComponents({ skills: 2 }) // => true
35
- * hasAnyComponents({}) // => false
36
- * hasAnyComponents(undefined) // => false
37
- */
38
- export declare function hasAnyComponents(components: PluginComponents | undefined): boolean;
39
- /**
40
- * Get total component count for a plugin
41
- * @param components - PluginComponents object
42
- * @returns Total number of components (hooks count as 1)
43
- * @example
44
- * getTotalComponentCount({ skills: 3, commands: 2, hooks: true }) // => 6
45
- */
46
- export declare function getTotalComponentCount(components: PluginComponents | undefined): number;
5
+ *
6
+ * This is a facade module that re-exports from the components/ submodules.
7
+ * For implementation details, see:
8
+ * - components/skillService.ts - Skill detection and SKILL.md parsing
9
+ * - components/markdownService.ts - Command/Agent markdown parsing
10
+ * - components/hookService.ts - Hook detection
11
+ * - components/serverService.ts - MCP/LSP server detection
12
+ * - components/utils.ts - Utility functions
13
+ */
14
+ export { detectPluginComponents, detectComponentsDetailed, parseSkillMdFull, getSkillDetailedInfo, getMarkdownComponentDetailedInfo, hasAnyComponents, getTotalComponentCount, } from './components/index.js';