@laststance/claude-plugin-dashboard 0.2.2 → 0.3.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.
@@ -1,20 +1,20 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * ComponentBadges component
4
- * Displays plugin component type badges with icons and counts
5
- * Icons: Skills(S), Commands(/), Agents(@), Hooks(H), MCP(M), LSP(L)
4
+ * Displays plugin component type badges with readable text labels and counts
5
+ * Labels: Skills, Slash, Agents, Hooks, MCP, LSP
6
6
  */
7
7
  import { Box, Text } from 'ink';
8
8
  /**
9
- * Badge configurations with intuitive abbreviations and colors
9
+ * Badge configurations with readable text labels and colors
10
10
  */
11
11
  const BADGE_CONFIGS = [
12
- { label: 'S', color: 'magenta', key: 'skills' },
13
- { label: '/', color: 'cyan', key: 'commands' },
14
- { label: '@', color: 'blue', key: 'agents' },
15
- { label: 'H', color: 'yellow', key: 'hooks', isBoolean: true },
16
- { label: 'M', color: 'green', key: 'mcpServers' },
17
- { label: 'L', color: 'blueBright', key: 'lspServers' },
12
+ { label: 'Skills', color: 'magenta', key: 'skills' },
13
+ { label: 'Slash', color: 'cyan', key: 'commands' },
14
+ { label: 'Agents', color: 'blue', key: 'agents' },
15
+ { label: 'Hooks', color: 'yellow', key: 'hooks', isBoolean: true },
16
+ { label: 'MCP', color: 'green', key: 'mcpServers' },
17
+ { label: 'LSP', color: 'blueBright', key: 'lspServers' },
18
18
  ];
19
19
  /**
20
20
  * Displays component type badges for a plugin
@@ -23,7 +23,7 @@ const BADGE_CONFIGS = [
23
23
  * @returns Badges component or null if no components
24
24
  * @example
25
25
  * <ComponentBadges components={{ skills: 5, commands: 2 }} />
26
- * // Renders: [S:5] [/:2]
26
+ * // Renders: Skills:5 Slash:2
27
27
  */
28
28
  export default function ComponentBadges({ components, }) {
29
29
  if (!components) {
@@ -43,9 +43,10 @@ export default function ComponentBadges({ components, }) {
43
43
  }
44
44
  /**
45
45
  * Single badge component
46
+ * Renders as "Label:count" without brackets for better readability
46
47
  */
47
48
  function Badge({ label, count, color, }) {
48
- return (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: "[" }), _jsx(Text, { color: color, bold: true, children: label }), count !== undefined && _jsxs(Text, { dimColor: true, children: [":", count] }), _jsx(Text, { dimColor: true, children: "]" })] }));
49
+ return (_jsxs(Text, { children: [_jsx(Text, { color: color, bold: true, children: label }), count !== undefined && _jsxs(Text, { dimColor: true, children: [":", count] })] }));
49
50
  }
