mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-05 23:42:03 +00:00
Merge pull request #1592 from QwenLM/feat/extension-improvements
feat(extensions): add plugin selection UI for Claude marketplace
This commit is contained in:
commit
6081052236
31 changed files with 1407 additions and 283 deletions
|
|
@ -5,8 +5,16 @@
|
|||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { extensionConsentString, requestConsentOrFail } from './consent.js';
|
||||
import type { ExtensionConfig } from '@qwen-code/qwen-code-core';
|
||||
import {
|
||||
extensionConsentString,
|
||||
requestConsentOrFail,
|
||||
requestChoicePluginNonInteractive,
|
||||
} from './consent.js';
|
||||
import type {
|
||||
ExtensionConfig,
|
||||
ClaudeMarketplaceConfig,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import prompts from 'prompts';
|
||||
|
||||
vi.mock('../../i18n/index.js', () => ({
|
||||
t: vi.fn((str: string, params?: Record<string, string>) => {
|
||||
|
|
@ -20,6 +28,8 @@ vi.mock('../../i18n/index.js', () => ({
|
|||
}),
|
||||
}));
|
||||
|
||||
vi.mock('prompts');
|
||||
|
||||
describe('extensionConsentString', () => {
|
||||
it('should include extension name', () => {
|
||||
const config: ExtensionConfig = {
|
||||
|
|
@ -241,3 +251,72 @@ describe('requestConsentOrFail', () => {
|
|||
expect(mockRequestConsent).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('requestChoicePluginNonInteractive', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should throw error when plugins array is empty', async () => {
|
||||
const marketplace: ClaudeMarketplaceConfig = {
|
||||
name: 'test-marketplace',
|
||||
owner: { name: 'Test Owner', email: 'test@example.com' },
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestChoicePluginNonInteractive(marketplace),
|
||||
).rejects.toThrow('No plugins available in this marketplace.');
|
||||
});
|
||||
|
||||
it('should return selected plugin name', async () => {
|
||||
const marketplace: ClaudeMarketplaceConfig = {
|
||||
name: 'test-marketplace',
|
||||
owner: { name: 'Test Owner', email: 'test@example.com' },
|
||||
plugins: [
|
||||
{
|
||||
name: 'plugin1',
|
||||
description: 'Plugin 1',
|
||||
version: '1.0.0',
|
||||
source: 'src1',
|
||||
},
|
||||
{
|
||||
name: 'plugin2',
|
||||
description: 'Plugin 2',
|
||||
version: '1.0.0',
|
||||
source: 'src2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
vi.mocked(prompts).mockResolvedValueOnce({ plugin: 'plugin2' });
|
||||
|
||||
const result = await requestChoicePluginNonInteractive(marketplace);
|
||||
|
||||
expect(result).toBe('plugin2');
|
||||
expect(prompts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: 'select',
|
||||
name: 'plugin',
|
||||
choices: expect.arrayContaining([
|
||||
expect.objectContaining({ value: 'plugin1' }),
|
||||
expect.objectContaining({ value: 'plugin2' }),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when selection is cancelled', async () => {
|
||||
const marketplace: ClaudeMarketplaceConfig = {
|
||||
name: 'test-marketplace',
|
||||
owner: { name: 'Test Owner', email: 'test@example.com' },
|
||||
plugins: [{ name: 'plugin1', version: '1.0.0', source: 'src1' }],
|
||||
};
|
||||
|
||||
vi.mocked(prompts).mockResolvedValueOnce({ plugin: undefined });
|
||||
|
||||
await expect(
|
||||
requestChoicePluginNonInteractive(marketplace),
|
||||
).rejects.toThrow('Plugin selection cancelled.');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type {
|
||||
ClaudeMarketplaceConfig,
|
||||
ExtensionConfig,
|
||||
ExtensionRequestOptions,
|
||||
SkillConfig,
|
||||
|
|
@ -6,6 +7,7 @@ import type {
|
|||
} from '@qwen-code/qwen-code-core';
|
||||
import type { ConfirmationRequest } from '../../ui/types.js';
|
||||
import chalk from 'chalk';
|
||||
import prompts from 'prompts';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
/**
|
||||
|
|
@ -27,6 +29,49 @@ export async function requestConsentNonInteractive(
|
|||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests plugin selection from the user in non-interactive mode.
|
||||
* Displays an interactive list with arrow key navigation.
|
||||
*
|
||||
* This should not be called from interactive mode as it will break the CLI.
|
||||
*
|
||||
* @param marketplace The marketplace config containing available plugins.
|
||||
* @returns The name of the selected plugin.
|
||||
*/
|
||||
export async function requestChoicePluginNonInteractive(
|
||||
marketplace: ClaudeMarketplaceConfig,
|
||||
): Promise<string> {
|
||||
const plugins = marketplace.plugins;
|
||||
|
||||
if (plugins.length === 0) {
|
||||
throw new Error(t('No plugins available in this marketplace.'));
|
||||
}
|
||||
|
||||
// Build choices for prompts select
|
||||
|
||||
const choices = plugins.map((plugin) => ({
|
||||
title: chalk.green(chalk.bold(`[${plugin.name}]`)),
|
||||
value: plugin.name,
|
||||
}));
|
||||
|
||||
const response = await prompts({
|
||||
type: 'select',
|
||||
name: 'plugin',
|
||||
message: t('Select a plugin to install from marketplace "{{name}}":', {
|
||||
name: marketplace.name,
|
||||
}),
|
||||
choices,
|
||||
initial: 0,
|
||||
});
|
||||
|
||||
// Handle cancellation (Ctrl+C)
|
||||
if (response.plugin === undefined) {
|
||||
throw new Error(t('Plugin selection cancelled.'));
|
||||
}
|
||||
|
||||
return response.plugin;
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests consent from the user to perform an action, in interactive mode.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ vi.mock('@qwen-code/qwen-code-core', () => ({
|
|||
vi.mock('./consent.js', () => ({
|
||||
requestConsentNonInteractive: mockRequestConsentNonInteractive,
|
||||
requestConsentOrFail: mockRequestConsentOrFail,
|
||||
requestChoicePluginNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../config/trustedFolders.js', () => ({
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { loadSettings } from '../../config/settings.js';
|
|||
import {
|
||||
requestConsentOrFail,
|
||||
requestConsentNonInteractive,
|
||||
requestChoicePluginNonInteractive,
|
||||
} from './consent.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
|
||||
|
|
@ -54,6 +55,7 @@ export async function handleInstall(args: InstallArgs) {
|
|||
loadSettings(workspaceDir).merged,
|
||||
),
|
||||
requestConsent,
|
||||
requestChoicePlugin: requestChoicePluginNonInteractive,
|
||||
});
|
||||
await extensionManager.refreshCache();
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ vi.mock('../../config/trustedFolders.js', () => ({
|
|||
vi.mock('./consent.js', () => ({
|
||||
requestConsentOrFail: vi.fn(),
|
||||
requestConsentNonInteractive: vi.fn(),
|
||||
requestChoicePluginNonInteractive: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('getExtensionManager', () => {
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { loadSettings } from '../../config/settings.js';
|
|||
import {
|
||||
requestConsentOrFail,
|
||||
requestConsentNonInteractive,
|
||||
requestChoicePluginNonInteractive,
|
||||
} from './consent.js';
|
||||
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
|
||||
import * as os from 'node:os';
|
||||
|
|
@ -22,6 +23,7 @@ export async function getExtensionManager(): Promise<ExtensionManager> {
|
|||
null,
|
||||
requestConsentNonInteractive,
|
||||
),
|
||||
requestChoicePlugin: requestChoicePluginNonInteractive,
|
||||
isWorkspaceTrusted: !!isWorkspaceTrusted(loadSettings(workspaceDir).merged),
|
||||
});
|
||||
await extensionManager.refreshCache();
|
||||
|
|
|
|||
|
|
@ -507,6 +507,19 @@ export default {
|
|||
'Manage extension settings.': 'Erweiterungseinstellungen verwalten.',
|
||||
'You need to specify a command (set or list).':
|
||||
'Sie müssen einen Befehl angeben (set oder list).',
|
||||
// ============================================================================
|
||||
// Plugin Choice / Marketplace
|
||||
// ============================================================================
|
||||
'No plugins available in this marketplace.':
|
||||
'In diesem Marktplatz sind keine Plugins verfügbar.',
|
||||
'Select a plugin to install from marketplace "{{name}}":':
|
||||
'Wählen Sie ein Plugin zur Installation aus Marktplatz "{{name}}":',
|
||||
'Plugin selection cancelled.': 'Plugin-Auswahl abgebrochen.',
|
||||
'Select a plugin from "{{name}}"': 'Plugin aus "{{name}}" auswählen',
|
||||
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
|
||||
'Verwenden Sie ↑↓ oder j/k zum Navigieren, Enter zum Auswählen, Escape zum Abbrechen',
|
||||
'{{count}} more above': '{{count}} weitere oben',
|
||||
'{{count}} more below': '{{count}} weitere unten',
|
||||
'manage IDE integration': 'IDE-Integration verwalten',
|
||||
'check status of IDE integration': 'Status der IDE-Integration prüfen',
|
||||
'install required IDE companion for {{ideName}}':
|
||||
|
|
|
|||
|
|
@ -515,6 +515,19 @@ export default {
|
|||
'Manage extension settings.': 'Manage extension settings.',
|
||||
'You need to specify a command (set or list).':
|
||||
'You need to specify a command (set or list).',
|
||||
// ============================================================================
|
||||
// Plugin Choice / Marketplace
|
||||
// ============================================================================
|
||||
'No plugins available in this marketplace.':
|
||||
'No plugins available in this marketplace.',
|
||||
'Select a plugin to install from marketplace "{{name}}":':
|
||||
'Select a plugin to install from marketplace "{{name}}":',
|
||||
'Plugin selection cancelled.': 'Plugin selection cancelled.',
|
||||
'Select a plugin from "{{name}}"': 'Select a plugin from "{{name}}"',
|
||||
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
|
||||
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel',
|
||||
'{{count}} more above': '{{count}} more above',
|
||||
'{{count}} more below': '{{count}} more below',
|
||||
'manage IDE integration': 'manage IDE integration',
|
||||
'check status of IDE integration': 'check status of IDE integration',
|
||||
'install required IDE companion for {{ideName}}':
|
||||
|
|
|
|||
|
|
@ -519,6 +519,19 @@ export default {
|
|||
'Manage extension settings.': 'Управление настройками расширений.',
|
||||
'You need to specify a command (set or list).':
|
||||
'Необходимо указать команду (set или list).',
|
||||
// ============================================================================
|
||||
// Plugin Choice / Marketplace
|
||||
// ============================================================================
|
||||
'No plugins available in this marketplace.':
|
||||
'В этом маркетплейсе нет доступных плагинов.',
|
||||
'Select a plugin to install from marketplace "{{name}}":':
|
||||
'Выберите плагин для установки из маркетплейса "{{name}}":',
|
||||
'Plugin selection cancelled.': 'Выбор плагина отменён.',
|
||||
'Select a plugin from "{{name}}"': 'Выберите плагин из "{{name}}"',
|
||||
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
|
||||
'Используйте ↑↓ или j/k для навигации, Enter для выбора, Escape для отмены',
|
||||
'{{count}} more above': 'ещё {{count}} выше',
|
||||
'{{count}} more below': 'ещё {{count}} ниже',
|
||||
'manage IDE integration': 'Управление интеграцией с IDE',
|
||||
'check status of IDE integration': 'Проверить статус интеграции с IDE',
|
||||
'install required IDE companion for {{ideName}}':
|
||||
|
|
|
|||
|
|
@ -490,6 +490,18 @@ export default {
|
|||
'Manage extension settings.': '管理扩展设置。',
|
||||
'You need to specify a command (set or list).':
|
||||
'您需要指定命令(set 或 list)。',
|
||||
// ============================================================================
|
||||
// Plugin Choice / Marketplace
|
||||
// ============================================================================
|
||||
'No plugins available in this marketplace.': '此市场中没有可用的插件。',
|
||||
'Select a plugin to install from marketplace "{{name}}":':
|
||||
'从市场 "{{name}}" 中选择要安装的插件:',
|
||||
'Plugin selection cancelled.': '插件选择已取消。',
|
||||
'Select a plugin from "{{name}}"': '从 "{{name}}" 中选择插件',
|
||||
'Use ↑↓ or j/k to navigate, Enter to select, Escape to cancel':
|
||||
'使用 ↑↓ 或 j/k 导航,回车选择,Esc 取消',
|
||||
'{{count}} more above': '上方还有 {{count}} 项',
|
||||
'{{count}} more below': '下方还有 {{count}} 项',
|
||||
'manage IDE integration': '管理 IDE 集成',
|
||||
'check status of IDE integration': '检查 IDE 集成状态',
|
||||
'install required IDE companion for {{ideName}}':
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
243
packages/cli/src/ui/components/PluginChoicePrompt.test.tsx
Normal file
243
packages/cli/src/ui/components/PluginChoicePrompt.test.tsx
Normal 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('❯');
|
||||
});
|
||||
});
|
||||
});
|
||||
195
packages/cli/src/ui/components/PluginChoicePrompt.tsx
Normal file
195
packages/cli/src/ui/components/PluginChoicePrompt.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue