@laststance/claude-plugin-dashboard 0.1.1 → 0.2.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.
package/dist/app.d.ts CHANGED
@@ -2,6 +2,30 @@
2
2
  * Main App component for Claude Code Plugin Dashboard
3
3
  * Interactive TUI to browse and manage Claude Code plugins
4
4
  */
5
+ import type { AppState, Action, Plugin, FocusZone } from './types/index.js';
6
+ /**
7
+ * Initial application state
8
+ */
9
+ export declare const initialState: AppState;
10
+ /**
11
+ * Get available focus zones for the current tab
12
+ * Errors tab has no search zone since it doesn't support filtering
13
+ * @param activeTab - The currently active tab
14
+ * @returns Array of available focus zones in navigation order
15
+ */
16
+ export declare function getAvailableZones(activeTab: AppState['activeTab']): FocusZone[];
17
+ /**
18
+ * State reducer for application state management
19
+ */
20
+ export declare function appReducer(state: AppState, action: Action): AppState;
21
+ /**
22
+ * Get items array for current tab
23
+ */
24
+ export declare function getItemsForTab(state: AppState): unknown[];
25
+ /**
26
+ * Get filtered and sorted plugins for discover tab
27
+ */
28
+ export declare function getFilteredPlugins(state: AppState): Plugin[];
5
29
  /**
6
30
  * Main App component
7
31
  */
package/dist/app.js CHANGED
@@ -3,23 +3,27 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  * Main App component for Claude Code Plugin Dashboard
4
4
  * Interactive TUI to browse and manage Claude Code plugins
5
5
  */
6
- import { useState, useEffect, useReducer } from 'react';
6
+ import { useEffect, useReducer } from 'react';
7
7
  import { Box, Text, useInput, useApp } from 'ink';
8
8
  import TabBar, { getNextTab } from './components/TabBar.js';
9
9
  import KeyHints from './components/KeyHints.js';
10
10
  import DiscoverTab from './tabs/DiscoverTab.js';
11
+ import EnabledTab from './tabs/EnabledTab.js';
11
12
  import InstalledTab from './tabs/InstalledTab.js';
12
13
  import MarketplacesTab from './tabs/MarketplacesTab.js';
13
14
  import ErrorsTab from './tabs/ErrorsTab.js';
14
- import { loadAllPlugins, loadMarketplaces, searchPlugins, sortPlugins, } from './services/pluginService.js';
15
+ import { loadAllPlugins, loadMarketplaces, searchPlugins, searchMarketplaces, sortPlugins, } from './services/pluginService.js';
15
16
  import { togglePlugin } from './services/settingsService.js';
16
17
  import { installPlugin, uninstallPlugin, } from './services/pluginActionsService.js';
17
18
  import ConfirmDialog from './components/ConfirmDialog.js';
19
+ import HelpOverlay from './components/HelpOverlay.js';
20
+ import packageJson from '../package.json' with { type: 'json' };
18
21
  /**
19
22
  * Initial application state
20
23
  */