50
51
  /**
51
52
  * Get a human-readable description of component types
@@ -0,0 +1,53 @@
1
+ /**
2
+ * ComponentList component
3
+ * Displays detailed plugin components in a collapsible list format
4
+ * Shows component names when available, falls back to counts
5
+ *
6
+ * Data Source Architecture:
7
+ * - Installed plugins: Names + descriptions from file system scan
8
+ * - Not installed: Names only from marketplace JSON (if available)
9
+ * - Fallback: Count-only display from PluginComponents
10
+ */
11
+ import type { PluginComponents, PluginComponentsDetailed } from '../types/index.js';
12
+ /**
13
+ * Props for ComponentList
14
+ */
15
+ export interface ComponentListProps {
16
+ /** Component counts (backward compat fallback) */
17
+ components?: PluginComponents;
18
+ /** Detailed component info with names */
19
+ componentsDetailed?: PluginComponentsDetailed;
20
+ /** Maximum visible items per category (default: 3) */
21
+ maxItems?: number;
22
+ }
23
+ /**
24
+ * Displays component details in a collapsible list
25
+ * Shows names when available, falls back to counts
26
+ * @param props - ComponentListProps
27
+ * @returns React node or null if no components
28
+ * @example
29
+ * <ComponentList
30
+ * componentsDetailed={{ skills: [{ name: 'xlsx', type: 'skill' }] }}
31
+ * maxItems={3}
32
+ * />
33
+ */
34
+ export default function ComponentList({ components, componentsDetailed, maxItems, }: ComponentListProps): React.ReactNode;
35
+ /**
36
+ * Check if PluginComponentsDetailed has any data
37
+ * @param detailed - Detailed components object
38
+ * @returns true if any category has items
39
+ * @example
40
+ * hasAnyDetailedComponents({ skills: [{ name: 'xlsx', type: 'skill' }] }) // => true
41
+ * hasAnyDetailedComponents({}) // => false
42
+ */
43
+ export declare function hasAnyDetailedComponents(detailed: PluginComponentsDetailed): boolean;
44
+ /**
45
+ * Check if PluginComponents has any count data
46
+ * @param components - Components counts object
47
+ * @returns true if any category has count > 0
48
+ * @example
49
+ * hasAnyCountComponents({ skills: 5 }) // => true
50
+ * hasAnyCountComponents({ hooks: true }) // => true
51
+ * hasAnyCountComponents({}) // => false
52
+ */
53
+ export declare function hasAnyCountComponents(components: PluginComponents): boolean;
@@ -0,0 +1,193 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * ComponentList component
4
+ * Displays detailed plugin components in a collapsible list format
5
+ * Shows component names when available, falls back to counts
6
+ *
7
+ * Data Source Architecture:
8
+ * - Installed plugins: Names + descriptions from file system scan
9
+ * - Not installed: Names only from marketplace JSON (if available)
10
+ * - Fallback: Count-only display from PluginComponents
11
+ */
12
+ import { Box, Text } from 'ink';
13
+ /**
14
+ * Category configurations with display settings
15
+ */
16
+ const CATEGORY_CONFIGS = [
17
+ {
18
+ label: 'Skills',
19
+ color: 'magenta',
20
+ detailedKey: 'skills',
21
+ countKey: 'skills',
22
+ type: 'skill',
23
+ },
24
+ {
25
+ label: 'Slash',
26
+ color: 'cyan',
27
+ detailedKey: 'commands',
28
+ countKey: 'commands',
29
+ type: 'command',
30
+ },
31
+ {
32
+ label: 'Agents',
33
+ color: 'blue',
34
+ detailedKey: 'agents',
35
+ countKey: 'agents',
36
+ type: 'agent',
37
+ },
38
+ {
39
+ label: 'MCP',
40
+ color: 'green',
41
+ detailedKey: 'mcpServers',
42
+ countKey: 'mcpServers',
43
+ type: 'mcp',
44
+ },
45
+ {
46
+ label: 'LSP',
47
+ color: 'blueBright',
48
+ detailedKey: 'lspServers',
49
+ countKey: 'lspServers',
50
+ type: 'lsp',
51
+ },
52
+ {
53
+ label: 'Hooks',
54
+ color: 'yellow',
55
+ detailedKey: 'hooks',
56
+ countKey: 'hooks',
57
+ type: 'hook',
58
+ },
59
+ ];
60
+ /**
61
+ * Default maximum visible items per category
62
+ */
63
+ const DEFAULT_MAX_ITEMS = 3;
64
+ /**
65
+ * Displays component details in a collapsible list
66
+ * Shows names when available, falls back to counts
67
+ * @param props - ComponentListProps
68
+ * @returns React node or null if no components
69
+ * @example
70
+ * <ComponentList
71
+ * componentsDetailed={{ skills: [{ name: 'xlsx', type: 'skill' }] }}
72
+ * maxItems={3}
73
+ * />
74
+ */
75
+ export default function ComponentList({ components, componentsDetailed, maxItems = DEFAULT_MAX_ITEMS, }) {
76
+ // No data at all
77
+ if (!components && !componentsDetailed) {
78
+ return null;
79
+ }
80
+ // Check if we have any components to display
81
+ const hasDetailedData = componentsDetailed && hasAnyDetailedComponents(componentsDetailed);
82
+ const hasCountData = components && hasAnyCountComponents(components);
83
+ if (!hasDetailedData && !hasCountData) {
84
+ return null;
85
+ }
86
+ 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
+ const detailedItems = componentsDetailed?.[config.detailedKey];
88
+ const count = components?.[config.countKey];
89
+ // Skip if no data for this category
90
+ if (!detailedItems?.length && !count) {
91
+ return null;
92
+ }
93
+ // Prefer detailed data, fall back to count
94
+ if (detailedItems && detailedItems.length > 0) {
95
+ // Type guard: detailedItems could be ComponentInfo[] or string[]
96
+ if (isComponentInfoArray(detailedItems)) {
97
+ return (_jsx(CategorySection, { label: config.label, color: config.color, items: detailedItems, maxItems: maxItems }, config.detailedKey));
98
+ }
99
+ else {
100
+ // String array (mcpServers, lspServers, hooks)
101
+ return (_jsx(CategorySectionSimple, { label: config.label, color: config.color, items: detailedItems, maxItems: maxItems }, config.detailedKey));
102
+ }
103
+ }
104
+ // Count-only fallback (number) or boolean hooks fallback
105
+ if (typeof count === 'number' && count > 0) {
106
+ return (_jsx(CountOnlySection, { label: config.label, color: config.color, count: count }, config.countKey));
107
+ }
108
+ // Boolean hooks fallback (hooks: true without detailed info)
109
+ if (config.countKey === 'hooks' && count === true) {
110
+ return (_jsxs(Box, { children: [_jsx(Text, { color: config.color, bold: true, children: config.label }), _jsx(Text, { dimColor: true, children: " (configured)" })] }, config.countKey));
111
+ }
112
+ return null;
113
+ })] }));
114
+ }
115
+ /**
116
+ * Single category section with collapsible ComponentInfo items
117
+ * Shows first N items, then "+M more..." for overflow
118
+ * @returns React node for the category section
119
+ */
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..."] }))] }));
124
+ }
125
+ /**
126
+ * Category section for simple string arrays (mcpServers, lspServers, hooks)
127
+ * @returns React node for the category section
128
+ */
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..."] }))] }));
133
+ }
134
+ /**
135
+ * Category section showing only count (fallback when no detailed info)
136
+ * @returns React node for the count-only section
137
+ */
138
+ function CountOnlySection({ label, color, count, }) {
139
+ return (_jsxs(Box, { children: [_jsx(Text, { color: color, bold: true, children: label }), _jsxs(Text, { dimColor: true, children: [": ", count] })] }));
140
+ }
141
+ /**
142
+ * Type guard to check if array is ComponentInfo[]
143
+ * @param arr - Array to check
144
+ * @returns true if array contains ComponentInfo objects
145
+ */
146
+ function isComponentInfoArray(arr) {
147
+ return arr.length > 0 && typeof arr[0] === 'object' && 'name' in arr[0];
148
+ }
149
+ /**
150
+ * Check if PluginComponentsDetailed has any data
151
+ * @param detailed - Detailed components object
152
+ * @returns true if any category has items
153
+ * @example
154
+ * hasAnyDetailedComponents({ skills: [{ name: 'xlsx', type: 'skill' }] }) // => true
155
+ * hasAnyDetailedComponents({}) // => false
156
+ */
157
+ export function hasAnyDetailedComponents(detailed) {
158
+ return ((detailed.skills?.length ?? 0) > 0 ||
159
+ (detailed.commands?.length ?? 0) > 0 ||
160
+ (detailed.agents?.length ?? 0) > 0 ||
161
+ (detailed.hooks?.length ?? 0) > 0 ||
162
+ (detailed.mcpServers?.length ?? 0) > 0 ||
163
+ (detailed.lspServers?.length ?? 0) > 0);
164
+ }
165
+ /**
166
+ * Check if PluginComponents has any count data
167
+ * @param components - Components counts object
168
+ * @returns true if any category has count > 0
169
+ * @example
170
+ * hasAnyCountComponents({ skills: 5 }) // => true
171
+ * hasAnyCountComponents({ hooks: true }) // => true
172
+ * hasAnyCountComponents({}) // => false
173
+ */
174
+ export function hasAnyCountComponents(components) {
175
+ return ((components.skills ?? 0) > 0 ||
176
+ (components.commands ?? 0) > 0 ||
177
+ (components.agents ?? 0) > 0 ||
178
+ components.hooks === true ||
179
+ (components.mcpServers ?? 0) > 0 ||
180
+ (components.lspServers ?? 0) > 0);
181
+ }
182
+ /**
183
+ * Truncate string to max length with ellipsis
184
+ * @param str - String to truncate
185
+ * @param maxLength - Maximum length
186
+ * @returns Truncated string
187
+ */
188
+ function truncate(str, maxLength) {
189
+ if (str.length <= maxLength) {
190
+ return str;
191
+ }
192
+ return str.slice(0, maxLength - 3) + '...';
193
+ }
@@ -4,40 +4,37 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  * Displays keyboard shortcuts footer at the bottom of the dashboard
5
5
  */
