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

@ -93,6 +93,7 @@ import {
useExtensionUpdates,
useConfirmUpdateRequests,
useSettingInputRequests,
usePluginChoiceRequests,
} from './hooks/useExtensionUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
import { t } from '../i18n/index.js';
@ -176,12 +177,34 @@ export const AppContainer = (props: AppContainerProps) => {
const { addSettingInputRequest, settingInputRequests } =
useSettingInputRequests();
const { addPluginChoiceRequest, pluginChoiceRequests } =
usePluginChoiceRequests();
extensionManager.setRequestConsent(
requestConsentOrFail.bind(null, (description) =>
requestConsentInteractive(description, addConfirmUpdateExtensionRequest),
),
);
extensionManager.setRequestChoicePlugin(
(marketplace) =>
new Promise<string>((resolve, reject) => {
addPluginChoiceRequest({
marketplaceName: marketplace.name,
plugins: marketplace.plugins.map((p) => ({
name: p.name,
description: p.description,
})),
onSelect: (pluginName) => {
resolve(pluginName);
},
onCancel: () => {
reject(new Error('Plugin selection cancelled'));
},
});
}),
);
extensionManager.setRequestSetting(
(setting) =>
new Promise<string>((resolve, reject) => {
@ -1307,6 +1330,7 @@ export const AppContainer = (props: AppContainerProps) => {
!!confirmationRequest ||
confirmUpdateExtensionRequests.length > 0 ||
settingInputRequests.length > 0 ||
pluginChoiceRequests.length > 0 ||
!!loopDetectionConfirmationRequest ||
isThemeDialogOpen ||
isSettingsDialogOpen ||
@ -1369,6 +1393,7 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
streamingState,
@ -1461,6 +1486,7 @@ export const AppContainer = (props: AppContainerProps) => {
confirmationRequest,
confirmUpdateExtensionRequests,
settingInputRequests,
pluginChoiceRequests,
loopDetectionConfirmationRequest,
geminiMdFileCount,
streamingState,

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>
);
};

View file

@ -15,6 +15,7 @@ import type {
HistoryItemWithoutId,
StreamingState,
SettingInputRequest,
PluginChoiceRequest,
} from '../types.js';
import type { QwenAuthState } from '../hooks/useQwenAuth.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
@ -61,6 +62,7 @@ export interface UIState {
confirmationRequest: ConfirmationRequest | null;
confirmUpdateExtensionRequests: ConfirmationRequest[];
settingInputRequests: SettingInputRequest[];
pluginChoiceRequests: PluginChoiceRequest[];
loopDetectionConfirmationRequest: LoopDetectionConfirmationRequest | null;
geminiMdFileCount: number;
streamingState: StreamingState;

View file

@ -13,6 +13,7 @@ import {
useExtensionUpdates,
useSettingInputRequests,
useConfirmUpdateRequests,
usePluginChoiceRequests,
} from './useExtensionUpdates.js';
import {
QWEN_DIR,
@ -490,3 +491,118 @@ describe('useExtensionUpdates', () => {
});
});
});
describe('usePluginChoiceRequests', () => {
it('should add a plugin choice request', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect = vi.fn();
const onCancel = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'test-marketplace',
plugins: [
{ name: 'plugin1', description: 'First plugin' },
{ name: 'plugin2', description: 'Second plugin' },
],
onSelect,
onCancel,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
expect(result.current.pluginChoiceRequests[0].marketplaceName).toBe(
'test-marketplace',
);
expect(result.current.pluginChoiceRequests[0].plugins).toHaveLength(2);
});
it('should remove a plugin choice request when a plugin is selected', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect = vi.fn();
const onCancel = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'test-marketplace',
plugins: [{ name: 'plugin1' }],
onSelect,
onCancel,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
// Select a plugin
act(() => {
result.current.pluginChoiceRequests[0].onSelect('plugin1');
});
expect(result.current.pluginChoiceRequests).toHaveLength(0);
expect(onSelect).toHaveBeenCalledWith('plugin1');
expect(onCancel).not.toHaveBeenCalled();
});
it('should remove a plugin choice request when cancelled', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect = vi.fn();
const onCancel = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'test-marketplace',
plugins: [{ name: 'plugin1' }],
onSelect,
onCancel,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
// Cancel the request
act(() => {
result.current.pluginChoiceRequests[0].onCancel();
});
expect(result.current.pluginChoiceRequests).toHaveLength(0);
expect(onCancel).toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});
it('should handle multiple plugin choice requests', () => {
const { result } = renderHook(() => usePluginChoiceRequests());
const onSelect1 = vi.fn();
const onCancel1 = vi.fn();
const onSelect2 = vi.fn();
const onCancel2 = vi.fn();
act(() => {
result.current.addPluginChoiceRequest({
marketplaceName: 'marketplace-1',
plugins: [{ name: 'plugin1' }],
onSelect: onSelect1,
onCancel: onCancel1,
});
result.current.addPluginChoiceRequest({
marketplaceName: 'marketplace-2',
plugins: [{ name: 'plugin2' }],
onSelect: onSelect2,
onCancel: onCancel2,
});
});
expect(result.current.pluginChoiceRequests).toHaveLength(2);
// Select from first request
act(() => {
result.current.pluginChoiceRequests[0].onSelect('plugin1');
});
expect(result.current.pluginChoiceRequests).toHaveLength(1);
expect(result.current.pluginChoiceRequests[0].marketplaceName).toBe(
'marketplace-2',
);
expect(onSelect1).toHaveBeenCalledWith('plugin1');
});
});

View file

@ -17,6 +17,7 @@ import {
MessageType,
type ConfirmationRequest,
type SettingInputRequest,
type PluginChoiceRequest,
} from '../types.js';
import { checkExhaustive } from '../../utils/checks.js';
@ -144,6 +145,71 @@ export const useSettingInputRequests = () => {
};
};
type PluginChoiceRequestWrapper = {
marketplaceName: string;
plugins: Array<{ name: string; description?: string }>;
onSelect: (pluginName: string) => void;
onCancel: () => void;
};
type PluginChoiceRequestAction =
| { type: 'add'; request: PluginChoiceRequestWrapper }
| { type: 'remove'; request: PluginChoiceRequestWrapper };
function pluginChoiceRequestsReducer(
state: PluginChoiceRequestWrapper[],
action: PluginChoiceRequestAction,
): PluginChoiceRequestWrapper[] {
switch (action.type) {
case 'add':
return [...state, action.request];
case 'remove':
return state.filter((r) => r !== action.request);
default:
checkExhaustive(action);
return state;
}
}
export const usePluginChoiceRequests = () => {
const [pluginChoiceRequests, dispatchPluginChoiceRequests] = useReducer(
pluginChoiceRequestsReducer,
[],
);
const addPluginChoiceRequest = useCallback(
(original: PluginChoiceRequest) => {
const wrappedRequest: PluginChoiceRequestWrapper = {
marketplaceName: original.marketplaceName,
plugins: original.plugins,
onSelect: (pluginName: string) => {
dispatchPluginChoiceRequests({
type: 'remove',
request: wrappedRequest,
});
original.onSelect(pluginName);
},
onCancel: () => {
dispatchPluginChoiceRequests({
type: 'remove',
request: wrappedRequest,
});
original.onCancel();
},
};
dispatchPluginChoiceRequests({
type: 'add',
request: wrappedRequest,
});
},
[dispatchPluginChoiceRequests],
);
return {
addPluginChoiceRequest,
pluginChoiceRequests,
dispatchPluginChoiceRequests,
};
};
export const useExtensionUpdates = (
extensionManager: ExtensionManager,
addItem: UseHistoryManagerReturn['addItem'],

View file

@ -422,3 +422,15 @@ export interface SettingInputRequest {
onSubmit: (value: string) => void;
onCancel: () => void;
}
export interface PluginChoice {
name: string;
description?: string;
}
export interface PluginChoiceRequest {
marketplaceName: string;
plugins: PluginChoice[];
onSelect: (pluginName: string) => void;
onCancel: () => void;
}