feat: add select ui for claude marketplace

This commit is contained in:
LaZzyMan 2026-01-23 16:23:30 +08:00
parent 674bb6386e
commit 9af9ea259d
31 changed files with 1388 additions and 286 deletions

View file

@ -12,6 +12,7 @@ import { FolderTrustDialog } from './FolderTrustDialog.js';
import { ShellConfirmationDialog } from './ShellConfirmationDialog.js';
import { ConsentPrompt } from './ConsentPrompt.js';
import { SettingInputPrompt } from './SettingInputPrompt.js';
import { PluginChoicePrompt } from './PluginChoicePrompt.js';
import { ThemeDialog } from './ThemeDialog.js';
import { SettingsDialog } from './SettingsDialog.js';
import { QwenOAuthProgress } from './QwenOAuthProgress.js';
@ -147,6 +148,19 @@ export const DialogManager = ({
/>
);
}
if (uiState.pluginChoiceRequests.length > 0) {
const request = uiState.pluginChoiceRequests[0];
return (
<PluginChoicePrompt
key={request.marketplaceName}
marketplaceName={request.marketplaceName}
plugins={request.plugins}
onSelect={request.onSelect}
onCancel={request.onCancel}
terminalWidth={terminalWidth}
/>
);
}
if (uiState.isThemeDialogOpen) {
return (
<Box flexDirection="column">

View file

@ -0,0 +1,243 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render } from 'ink-testing-library';
import { PluginChoicePrompt } from './PluginChoicePrompt.js';
import { useKeypress } from '../hooks/useKeypress.js';
vi.mock('../hooks/useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
const mockedUseKeypress = vi.mocked(useKeypress);
describe('PluginChoicePrompt', () => {
const onSelect = vi.fn();
const onCancel = vi.fn();
const terminalWidth = 80;
beforeEach(() => {
vi.clearAllMocks();
});
describe('rendering', () => {
it('renders marketplace name in title', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test-marketplace"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('test-marketplace');
});
it('renders plugin names', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1', description: 'First plugin' },
{ name: 'plugin2', description: 'Second plugin' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('plugin1');
expect(lastFrame()).toContain('plugin2');
});
it('renders description for selected plugin only', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1', description: 'First plugin description' },
{ name: 'plugin2', description: 'Second plugin description' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// First plugin is selected by default, should show its description
expect(lastFrame()).toContain('First plugin description');
});
it('renders help text', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('↑↓');
expect(lastFrame()).toContain('Enter');
expect(lastFrame()).toContain('Escape');
});
});
describe('scrolling behavior', () => {
it('does not show scroll indicators for small lists', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1' },
{ name: 'plugin2' },
{ name: 'plugin3' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).not.toContain('more above');
expect(lastFrame()).not.toContain('more below');
});
it('shows "more below" indicator for long lists', () => {
const plugins = Array.from({ length: 15 }, (_, i) => ({
name: `plugin${i + 1}`,
}));
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={plugins}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// At the beginning, should show "more below" but not "more above"
expect(lastFrame()).not.toContain('more above');
expect(lastFrame()).toContain('more below');
});
it('shows progress indicator for long lists', () => {
const plugins = Array.from({ length: 15 }, (_, i) => ({
name: `plugin${i + 1}`,
}));
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={plugins}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
// Should show progress like "(1/15)"
expect(lastFrame()).toContain('(1/15)');
});
});
describe('keyboard navigation', () => {
it('registers keypress handler', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(mockedUseKeypress).toHaveBeenCalledWith(expect.any(Function), {
isActive: true,
});
});
it('calls onCancel when escape is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'escape', sequence: '\x1b' } as never);
expect(onCancel).toHaveBeenCalled();
});
it('calls onSelect with plugin name when enter is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'test-plugin' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: 'return', sequence: '\r' } as never);
expect(onSelect).toHaveBeenCalledWith('test-plugin');
});
it('calls onSelect with correct plugin when number key 1-9 is pressed', () => {
render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[
{ name: 'plugin1' },
{ name: 'plugin2' },
{ name: 'plugin3' },
]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
const keypressHandler = mockedUseKeypress.mock.calls[0][0];
keypressHandler({ name: '2', sequence: '2' } as never);
expect(onSelect).toHaveBeenCalledWith('plugin2');
});
});
describe('selection indicator', () => {
it('shows selection indicator for first plugin by default', () => {
const { lastFrame } = render(
<PluginChoicePrompt
marketplaceName="test"
plugins={[{ name: 'plugin1' }, { name: 'plugin2' }]}
onSelect={onSelect}
onCancel={onCancel}
terminalWidth={terminalWidth}
/>,
);
expect(lastFrame()).toContain('');
});
});
});