6
6
  import { Box, Text } from 'ink';
7
+ import { match } from 'ts-pattern';
7
8
  /**
8
9
  * Get base hints based on current focus zone
9
10
  * @param focusZone - Current focus zone
10
11
  * @returns Array of hint objects
11
12
  */
12
13
  function getBaseHints(focusZone) {
13
- switch (focusZone) {
14
- case 'tabbar':
15
- return [
16
- { key: '←/→', action: 'switch tabs' },
17
- { key: '', action: 'search/list' },
18
- { key: 'Tab', action: 'next tab' },
19
- { key: 'h', action: 'help' },
20
- { key: 'q or ^C', action: 'quit' },
21
- ];
22
- case 'search':
23
- return [
24
- { key: '', action: 'tabs' },
25
- { key: '↓/Enter', action: 'list' },
26
- { key: 'ESC', action: 'clear/exit' },
27
- { key: 'h', action: 'help' },
28
- { key: 'q or ^C', action: 'quit' },
29
- ];
30
- case 'list':
31
- default:
32
- return [
33
- { key: '↑/↓', action: 'navigate' },
34
- { key: '↑(top)', action: 'search' },
35
- { key: 'Space', action: 'toggle' },
36
- { key: 'Tab', action: 'next tab' },
37
- { key: 'h', action: 'help' },
38
- { key: 'q or ^C', action: 'quit' },
39
- ];
40
- }
14
+ return match(focusZone)
15
+ .with('tabbar', () => [
16
+ { key: '←/→', action: 'switch tabs' },
17
+ { key: '', action: 'search/list' },
18
+ { key: 'Tab', action: 'next tab' },
19
+ { key: 'h', action: 'help' },
20
+ { key: 'q or ^C', action: 'quit' },
21
+ ])
22
+ .with('search', () => [
23
+ { key: '', action: 'tabs' },
24
+ { key: '↓/Enter', action: 'list' },
25
+ { key: 'ESC', action: 'clear/exit' },
26
+ { key: 'h', action: 'help' },
27
+ { key: 'q or ^C', action: 'quit' },
28
+ ])
29
+ .with('list', () => [
30
+ { key: '↑/↓', action: 'navigate' },
31
+ { key: '↑(top)', action: 'search' },
32
+ { key: 'Space', action: 'toggle' },
33
+ { key: 'Tab', action: 'next tab' },
34
+ { key: 'h', action: 'help' },
35
+ { key: 'q or ^C', action: 'quit' },
36
+ ])
37
+ .exhaustive();
41
38
  }