21
- const initialState = {
22
- activeTab: 'discover',
24
+ export const initialState = {
25
+ activeTab: 'enabled',
26
+ focusZone: 'list',
23
27
  plugins: [],
24
28
  marketplaces: [],
25
29
  errors: [],
@@ -33,16 +37,30 @@ const initialState = {
33
37
  operation: 'idle',
34
38
  operationPluginId: null,
35
39
  confirmUninstall: false,
40
+ showHelp: false,
36
41
  };
42
+ /**
43
+ * Get available focus zones for the current tab
44
+ * Errors tab has no search zone since it doesn't support filtering
45
+ * @param activeTab - The currently active tab
46
+ * @returns Array of available focus zones in navigation order
47
+ */
48
+ export function getAvailableZones(activeTab) {
49
+ if (activeTab === 'errors') {
50
+ return ['tabbar', 'list'];
51
+ }
52
+ return ['tabbar', 'search', 'list'];
53
+ }
37
54
  /**
38
55
  * State reducer for application state management
39
56
  */
40
- function appReducer(state, action) {
57
+ export function appReducer(state, action) {
41
58
  switch (action.type) {
42
59
  case 'SET_TAB':
43
60
  return {
44
61
  ...state,
45
62
  activeTab: action.payload,
63
+ focusZone: 'list',
46
64
  selectedIndex: 0,
47
65
  searchQuery: '',
48
66
  message: null,
@@ -51,6 +69,7 @@ function appReducer(state, action) {
51
69
  return {
52
70
  ...state,
53
71
  activeTab: getNextTab(state.activeTab, 'next'),
72
+ focusZone: 'list',
54
73
  selectedIndex: 0,
55
74
  searchQuery: '',
56
75
  message: null,
@@ -59,10 +78,16 @@ function appReducer(state, action) {
59
78
  return {
60
79
  ...state,
61
80
  activeTab: getNextTab(state.activeTab, 'prev'),
81
+ focusZone: 'list',
62
82
  selectedIndex: 0,
63
83
  searchQuery: '',
64
84
  message: null,
65
85
  };
86
+ case 'SET_FOCUS_ZONE':
87
+ return {
88
+ ...state,
89
+ focusZone: action.payload,
90
+ };
66
91
  case 'SET_PLUGINS':
67
92
  return {
68
93
  ...state,
@@ -180,6 +205,11 @@ function appReducer(state, action) {
180
205
  confirmUninstall: false,
181
206
  operationPluginId: null,
182
207
  };
208
+ case 'TOGGLE_HELP':
209
+ return {
210
+ ...state,
211
+ showHelp: !state.showHelp,
212
+ };
183
213
  default:
184
214
  return state;
185
215
  }
@@ -187,12 +217,14 @@ function appReducer(state, action) {
187
217
  /**
188
218
  * Get items array for current tab
189
219
  */
190
- function getItemsForTab(state) {
220
+ export function getItemsForTab(state) {
191
221
  switch (state.activeTab) {
192
- case 'discover':
193
- return getFilteredPlugins(state);
222
+ case 'enabled':
223
+ return state.plugins.filter((p) => p.isInstalled && p.isEnabled);
194
224
  case 'installed':
195
225
  return state.plugins.filter((p) => p.isInstalled);
226
+ case 'discover':
227
+ return getFilteredPlugins(state);
196
228
  case 'marketplaces':
197
229
  return state.marketplaces;
198
230
  case 'errors':
@@ -204,7 +236,7 @@ function getItemsForTab(state) {
204
236
  /**
205
237
  * Get filtered and sorted plugins for discover tab
206
238
  */
207
- function getFilteredPlugins(state) {
239
+ export function getFilteredPlugins(state) {
208
240
  let plugins = state.plugins;
209
241
  // Apply search filter
210
242
  if (state.searchQuery) {
@@ -220,7 +252,6 @@ function getFilteredPlugins(state) {
220
252
  export default function App() {
221
253
  const { exit } = useApp();
222
254
  const [state, dispatch] = useReducer(appReducer, initialState);
223
- const [isSearchMode, setIsSearchMode] = useState(false);
224
255
  // Load data on mount
225
256
  useEffect(() => {
226
257
  try {
@@ -288,6 +319,23 @@ export default function App() {
288
319
  if (state.operation !== 'idle') {
289
320
  return;
290
321
  }
322
+ // Handle help overlay
323
+ if (state.showHelp) {
324
+ if (input === 'h' || key.escape) {
325
+ dispatch({ type: 'TOGGLE_HELP' });
326
+ }
327
+ return;
328
+ }
329
+ // Exit (q or Ctrl+C) - Global handler, works in all focus zones
330
+ if (input === 'q' || (key.ctrl && input === 'c')) {
331
+ exit();
332
+ return;
333
+ }
334
+ // Toggle help (h key)
335
+ if (input === 'h') {
336
+ dispatch({ type: 'TOGGLE_HELP' });
337
+ return;
338
+ }
291
339
  // Handle confirmation dialog
292
340
  if (state.confirmUninstall && state.operationPluginId) {
293
341
  if (input === 'y' || input === 'Y') {
@@ -302,10 +350,19 @@ export default function App() {
302
350
  }
303
351
  return;
304
352
  }
305
- // Search mode input
306
- if (isSearchMode) {
307
- if (key.escape || key.return) {
308
- setIsSearchMode(false);
353
+ // Search mode input (when focusZone is 'search')
354
+ if (state.focusZone === 'search') {
355
+ // Up arrow: move focus to tabbar
356
+ if (key.upArrow || (key.ctrl && input === 'p')) {
357
+ dispatch({ type: 'SET_FOCUS_ZONE', payload: 'tabbar' });
358
+ return;
359
+ }
360
+ // Down arrow, Enter, or Escape: move focus to list
361
+ if (key.escape ||
362
+ key.return ||
363
+ key.downArrow ||
364
+ (key.ctrl && input === 'n')) {
365
+ dispatch({ type: 'SET_FOCUS_ZONE', payload: 'list' });
309
366
  return;
310
367
  }
311
368
  if (key.backspace || key.delete) {
@@ -324,48 +381,124 @@ export default function App() {
324
381
  }
325
382
  return;
326
383
  }
327
- // Emacs-style navigation (Ctrl+P / Ctrl+N)
328
- if (key.ctrl && input === 'p') {
329
- dispatch({ type: 'MOVE_SELECTION', payload: 'up' });
330
- return;
331
- }
332
- if (key.ctrl && input === 'n') {
333
- dispatch({ type: 'MOVE_SELECTION', payload: 'down' });
384
+ // TabBar focus zone navigation
385
+ if (state.focusZone === 'tabbar') {
386
+ // Down arrow: move to search (or list if no search)
387
+ if (key.downArrow || (key.ctrl && input === 'n')) {
388
+ const zones = getAvailableZones(state.activeTab);
389
+ dispatch({
390
+ type: 'SET_FOCUS_ZONE',
391
+ payload: zones.includes('search') ? 'search' : 'list',
392
+ });
393
+ return;
394
+ }
395
+ // Left/Right arrows and Ctrl+B/F: tab switching (only in tabbar)
396
+ // Keep focus on tabbar after navigation
397
+ if (key.leftArrow || (key.ctrl && input === 'b')) {
398
+ dispatch({ type: 'PREV_TAB' });
399
+ dispatch({ type: 'SET_FOCUS_ZONE', payload: 'tabbar' });
400
+ return;
401
+ }
402
+ if (key.rightArrow || (key.ctrl && input === 'f')) {
403
+ dispatch({ type: 'NEXT_TAB' });
404
+ dispatch({ type: 'SET_FOCUS_ZONE', payload: 'tabbar' });
405
+ return;
406
+ }
407
+ // Tab key: next tab (resets focus to list)
408
+ if (key.tab) {
409
+ dispatch({ type: 'NEXT_TAB' });
410
+ return;
411
+ }
334
412
  return;
335
413
  }
336
- // Tab navigation
337
- if (key.leftArrow) {
338
- dispatch({ type: 'PREV_TAB' });
414
+ // List focus zone navigation (default zone)
415
+ // Up arrow: move up in list or focus search/tabbar at top
416
+ if (key.upArrow || (key.ctrl && input === 'p')) {
417
+ if (state.selectedIndex === 0) {
418
+ // At top of list: move focus to search (or tabbar if no search)
419
+ const zones = getAvailableZones(state.activeTab);
420
+ dispatch({
421
+ type: 'SET_FOCUS_ZONE',
422
+ payload: zones.includes('search') ? 'search' : 'tabbar',
423
+ });
424
+ }
425
+ else {
426
+ dispatch({ type: 'MOVE_SELECTION', payload: 'up' });
427
+ }
339
428
  return;
340
429
  }
341
- if (key.rightArrow) {
342
- dispatch({ type: 'NEXT_TAB' });
430
+ // Down arrow: move down in list
431
+ if (key.downArrow || (key.ctrl && input === 'n')) {
432
+ dispatch({ type: 'MOVE_SELECTION', payload: 'down' });
343
433
  return;
344
434
  }
435
+ // Tab key: next tab (from list zone)
345
436
  if (key.tab) {
346
437
  dispatch({ type: 'NEXT_TAB' });
347
438
  return;
348
439
  }
349
- // List navigation
350
- if (key.upArrow) {
351
- dispatch({ type: 'MOVE_SELECTION', payload: 'up' });
440
+ // Enter search mode (/ key on supported tabs)
441
+ const searchEnabledTabs = [
442
+ 'enabled',
443
+ 'installed',
444
+ 'discover',
445
+ 'marketplaces',
446
+ ];
447
+ if (input === '/' && searchEnabledTabs.includes(state.activeTab)) {
448
+ dispatch({ type: 'SET_FOCUS_ZONE', payload: 'search' });
352
449
  return;
353
450
  }
354
- if (key.downArrow) {
355
- dispatch({ type: 'MOVE_SELECTION', payload: 'down' });
356
- return;
357
- }
358
- // Enter search mode
359
- if (input === '/' && state.activeTab === 'discover') {
360
- setIsSearchMode(true);
451
+ // Enter key: Install (non-installed) or Toggle (installed)
452
+ if (key.return &&
453
+ (state.activeTab === 'enabled' ||
454
+ state.activeTab === 'installed' ||
455
+ state.activeTab === 'discover')) {
456
+ const items = state.activeTab === 'enabled'
457
+ ? state.plugins.filter((p) => p.isInstalled && p.isEnabled)
458
+ : state.activeTab === 'installed'
459
+ ? state.plugins.filter((p) => p.isInstalled)
460
+ : getFilteredPlugins(state);
461
+ const selectedPlugin = items[state.selectedIndex];
462
+ if (selectedPlugin) {
463
+ if (!selectedPlugin.isInstalled) {
464
+ // Install non-installed plugin
465
+ handleInstall(selectedPlugin.id);
466
+ }
467
+ else {
468
+ // Toggle installed plugin
469
+ try {
470
+ const newState = togglePlugin(selectedPlugin.id);
471
+ dispatch({
472
+ type: 'TOGGLE_PLUGIN_ENABLED',
473
+ payload: selectedPlugin.id,
474
+ });
475
+ dispatch({
476
+ type: 'SET_MESSAGE',
477
+ payload: newState
478
+ ? `✅ ${selectedPlugin.name} enabled`
479
+ : `❌ ${selectedPlugin.name} disabled`,
480
+ });
481
+ }
482
+ catch (error) {
483
+ dispatch({
484
+ type: 'SET_MESSAGE',
485
+ payload: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`,
486
+ });
487
+ }
488
+ }
489
+ }
361
490
  return;
362
491
  }
363
- // Toggle plugin (Space or Enter)
364
- if ((input === ' ' || key.return) &&
365
- (state.activeTab === 'discover' || state.activeTab === 'installed')) {
366
- const items = state.activeTab === 'installed'
367
- ? state.plugins.filter((p) => p.isInstalled)
368
- : getFilteredPlugins(state);
492
+ // Toggle plugin (Space only)
493
+ if (input === ' ' &&
494
+ (state.activeTab === 'enabled' ||
495
+ state.activeTab === 'installed' ||
496
+ state.activeTab === 'discover')) {
497
+ const items = state.activeTab === 'enabled'
498
+ ? state.plugins.filter((p) => p.isInstalled && p.isEnabled)
499
+ : state.activeTab === 'installed'
500
+ ? state.plugins.filter((p) => p.isInstalled)
501
+ : getFilteredPlugins(state);
369
502
  const selectedPlugin = items[state.selectedIndex];
370
503
  if (selectedPlugin && selectedPlugin.isInstalled) {
371
504
  try {
@@ -419,12 +552,16 @@ export default function App() {
419
552
  dispatch({ type: 'SET_SEARCH_QUERY', payload: '' });
420
553
  return;
421
554
  }
422
- // Install (i key) - only on discover/installed tabs
555
+ // Install (i key) - only on enabled/installed/discover tabs
423
556
  if (input === 'i' &&
424
- (state.activeTab === 'discover' || state.activeTab === 'installed')) {
425
- const items = state.activeTab === 'installed'
426
- ? state.plugins.filter((p) => p.isInstalled)
427
- : getFilteredPlugins(state);
557
+ (state.activeTab === 'enabled' ||
558
+ state.activeTab === 'installed' ||
559
+ state.activeTab === 'discover')) {
560
+ const items = state.activeTab === 'enabled'
561
+ ? state.plugins.filter((p) => p.isInstalled && p.isEnabled)
562
+ : state.activeTab === 'installed'
563
+ ? state.plugins.filter((p) => p.isInstalled)
564
+ : getFilteredPlugins(state);
428
565
  const selectedPlugin = items[state.selectedIndex];
429
566
  if (selectedPlugin && !selectedPlugin.isInstalled) {
430
567
  handleInstall(selectedPlugin.id);
@@ -437,12 +574,16 @@ export default function App() {
437
574
  }
438
575
  return;
439
576
  }
440
- // Uninstall (u key) - only on discover/installed tabs
577
+ // Uninstall (u key) - only on enabled/installed/discover tabs
441
578
  if (input === 'u' &&
442
- (state.activeTab === 'discover' || state.activeTab === 'installed')) {
443
- const items = state.activeTab === 'installed'
444
- ? state.plugins.filter((p) => p.isInstalled)
445
- : getFilteredPlugins(state);
579
+ (state.activeTab === 'enabled' ||
580
+ state.activeTab === 'installed' ||
581
+ state.activeTab === 'discover')) {
582
+ const items = state.activeTab === 'enabled'
583
+ ? state.plugins.filter((p) => p.isInstalled && p.isEnabled)
584
+ : state.activeTab === 'installed'
585
+ ? state.plugins.filter((p) => p.isInstalled)
586
+ : getFilteredPlugins(state);
446
587
  const selectedPlugin = items[state.selectedIndex];
447
588
  if (selectedPlugin && selectedPlugin.isInstalled) {
448
589
  dispatch({ type: 'SHOW_CONFIRM_UNINSTALL', payload: selectedPlugin.id });
@@ -452,11 +593,6 @@ export default function App() {
452
593
  }
453
594
  return;
454
595
  }
455
- // Exit (q or Ctrl+C)
456
- if (input === 'q' || (key.ctrl && input === 'c')) {
457
- exit();
458
- return;
459
- }
460
596
  });
461
597
  // Loading state
462
598
  if (state.loading) {
@@ -468,14 +604,65 @@ export default function App() {
468
604
  }
469
605
  // Get filtered data for current tab
470
606
  const filteredPlugins = getFilteredPlugins(state);
471
- const installedPlugins = state.plugins.filter((p) => p.isInstalled);
472
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsx(Text, { bold: true, color: "magenta", children: "\u26A1 Claude Code Plugin Dashboard" }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { dimColor: true, children: "v0.1.0" })] }), _jsx(TabBar, { activeTab: state.activeTab }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [state.activeTab === 'discover' && (_jsx(DiscoverTab, { plugins: filteredPlugins, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, sortBy: state.sortBy, sortOrder: state.sortOrder, isSearchMode: isSearchMode })), state.activeTab === 'installed' && (_jsx(InstalledTab, { plugins: installedPlugins, selectedIndex: state.selectedIndex })), state.activeTab === 'marketplaces' && (_jsx(MarketplacesTab, { marketplaces: state.marketplaces, selectedIndex: state.selectedIndex })), state.activeTab === 'errors' && (_jsx(ErrorsTab, { errors: state.errors, selectedIndex: state.selectedIndex }))] }), state.confirmUninstall && state.operationPluginId && (_jsx(ConfirmDialog, { message: `Uninstall ${state.operationPluginId}?` })), state.message && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: state.message }) })), _jsx(KeyHints, { extraHints: state.activeTab === 'discover' || state.activeTab === 'installed'
473
- ? [
474
- { key: 'i', action: 'install' },
475
- { key: 'u', action: 'uninstall' },
476
- ...(state.activeTab === 'discover'
477
- ? [{ key: 's', action: 'sort' }]
478
- : []),
479
- ]
480
- : undefined })] }));
607
+ // Apply search filter to enabled plugins
608
+ const enabledPluginsBase = state.plugins.filter((p) => p.isInstalled && p.isEnabled);
609
+ const enabledPlugins = state.searchQuery
610
+ ? searchPlugins(state.searchQuery, enabledPluginsBase)
611
+ : enabledPluginsBase;
612
+ // Apply search filter to installed plugins
613
+ const installedPluginsBase = state.plugins.filter((p) => p.isInstalled);
614
+ const installedPlugins = state.searchQuery
615
+ ? searchPlugins(state.searchQuery, installedPluginsBase)
616
+ : installedPluginsBase;
617
+ // Apply search filter to marketplaces
618
+ const filteredMarketplaces = state.searchQuery
619
+ ? searchMarketplaces(state.searchQuery, state.marketplaces)
620
+ : state.marketplaces;
621
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsx(Text, { bold: true, color: "magenta", children: "\u26A1 Claude Code Plugin Dashboard" }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { dimColor: true, children: ["v", packageJson.version] })] }), _jsx(TabBar, { activeTab: state.activeTab, isFocused: state.focusZone === 'tabbar' }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [state.activeTab === 'enabled' && (_jsx(EnabledTab, { plugins: enabledPlugins, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, focusZone: state.focusZone })), state.activeTab === 'installed' && (_jsx(InstalledTab, { plugins: installedPlugins, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, focusZone: state.focusZone })), state.activeTab === 'discover' && (_jsx(DiscoverTab, { plugins: filteredPlugins, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, sortBy: state.sortBy, sortOrder: state.sortOrder, focusZone: state.focusZone })), state.activeTab === 'marketplaces' && (_jsx(MarketplacesTab, { marketplaces: filteredMarketplaces, selectedIndex: state.selectedIndex, searchQuery: state.searchQuery, focusZone: state.focusZone })), state.activeTab === 'errors' && (_jsx(ErrorsTab, { errors: state.errors, selectedIndex: state.selectedIndex }))] }), state.confirmUninstall && state.operationPluginId && (_jsx(ConfirmDialog, { message: `Uninstall ${state.operationPluginId}?` })), _jsx(HelpOverlay, { isVisible: state.showHelp }), state.message && (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: "yellow", children: state.message }) })), _jsx(KeyHints, { focusZone: state.focusZone, extraHints: (() => {
622
+ // Search mode - no extra hints (base hints cover it)
623
+ if (state.focusZone === 'search') {
624
+ return undefined;
625
+ }
626
+ // TabBar mode - no extra hints
627
+ if (state.focusZone === 'tabbar') {
628
+ return undefined;
629
+ }
630
+ // List mode - add tab-specific hints
631
+ // Plugin tabs hints (enabled, installed, discover)
632
+ if (state.activeTab === 'enabled' ||
633
+ state.activeTab === 'installed' ||
634
+ state.activeTab === 'discover') {
635
+ const hints = [
636
+ { key: '/', action: 'search' },
637
+ { key: 'i', action: 'install' },
638
+ { key: 'u', action: 'uninstall' },
639
+ ];
640
+ // Get selected plugin to determine Enter action
641
+ const items = state.activeTab === 'enabled'
642
+ ? enabledPlugins
643
+ : state.activeTab === 'installed'
644
+ ? installedPlugins
645
+ : filteredPlugins;
646
+ const selectedPlugin = items[state.selectedIndex];
647
+ // Add contextual Enter hint
648
+ if (selectedPlugin) {
649
+ if (!selectedPlugin.isInstalled) {
650
+ hints.push({ key: 'Enter', action: 'install' });
651
+ }
652
+ else {
653
+ hints.push({ key: 'Enter', action: 'toggle' });
654
+ }
655
+ }
656
+ // Add sort hint for discover tab
657
+ if (state.activeTab === 'discover') {
658
+ hints.push({ key: 's', action: 'sort' });
659
+ }
660
+ return hints;
661
+ }
662
+ // Marketplaces tab hints
663
+ if (state.activeTab === 'marketplaces') {
664
+ return [{ key: '/', action: 'search' }];
665
+ }
666
+ return undefined;
667
+ })() })] }));
481
668
  }
@@ -0,0 +1,15 @@
1
+ /**
2
+ * HelpOverlay component
3
+ * Displays a full-screen overlay with all available keyboard shortcuts
4
+ */
5
+ interface HelpOverlayProps {
6
+ /** Whether the overlay is visible */
7
+ isVisible: boolean;
8
+ }
9
+ /**
10
+ * Full-screen help overlay showing all keyboard shortcuts
11
+ * @example
12
+ * <HelpOverlay isVisible={showHelp} />
13
+ */
14
+ export default function HelpOverlay({ isVisible }: HelpOverlayProps): import("react/jsx-runtime").JSX.Element | null;
15
+ export {};
@@ -0,0 +1,51 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ /**
3
+ * HelpOverlay component
4
+ * Displays a full-screen overlay with all available keyboard shortcuts
5
+ */
6
+ import { Box, Text } from 'ink';
7
+ const helpSections = [
8
+ {
9
+ title: 'Navigation',
10
+ items: [
11
+ { key: '←/→, Tab', description: 'Switch tabs' },
12
+ { key: '↑/↓, ^P/^N', description: 'Navigate list' },
13
+ { key: '^F/^B', description: 'Switch tabs (Emacs)' },
14
+ ],
15
+ },
16
+ {
17
+ title: 'Actions',
18
+ items: [
19
+ { key: 'i, Enter', description: 'Install / Toggle plugin' },
20
+ { key: 'u', description: 'Uninstall plugin' },
21
+ { key: 'Space', description: 'Toggle enable/disable' },
22
+ { key: 's/S', description: 'Sort options / order' },
23
+ ],
24
+ },
25
+ {
26
+ title: 'Search',
27
+ items: [
28
+ { key: '/', description: 'Enter search mode' },
29
+ { key: 'Esc, ↓', description: 'Exit search mode' },
30
+ ],
31
+ },
32
+ {
33
+ title: 'General',
34
+ items: [
35
+ { key: 'q, ^C', description: 'Quit' },
36
+ { key: 'h', description: 'Toggle this help' },
37
+ ],
38
+ },
39
+ ];
40
+ /**
41
+ * Full-screen help overlay showing all keyboard shortcuts
42
+ * @example
43
+ * <HelpOverlay isVisible={showHelp} />
44
+ */
45
+ export default function HelpOverlay({ isVisible }) {
46
+ if (!isVisible) {
47
+ return null;
48
+ }
49
+ const keyWidth = 14;
50
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "double", borderColor: "cyan", paddingX: 2, paddingY: 1, children: [_jsx(Box, { justifyContent: "center", marginBottom: 1, children: _jsx(Text, { bold: true, color: "cyan", children: "\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 Help \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500" }) }), helpSections.map((section) => (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsx(Text, { bold: true, color: "yellow", children: section.title }), section.items.map((item, itemIndex) => (_jsxs(Box, { children: [_jsx(Box, { width: keyWidth, children: _jsx(Text, { color: "green", children: item.key.padEnd(keyWidth - 2) }) }), _jsx(Text, { dimColor: true, children: item.description })] }, itemIndex)))] }, section.title))), _jsx(Box, { justifyContent: "center", marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press h or Esc to close" }) })] }));
51
+ }
@@ -2,18 +2,21 @@
2
2
  * KeyHints component
3
3
  * Displays keyboard shortcuts footer at the bottom of the dashboard
4
4
  */
5
+ import type { FocusZone } from '../types/index.js';
5
6
  interface KeyHintsProps {
6
7
  /** Additional context-specific hints */
7
8
  extraHints?: Array<{
8
9
  key: string;
9
10
  action: string;
10
11
  }>;
12
+ /** Current focus zone for context-aware hints */
13
+ focusZone?: FocusZone;
11
14
  }
12
15
  /**
13
16
  * Displays keyboard shortcut hints in the footer
14
17
  * @example
15
- * <KeyHints />
16
- * <KeyHints extraHints={[{ key: 'i', action: 'install' }]} />
18
+ * <KeyHints focusZone="list" />
19
+ * <KeyHints focusZone="search" extraHints={[{ key: 'i', action: 'install' }]} />
17
20
  */
18
- export default function KeyHints({ extraHints }: KeyHintsProps): import("react/jsx-runtime").JSX.Element;
21
+ export default function KeyHints({ extraHints, focusZone, }: KeyHintsProps): import("react/jsx-runtime").JSX.Element;
19
22
  export {};
@@ -4,20 +4,49 @@ 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
+ /**
8
+ * Get base hints based on current focus zone
9
+ * @param focusZone - Current focus zone
10
+ * @returns Array of hint objects
11
+ */
12
+ 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
+ }
41
+ }
7
42
  /**
8
43
  * Displays keyboard shortcut hints in the footer
9
44
  * @example
10
- * <KeyHints />
11
- * <KeyHints extraHints={[{ key: 'i', action: 'install' }]} />
45
+ * <KeyHints focusZone="list" />
46
+ * <KeyHints focusZone="search" extraHints={[{ key: 'i', action: 'install' }]} />
12
47
  */
13
- export default function KeyHints({ extraHints }) {
14
- const baseHints = [
15
- { key: '←/→', action: 'tabs' },
16
- { key: '↑/↓', action: 'navigate' },
17
- { key: 'Space', action: 'toggle' },
18
- { key: '/', action: 'search' },
19
- { key: 'q', action: 'quit' },
20
- ];
48
+ export default function KeyHints({ extraHints, focusZone = 'list', }) {
49
+ const baseHints = getBaseHints(focusZone);
21
50
  const allHints = extraHints ? [...baseHints, ...extraHints] : baseHints;
22
51
  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))) }) }));
23
52
  }
@@ -6,11 +6,13 @@ import type { Marketplace } from '../types/index.js';
6
6
  interface MarketplaceListProps {
7
7
  marketplaces: Marketplace[];
8
8
  selectedIndex: number;
9
+ /** Whether the list has keyboard focus */
10
+ isFocused?: boolean;
9
11
  }
10
12
  /**
11
13
  * Displays a list of marketplaces
12
14
  * @example
13
- * <MarketplaceList marketplaces={marketplaces} selectedIndex={0} />
15
+ * <MarketplaceList marketplaces={marketplaces} selectedIndex={0} isFocused={true} />
14
16
  */
15
- export default function MarketplaceList({ marketplaces, selectedIndex, }: MarketplaceListProps): import("react/jsx-runtime").JSX.Element;
17
+ export default function MarketplaceList({ marketplaces, selectedIndex, isFocused, }: MarketplaceListProps): import("react/jsx-runtime").JSX.Element;
16
18
  export {};
@@ -7,15 +7,19 @@ import { Box, Text } from 'ink';
7
7
  /**
8
8
  * Displays a list of marketplaces
9
9
  * @example
10
- * <MarketplaceList marketplaces={marketplaces} selectedIndex={0} />
10
+ * <MarketplaceList marketplaces={marketplaces} selectedIndex={0} isFocused={true} />
11
11
  */
12
- export default function MarketplaceList({ marketplaces, selectedIndex, }) {
12
+ export default function MarketplaceList({ marketplaces, selectedIndex, isFocused = true, }) {
13
13
  if (marketplaces.length === 0) {
14
14
  return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "gray", children: "No marketplaces found" }) }));
15
15
  }
16
16
  return (_jsx(Box, { flexDirection: "column", children: marketplaces.map((marketplace, index) => {
17
17
  const isSelected = index === selectedIndex;
18
- return (_jsxs(Box, { paddingX: 1, children: [_jsx(Box, { width: 2, children: isSelected ? _jsx(Text, { color: "cyan", children: '>' }) : _jsx(Text, { children: " " }) }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, color: isSelected ? 'cyan' : 'white', children: marketplace.name || marketplace.id }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsxs(Text, { color: "gray", children: [marketplace.pluginCount || 0, " plugins"] })] }), _jsx(Text, { dimColor: true, wrap: "truncate", children: getSourceDisplay(marketplace) })] })] }, marketplace.id));
18
+ return (_jsxs(Box, { paddingX: 1, children: [_jsx(Box, { width: 2, children: isSelected && isFocused ? (_jsx(Text, { color: "cyan", children: '>' })) : isSelected ? (_jsx(Text, { color: "gray", children: '›' })) : (_jsx(Text, { children: " " })) }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, color: isSelected && isFocused
19
+ ? 'cyan'
20
+ : isSelected
21
+ ? 'gray'
22
+ : 'white', children: marketplace.name || marketplace.id }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsxs(Text, { color: "gray", children: [marketplace.pluginCount || 0, " plugins"] })] }), _jsx(Text, { dimColor: true, wrap: "truncate", children: getSourceDisplay(marketplace) })] })] }, marketplace.id));
19
23
  }) }));
20
24
  }
21
25
  /**
@@ -9,11 +9,38 @@ interface PluginListProps {
9
9
  selectedIndex: number;
10
10
  /** Maximum visible items (for virtual scrolling) */
11
11
  visibleCount?: number;
12
+ /** Whether the list has keyboard focus */
13
+ isFocused?: boolean;
12
14
  }
13
15
  /**
14
16
  * Scrollable plugin list with selection
15
17
  * @example
16
- * <PluginList plugins={plugins} selectedIndex={0} visibleCount={15} />
18
+ * <PluginList plugins={plugins} selectedIndex={0} visibleCount={15} isFocused={true} />
17
19
  */
18
- export default function PluginList({ plugins, selectedIndex, visibleCount, }: PluginListProps): import("react/jsx-runtime").JSX.Element;
20
+ export default function PluginList({ plugins, selectedIndex, visibleCount, isFocused, }: PluginListProps): import("react/jsx-runtime").JSX.Element;
21
+ /**
22
+ * Truncate text to max length with ellipsis
23
+ * @param text - The text to truncate
24
+ * @param maxLength - Maximum length including ellipsis
25
+ * @returns
26
+ * - Original text if within maxLength
27
+ * - Truncated text with "..." suffix if exceeds maxLength
28
+ * @example
29
+ * truncate('Hello', 10) // => 'Hello'
30
+ * truncate('Hello World', 8) // => 'Hello...'
31
+ */
32
+ export declare function truncate(text: string, maxLength: number): string;
33
+ /**
34
+ * Format large numbers with K/M suffix
35
+ * @param count - The number to format
36
+ * @returns
37
+ * - "X.XM" for millions (>= 1,000,000)
38
+ * - "X.XK" for thousands (>= 1,000)
39
+ * - String representation for smaller numbers
40
+ * @example
41
+ * formatCount(1500) // => '1.5K'
42
+ * formatCount(1200000) // => '1.2M'
43
+ * formatCount(500) // => '500'
44
+ */
45
+ export declare function formatCount(count: number): string;
19
46
  export {};
@@ -9,9 +9,9 @@ import StatusIcon from './StatusIcon.js';
9
9
  /**
10
10
  * Scrollable plugin list with selection
11
11
  * @example
12
- * <PluginList plugins={plugins} selectedIndex={0} visibleCount={15} />
12
+ * <PluginList plugins={plugins} selectedIndex={0} visibleCount={15} isFocused={true} />
13
13
  */
14
- export default function PluginList({ plugins, selectedIndex, visibleCount = 15, }) {
14
+ export default function PluginList({ plugins, selectedIndex, visibleCount = 15, isFocused = true, }) {
15
15
  if (plugins.length === 0) {
16
16
  return (_jsx(Box, { padding: 1, children: _jsx(Text, { color: "gray", children: "No plugins found" }) }));
17
17
  }
@@ -29,21 +29,42 @@ export default function PluginList({ plugins, selectedIndex, visibleCount = 15,
29
29
  return (_jsxs(Box, { flexDirection: "column", children: [hasPrevious && _jsxs(Text, { dimColor: true, children: ["\u2191 ", startIndex, " more above"] }), visiblePlugins.map((plugin, index) => {
30
30
  const actualIndex = startIndex + index;
31
31
  const isSelected = actualIndex === selectedIndex;
32
- return (_jsxs(Box, { paddingX: 1, children: [_jsx(Box, { width: 2, children: isSelected ? _jsx(Text, { color: "cyan", children: '>' }) : _jsx(Text, { children: " " }) }), _jsx(Box, { width: 2, children: _jsx(StatusIcon, { isInstalled: plugin.isInstalled, isEnabled: plugin.isEnabled }) }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, color: isSelected ? 'cyan' : 'white', children: plugin.name }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { color: "gray", children: truncate(plugin.marketplace, 20) }), plugin.installCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "\u00B7" }), _jsxs(Text, { color: "gray", children: [formatCount(plugin.installCount), " installs"] })] }))] }), _jsx(Text, { dimColor: true, wrap: "truncate", children: truncate(plugin.description, 60) })] })] }, plugin.id));
32
+ return (_jsxs(Box, { paddingX: 1, children: [_jsx(Box, { width: 2, children: isSelected && isFocused ? (_jsx(Text, { color: "cyan", children: '>' })) : isSelected ? (_jsx(Text, { color: "gray", children: '›' })) : (_jsx(Text, { children: " " })) }), _jsx(Box, { width: 2, children: _jsx(StatusIcon, { isInstalled: plugin.isInstalled, isEnabled: plugin.isEnabled }) }), _jsxs(Box, { flexGrow: 1, flexDirection: "column", children: [_jsxs(Box, { gap: 1, children: [_jsx(Text, { bold: true, color: isSelected && isFocused
33
+ ? 'cyan'
34
+ : isSelected
35
+ ? 'gray'
36
+ : 'white', children: plugin.name }), _jsx(Text, { dimColor: true, children: "\u00B7" }), _jsx(Text, { color: "gray", children: truncate(plugin.marketplace, 20) }), plugin.installCount > 0 && (_jsxs(_Fragment, { children: [_jsx(Text, { dimColor: true, children: "\u00B7" }), _jsxs(Text, { color: "gray", children: [formatCount(plugin.installCount), " installs"] })] }))] }), _jsx(Text, { dimColor: true, wrap: "truncate", children: truncate(plugin.description, 60) })] })] }, plugin.id));
33
37
  }), hasMore && (_jsxs(Text, { dimColor: true, children: ["\u2193 ", plugins.length - endIndex, " more below"] }))] }));
34
38
  }
35
39
  /**
36
40
  * Truncate text to max length with ellipsis
41
+ * @param text - The text to truncate
42
+ * @param maxLength - Maximum length including ellipsis
43
+ * @returns
44
+ * - Original text if within maxLength
45
+ * - Truncated text with "..." suffix if exceeds maxLength
46
+ * @example
47
+ * truncate('Hello', 10) // => 'Hello'
48
+ * truncate('Hello World', 8) // => 'Hello...'
37
49
  */
38
- function truncate(text, maxLength) {
50
+ export function truncate(text, maxLength) {
39
51
  if (text.length <= maxLength)
40
52
  return text;
41
53
  return text.slice(0, maxLength - 3) + '...';
42
54
  }
43
55
  /**
44
56
  * Format large numbers with K/M suffix
57
+ * @param count - The number to format
58
+ * @returns
59
+ * - "X.XM" for millions (>= 1,000,000)
60
+ * - "X.XK" for thousands (>= 1,000)
61
+ * - String representation for smaller numbers
62
+ * @example
63
+ * formatCount(1500) // => '1.5K'
64
+ * formatCount(1200000) // => '1.2M'
65
+ * formatCount(500) // => '500'
45
66
  */
46
- function formatCount(count) {
67
+ export function formatCount(count) {
47
68
  if (count >= 1000000) {
48
69
  return `${(count / 1000000).toFixed(1)}M`;
49
70
  }
@@ -10,5 +10,5 @@ import { Box, Text } from 'ink';
10
10
  * <SearchInput query={searchQuery} isActive={isSearchMode} />
11
11
  */
12
12
  export default function SearchInput({ query, isActive = false, placeholder = 'Type to search...', }) {
13
- return (_jsxs(Box, { borderStyle: isActive ? 'round' : 'single', borderColor: isActive ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(Text, { color: isActive ? 'cyan' : 'gray', children: "Q " }), query ? _jsx(Text, { children: query }) : _jsx(Text, { dimColor: true, children: placeholder }), isActive && _jsx(Text, { color: "cyan", children: "\u258C" })] }));
13
+ return (_jsxs(Box, { borderStyle: isActive ? 'round' : 'single', borderColor: isActive ? 'cyan' : 'gray', paddingX: 1, children: [_jsx(Text, { color: isActive ? 'cyan' : 'gray', children: "\uD83D\uDD0D " }), query ? _jsx(Text, { children: query }) : _jsx(Text, { dimColor: true, children: placeholder }), isActive && _jsx(Text, { color: "cyan", children: "\u258C" })] }));
14
14
  }
@@ -3,17 +3,19 @@
3
3
  * Horizontal tab navigation for the dashboard
4
4
  * Supports ← → arrow key navigation
5
5
  */
6
- type Tab = 'discover' | 'installed' | 'marketplaces' | 'errors';
6
+ type Tab = 'enabled' | 'installed' | 'discover' | 'marketplaces' | 'errors';
7
7
  interface TabBarProps {
8
8
  activeTab: Tab;
9
9
  onTabChange?: (tab: Tab) => void;
10
+ /** Whether the tab bar has keyboard focus */
11
+ isFocused?: boolean;
10
12
  }
11
13
  /**
12
14
  * Horizontal tab bar component
13
15
  * @example
14
- * <TabBar activeTab="discover" onTabChange={setActiveTab} />
16
+ * <TabBar activeTab="discover" isFocused={true} />
15
17
  */
16
- export default function TabBar({ activeTab }: TabBarProps): import("react/jsx-runtime").JSX.Element;
18
+ export default function TabBar({ activeTab, isFocused }: TabBarProps): import("react/jsx-runtime").JSX.Element;
17
19
  /**
18
20
  * Get the next tab in the cycle
19
21
  * @param currentTab - Current active tab
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * TabBar component
4
4
  * Horizontal tab navigation for the dashboard
@@ -6,21 +6,33 @@ import { jsx as _jsx } from "react/jsx-runtime";
6
6
  */
7
7
  import { Box, Text } from 'ink';
8
8
  const TABS = [
9
- { id: 'discover', label: 'Discover' },
9
+ { id: 'enabled', label: 'Enabled' },
10
10
  { id: 'installed', label: 'Installed' },
11
+ { id: 'discover', label: 'Discover' },
11
12
  { id: 'marketplaces', label: 'Marketplaces' },
12
13
  { id: 'errors', label: 'Errors' },
13
14
  ];
15
+ /** Color constants for consistent theming */
16
+ const COLORS = {
17
+ /** Background color when tab bar is focused */
18
+ FOCUS_BG: '#1a3a4a',
19
+ /** Background color for active tab (not focused) */
20
+ ACTIVE_BG: '#333333',
21
+ /** Foreground color for active/focused elements */
22
+ ACTIVE_FG: 'cyan',
23
+ /** Foreground color for inactive elements */
24
+ INACTIVE_FG: 'gray',
25
+ };
14
26
  /**
15
27
  * Horizontal tab bar component
16
28
  * @example
17
- * <TabBar activeTab="discover" onTabChange={setActiveTab} />
29
+ * <TabBar activeTab="discover" isFocused={true} />
18
30
  */
19
- export default function TabBar({ activeTab }) {
20
- return (_jsx(Box, { gap: 2, marginBottom: 1, children: TABS.map((tab) => {
21
- const isActive = tab.id === activeTab;
22
- return (_jsx(Box, { children: isActive ? (_jsx(Text, { bold: true, color: "cyan", backgroundColor: "#333333", children: ` ${tab.label} ` })) : (_jsx(Text, { color: "gray", children: ` ${tab.label} ` })) }, tab.id));
23
- }) }));
31
+ export default function TabBar({ activeTab, isFocused = false }) {
32
+ return (_jsxs(Box, { gap: 2, marginBottom: 1, children: [isFocused && _jsx(Text, { color: COLORS.ACTIVE_FG, children: "\u25B6" }), TABS.map((tab) => {
33
+ const isActive = tab.id === activeTab;
34
+ return (_jsx(Box, { children: isActive ? (_jsx(Text, { bold: true, color: COLORS.ACTIVE_FG, backgroundColor: isFocused ? COLORS.FOCUS_BG : COLORS.ACTIVE_BG, children: isFocused ? `[${tab.label}]` : ` ${tab.label} ` })) : (_jsx(Text, { color: COLORS.INACTIVE_FG, children: ` ${tab.label} ` })) }, tab.id));
35
+ })] }));
24
36
  }
25
37
  /**
26
38
  * Get the next tab in the cycle
@@ -54,6 +54,16 @@ export declare function searchPlugins(query: string, plugins?: Plugin[]): Plugin
54
54
  * @returns Sorted plugins array
55
55
  */
56
56
  export declare function sortPlugins(plugins: Plugin[], sortBy: 'installs' | 'name' | 'date', order: 'asc' | 'desc'): Plugin[];
57
+ /**
58
+ * Search marketplaces by query
59
+ * Filters marketplaces by name, id, and source URL/repo
60
+ * @param query - Search query
61
+ * @param marketplaces - Marketplaces to search
62
+ * @returns Filtered marketplaces matching the query
63
+ * @example
64
+ * searchMarketplaces('official', marketplaces) // => marketplaces with 'official' in name/id
65
+ */
66
+ export declare function searchMarketplaces(query: string, marketplaces: Marketplace[]): Marketplace[];
57
67
  /**
58
68
  * Get plugin statistics
59
69
  * @returns Object with various plugin counts
@@ -172,6 +172,22 @@ export function sortPlugins(plugins, sortBy, order) {
172
172
  });
173
173
  return sorted;
174
174
  }
175
+ /**
176
+ * Search marketplaces by query
177
+ * Filters marketplaces by name, id, and source URL/repo
178
+ * @param query - Search query
179
+ * @param marketplaces - Marketplaces to search
180
+ * @returns Filtered marketplaces matching the query
181
+ * @example
182
+ * searchMarketplaces('official', marketplaces) // => marketplaces with 'official' in name/id
183
+ */
184
+ export function searchMarketplaces(query, marketplaces) {
185
+ const lowerQuery = query.toLowerCase();
186
+ return marketplaces.filter((m) => m.name.toLowerCase().includes(lowerQuery) ||
187
+ m.id.toLowerCase().includes(lowerQuery) ||
188
+ m.source.url?.toLowerCase().includes(lowerQuery) ||
189
+ m.source.repo?.toLowerCase().includes(lowerQuery));
190
+ }
175
191
  /**
176
192
  * Get plugin statistics
177
193
  * @returns Object with various plugin counts
@@ -2,14 +2,15 @@
2
2
  * DiscoverTab component
3
3
  * Browse all available plugins from all marketplaces
4
4
  */
5
- import type { Plugin, AppState } from '../types/index.js';
5
+ import type { Plugin, AppState, FocusZone } from '../types/index.js';
6
6
  interface DiscoverTabProps {
7
7
  plugins: Plugin[];
8
8
  selectedIndex: number;
9
9
  searchQuery: string;
10
10
  sortBy: AppState['sortBy'];
11
11
  sortOrder: AppState['sortOrder'];
12
- isSearchMode?: boolean;
12
+ /** Current focus zone for keyboard navigation */
13
+ focusZone?: FocusZone;
13
14
  }
14
15
  /**
15
16
  * Discover tab - browse all plugins
@@ -20,7 +21,8 @@ interface DiscoverTabProps {
20
21
  * searchQuery={state.searchQuery}
21
22
  * sortBy={state.sortBy}
22
23
  * sortOrder={state.sortOrder}
24
+ * focusZone="list"
23
25
  * />
24
26
  */
25
- export default function DiscoverTab({ plugins, selectedIndex, searchQuery, sortBy, sortOrder, isSearchMode, }: DiscoverTabProps): import("react/jsx-runtime").JSX.Element;
27
+ export default function DiscoverTab({ plugins, selectedIndex, searchQuery, sortBy, sortOrder, focusZone, }: DiscoverTabProps): import("react/jsx-runtime").JSX.Element;
26
28
  export {};
@@ -17,9 +17,10 @@ import SortDropdown from '../components/SortDropdown.js';
17
17
  * searchQuery={state.searchQuery}
18
18
  * sortBy={state.sortBy}
19
19
  * sortOrder={state.sortOrder}
20
+ * focusZone="list"
20
21
  * />
21
22
  */
22
- export default function DiscoverTab({ plugins, selectedIndex, searchQuery, sortBy, sortOrder, isSearchMode = false, }) {
23
+ export default function DiscoverTab({ plugins, selectedIndex, searchQuery, sortBy, sortOrder, focusZone = 'list', }) {
23
24
  const selectedPlugin = plugins[selectedIndex] ?? null;
24
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, children: ["Discover plugins (", plugins.length > 0 ? `${selectedIndex + 1}/${plugins.length}` : '0', ")"] }), _jsx(Box, { flexGrow: 1 }), _jsx(SortDropdown, { sortBy: sortBy, sortOrder: sortOrder })] }), _jsx(Box, { marginBottom: 1, children: _jsx(SearchInput, { query: searchQuery, isActive: isSearchMode, placeholder: "Type to search..." }) }), _jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: "50%", flexDirection: "column", children: _jsx(PluginList, { plugins: plugins, selectedIndex: selectedIndex, visibleCount: 12 }) }), _jsx(Box, { width: "50%", flexDirection: "column", children: _jsx(PluginDetail, { plugin: selectedPlugin }) })] })] }));
25
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, children: ["Discover plugins (", plugins.length > 0 ? `${selectedIndex + 1}/${plugins.length}` : '0', ")"] }), _jsx(Box, { flexGrow: 1 }), _jsx(SortDropdown, { sortBy: sortBy, sortOrder: sortOrder })] }), _jsx(Box, { marginBottom: 1, children: _jsx(SearchInput, { query: searchQuery, isActive: focusZone === 'search', placeholder: "Type to search..." }) }), _jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: "50%", flexDirection: "column", children: _jsx(PluginList, { plugins: plugins, selectedIndex: selectedIndex, visibleCount: 12, isFocused: focusZone === 'list' }) }), _jsx(Box, { width: "50%", flexDirection: "column", children: _jsx(PluginDetail, { plugin: selectedPlugin }) })] })] }));
25
26
  }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * EnabledTab component
3
+ * View and manage enabled plugins (installed + enabled)
4
+ */
5
+ import type { Plugin, FocusZone } from '../types/index.js';
6
+ interface EnabledTabProps {
7
+ plugins: Plugin[];
8
+ selectedIndex: number;
9
+ searchQuery?: string;
10
+ /** Current focus zone for keyboard navigation */
11
+ focusZone?: FocusZone;
12
+ }
13
+ /**
14
+ * Enabled tab - view currently active plugins
15
+ * @param plugins - Filtered enabled plugins (search already applied by parent)
16
+ * @param selectedIndex - Currently selected item index
17
+ * @param searchQuery - Current search query string
18
+ * @param focusZone - Current focus zone for keyboard navigation
19
+ * @returns Enabled tab component
20
+ * @example
21
+ * <EnabledTab plugins={enabledPlugins} selectedIndex={0} searchQuery="" focusZone="list" />
22
+ */
23
+ export default function EnabledTab({ plugins, selectedIndex, searchQuery, focusZone, }: EnabledTabProps): import("react/jsx-runtime").JSX.Element;
24
+ export {};
@@ -0,0 +1,26 @@
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
+ /**
3
+ * EnabledTab component
4
+ * View and manage enabled plugins (installed + enabled)
5
+ */
6
+ import { Box, Text } from 'ink';
7
+ import PluginList from '../components/PluginList.js';
8
+ import PluginDetail from '../components/PluginDetail.js';
9
+ import SearchInput from '../components/SearchInput.js';
10
+ /**
11
+ * Enabled tab - view currently active plugins
12
+ * @param plugins - Filtered enabled plugins (search already applied by parent)
13
+ * @param selectedIndex - Currently selected item index
14
+ * @param searchQuery - Current search query string
15
+ * @param focusZone - Current focus zone for keyboard navigation
16
+ * @returns Enabled tab component
17
+ * @example
18
+ * <EnabledTab plugins={enabledPlugins} selectedIndex={0} searchQuery="" focusZone="list" />
19
+ */
20
+ export default function EnabledTab({ plugins, selectedIndex, searchQuery = '', focusZone = 'list', }) {
21
+ // Plugins are already filtered by parent, use directly
22
+ const selectedPlugin = plugins[selectedIndex] ?? null;
23
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, children: ["Enabled plugins (", plugins.length > 0 ? `${selectedIndex + 1}/${plugins.length}` : '0', ")"] }), _jsx(Box, { flexGrow: 1 }), _jsx(Text, { dimColor: true, children: "Currently active in Claude Code" })] }), _jsx(Box, { marginBottom: 1, children: _jsx(SearchInput, { query: searchQuery, isActive: focusZone === 'search', placeholder: "Type to search enabled plugins..." }) }), _jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: "50%", flexDirection: "column", children: plugins.length === 0 ? (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: searchQuery ? 'No matching plugins' : 'No enabled plugins' }), _jsx(Text, { dimColor: true, children: searchQuery
24
+ ? 'Try a different search term'
25
+ : 'Enable plugins in the Installed tab or use /plugin enable' })] })) : (_jsx(PluginList, { plugins: plugins, selectedIndex: selectedIndex, visibleCount: 12, isFocused: focusZone === 'list' })) }), _jsx(Box, { width: "50%", flexDirection: "column", children: _jsx(PluginDetail, { plugin: selectedPlugin }) })] })] }));
26
+ }
@@ -2,15 +2,22 @@
2
2
  * InstalledTab component
3
3
  * View and manage installed plugins
4
4
  */
5
- import type { Plugin } from '../types/index.js';
5
+ import type { Plugin, FocusZone } from '../types/index.js';
6
6
  interface InstalledTabProps {
7
7
  plugins: Plugin[];
8
8
  selectedIndex: number;
9
+ searchQuery?: string;
10
+ /** Current focus zone for keyboard navigation */
11
+ focusZone?: FocusZone;
9
12
  }
10
13
  /**
11
14
  * Installed tab - manage installed plugins
15
+ * @param plugins - Filtered installed plugins (search already applied by parent)
16
+ * @param selectedIndex - Currently selected item index
17
+ * @param searchQuery - Current search query string
18
+ * @param focusZone - Current focus zone for keyboard navigation
12
19
  * @example
13
- * <InstalledTab plugins={installedPlugins} selectedIndex={0} />
20
+ * <InstalledTab plugins={installedPlugins} selectedIndex={0} searchQuery="" focusZone="list" />
14
21
  */
15
- export default function InstalledTab({ plugins, selectedIndex, }: InstalledTabProps): import("react/jsx-runtime").JSX.Element;
22
+ export default function InstalledTab({ plugins, selectedIndex, searchQuery, focusZone, }: InstalledTabProps): import("react/jsx-runtime").JSX.Element;
16
23
  export {};
@@ -6,19 +6,23 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
6
6
  import { Box, Text } from 'ink';
7
7
  import PluginList from '../components/PluginList.js';
8
8
  import PluginDetail from '../components/PluginDetail.js';
9
+ import SearchInput from '../components/SearchInput.js';
9
10
  /**
10
11
  * Installed tab - manage installed plugins
12
+ * @param plugins - Filtered installed plugins (search already applied by parent)
13
+ * @param selectedIndex - Currently selected item index
14
+ * @param searchQuery - Current search query string
15
+ * @param focusZone - Current focus zone for keyboard navigation
11
16
  * @example
12
- * <InstalledTab plugins={installedPlugins} selectedIndex={0} />
17
+ * <InstalledTab plugins={installedPlugins} selectedIndex={0} searchQuery="" focusZone="list" />
13
18
  */
14
- export default function InstalledTab({ plugins, selectedIndex, }) {
15
- // Filter to installed plugins only
16
- const installedPlugins = plugins.filter((p) => p.isInstalled);
17
- const selectedPlugin = installedPlugins[selectedIndex] ?? null;
19
+ export default function InstalledTab({ plugins, selectedIndex, searchQuery = '', focusZone = 'list', }) {
20
+ // Plugins are already filtered by parent, use directly
21
+ const selectedPlugin = plugins[selectedIndex] ?? null;
18
22
  // Count enabled/disabled
19
- const enabledCount = installedPlugins.filter((p) => p.isEnabled).length;
20
- const disabledCount = installedPlugins.length - enabledCount;
21
- return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, children: ["Installed plugins (", installedPlugins.length > 0
22
- ? `${selectedIndex + 1}/${installedPlugins.length}`
23
- : '0', ")"] }), _jsx(Box, { flexGrow: 1 }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { color: "green", children: ["\u25CF ", enabledCount, " enabled"] }), _jsxs(Text, { color: "yellow", children: ["\u25D0 ", disabledCount, " disabled"] })] })] }), _jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: "50%", flexDirection: "column", children: installedPlugins.length === 0 ? (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "No plugins installed" }), _jsxs(Text, { dimColor: true, children: ["Use the Discover tab or", ' ', _jsx(Text, { color: "white", children: "/plugin install" }), " in Claude Code"] })] })) : (_jsx(PluginList, { plugins: installedPlugins, selectedIndex: selectedIndex, visibleCount: 12 })) }), _jsx(Box, { width: "50%", flexDirection: "column", children: _jsx(PluginDetail, { plugin: selectedPlugin }) })] })] }));
23
+ const enabledCount = plugins.filter((p) => p.isEnabled).length;
24
+ const disabledCount = plugins.length - enabledCount;
25
+ return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, children: ["Installed plugins (", plugins.length > 0 ? `${selectedIndex + 1}/${plugins.length}` : '0', ")"] }), _jsx(Box, { flexGrow: 1 }), _jsxs(Box, { gap: 2, children: [_jsxs(Text, { color: "green", children: ["\u25CF ", enabledCount, " enabled"] }), _jsxs(Text, { color: "yellow", children: ["\u25D0 ", disabledCount, " disabled"] })] })] }), _jsx(Box, { marginBottom: 1, children: _jsx(SearchInput, { query: searchQuery, isActive: focusZone === 'search', placeholder: "Type to search installed plugins..." }) }), _jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: "50%", flexDirection: "column", children: plugins.length === 0 ? (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: searchQuery ? 'No matching plugins' : 'No plugins installed' }), _jsx(Text, { dimColor: true, children: searchQuery
26
+ ? 'Try a different search term'
27
+ : 'Use the Discover tab or /plugin install in Claude Code' })] })) : (_jsx(PluginList, { plugins: plugins, selectedIndex: selectedIndex, visibleCount: 12, isFocused: focusZone === 'list' })) }), _jsx(Box, { width: "50%", flexDirection: "column", children: _jsx(PluginDetail, { plugin: selectedPlugin }) })] })] }));
24
28
  }
@@ -2,15 +2,22 @@
2
2
  * MarketplacesTab component
3
3
  * View and manage marketplace sources
4
4
  */
5
- import type { Marketplace } from '../types/index.js';
5
+ import type { Marketplace, FocusZone } from '../types/index.js';
6
6
  interface MarketplacesTabProps {
7
7
  marketplaces: Marketplace[];
8
8
  selectedIndex: number;
9
+ searchQuery?: string;
10
+ /** Current focus zone for keyboard navigation */
11
+ focusZone?: FocusZone;
9
12
  }
10
13
  /**
11
14
  * Marketplaces tab - manage plugin sources
15
+ * @param marketplaces - Filtered marketplaces (search already applied by parent)
16
+ * @param selectedIndex - Currently selected item index
17
+ * @param searchQuery - Current search query string
18
+ * @param focusZone - Current focus zone for keyboard navigation
12
19
  * @example
13
- * <MarketplacesTab marketplaces={marketplaces} selectedIndex={0} />
20
+ * <MarketplacesTab marketplaces={marketplaces} selectedIndex={0} searchQuery="" focusZone="list" />
14
21
  */
15
- export default function MarketplacesTab({ marketplaces, selectedIndex, }: MarketplacesTabProps): import("react/jsx-runtime").JSX.Element;
22
+ export default function MarketplacesTab({ marketplaces, selectedIndex, searchQuery, focusZone, }: MarketplacesTabProps): import("react/jsx-runtime").JSX.Element;
16
23
  export {};
@@ -6,16 +6,25 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
6
6
  import { Box, Text } from 'ink';
7
7
  import MarketplaceList from '../components/MarketplaceList.js';
8
8
  import MarketplaceDetail from '../components/MarketplaceDetail.js';
9
+ import SearchInput from '../components/SearchInput.js';
9
10
  /**
10
11
  * Marketplaces tab - manage plugin sources
12
+ * @param marketplaces - Filtered marketplaces (search already applied by parent)
13
+ * @param selectedIndex - Currently selected item index
14
+ * @param searchQuery - Current search query string
15
+ * @param focusZone - Current focus zone for keyboard navigation
11
16
  * @example
12
- * <MarketplacesTab marketplaces={marketplaces} selectedIndex={0} />
17
+ * <MarketplacesTab marketplaces={marketplaces} selectedIndex={0} searchQuery="" focusZone="list" />
13
18
  */
14
- export default function MarketplacesTab({ marketplaces, selectedIndex, }) {
19
+ export default function MarketplacesTab({ marketplaces, selectedIndex, searchQuery = '', focusZone = 'list', }) {
15
20
  const selectedMarketplace = marketplaces[selectedIndex] ?? null;
16
21
  // Count total plugins across all marketplaces
17
22
  const totalPlugins = marketplaces.reduce((sum, m) => sum + (m.pluginCount || 0), 0);
18
23
  return (_jsxs(Box, { flexDirection: "column", flexGrow: 1, children: [_jsxs(Box, { marginBottom: 1, gap: 2, children: [_jsxs(Text, { bold: true, children: ["Marketplaces (", marketplaces.length > 0
19
24
  ? `${selectedIndex + 1}/${marketplaces.length}`
20
- : '0', ")"] }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { color: "gray", children: [totalPlugins, " total plugins"] })] }), _jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: "50%", flexDirection: "column", children: marketplaces.length === 0 ? (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: "No marketplaces found" }), _jsxs(Text, { dimColor: true, children: ["Add marketplaces with", ' ', _jsx(Text, { color: "white", children: "/plugin add-marketplace" })] })] })) : (_jsx(MarketplaceList, { marketplaces: marketplaces, selectedIndex: selectedIndex })) }), _jsx(Box, { width: "50%", flexDirection: "column", children: _jsx(MarketplaceDetail, { marketplace: selectedMarketplace }) })] })] }));
25
+ : '0', ")"] }), _jsx(Box, { flexGrow: 1 }), _jsxs(Text, { color: "gray", children: [totalPlugins, " total plugins"] })] }), _jsx(Box, { marginBottom: 1, children: _jsx(SearchInput, { query: searchQuery, isActive: focusZone === 'search', placeholder: "Type to search marketplaces..." }) }), _jsxs(Box, { flexGrow: 1, children: [_jsx(Box, { width: "50%", flexDirection: "column", children: marketplaces.length === 0 ? (_jsxs(Box, { padding: 1, flexDirection: "column", children: [_jsx(Text, { color: "gray", children: searchQuery
26
+ ? 'No matching marketplaces'
27
+ : 'No marketplaces found' }), _jsx(Text, { dimColor: true, children: searchQuery
28
+ ? 'Try a different search term'
29
+ : 'Add marketplaces with /plugin add-marketplace' })] })) : (_jsx(MarketplaceList, { marketplaces: marketplaces, selectedIndex: selectedIndex, isFocused: focusZone === 'list' })) }), _jsx(Box, { width: "50%", flexDirection: "column", children: _jsx(MarketplaceDetail, { marketplace: selectedMarketplace }) })] })] }));
21
30
  }
@@ -152,12 +152,19 @@ export interface Settings {
152
152
  enabledPlugins?: Record<string, boolean>;
153
153
  [key: string]: unknown;
154
154
  }
155
+ /**
156
+ * Focus zones for keyboard navigation
157
+ * Defines which UI area currently has keyboard focus
158
+ */
159
+ export type FocusZone = 'tabbar' | 'search' | 'list';
155
160
  /**
156
161
  * Application state for useReducer
157
162
  */
158
163
  export interface AppState {
159
164
  /** Current active tab */
160
- activeTab: 'discover' | 'installed' | 'marketplaces' | 'errors';
165
+ activeTab: 'enabled' | 'installed' | 'discover' | 'marketplaces' | 'errors';
166
+ /** Current focus zone for keyboard navigation */
167
+ focusZone: FocusZone;
161
168
  /** All plugins from all marketplaces */
162
169
  plugins: Plugin[];
163
170
  /** All marketplaces */
@@ -184,6 +191,8 @@ export interface AppState {
184
191
  operationPluginId: string | null;
185
192
  /** Whether confirmation dialog is showing */
186
193
  confirmUninstall: boolean;
194
+ /** Whether help overlay is showing */
195
+ showHelp: boolean;
187
196
  }
188
197
  /**
189
198
  * Action types for useReducer
@@ -247,4 +256,9 @@ export type Action = {
247
256
  payload: string;
248
257
  } | {
249
258
  type: 'HIDE_CONFIRM_UNINSTALL';
259
+ } | {
260
+ type: 'TOGGLE_HELP';
261
+ } | {
262
+ type: 'SET_FOCUS_ZONE';
263
+ payload: FocusZone;
250
264
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laststance/claude-plugin-dashboard",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Interactive CLI dashboard to manage Claude Code plugins",
5
5
  "license": "MIT",
6
6
  "author": "Ryota Murakami <ryota.murakami@laststance.io> (https://github.com/ryota-murakami)",
@@ -24,6 +24,8 @@
24
24
  "bin": {
25
25
  "claude-plugin-dashboard": "./dist/cli.js"
26
26
  },
27
+ "main": "./dist/app.js",
28
+ "types": "./dist/app.d.ts",
27
29
  "type": "module",
28
30
  "engines": {
29
31
  "node": ">=20"
@@ -42,7 +44,10 @@
42
44
  "link": "pnpm build && pnpm link --global",
43
45
  "prepublishOnly": "pnpm build",
44
46
  "prepare": "husky",
45
- "prettier": "prettier --ignore-unknown --write ."
47
+ "prettier": "prettier --ignore-unknown --write .",
48
+ "test": "vitest",
49
+ "test:run": "vitest run",
50
+ "test:coverage": "vitest run --coverage"
46
51
  },
47
52
  "dependencies": {
48
53
  "ink": "^5.1.0",
@@ -52,9 +57,12 @@
52
57
  "@sindresorhus/tsconfig": "^6.0.0",
53
58
  "@types/node": "^22.10.2",
54
59
  "@types/react": "^18.3.18",
60
+ "@vitest/coverage-v8": "^4.0.16",
55
61
  "husky": "^9.1.7",
62
+ "ink-testing-library": "^4.0.0",
56
63
  "lint-staged": "^16.2.7",
57
64
  "prettier": "^3.7.4",
58
- "typescript": "^5.7.2"
65
+ "typescript": "^5.7.2",
66
+ "vitest": "^4.0.16"
59
67
  }
60
- }
68
+ }