View file

@ -0,0 +1,195 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { useState, useCallback, useMemo } from 'react';
import { theme } from '../semantic-colors.js';
import { t } from '../../i18n/index.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
interface PluginChoice {
name: string;
description?: string;
}
type PluginChoicePromptProps = {
marketplaceName: string;
plugins: PluginChoice[];
onSelect: (pluginName: string) => void;
onCancel: () => void;
terminalWidth: number;
};
// Maximum number of visible items in the list
const MAX_VISIBLE_ITEMS = 8;
export const PluginChoicePrompt = (props: PluginChoicePromptProps) => {
const { marketplaceName, plugins, onSelect, onCancel } = props;
const [selectedIndex, setSelectedIndex] = useState(0);
const prefixWidth = 2; // " " or " "
const handleKeypress = useCallback(
(key: Key) => {
const { name, sequence } = key;
if (name === 'escape') {
onCancel();
return;
}
if (name === 'return') {
const plugin = plugins[selectedIndex];
if (plugin) {
onSelect(plugin.name);
}
return;
}
// Navigate up
if (name === 'up' || sequence === 'k') {
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : plugins.length - 1));
return;
}
// Navigate down
if (name === 'down' || sequence === 'j') {
setSelectedIndex((prev) => (prev < plugins.length - 1 ? prev + 1 : 0));
return;
}
// Number shortcuts (1-9)
const num = parseInt(sequence || '', 10);
if (!isNaN(num) && num >= 1 && num <= plugins.length && num <= 9) {
setSelectedIndex(num - 1);
const plugin = plugins[num - 1];
if (plugin) {
onSelect(plugin.name);
}
}
},
[plugins, selectedIndex, onSelect, onCancel],
);
useKeypress(handleKeypress, { isActive: true });
// Calculate visible range for scrolling
const { visiblePlugins, startIndex, hasMore, hasLess } = useMemo(() => {
const total = plugins.length;
if (total <= MAX_VISIBLE_ITEMS) {
return {
visiblePlugins: plugins,
startIndex: 0,
hasMore: false,
hasLess: false,
};
}
// Calculate window position to keep selected item visible
let start = 0;
const halfWindow = Math.floor(MAX_VISIBLE_ITEMS / 2);
if (selectedIndex <= halfWindow) {
// Near the beginning
start = 0;
} else if (selectedIndex >= total - halfWindow) {
// Near the end
start = total - MAX_VISIBLE_ITEMS;
} else {
// In the middle - center on selected
start = selectedIndex - halfWindow;
}
const end = Math.min(start + MAX_VISIBLE_ITEMS, total);
return {
visiblePlugins: plugins.slice(start, end),
startIndex: start,
hasLess: start > 0,
hasMore: end < total,
};
}, [plugins, selectedIndex]);
return (
<Box
borderStyle="round"
borderColor={theme.border.default}
flexDirection="column"
paddingY={1}
paddingX={2}
width="100%"
>
<Text bold color={theme.text.accent}>
{t('Select a plugin from "{{name}}"', { name: marketplaceName })}
</Text>
<Box marginTop={1} flexDirection="column">
{/* Show "more items above" indicator */}
{hasLess && (
<Box>
<Text dimColor>
{' '}
{t('{{count}} more above', { count: String(startIndex) })}
</Text>
</Box>
)}
{visiblePlugins.map((plugin, visibleIndex) => {
const actualIndex = startIndex + visibleIndex;
const isSelected = actualIndex === selectedIndex;
const prefix = isSelected ? ' ' : ' ';
return (
<Box key={plugin.name} flexDirection="column">
<Box flexDirection="row">
<Text color={isSelected ? theme.text.accent : undefined}>
{prefix}
</Text>
<Text
bold={isSelected}
color={isSelected ? theme.text.accent : undefined}
>
{plugin.name}
</Text>
</Box>
{/* Show full description only for selected item */}
{isSelected && plugin.description && (
<Box marginLeft={prefixWidth}>
<Text color={theme.text.accent}>{plugin.description}</Text>
</Box>
)}
</Box>
);
})}
{/* Show "more items below" indicator */}
{hasMore && (
<Box>
<Text dimColor>
{' '}
{' '}
{t('{{count}} more below', {
count: String(plugins.length - startIndex - MAX_VISIBLE_ITEMS),
})}
</Text>
</Box>
)}
</Box>
<Box marginTop={1} flexDirection="row" gap={2}>
<Text dimColor>
{t('Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel')}
</Text>
{plugins.length > MAX_VISIBLE_ITEMS && (
<Text dimColor>
({selectedIndex + 1}/{plugins.length})
</Text>
)}
</Box>
</Box>
);
};