42
39
  /**
43
40
  * Displays keyboard shortcut hints in the footer
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Action menu component for marketplace operations
3
+ * Displays selectable list of actions: Browse, Update, Auto-update toggle, Remove
4
+ */
5
+ import type { Marketplace } from '../types/index.js';
6
+ /**
7
+ * Available marketplace actions
8
+ */
9
+ export type MarketplaceAction = 'browse' | 'update' | 'autoUpdate' | 'remove';
10
+ /**
11
+ * Props for MarketplaceActionMenu component
12
+ */
13
+ export interface MarketplaceActionMenuProps {
14
+ /** Currently selected marketplace */
15
+ marketplace: Marketplace;
16
+ /** Index of selected action in menu */
17
+ selectedIndex: number;
18
+ }
19
+ /**
20
+ * MarketplaceActionMenu - Selectable action list for marketplace operations
21
+ * @param props - Component props
22
+ * @returns Action menu UI with keyboard navigation support
23
+ * @example
24
+ * <MarketplaceActionMenu
25
+ * marketplace={selectedMarketplace}
26
+ * selectedIndex={0}
27
+ * />
28
+ */
29
+ export default function MarketplaceActionMenu({ marketplace, selectedIndex, }: MarketplaceActionMenuProps): import("react/jsx-runtime").JSX.Element;
30
+ /**
31
+ * Get action at specified index
32
+ * @param marketplace - The marketplace
33
+ * @param index - Action index
34
+ * @returns The action id at the index
35
+ */
36
+ export declare function getActionAtIndex(marketplace: Marketplace, index: number): MarketplaceAction;
37
+ /**
38
+ * Get total number of actions available
39
+ * @returns Number of actions in menu
40
+ */
41
+ export declare function getActionCount(): number;
@@ -0,0 +1,68 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * Action menu component for marketplace operations
4
+ * Displays selectable list of actions: Browse, Update, Auto-update toggle, Remove
5
+ */
6
+ import { Box, Text } from 'ink';
7
+ /**
8
+ * Get action items for the marketplace
9
+ * @param marketplace - The marketplace to get actions for
10
+ * @returns Array of action items with dynamic labels
11
+ */
12
+ function getActionItems(marketplace) {
13
+ return [
14
+ {
15
+ id: 'browse',
16
+ label: `Browse plugins (${marketplace.pluginCount || 0})`,
17
+ },
18
+ {
19
+ id: 'update',
20
+ label: 'Update marketplace',
21
+ description: `last updated ${marketplace.lastUpdated}`,
22
+ },
23
+ {
24
+ id: 'autoUpdate',
25
+ label: marketplace.autoUpdate
26
+ ? 'Disable auto-update'
27
+ : 'Enable auto-update',
28
+ },
29
+ {
30
+ id: 'remove',
31
+ label: 'Remove marketplace',
32
+ },
33
+ ];
34
+ }
35
+ /**
36
+ * MarketplaceActionMenu - Selectable action list for marketplace operations
37
+ * @param props - Component props
38
+ * @returns Action menu UI with keyboard navigation support
39
+ * @example
40
+ * <MarketplaceActionMenu
41
+ * marketplace={selectedMarketplace}
42
+ * selectedIndex={0}
43
+ * />
44
+ */
45
+ export default function MarketplaceActionMenu({ marketplace, selectedIndex, }) {
46
+ const actions = getActionItems(marketplace);
47
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [actions.map((action, index) => {
48
+ const isSelected = index === selectedIndex;
49
+ return (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: isSelected ? 'cyan' : 'white', children: isSelected ? '❯' : ' ' }), _jsx(Text, { color: isSelected ? 'cyan' : 'white', bold: isSelected, children: action.label }), action.description && _jsxs(Text, { dimColor: true, children: ["(", action.description, ")"] })] }, action.id));
50
+ }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Enter to select \u00B7 escape to go back" }) })] }));
51
+ }
52
+ /**
53
+ * Get action at specified index
54
+ * @param marketplace - The marketplace
55
+ * @param index - Action index
56
+ * @returns The action id at the index
57
+ */
58
+ export function getActionAtIndex(marketplace, index) {
59
+ const actions = getActionItems(marketplace);
60
+ return actions[index]?.id ?? 'browse';
61
+ }
62
+ /**
63
+ * Get total number of actions available
64
+ * @returns Number of actions in menu
65
+ */
66
+ export function getActionCount() {
67
+ return 4; // browse, update, autoUpdate, remove
68
+ }
@@ -1,15 +1,22 @@
1
1
  /**
2
2
  * MarketplaceDetail component
3
- * Right panel showing marketplace information
3
+ * Right panel showing marketplace information and action menu
4
4
  */
5
5
  import type { Marketplace } from '../types/index.js';
6
6
  interface MarketplaceDetailProps {
7
7
  marketplace: Marketplace | null;
8
+ showActionMenu?: boolean;
9
+ actionMenuSelectedIndex?: number;
8
10
  }
9
11
  /**
10
12
  * Displays detailed information about a selected marketplace
13
+ * Shows action menu when showActionMenu is true
11
14
  * @example
12
- * <MarketplaceDetail marketplace={selectedMarketplace} />
15
+ * <MarketplaceDetail
16
+ * marketplace={selectedMarketplace}
17
+ * showActionMenu={state.showMarketplaceActionMenu}
18
+ * actionMenuSelectedIndex={state.actionMenuSelectedIndex}
19
+ * />
13
20
  */
14
- export default function MarketplaceDetail({ marketplace, }: MarketplaceDetailProps): import("react/jsx-runtime").JSX.Element;
21
+ export default function MarketplaceDetail({ marketplace, showActionMenu, actionMenuSelectedIndex, }: MarketplaceDetailProps): import("react/jsx-runtime").JSX.Element;
15
22
  export {};
@@ -1,19 +1,25 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * MarketplaceDetail component
4
- * Right panel showing marketplace information
4
+ * Right panel showing marketplace information and action menu
5
5
  */
6
6
  import { Box, Text } from 'ink';
7
+ import MarketplaceActionMenu from './MarketplaceActionMenu.js';
7
8
  /**
8
9
  * Displays detailed information about a selected marketplace
10
+ * Shows action menu when showActionMenu is true
9
11
  * @example
10
- * <MarketplaceDetail marketplace={selectedMarketplace} />
12
+ * <MarketplaceDetail
13
+ * marketplace={selectedMarketplace}
14
+ * showActionMenu={state.showMarketplaceActionMenu}
15
+ * actionMenuSelectedIndex={state.actionMenuSelectedIndex}
16
+ * />
11
17
  */
12
- export default function MarketplaceDetail({ marketplace, }) {
18
+ export default function MarketplaceDetail({ marketplace, showActionMenu = false, actionMenuSelectedIndex = 0, }) {
13
19
  if (!marketplace) {
14
20
  return (_jsx(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "gray", children: _jsx(Text, { dimColor: true, children: "Select a marketplace to view details" }) }));
15
21
  }
16
- return (_jsxs(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "cyan", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: marketplace.name || marketplace.id }) }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(DetailRow, { label: "ID", value: marketplace.id }), _jsx(DetailRow, { label: "Plugins", value: `${marketplace.pluginCount || 0}` }), _jsx(DetailRow, { label: "Source", value: marketplace.source.source }), marketplace.source.url && (_jsx(DetailRow, { label: "URL", value: marketplace.source.url })), marketplace.source.repo && (_jsx(DetailRow, { label: "Repo", value: marketplace.source.repo })), _jsx(DetailRow, { label: "Last Updated", value: formatDate(marketplace.lastUpdated) })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Install Location:" }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: marketplace.installLocation })] })] }));
22
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "cyan", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: marketplace.name || marketplace.id }) }), _jsxs(Box, { flexDirection: "column", gap: 0, children: [_jsx(DetailRow, { label: "ID", value: marketplace.id }), _jsx(DetailRow, { label: "Plugins", value: `${marketplace.pluginCount || 0}` }), _jsx(DetailRow, { label: "Source", value: marketplace.source.source }), marketplace.source.url && (_jsx(DetailRow, { label: "URL", value: marketplace.source.url })), marketplace.source.repo && (_jsx(DetailRow, { label: "Repo", value: marketplace.source.repo })), _jsx(DetailRow, { label: "Last Updated", value: formatDate(marketplace.lastUpdated) }), _jsx(DetailRow, { label: "Auto-update", value: marketplace.autoUpdate ? 'Enabled' : 'Disabled' })] }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "Install Location:" }), _jsx(Text, { dimColor: true, wrap: "truncate-end", children: marketplace.installLocation })] }), showActionMenu ? (_jsx(MarketplaceActionMenu, { marketplace: marketplace, selectedIndex: actionMenuSelectedIndex })) : (_jsx(Box, { marginTop: 1, borderStyle: "single", borderColor: "gray", paddingX: 1, children: _jsxs(Text, { dimColor: true, children: [_jsx(Text, { bold: true, color: "white", children: "Enter" }), ' ', "actions |", ' ', _jsx(Text, { bold: true, color: "white", children: "a" }), ' ', "add |", ' ', _jsx(Text, { bold: true, color: "white", children: "u" }), ' ', "update"] }) }))] }));
17
23
  }
18
24
  /**
19
25
  * Single detail row with label and value
@@ -1,6 +1,9 @@
1
1
  /**
2
2
  * PluginDetail component
3
3
  * Right panel showing detailed plugin information
4
+ *
5
+ * Uses fixed height to prevent layout jumping when switching between plugins
6
+ * with different amounts of content (e.g., varying component counts).
4
7
  */
5
8
  import type { Plugin } from '../types/index.js';
6
9
  interface PluginDetailProps {
@@ -1,11 +1,21 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * PluginDetail component
4
4
  * Right panel showing detailed plugin information
5
+ *
6
+ * Uses fixed height to prevent layout jumping when switching between plugins
7
+ * with different amounts of content (e.g., varying component counts).
5
8
  */
6
9
  import { Box, Text } from 'ink';
7
10
  import StatusIcon from './StatusIcon.js';
8
11
  import ComponentBadges from './ComponentBadges.js';
12
+ import ComponentList, { hasAnyCountComponents, hasAnyDetailedComponents, } from './ComponentList.js';
13
+ /**
14
+ * Fixed height for PluginDetail panel (in terminal lines)
15
+ * Matches PluginList's visibleCount * 2 + 2 = 26 lines
16
+ * This ensures both panels have consistent height regardless of content
17
+ */
18
+ const DETAIL_PANEL_HEIGHT = 26;
9
19
  /**
10
20
  * Displays detailed information about a selected plugin
11
21
  * @example
@@ -13,15 +23,29 @@ import ComponentBadges from './ComponentBadges.js';
13
23
  */
14
24
  export default function PluginDetail({ plugin }) {
15
25
  if (!plugin) {
16
- return (_jsx(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "gray", children: _jsx(Text, { dimColor: true, children: "Select a plugin to view details" }) }));
26
+ 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" }) }));
17
27
  }
18
- return (_jsxs(Box, { flexDirection: "column", padding: 1, borderStyle: "round", borderColor: "cyan", 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) }), plugin.category && (_jsx(DetailRow, { label: "Category", value: plugin.category })), plugin.author && (_jsx(DetailRow, { label: "Author", value: plugin.author.name })), plugin.homepage && (_jsx(DetailRow, { label: "Homepage", value: plugin.homepage })), plugin.components && (_jsxs(Box, { gap: 1, children: [_jsx(Text, { color: "gray", children: "Components:" }), _jsx(ComponentBadges, { components: plugin.components })] }))] }), _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, children: "Installed & Enabled" })) : (_jsx(Text, { color: "yellow", bold: true, children: "Installed & Disabled" }))) : (_jsx(Text, { color: "gray", children: "Not Installed" }))] }), plugin.installedAt && (_jsxs(Text, { dimColor: true, 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"] })) })] }));
28
+ 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
+ (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"] })) })] }));
19
31
  }
20
32
  /**
21
33
  * Single detail row with label and value
34
+ * Pads both label and value to fixed widths to prevent ghost text artifacts
35
+ * when content changes between selections
36
+ * @param label - Label text (e.g., "Marketplace", "Version")
37
+ * @param value - Value to display
38
+ * @returns Detail row with fixed-width padding to overwrite previous content
22
39
  */
23
40
  function DetailRow({ label, value }) {
24
- return (_jsxs(Box, { gap: 1, children: [_jsxs(Text, { color: "gray", children: [label, ":"] }), _jsx(Text, { children: value })] }));
41
+ // Fixed widths to ensure consistent line length and prevent ghost text
42
+ const LABEL_WIDTH = 12;
43
+ const VALUE_WIDTH = 40;
44
+ const paddedLabel = `${label}:`.padEnd(LABEL_WIDTH);
45
+ // Truncate long values, pad short values with spaces to overwrite old content
46
+ const truncatedValue = value.length > VALUE_WIDTH ? value.slice(0, VALUE_WIDTH - 1) + '…' : value;
47
+ const paddedValue = truncatedValue.padEnd(VALUE_WIDTH);
48
+ return (_jsxs(Text, { children: [_jsx(Text, { color: "gray", children: paddedLabel }), _jsx(Text, { children: paddedValue })] }));
25
49
  }
26
50
  /**
27
51
  * Format large numbers with K/M suffix