Merge remote-tracking branch 'origin/main' into feat/ask-user-question-tool

This commit is contained in:
tanzhenxin 2026-03-08 23:05:32 +08:00
commit 2e91f0a4cd
122 changed files with 8126 additions and 6309 deletions

View file

@ -34,6 +34,8 @@ import { IdeTrustChangeDialog } from './IdeTrustChangeDialog.js';
import { WelcomeBackDialog } from './WelcomeBackDialog.js';
import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js';
import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js';
import { ExtensionsManagerDialog } from './extensions/ExtensionsManagerDialog.js';
import { MCPManagementDialog } from './mcp/MCPManagementDialog.js';
import { SessionPicker } from './SessionPicker.js';
interface DialogManagerProps {
@ -292,6 +294,18 @@ export const DialogManager = ({
);
}
if (uiState.isExtensionsManagerDialogOpen) {
return (
<ExtensionsManagerDialog
onClose={uiActions.closeExtensionsManagerDialog}
config={config}
/>
);
}
if (uiState.isMcpDialogOpen) {
return <MCPManagementDialog onClose={uiActions.closeMcpDialog} />;
}
if (uiState.isResumeDialogOpen) {
return (
<SessionPicker

View file

@ -0,0 +1,153 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js';
import { UIStateContext } from '../../contexts/UIStateContext.js';
import { KeypressProvider } from '../../contexts/KeypressContext.js';
import type { UIState } from '../../contexts/UIStateContext.js';
import type { Config, Extension } from '@qwen-code/qwen-code-core';
import { ExtensionUpdateState } from '../../state/extensions.js';
const createMockExtension = (
name: string,
isActive = true,
version = '1.0.0',
): Extension =>
({
id: name,
name,
version,
path: `/home/user/.qwen/extensions/${name}`,
isActive,
installMetadata: {
type: 'git',
source: `github:user/${name}`,
},
mcpServers: {},
commands: [],
skills: [],
agents: [],
resolvedSettings: [],
config: {},
contextFiles: [],
}) as unknown as Extension;
const createMockConfig = (extensions: Extension[] = []): Config =>
({
getExtensions: () => extensions,
getExtensionManager: () => ({
getLoadedExtensions: () => extensions,
refreshCache: vi.fn().mockResolvedValue(undefined),
checkForAllExtensionUpdates: vi.fn().mockResolvedValue(undefined),
disableExtension: vi.fn().mockResolvedValue(undefined),
enableExtension: vi.fn().mockResolvedValue(undefined),
uninstallExtension: vi.fn().mockResolvedValue(undefined),
updateExtension: vi.fn().mockResolvedValue(undefined),
}),
getLoadedExtensions: () => extensions,
}) as unknown as Config;
const createMockUIState = (
extensionsUpdateState = new Map<string, ExtensionUpdateState>(),
): UIState =>
({
extensionsUpdateState,
}) as unknown as UIState;
describe('ExtensionsManagerDialog Snapshots', () => {
const baseProps = {
onClose: vi.fn(),
config: createMockConfig(),
};
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it('should render empty state when no extensions installed', () => {
const uiState = createMockUIState();
const { lastFrame } = render(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog {...baseProps} />
</KeypressProvider>
</UIStateContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render extension list with extensions', () => {
const extensions = [
createMockExtension('test-extension', true),
createMockExtension('another-extension', false),
];
const uiState = createMockUIState(
new Map([
['test-extension', ExtensionUpdateState.UP_TO_DATE],
['another-extension', ExtensionUpdateState.UPDATE_AVAILABLE],
]),
);
const { lastFrame } = render(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog
{...baseProps}
config={createMockConfig(extensions)}
/>
</KeypressProvider>
</UIStateContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with update available status', () => {
const extensions = [createMockExtension('outdated-extension', true)];
const uiState = createMockUIState(
new Map([['outdated-extension', ExtensionUpdateState.UPDATE_AVAILABLE]]),
);
const { lastFrame } = render(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog
{...baseProps}
config={createMockConfig(extensions)}
/>
</KeypressProvider>
</UIStateContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with checking status', () => {
const extensions = [createMockExtension('checking-extension', true)];
const uiState = createMockUIState(
new Map([
['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES],
]),
);
const { lastFrame } = render(
<UIStateContext.Provider value={uiState}>
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionsManagerDialog
{...baseProps}
config={createMockConfig(extensions)}
/>
</KeypressProvider>
</UIStateContext.Provider>,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,526 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useMemo, useEffect } from 'react';
import { Box, Text } from 'ink';
import {
ExtensionListStep,
ExtensionDetailStep,
ActionSelectionStep,
UninstallConfirmStep,
ScopeSelectStep,
} from './steps/index.js';
import { MANAGEMENT_STEPS, type ExtensionAction } from './types.js';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { useUIState } from '../../contexts/UIStateContext.js';
import { t } from '../../../i18n/index.js';
import type { Extension, Config } from '@qwen-code/qwen-code-core';
import { SettingScope, createDebugLogger } from '@qwen-code/qwen-code-core';
import { ExtensionUpdateState } from '../../state/extensions.js';
import { getErrorMessage } from '../../../utils/errors.js';
interface ExtensionsManagerDialogProps {
onClose: () => void;
config: Config | null;
}
const debugLogger = createDebugLogger('EXTENSIONS_MANAGER_DIALOG');
export function ExtensionsManagerDialog({
onClose,
config,
}: ExtensionsManagerDialogProps) {
const { extensionsUpdateState } = useUIState();
const [extensions, setExtensions] = useState<Extension[]>([]);
const [selectedExtensionIndex, setSelectedExtensionIndex] =
useState<number>(-1);
const [navigationStack, setNavigationStack] = useState<string[]>([
MANAGEMENT_STEPS.EXTENSION_LIST,
]);
const [updateInProgress, setUpdateInProgress] = useState(false);
const [updateError, setUpdateError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
// Load extensions
const loadExtensions = useCallback(async () => {
if (!config) return;
const extensionManager = config.getExtensionManager();
if (!extensionManager) {
debugLogger.error('ExtensionManager not available');
return;
}
try {
await extensionManager.refreshCache();
const loadedExtensions = extensionManager.getLoadedExtensions();
setExtensions(loadedExtensions);
} catch (error) {
debugLogger.error('Failed to load extensions:', error);
}
}, [config]);
// Initial load
useEffect(() => {
loadExtensions();
}, [loadExtensions]);
// Memoized selected extension
const selectedExtension = useMemo(
() =>
selectedExtensionIndex >= 0 ? extensions[selectedExtensionIndex] : null,
[extensions, selectedExtensionIndex],
);
// Check if update is available for selected extension
const hasUpdateAvailable = useMemo(() => {
if (!selectedExtension) return false;
const state = extensionsUpdateState.get(selectedExtension.name);
return state === ExtensionUpdateState.UPDATE_AVAILABLE;
}, [selectedExtension, extensionsUpdateState]);
// Helper to get current step
const getCurrentStep = useCallback(
() =>
navigationStack[navigationStack.length - 1] ||
MANAGEMENT_STEPS.EXTENSION_LIST,
[navigationStack],
);
const handleSelectExtension = useCallback((extensionIndex: number) => {
setSelectedExtensionIndex(extensionIndex);
setSuccessMessage(null); // Clear success message when navigating
setErrorMessage(null); // Clear error message when navigating
setNavigationStack((prev) => [...prev, MANAGEMENT_STEPS.ACTION_SELECTION]);
}, []);
const handleNavigateToStep = useCallback((step: string) => {
setNavigationStack((prev) => [...prev, step]);
}, []);
const handleNavigateBack = useCallback(() => {
setNavigationStack((prev) => {
if (prev.length <= 1) {
return prev;
}
return prev.slice(0, -1);
});
// Clear messages when navigating back
setErrorMessage(null);
}, []);
const handleUpdateExtension = useCallback(async () => {
if (!config || !selectedExtension) return;
setUpdateInProgress(true);
setUpdateError(null);
try {
const extensionManager = config.getExtensionManager();
if (!extensionManager) {
throw new Error('ExtensionManager not available');
}
const state = extensionsUpdateState.get(selectedExtension.name);
if (state !== ExtensionUpdateState.UPDATE_AVAILABLE) {
throw new Error('No update available');
}
// Use the extension manager to update
await extensionManager.updateExtension(
selectedExtension,
ExtensionUpdateState.UPDATE_AVAILABLE,
(name, newState) => {
debugLogger.debug(`Update state for ${name}:`, newState);
},
);
// Reload extensions after update to get new version info
await loadExtensions();
// Trigger a re-check of update status for all extensions
await extensionManager.checkForAllExtensionUpdates((name, newState) => {
debugLogger.debug(`Recheck update state for ${name}:`, newState);
});
// Show success message
setSuccessMessage(
t('Extension "{{name}}" updated successfully.', {
name: selectedExtension.name,
}),
);
// Go back to action selection
handleNavigateBack();
} catch (error) {
debugLogger.error('Failed to update extension:', error);
setUpdateError(
error instanceof Error ? error.message : 'Unknown error occurred',
);
} finally {
setUpdateInProgress(false);
}
}, [
config,
selectedExtension,
extensionsUpdateState,
loadExtensions,
handleNavigateBack,
]);
const handleActionSelect = useCallback(
(action: ExtensionAction) => {
switch (action) {
case 'view':
handleNavigateToStep(MANAGEMENT_STEPS.EXTENSION_DETAIL);
break;
case 'update':
handleNavigateToStep(MANAGEMENT_STEPS.UPDATE_PROGRESS);
handleUpdateExtension();
break;
case 'disable':
handleNavigateToStep(MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT);
break;
case 'enable':
handleNavigateToStep(MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT);
break;
case 'uninstall':
handleNavigateToStep(MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION);
break;
default:
break;
}
},
[handleNavigateToStep, handleUpdateExtension],
);
// Unified handler for toggling extension state (enable/disable)
const handleToggleExtensionState = useCallback(
async (scope: 'user' | 'workspace', newState: boolean) => {
if (!config || !selectedExtension) return;
try {
const extensionManager = config.getExtensionManager();
if (!extensionManager) {
throw new Error('ExtensionManager not available');
}
const settingScope =
scope === 'user' ? SettingScope.User : SettingScope.Workspace;
if (newState) {
await extensionManager.enableExtension(
selectedExtension.name,
settingScope,
);
} else {
await extensionManager.disableExtension(
selectedExtension.name,
settingScope,
);
}
// Update local state
setExtensions((prev) =>
prev.map((ext) =>
ext.name === selectedExtension.name
? { ...ext, isActive: newState }
: ext,
),
);
// Show success message
const actionKey = newState ? 'enabled' : 'disabled';
setSuccessMessage(
t(`Extension "{{name}}" ${actionKey} successfully.`, {
name: selectedExtension.name,
}),
);
setErrorMessage(null);
// Go back to extension list to show success message
setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]);
} catch (error) {
debugLogger.error(
`Failed to ${newState ? 'enable' : 'disable'} extension:`,
error,
);
setErrorMessage(
t('Failed to {{action}} extension "{{name}}": {{error}}', {
action: newState ? 'enable' : 'disable',
name: selectedExtension.name,
error: getErrorMessage(error),
}),
);
setSuccessMessage(null);
}
},
[config, selectedExtension],
);
const handleDisableExtension = useCallback(
async (scope: 'user' | 'workspace') => {
await handleToggleExtensionState(scope, false);
},
[handleToggleExtensionState],
);
const handleEnableExtension = useCallback(
async (scope: 'user' | 'workspace') => {
await handleToggleExtensionState(scope, true);
},
[handleToggleExtensionState],
);
const handleUninstallExtension = useCallback(
async (extension: Extension) => {
if (!config) return;
try {
const extensionManager = config.getExtensionManager();
if (!extensionManager) {
throw new Error('ExtensionManager not available');
}
await extensionManager.uninstallExtension(extension.name, false);
// Reload extensions
await loadExtensions();
// Navigate back to extension list
setNavigationStack([MANAGEMENT_STEPS.EXTENSION_LIST]);
setSelectedExtensionIndex(-1);
} catch (error) {
debugLogger.error('Failed to uninstall extension:', error);
throw error;
}
},
[config, loadExtensions],
);
// Centralized ESC key handling
useKeypress(
(key) => {
if (key.name !== 'escape') {
return;
}
const currentStep = getCurrentStep();
// If there's a success message, clear it first instead of closing
if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
setSuccessMessage(null);
return;
}
if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
onClose();
} else {
handleNavigateBack();
}
},
{ isActive: true },
);
const renderStepHeader = useCallback(() => {
const currentStep = getCurrentStep();
const getStepHeaderText = () => {
switch (currentStep) {
case MANAGEMENT_STEPS.EXTENSION_LIST:
return t('Manage Extensions');
case MANAGEMENT_STEPS.ACTION_SELECTION:
return selectedExtension?.name || t('Choose Action');
case MANAGEMENT_STEPS.EXTENSION_DETAIL:
return t('Extension Details');
case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
return t('Disable Extension');
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
return t('Enable Extension');
case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION:
return t('Uninstall Extension');
case MANAGEMENT_STEPS.UPDATE_PROGRESS:
return t('Update Extension');
default:
return t('Unknown Step');
}
};
return (
<Box>
<Text color={theme.text.accent} bold>
{getStepHeaderText()}
</Text>
</Box>
);
}, [getCurrentStep, selectedExtension]);
const renderStepFooter = useCallback(() => {
const currentStep = getCurrentStep();
const getNavigationInstructions = () => {
if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
if (extensions.length === 0) {
return t('Esc to close');
}
return t('Enter to select, ↑↓ to navigate, Esc to close');
}
if (currentStep === MANAGEMENT_STEPS.EXTENSION_DETAIL) {
return t('Esc to go back');
}
if (currentStep === MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION) {
return t('Y/Enter to confirm, N/Esc to cancel');
}
if (currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) {
return updateInProgress ? t('Updating...') : '';
}
return t('Enter to select, ↑↓ to navigate, Esc to go back');
};
return (
<Box>
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
</Box>
);
}, [getCurrentStep, extensions.length, updateInProgress]);
const renderStepContent = useCallback(() => {
const currentStep = getCurrentStep();
// Show error message if present (only on extension list step)
if (errorMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
);
}
// Show success message if present (only on extension list step)
if (successMessage && currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.success}>{successMessage}</Text>
</Box>
);
}
if (updateError && currentStep === MANAGEMENT_STEPS.UPDATE_PROGRESS) {
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.error}>{t('Update failed:')}</Text>
<Text>{updateError}</Text>
</Box>
);
}
switch (currentStep) {
case MANAGEMENT_STEPS.EXTENSION_LIST:
return (
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={extensionsUpdateState}
onExtensionSelect={handleSelectExtension}
/>
);
case MANAGEMENT_STEPS.ACTION_SELECTION:
return (
<ActionSelectionStep
selectedExtension={selectedExtension}
hasUpdateAvailable={hasUpdateAvailable}
onNavigateToStep={handleNavigateToStep}
onNavigateBack={handleNavigateBack}
onActionSelect={handleActionSelect}
/>
);
case MANAGEMENT_STEPS.EXTENSION_DETAIL:
return <ExtensionDetailStep selectedExtension={selectedExtension} />;
case MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
return (
<ScopeSelectStep
selectedExtension={selectedExtension}
mode="disable"
onScopeSelect={handleDisableExtension}
onNavigateBack={handleNavigateBack}
/>
);
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
return (
<ScopeSelectStep
selectedExtension={selectedExtension}
mode="enable"
onScopeSelect={handleEnableExtension}
onNavigateBack={handleNavigateBack}
/>
);
case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION:
return (
<UninstallConfirmStep
selectedExtension={selectedExtension}
onConfirm={handleUninstallExtension}
onNavigateBack={handleNavigateBack}
/>
);
case MANAGEMENT_STEPS.UPDATE_PROGRESS:
return (
<Box flexDirection="column" gap={1}>
<Text>
{updateInProgress
? t('Updating {{name}}...', {
name: selectedExtension?.name || '',
})
: t('Update complete!')}
</Text>
</Box>
);
default:
return (
<Box>
<Text color={theme.status.error}>
{t('Invalid step: {{step}}', { step: currentStep })}
</Text>
</Box>
);
}
}, [
getCurrentStep,
extensions,
extensionsUpdateState,
selectedExtension,
hasUpdateAvailable,
updateInProgress,
updateError,
successMessage,
errorMessage,
handleSelectExtension,
handleNavigateToStep,
handleNavigateBack,
handleActionSelect,
handleDisableExtension,
handleEnableExtension,
handleUninstallExtension,
]);
return (
<Box flexDirection="column">
<Box
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
gap={1}
>
{renderStepHeader()}
{renderStepContent()}
{renderStepFooter()}
</Box>
</Box>
);
}

View file

@ -0,0 +1,53 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExtensionsManagerDialog Snapshots > should render empty state when no extensions installed 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ Manage Extensions │
│ │
│ No extensions installed. │
│ Use '/extensions install' to install your first extension. │
│ │
│ Esc to close │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`ExtensionsManagerDialog Snapshots > should render extension list with extensions 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ Manage Extensions │
│ │
│ No extensions installed. │
│ Use '/extensions install' to install your first extension. │
│ │
│ Esc to close │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`ExtensionsManagerDialog Snapshots > should render with checking status 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ Manage Extensions │
│ │
│ No extensions installed. │
│ Use '/extensions install' to install your first extension. │
│ │
│ Esc to close │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;
exports[`ExtensionsManagerDialog Snapshots > should render with update available status 1`] = `
"┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ │
│ Manage Extensions │
│ │
│ No extensions installed. │
│ Use '/extensions install' to install your first extension. │
│ │
│ Esc to close │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
`;

View file

@ -0,0 +1,9 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
export { ExtensionsManagerDialog } from './ExtensionsManagerDialog.js';
export type { ExtensionsManagerDialogProps } from './types.js';
export { MANAGEMENT_STEPS } from './types.js';

View file

@ -0,0 +1,109 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ActionSelectionStep } from './ActionSelectionStep.js';
import { KeypressProvider } from '../../../contexts/KeypressContext.js';
import type { Extension } from '@qwen-code/qwen-code-core';
const createMockExtension = (name: string, isActive = true): Extension =>
({
id: name,
name,
version: '1.0.0',
path: `/home/user/.qwen/extensions/${name}`,
isActive,
installMetadata: {
type: 'git',
source: `github:user/${name}`,
},
mcpServers: {},
commands: [],
skills: [],
agents: [],
resolvedSettings: [],
config: {},
contextFiles: [],
}) as unknown as Extension;
describe('ActionSelectionStep Snapshots', () => {
const baseProps = {
onNavigateToStep: vi.fn(),
onNavigateBack: vi.fn(),
onActionSelect: vi.fn(),
};
it('should render for active extension without update', () => {
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ActionSelectionStep
selectedExtension={createMockExtension('active-ext', true)}
hasUpdateAvailable={false}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render for disabled extension', () => {
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ActionSelectionStep
selectedExtension={createMockExtension('disabled-ext', false)}
hasUpdateAvailable={false}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render for extension with update available', () => {
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ActionSelectionStep
selectedExtension={createMockExtension('update-ext', true)}
hasUpdateAvailable={true}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render for disabled extension with update', () => {
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ActionSelectionStep
selectedExtension={createMockExtension('disabled-update-ext', false)}
hasUpdateAvailable={true}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with no extension selected', () => {
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ActionSelectionStep
selectedExtension={null}
hasUpdateAvailable={false}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,116 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo } from 'react';
import { Box } from 'ink';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { type Extension } from '@qwen-code/qwen-code-core';
import { t } from '../../../../i18n/index.js';
import { type ExtensionAction } from '../types.js';
interface ActionSelectionStepProps {
selectedExtension: Extension | null;
hasUpdateAvailable: boolean;
onNavigateToStep: (step: string) => void;
onNavigateBack: () => void;
onActionSelect: (action: ExtensionAction) => void;
}
export const ActionSelectionStep = ({
selectedExtension,
hasUpdateAvailable,
onNavigateBack,
onActionSelect,
}: ActionSelectionStepProps) => {
const [selectedAction, setSelectedAction] = useState<ExtensionAction | null>(
null,
);
const isActive = selectedExtension?.isActive ?? false;
// Build action list based on extension state
const actions = useMemo(() => {
const allActions = [
{
key: 'view',
get label() {
return t('View Details');
},
value: 'view' as const,
},
...(hasUpdateAvailable
? [
{
key: 'update',
get label() {
return t('Update Extension');
},
value: 'update' as const,
},
]
: []),
...(isActive
? [
{
key: 'disable',
get label() {
return t('Disable Extension');
},
value: 'disable' as const,
},
]
: [
{
key: 'enable',
get label() {
return t('Enable Extension');
},
value: 'enable' as const,
},
]),
{
key: 'uninstall',
get label() {
return t('Uninstall Extension');
},
value: 'uninstall' as const,
},
{
key: 'back',
get label() {
return t('Back');
},
value: 'back' as const,
},
];
return allActions;
}, [hasUpdateAvailable, isActive]);
const handleActionSelect = (value: ExtensionAction) => {
if (value === 'back') {
onNavigateBack();
return;
}
setSelectedAction(value);
onActionSelect(value);
};
const selectedIndex = selectedAction
? actions.findIndex((action) => action.value === selectedAction)
: 0;
return (
<Box flexDirection="column">
<RadioButtonSelect
items={actions}
initialIndex={selectedIndex}
onSelect={handleActionSelect}
showNumbers={false}
/>
</Box>
);
};

View file

@ -0,0 +1,128 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { type Extension } from '@qwen-code/qwen-code-core';
import { t } from '../../../../i18n/index.js';
interface ExtensionDetailStepProps {
selectedExtension: Extension | null;
}
export const ExtensionDetailStep = ({
selectedExtension,
}: ExtensionDetailStepProps) => {
if (!selectedExtension) {
return (
<Box>
<Text color={theme.status.error}>{t('No extension selected')}</Text>
</Box>
);
}
const ext = selectedExtension;
const isActive = ext.isActive;
const activeColor = isActive ? theme.status.success : theme.text.secondary;
const activeString = isActive ? t('active') : t('disabled');
// Fixed width for labels to ensure alignment
const LABEL_WIDTH = 12;
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Name:')}</Text>
</Box>
<Text>{ext.name}</Text>
</Box>
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Version:')}</Text>
</Box>
<Text>{ext.version}</Text>
</Box>
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Status:')}</Text>
</Box>
<Text color={activeColor}>{activeString}</Text>
</Box>
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Path:')}</Text>
</Box>
<Text>{ext.path}</Text>
</Box>
{ext.installMetadata && (
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Source:')}</Text>
</Box>
<Text>{ext.installMetadata.source}</Text>
</Box>
)}
{ext.mcpServers && Object.keys(ext.mcpServers).length > 0 && (
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('MCP Servers:')}</Text>
</Box>
<Text>{Object.keys(ext.mcpServers).join(', ')}</Text>
</Box>
)}
{ext.commands && ext.commands.length > 0 && (
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Commands:')}</Text>
</Box>
<Text>{ext.commands.join(', ')}</Text>
</Box>
)}
{ext.skills && ext.skills.length > 0 && (
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Skills:')}</Text>
</Box>
<Text>{ext.skills.map((s) => s.name).join(', ')}</Text>
</Box>
)}
{ext.agents && ext.agents.length > 0 && (
<Box>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Agents:')}</Text>
</Box>
<Text>{ext.agents.map((a) => a.name).join(', ')}</Text>
</Box>
)}
{ext.resolvedSettings && ext.resolvedSettings.length > 0 && (
<Box flexDirection="column" marginTop={1}>
<Box width={LABEL_WIDTH} flexShrink={0}>
<Text color={theme.text.primary}>{t('Settings:')}</Text>
</Box>
<Box flexDirection="column" paddingLeft={2}>
{ext.resolvedSettings.map((setting) => (
<Text key={setting.name}>
- {setting.name}: {setting.value}
</Text>
))}
</Box>
</Box>
)}
</Box>
</Box>
);
};

View file

@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { render } from 'ink-testing-library';
import { describe, it, expect, vi } from 'vitest';
import { ExtensionListStep } from './ExtensionListStep.js';
import { KeypressProvider } from '../../../contexts/KeypressContext.js';
import type { Extension } from '@qwen-code/qwen-code-core';
import { ExtensionUpdateState } from '../../../state/extensions.js';
const createMockExtension = (
name: string,
isActive = true,
version = '1.0.0',
): Extension =>
({
id: name,
name,
version,
path: `/home/user/.qwen/extensions/${name}`,
isActive,
installMetadata: {
type: 'git',
source: `github:user/${name}`,
},
mcpServers: {},
commands: [],
skills: [],
agents: [],
resolvedSettings: [],
config: {},
contextFiles: [],
}) as unknown as Extension;
describe('ExtensionListStep Snapshots', () => {
const baseProps = {
onExtensionSelect: vi.fn(),
};
it('should render empty state', () => {
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionListStep
extensions={[]}
extensionsUpdateState={new Map()}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render list with single extension', () => {
const extensions = [createMockExtension('test-extension', true)];
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={new Map()}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render list with multiple extensions', () => {
const extensions = [
createMockExtension('active-extension', true),
createMockExtension('disabled-extension', false),
createMockExtension('update-available', true),
];
const updateState = new Map([
['active-extension', ExtensionUpdateState.UP_TO_DATE],
['disabled-extension', ExtensionUpdateState.NOT_UPDATABLE],
['update-available', ExtensionUpdateState.UPDATE_AVAILABLE],
]);
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={updateState}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with checking status', () => {
const extensions = [createMockExtension('checking-extension', true)];
const updateState = new Map([
['checking-extension', ExtensionUpdateState.CHECKING_FOR_UPDATES],
]);
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={updateState}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
it('should render with error status', () => {
const extensions = [createMockExtension('error-extension', true)];
const updateState = new Map([
['error-extension', ExtensionUpdateState.ERROR],
]);
const { lastFrame } = render(
<KeypressProvider kittyProtocolEnabled={false}>
<ExtensionListStep
extensions={extensions}
extensionsUpdateState={updateState}
{...baseProps}
/>
</KeypressProvider>,
);
expect(lastFrame()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,177 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { type Extension } from '@qwen-code/qwen-code-core';
import { t } from '../../../../i18n/index.js';
import { ExtensionUpdateState } from '../../../state/extensions.js';
interface ExtensionListStepProps {
extensions: Extension[];
extensionsUpdateState: Map<string, string>;
onExtensionSelect: (extensionIndex: number) => void;
}
export const ExtensionListStep = ({
extensions,
extensionsUpdateState,
onExtensionSelect,
}: ExtensionListStepProps) => {
const [selectedIndex, setSelectedIndex] = useState(0);
// Calculate max widths for each column for alignment
const { maxNameWidth, maxVersionWidth, maxStatusWidth } = useMemo(() => {
let maxName = 0;
let maxVersion = 0;
let maxStatus = 0;
for (const ext of extensions) {
maxName = Math.max(maxName, ext.name.length);
maxVersion = Math.max(maxVersion, ext.version.length);
const statusLength = ext.isActive
? t('active').length
: t('disabled').length;
maxStatus = Math.max(maxStatus, statusLength);
}
return {
maxNameWidth: maxName,
maxVersionWidth: maxVersion,
maxStatusWidth: maxStatus,
};
}, [extensions]);
// Reset selection when extensions change
useEffect(() => {
if (extensions.length > 0 && selectedIndex >= extensions.length) {
setSelectedIndex(0);
}
}, [extensions, selectedIndex]);
// Keyboard navigation
useKeypress(
(key) => {
if (key.name === 'up' || key.name === 'k') {
setSelectedIndex((prev) =>
prev > 0 ? prev - 1 : extensions.length - 1,
);
} else if (key.name === 'down' || key.name === 'j') {
setSelectedIndex((prev) =>
prev < extensions.length - 1 ? prev + 1 : 0,
);
} else if (key.name === 'return' || key.name === 'space') {
if (extensions.length > 0) {
onExtensionSelect(selectedIndex);
}
}
},
{ isActive: true },
);
if (extensions.length === 0) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{t('No extensions installed.')}
</Text>
<Text color={theme.text.secondary}>
{t("Use '/extensions install' to install your first extension.")}
</Text>
</Box>
);
}
const getUpdateStateColor = (state: string | undefined): string => {
if (!state) return theme.text.secondary;
switch (state) {
case ExtensionUpdateState.CHECKING_FOR_UPDATES:
case ExtensionUpdateState.UPDATING:
return theme.text.secondary;
case ExtensionUpdateState.UPDATE_AVAILABLE:
case ExtensionUpdateState.UPDATED_NEEDS_RESTART:
return theme.status.warning;
case ExtensionUpdateState.ERROR:
return theme.status.error;
case ExtensionUpdateState.UP_TO_DATE:
case ExtensionUpdateState.NOT_UPDATABLE:
case ExtensionUpdateState.UPDATED:
return theme.status.success;
default:
return theme.text.secondary;
}
};
const getLocalizedUpdateState = (state: string | undefined): string => {
if (!state) return '';
// Map internal state values to translation keys
const stateMap: Record<string, string> = {
'up to date': t('up to date'),
'update available': t('update available'),
'checking...': t('checking...'),
'not updatable': t('not updatable'),
error: t('error'),
};
return stateMap[state] || state;
};
const renderExtensionItem = (
extension: Extension,
index: number,
isSelected: boolean,
) => {
const isActive = extension.isActive;
const activeColor = isActive ? theme.status.success : theme.text.secondary;
const activeString = isActive ? t('active') : t('disabled');
const updateState = extensionsUpdateState.get(extension.name);
const stateColor = getUpdateStateColor(updateState);
const stateText = getLocalizedUpdateState(updateState);
return (
<Box key={extension.name} alignItems="center">
<Box minWidth={2} flexShrink={0}>
<Text color={isSelected ? theme.text.accent : theme.text.primary}>
{isSelected ? '●' : ' '}
</Text>
</Box>
<Box width={maxNameWidth} flexShrink={0}>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
wrap="truncate"
>
{extension.name}
</Text>
</Box>
<Box width={maxVersionWidth + 8} flexShrink={0}>
<Text color={theme.text.secondary}> v{extension.version}</Text>
</Box>
<Box width={maxStatusWidth + 8} flexShrink={0}>
<Text color={activeColor}>({activeString})</Text>
</Box>
{stateText && <Text color={stateColor}>[{stateText}]</Text>}
</Box>
);
};
return (
<Box flexDirection="column">
<Box flexDirection="column" marginBottom={1}>
{extensions.map((extension, index) =>
renderExtensionItem(extension, index, index === selectedIndex),
)}
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('{{count}} extensions installed', {
count: extensions.length.toString(),
})}
</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,83 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { type Extension } from '@qwen-code/qwen-code-core';
import { theme } from '../../../semantic-colors.js';
import { t } from '../../../../i18n/index.js';
interface ScopeSelectStepProps {
selectedExtension: Extension | null;
mode: 'disable' | 'enable';
onScopeSelect: (scope: 'user' | 'workspace') => void;
onNavigateBack: () => void;
}
export function ScopeSelectStep({
selectedExtension,
mode,
onScopeSelect,
onNavigateBack,
}: ScopeSelectStepProps) {
const scopeItems = [
{
key: 'user',
get label() {
return t('User (global)');
},
value: 'user' as const,
},
{
key: 'workspace',
get label() {
return t('Workspace (project-specific)');
},
value: 'workspace' as const,
},
{
key: 'back',
get label() {
return t('Back');
},
value: 'back' as const,
},
];
const handleSelect = (value: 'user' | 'workspace' | 'back') => {
if (value === 'back') {
onNavigateBack();
return;
}
onScopeSelect(value);
};
if (!selectedExtension) {
return (
<Box>
<Text color={theme.status.error}>{t('No extension selected')}</Text>
</Box>
);
}
const title =
mode === 'disable'
? t('Disable "{{name}}" - Select Scope', { name: selectedExtension.name })
: t('Enable "{{name}}" - Select Scope', { name: selectedExtension.name });
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.text.primary}>{title}</Text>
<Box marginTop={1}>
<RadioButtonSelect
items={scopeItems}
onSelect={handleSelect}
showNumbers={false}
/>
</Box>
</Box>
);
}

View file

@ -0,0 +1,68 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { type Extension } from '@qwen-code/qwen-code-core';
import { createDebugLogger } from '@qwen-code/qwen-code-core';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
interface UninstallConfirmStepProps {
selectedExtension: Extension | null;
onConfirm: (extension: Extension) => Promise<void>;
onNavigateBack: () => void;
}
const debugLogger = createDebugLogger('EXTENSION_UNINSTALL_STEP');
export function UninstallConfirmStep({
selectedExtension,
onConfirm,
onNavigateBack,
}: UninstallConfirmStepProps) {
useKeypress(
async (key) => {
if (!selectedExtension) return;
if (key.name === 'y' || key.name === 'return') {
try {
await onConfirm(selectedExtension);
// Navigation will be handled by the parent component after successful uninstall
} catch (error) {
debugLogger.error('Failed to uninstall extension:', error);
}
} else if (key.name === 'n' || key.name === 'escape') {
onNavigateBack();
}
},
{ isActive: true },
);
if (!selectedExtension) {
return (
<Box>
<Text color={theme.status.error}>{t('No extension selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.status.error}>
{t('Are you sure you want to uninstall extension "{{name}}"?', {
name: selectedExtension.name,
})}
</Text>
<Text color={theme.text.secondary}>
{t('This action cannot be undone.')}
</Text>
<Box marginTop={1}>
<Text>{t('Press Y/Enter to confirm, N/Esc to cancel')}</Text>
</Box>
</Box>
);
}

View file

@ -0,0 +1,38 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ActionSelectionStep Snapshots > should render for active extension without update 1`] = `
"● View Details
Disable Extension
Uninstall Extension
Back"
`;
exports[`ActionSelectionStep Snapshots > should render for disabled extension 1`] = `
"● View Details
Enable Extension
Uninstall Extension
Back"
`;
exports[`ActionSelectionStep Snapshots > should render for disabled extension with update 1`] = `
"● View Details
Update Extension
Enable Extension
Uninstall Extension
Back"
`;
exports[`ActionSelectionStep Snapshots > should render for extension with update available 1`] = `
"● View Details
Update Extension
Disable Extension
Uninstall Extension
Back"
`;
exports[`ActionSelectionStep Snapshots > should render with no extension selected 1`] = `
"● View Details
Enable Extension
Uninstall Extension
Back"
`;

View file

@ -0,0 +1,36 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ExtensionListStep Snapshots > should render empty state 1`] = `
"No extensions installed.
Use '/extensions install' to install your first extension."
`;
exports[`ExtensionListStep Snapshots > should render list with multiple extensions 1`] = `
"● active-extension v1.0.0 (active) [up to date]
disabled-extension v1.0.0 (disabled) [not updatable]
update-available v1.0.0 (active) [update available]
3 extensions installed"
`;
exports[`ExtensionListStep Snapshots > should render list with single extension 1`] = `
"● test-extension v1.0.0 (active)
1 extensions installed"
`;
exports[`ExtensionListStep Snapshots > should render with checking status 1`] = `
"● checking-extension v1.0.0 (active) [checking for updates]
1 extensions installed"
`;
exports[`ExtensionListStep Snapshots > should render with error status 1`] = `
"● error-extension v1.0.0 (active) [error]
1 extensions installed"
`;

View file

@ -0,0 +1,11 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
export { ExtensionListStep } from './ExtensionListStep.js';
export { ExtensionDetailStep } from './ExtensionDetailStep.js';
export { ActionSelectionStep } from './ActionSelectionStep.js';
export { UninstallConfirmStep } from './UninstallConfirmStep.js';
export { ScopeSelectStep } from './ScopeSelectStep.js';

View file

@ -0,0 +1,89 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { Extension, Config } from '@qwen-code/qwen-code-core';
/**
* Management steps for the extensions manager dialog.
*/
export const MANAGEMENT_STEPS = {
EXTENSION_LIST: 'extension-list',
ACTION_SELECTION: 'action-selection',
EXTENSION_DETAIL: 'extension-detail',
UNINSTALL_CONFIRMATION: 'uninstall-confirmation',
DISABLE_SCOPE_SELECT: 'disable-scope-select',
ENABLE_SCOPE_SELECT: 'enable-scope-select',
UPDATE_PROGRESS: 'update-progress',
} as const;
/**
* Props for step navigation.
*/
export interface StepNavigationProps {
onNavigateToStep: (step: string) => void;
onNavigateBack: () => void;
}
/**
* Props for the extension list step.
*/
export interface ExtensionListStepProps extends StepNavigationProps {
extensions: Extension[];
extensionsUpdateState: Map<string, string>;
onExtensionSelect: (extensionIndex: number) => void;
}
/**
* Props for the extension detail step.
*/
export interface ExtensionDetailStepProps extends StepNavigationProps {
selectedExtension: Extension | null;
}
/**
* Props for the action selection step.
*/
export interface ActionSelectionStepProps extends StepNavigationProps {
selectedExtension: Extension | null;
hasUpdateAvailable: boolean;
onActionSelect: (action: ExtensionAction) => void;
}
/**
* Props for the uninstall confirmation step.
*/
export interface UninstallConfirmStepProps extends StepNavigationProps {
selectedExtension: Extension | null;
onConfirm: (extension: Extension) => Promise<void>;
}
/**
* Props for the scope selection step.
*/
export interface ScopeSelectStepProps extends StepNavigationProps {
selectedExtension: Extension | null;
mode: 'disable' | 'enable';
onScopeSelect: (scope: 'user' | 'workspace') => void;
}
/**
* Available actions for an extension.
*/
export type ExtensionAction =
| 'view'
| 'update'
| 'disable'
| 'enable'
| 'uninstall'
| 'back';
/**
* Props for the ExtensionsManagerDialog component.
*/
export interface ExtensionsManagerDialogProps {
onClose: () => void;
config: Config | null;
}

View file

@ -0,0 +1,554 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useKeypress } from '../../hooks/useKeypress.js';
import { t } from '../../../i18n/index.js';
import type {
MCPManagementDialogProps,
MCPServerDisplayInfo,
MCPToolDisplayInfo,
} from './types.js';
import { MCP_MANAGEMENT_STEPS } from './types.js';
import { ServerListStep } from './steps/ServerListStep.js';
import { ServerDetailStep } from './steps/ServerDetailStep.js';
import { ToolListStep } from './steps/ToolListStep.js';
import { ToolDetailStep } from './steps/ToolDetailStep.js';
import { DisableScopeSelectStep } from './steps/DisableScopeSelectStep.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import {
getMCPServerStatus,
DiscoveredMCPTool,
type MCPServerConfig,
type AnyDeclarativeTool,
type DiscoveredMCPPrompt,
createDebugLogger,
} from '@qwen-code/qwen-code-core';
import { loadSettings, SettingScope } from '../../../config/settings.js';
import { isToolValid, getToolInvalidReasons } from './utils.js';
const debugLogger = createDebugLogger('MCP_DIALOG');
export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
onClose,
}) => {
const config = useConfig();
const [servers, setServers] = useState<MCPServerDisplayInfo[]>([]);
const [selectedServerIndex, setSelectedServerIndex] = useState<number>(-1);
const [selectedTool, setSelectedTool] = useState<MCPToolDisplayInfo | null>(
null,
);
const [navigationStack, setNavigationStack] = useState<string[]>([
MCP_MANAGEMENT_STEPS.SERVER_LIST,
]);
const [isLoading, setIsLoading] = useState(true);
// Load MCP server data - extracted to a separate function for reuse
const fetchServerData = useCallback(async (): Promise<
MCPServerDisplayInfo[]
> => {
if (!config) return [];
const mcpServers = config.getMcpServers() || {};
const toolRegistry = config.getToolRegistry();
const promptRegistry = config.getPromptRegistry();
// Get settings to determine the scope of each server
const settings = loadSettings();
const userSettings = settings.forScope(SettingScope.User).settings;
const workspaceSettings = settings.forScope(
SettingScope.Workspace,
).settings;
const serverInfos: MCPServerDisplayInfo[] = [];
for (const [name, serverConfig] of Object.entries(mcpServers) as Array<
[string, MCPServerConfig]
>) {
const status = getMCPServerStatus(name);
// Get tools for this server
const allTools: AnyDeclarativeTool[] = toolRegistry?.getAllTools() || [];
const serverTools = allTools.filter(
(t): t is DiscoveredMCPTool =>
t instanceof DiscoveredMCPTool && t.serverName === name,
);
// Get prompts for this server
const allPrompts: DiscoveredMCPPrompt[] =
promptRegistry?.getAllPrompts() || [];
const serverPrompts = allPrompts.filter(
(p) => 'serverName' in p && p.serverName === name,
);
// Determine source type
let source: 'user' | 'project' | 'extension' = 'user';
if (serverConfig.extensionName) {
source = 'extension';
}
// Determine the scope of the configuration
let scope: 'user' | 'workspace' | 'extension' = 'user';
if (serverConfig.extensionName) {
scope = 'extension';
} else if (workspaceSettings.mcpServers?.[name]) {
scope = 'workspace';
} else if (userSettings.mcpServers?.[name]) {
scope = 'user';
}
// Use config.isMcpServerDisabled() to check if server is disabled
const isDisabled = config.isMcpServerDisabled(name);
// Count invalid tools (missing name or description)
const invalidToolCount = serverTools.filter(
(t) => !t.name || !t.description,
).length;
serverInfos.push({
name,
status,
source,
scope,
config: serverConfig,
toolCount: serverTools.length,
invalidToolCount,
promptCount: serverPrompts.length,
isDisabled,
});
}
return serverInfos;
}, [config]);
// Load MCP server data on initial render
useEffect(() => {
const loadServers = async () => {
setIsLoading(true);
try {
const serverInfos = await fetchServerData();
setServers(serverInfos);
} catch (error) {
debugLogger.error('Error loading MCP servers:', error);
} finally {
setIsLoading(false);
}
};
loadServers();
}, [fetchServerData]);
// Selected server
const selectedServer = useMemo(() => {
if (selectedServerIndex >= 0 && selectedServerIndex < servers.length) {
return servers[selectedServerIndex];
}
return null;
}, [servers, selectedServerIndex]);
// Current step
const getCurrentStep = useCallback(
() =>
navigationStack[navigationStack.length - 1] ||
MCP_MANAGEMENT_STEPS.SERVER_LIST,
[navigationStack],
);
// Navigation handlers
const handleNavigateToStep = useCallback((step: string) => {
setNavigationStack((prev) => [...prev, step]);
}, []);
const handleNavigateBack = useCallback(() => {
setNavigationStack((prev) => {
if (prev.length <= 1) return prev;
return prev.slice(0, -1);
});
}, []);
// Select server
const handleSelectServer = useCallback(
(index: number) => {
setSelectedServerIndex(index);
handleNavigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL);
},
[handleNavigateToStep],
);
// Get server tool list
const getServerTools = useCallback((): MCPToolDisplayInfo[] => {
if (!config || !selectedServer) return [];
const toolRegistry = config.getToolRegistry();
if (!toolRegistry) return [];
const allTools: AnyDeclarativeTool[] = toolRegistry.getAllTools();
const mcpTools: DiscoveredMCPTool[] = [];
for (const tool of allTools) {
if (
tool instanceof DiscoveredMCPTool &&
tool.serverName === selectedServer.name
) {
mcpTools.push(tool);
}
}
return mcpTools.map((tool) => {
// Check if tool is valid (has both name and description required by LLM)
const isValid = isToolValid(tool.name, tool.description);
let invalidReason: string | undefined;
if (!isValid) {
const reasons = getToolInvalidReasons(tool.name, tool.description);
invalidReason = reasons.map((r) => t(r)).join(', ');
}
return {
name: tool.name || t('(unnamed)'),
description: tool.description,
serverName: tool.serverName,
schema: tool.parameterSchema as object | undefined,
annotations: tool.annotations,
isValid,
invalidReason,
};
});
}, [config, selectedServer]);
// View tool list
const handleViewTools = useCallback(() => {
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST);
}, [handleNavigateToStep]);
// Select tool
const handleSelectTool = useCallback(
(tool: MCPToolDisplayInfo) => {
setSelectedTool(tool);
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL);
},
[handleNavigateToStep],
);
// Reload server data - uses the extracted fetchServerData function
const reloadServers = useCallback(async () => {
setIsLoading(true);
try {
const serverInfos = await fetchServerData();
setServers(serverInfos);
} catch (error) {
debugLogger.error('Error reloading MCP servers:', error);
} finally {
setIsLoading(false);
}
}, [fetchServerData]);
// Reconnect server
const handleReconnect = useCallback(async () => {
if (!config || !selectedServer) return;
try {
setIsLoading(true);
const toolRegistry = config.getToolRegistry();
if (toolRegistry) {
await toolRegistry.discoverToolsForServer(selectedServer.name);
}
// Reload server data to update status
await reloadServers();
} catch (error) {
debugLogger.error(
`Error reconnecting to server '${selectedServer.name}':`,
error,
);
} finally {
setIsLoading(false);
}
}, [config, selectedServer, reloadServers]);
// Enable server
const handleEnableServer = useCallback(async () => {
if (!config || !selectedServer) return;
try {
setIsLoading(true);
const server = selectedServer;
const settings = loadSettings();
// Remove from user and workspace exclusion lists
for (const scope of [SettingScope.User, SettingScope.Workspace]) {
const scopeSettings = settings.forScope(scope).settings;
const currentExcluded = scopeSettings.mcp?.excluded || [];
if (currentExcluded.includes(server.name)) {
const newExcluded = currentExcluded.filter(
(name: string) => name !== server.name,
);
settings.setValue(scope, 'mcp.excluded', newExcluded);
}
}
// Update runtime config exclusion list
const currentExcluded = config.getExcludedMcpServers() || [];
const newExcluded = currentExcluded.filter(
(name: string) => name !== server.name,
);
config.setExcludedMcpServers(newExcluded);
// Rediscover tools for this server
const toolRegistry = config.getToolRegistry();
if (toolRegistry) {
await toolRegistry.discoverToolsForServer(server.name);
}
// Reload server data
await reloadServers();
} catch (error) {
debugLogger.error(
`Error enabling server '${selectedServer.name}':`,
error,
);
} finally {
setIsLoading(false);
}
}, [config, selectedServer, reloadServers]);
// Handle disable/enable action
const handleDisable = useCallback(() => {
if (!selectedServer) return;
// If server is already disabled, enable it directly
if (selectedServer.isDisabled) {
void handleEnableServer();
} else {
// Otherwise navigate to disable scope selection
handleNavigateToStep(MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT);
}
}, [selectedServer, handleEnableServer, handleNavigateToStep]);
// Execute disable after selecting scope
const handleSelectDisableScope = useCallback(
async (scope: 'user' | 'workspace') => {
if (!config || !selectedServer) return;
try {
setIsLoading(true);
const server = selectedServer;
const settings = loadSettings();
// Get current exclusion list
const scopeSettings = settings.forScope(
scope === 'user' ? SettingScope.User : SettingScope.Workspace,
).settings;
const currentExcluded = scopeSettings.mcp?.excluded || [];
// If server is not in exclusion list, add it
if (!currentExcluded.includes(server.name)) {
const newExcluded = [...currentExcluded, server.name];
settings.setValue(
scope === 'user' ? SettingScope.User : SettingScope.Workspace,
'mcp.excluded',
newExcluded,
);
}
// Use new disableMcpServer method to disable server
const toolRegistry = config.getToolRegistry();
if (toolRegistry) {
await toolRegistry.disableMcpServer(server.name);
}
// Reload server list
await reloadServers();
// Return to server detail page
handleNavigateBack();
} catch (error) {
debugLogger.error(
`Error disabling server '${selectedServer.name}':`,
error,
);
} finally {
setIsLoading(false);
}
},
[config, selectedServer, handleNavigateBack, reloadServers],
);
// Render step header
const renderStepHeader = useCallback(() => {
const currentStep = getCurrentStep();
let headerText = '';
switch (currentStep) {
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
headerText = t('Manage MCP servers');
break;
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
headerText = selectedServer?.name || t('Server Detail');
break;
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
headerText = t('Disable Server');
break;
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
headerText = t('Tools');
break;
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
headerText = selectedTool?.name || t('Tool Detail');
break;
default:
headerText = t('MCP Management');
}
return (
<Box>
<Text color={theme.text.accent} bold>
{headerText}
</Text>
</Box>
);
}, [getCurrentStep, selectedServer, selectedTool]);
// Render step content
const renderStepContent = useCallback(() => {
if (isLoading) {
return <Text color={theme.text.secondary}>{t('Loading...')}</Text>;
}
const currentStep = getCurrentStep();
switch (currentStep) {
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
return (
<ServerListStep servers={servers} onSelect={handleSelectServer} />
);
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
return (
<ServerDetailStep
server={selectedServer}
onViewTools={handleViewTools}
onReconnect={handleReconnect}
onDisable={handleDisable}
onBack={handleNavigateBack}
/>
);
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
return (
<DisableScopeSelectStep
server={selectedServer}
onSelectScope={handleSelectDisableScope}
onBack={handleNavigateBack}
/>
);
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
return (
<ToolListStep
tools={getServerTools()}
serverName={selectedServer?.name || ''}
onSelect={handleSelectTool}
onBack={handleNavigateBack}
/>
);
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
return (
<ToolDetailStep tool={selectedTool} onBack={handleNavigateBack} />
);
default:
return (
<Box>
<Text color={theme.status.error}>{t('Unknown step')}</Text>
</Box>
);
}
}, [
isLoading,
getCurrentStep,
servers,
selectedServer,
selectedTool,
handleSelectServer,
handleViewTools,
handleReconnect,
handleDisable,
handleNavigateBack,
handleSelectTool,
handleSelectDisableScope,
getServerTools,
]);
// Render step footer
const renderStepFooter = useCallback(() => {
const currentStep = getCurrentStep();
let footerText = '';
switch (currentStep) {
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
if (servers.length === 0) {
footerText = t('Esc to close');
} else {
footerText = t('↑↓ to navigate · Enter to select · Esc to close');
}
break;
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
footerText = t('↑↓ to navigate · Enter to select · Esc to back');
break;
case MCP_MANAGEMENT_STEPS.DISABLE_SCOPE_SELECT:
footerText = t('↑↓ to navigate · Enter to confirm · Esc to back');
break;
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
footerText = t('↑↓ to navigate · Enter to select · Esc to back');
break;
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
footerText = t('Esc to back');
break;
default:
footerText = t('Esc to close');
}
return (
<Box>
<Text color={theme.text.secondary}>{footerText}</Text>
</Box>
);
}, [getCurrentStep, servers.length]);
// ESC key handler - only close dialog, child components handle back navigation to avoid duplicate triggers
useKeypress(
(key) => {
if (
key.name === 'escape' &&
getCurrentStep() === MCP_MANAGEMENT_STEPS.SERVER_LIST
) {
onClose();
}
},
{ isActive: true },
);
return (
<Box flexDirection="column">
<Box
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
gap={1}
>
{renderStepHeader()}
{renderStepContent()}
{renderStepFooter()}
</Box>
</Box>
);
};

View file

@ -0,0 +1,47 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* MCP管理相关常量
*/
/**
*
*/
export const MAX_DISPLAY_TOOLS = 10;
/**
* prompt数量
*/
export const MAX_DISPLAY_PROMPTS = 10;
/**
*
*/
export const VISIBLE_LOGS_COUNT = 15;
/**
*
*/
export const VISIBLE_TOOLS_COUNT = 10;
/**
*
*/
export const SOURCE_DISPLAY_NAMES: Record<string, string> = {
user: 'User MCPs',
project: 'Project MCPs',
extension: 'Extension MCPs',
};
/**
*
*/
export const STATUS_TEXT: Record<string, string> = {
connected: 'connected',
connecting: 'connecting',
disconnected: 'failed',
};

View file

@ -0,0 +1,30 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
// Main Dialog
export { MCPManagementDialog } from './MCPManagementDialog.js';
// Steps
export { ServerListStep } from './steps/ServerListStep.js';
export { ServerDetailStep } from './steps/ServerDetailStep.js';
export { ToolListStep } from './steps/ToolListStep.js';
export { ToolDetailStep } from './steps/ToolDetailStep.js';
// Types
export type {
MCPManagementDialogProps,
MCPServerDisplayInfo,
MCPToolDisplayInfo,
MCPPromptDisplayInfo,
ServerListStepProps,
ServerDetailStepProps,
ToolListStepProps,
ToolDetailStepProps,
MCPManagementStep,
} from './types.js';
// Constants
export { MCP_MANAGEMENT_STEPS } from './types.js';

View file

@ -0,0 +1,88 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { t } from '../../../../i18n/index.js';
import type { DisableScopeSelectStepProps } from '../types.js';
export const DisableScopeSelectStep: React.FC<DisableScopeSelectStepProps> = ({
server,
onSelectScope,
onBack,
}) => {
const [selectedScope, setSelectedScope] = useState<'user' | 'workspace'>(
'user',
);
const scopes = [
{
key: 'user',
get label() {
return t('User Settings (global)');
},
value: 'user' as const,
},
{
key: 'workspace',
get label() {
return t('Workspace Settings (project-specific)');
},
value: 'workspace' as const,
},
];
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
} else if (key.name === 'return') {
onSelectScope(selectedScope);
}
},
{ isActive: true },
);
if (!server) {
return (
<Box>
<Text color={theme.status.error}>{t('No server selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
<Box flexDirection="column">
<Text color={theme.text.primary}>
{t('Disable server:')} {server.name}
</Text>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Select where to add the server to the exclude list:')}
</Text>
</Box>
</Box>
<Box marginTop={1}>
<RadioButtonSelect<'user' | 'workspace'>
items={scopes}
onHighlight={(value: 'user' | 'workspace') => setSelectedScope(value)}
onSelect={(value: 'user' | 'workspace') => onSelectScope(value)}
/>
</Box>
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Press Enter to confirm, Esc to cancel')}
</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,223 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { RadioButtonSelect } from '../../shared/RadioButtonSelect.js';
import { t } from '../../../../i18n/index.js';
import type { ServerDetailStepProps } from '../types.js';
import {
getStatusColor,
getStatusIcon,
formatServerCommand,
} from '../utils.js';
// 标签列宽度
const LABEL_WIDTH = 15;
type ServerAction = 'view-tools' | 'reconnect' | 'toggle-disable';
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
server,
onViewTools,
onReconnect,
onDisable,
onBack,
}) => {
const [selectedAction, setSelectedAction] =
useState<ServerAction>('view-tools');
const statusColor = server ? getStatusColor(server.status) : 'gray';
const actions = [
{
key: 'view-tools',
get label() {
return t('View tools');
},
value: 'view-tools' as const,
},
{
key: 'reconnect',
get label() {
return t('Reconnect');
},
value: 'reconnect' as const,
},
{
key: 'toggle-disable',
get label() {
return server?.isDisabled ? t('Enable') : t('Disable');
},
value: 'toggle-disable' as const,
},
];
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
} else if (key.name === 'return') {
switch (selectedAction) {
case 'view-tools':
onViewTools();
break;
case 'reconnect':
onReconnect?.();
break;
case 'toggle-disable':
onDisable?.();
break;
default:
break;
}
}
},
{ isActive: true },
);
if (!server) {
return (
<Box>
<Text color={theme.status.error}>{t('No server selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
{/* 服务器详情 */}
<Box flexDirection="column">
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Status:')}</Text>
</Box>
<Box>
<Text
color={
statusColor === 'green'
? theme.status.success
: statusColor === 'yellow'
? theme.status.warning
: theme.status.error
}
>
{getStatusIcon(server.status)} {t(server.status)}
{server.isDisabled && (
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
)}
</Text>
</Box>
</Box>
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Source:')}</Text>
</Box>
<Box>
<Text color={theme.text.secondary}>
{server.scope === 'user'
? t('User Settings')
: server.scope === 'workspace'
? t('Workspace Settings')
: t('Extension')}
</Text>
</Box>
</Box>
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Command:')}</Text>
</Box>
<Box>
<Text wrap="truncate">{formatServerCommand(server)}</Text>
</Box>
</Box>
{server.config.cwd && (
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Working Directory:')}</Text>
</Box>
<Box>
<Text wrap="truncate">{server.config.cwd}</Text>
</Box>
</Box>
)}
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Capabilities:')}</Text>
</Box>
<Box>
<Text>
{server.toolCount > 0 ? t('tools') : ''}
{server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''}
{server.promptCount > 0 ? t('prompts') : ''}
</Text>
</Box>
</Box>
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Tools:')}</Text>
</Box>
<Box>
<Text>
{server.toolCount}{' '}
{server.toolCount === 1 ? t('tool') : t('tools')}
{!!server.invalidToolCount && server.invalidToolCount > 0 && (
<Text color={theme.status.warning}>
{' '}
({server.invalidToolCount}{' '}
{server.invalidToolCount === 1 ? t('invalid') : t('invalid')})
</Text>
)}
</Text>
</Box>
</Box>
{server.errorMessage && (
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.status.error}>{t('Error:')}</Text>
</Box>
<Box>
<Text color={theme.status.error} wrap="wrap">
{server.errorMessage}
</Text>
</Box>
</Box>
)}
</Box>
{/* 操作列表 */}
<Box>
<RadioButtonSelect<ServerAction>
items={actions}
onHighlight={(value: ServerAction) => setSelectedAction(value)}
onSelect={(value: ServerAction) => {
switch (value) {
case 'view-tools':
onViewTools();
break;
case 'reconnect':
onReconnect?.();
break;
case 'toggle-disable':
onDisable?.();
break;
default:
break;
}
}}
/>
</Box>
</Box>
);
};

View file

@ -0,0 +1,185 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
import type { ServerListStepProps, MCPServerDisplayInfo } from '../types.js';
import {
groupServersBySource,
getStatusIcon,
getStatusColor,
} from '../utils.js';
export const ServerListStep: React.FC<ServerListStepProps> = ({
servers,
onSelect,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
const groupedServers = useMemo(
() => groupServersBySource(servers),
[servers],
);
// 动态计算服务器名称列的最大宽度(基于实际内容)
const serverNameWidth = useMemo(() => {
if (servers.length === 0) return 20;
const maxLength = Math.max(...servers.map((s) => s.name.length));
// 最小 20最大 35留一些余量
return Math.min(Math.max(maxLength + 2, 20), 35);
}, [servers]);
// 计算扁平化的服务器列表用于导航
const flatServers = useMemo(() => {
const result: MCPServerDisplayInfo[] = [];
for (const group of groupedServers) {
result.push(...group.servers);
}
return result;
}, [groupedServers]);
// 键盘导航
useKeypress(
(key) => {
if (key.name === 'up') {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
setSelectedIndex((prev) => Math.min(flatServers.length - 1, prev + 1));
} else if (key.name === 'return') {
onSelect(selectedIndex);
}
},
{ isActive: true },
);
if (servers.length === 0) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{t('No MCP servers configured.')}
</Text>
<Text color={theme.text.secondary}>
{t('Add MCP servers to your settings to get started.')}
</Text>
</Box>
);
}
// 计算当前选中项在分组中的位置
const getSelectionPosition = (globalIndex: number) => {
let currentIndex = 0;
for (const group of groupedServers) {
if (globalIndex < currentIndex + group.servers.length) {
return {
groupIndex: groupedServers.indexOf(group),
itemIndex: globalIndex - currentIndex,
};
}
currentIndex += group.servers.length;
}
return { groupIndex: 0, itemIndex: 0 };
};
const currentPosition = getSelectionPosition(selectedIndex);
return (
<Box flexDirection="column">
{/* 服务器统计 */}
<Box marginBottom={1}>
<Text color={theme.text.secondary}>
{servers.length} {servers.length === 1 ? t('server') : t('servers')}
</Text>
</Box>
{/* 分组服务器列表 */}
{groupedServers.map((group, groupIndex) => (
<Box key={group.source} flexDirection="column" marginBottom={1}>
<Text bold color={theme.text.primary}>
{group.displayName}
{group.servers[0]?.configPath && (
<Text color={theme.text.secondary}>
{' '}
({group.servers[0].configPath})
</Text>
)}
</Text>
<Box flexDirection="column" marginTop={1}>
{group.servers.map((server, itemIndex) => {
const isSelected =
groupIndex === currentPosition.groupIndex &&
itemIndex === currentPosition.itemIndex;
const statusColor = getStatusColor(server.status);
return (
<Box key={server.name}>
<Box minWidth={2}>
<Text
color={
isSelected ? theme.text.accent : theme.text.primary
}
>
{isSelected ? '' : ' '}
</Text>
</Box>
{/* 服务器名称 - 固定宽度 */}
<Box width={serverNameWidth}>
<Text
color={
isSelected ? theme.text.accent : theme.text.primary
}
wrap="truncate"
>
{server.name}
</Text>
</Box>
<Text color={theme.text.secondary}> · </Text>
{/* 状态图标和文本 */}
<Text
color={
statusColor === 'green'
? theme.status.success
: statusColor === 'yellow'
? theme.status.warning
: theme.status.error
}
>
{getStatusIcon(server.status)} {t(server.status)}
</Text>
{/* 显示 Scope 和禁用状态 */}
<Text color={theme.text.secondary}> [{server.scope}]</Text>
{server.isDisabled && (
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
)}
{/* 显示无效工具警告 */}
{!!server.invalidToolCount && server.invalidToolCount > 0 && (
<Text color={theme.status.warning}>
{' '}
{t('{{count}} invalid tools', {
count: String(server.invalidToolCount),
})}
</Text>
)}
</Box>
);
})}
</Box>
</Box>
))}
{/* 提示信息 */}
{servers.some((s) => s.status === 'disconnected') && (
<Box>
<Text color={theme.status.warning}>
{t('Run qwen --debug to see error logs')}
</Text>
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,217 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
import type { ToolDetailStepProps } from '../types.js';
/**
*
*/
const truncate = (str: string, maxLen: number = 50): string => {
if (str.length <= maxLen) return str;
return str.substring(0, maxLen - 3) + '...';
};
/**
*
*/
const renderParameter = (
name: string,
param: Record<string, unknown>,
isRequired: boolean,
): React.ReactNode => {
const type = (param['type'] as string) || 'any';
const description = (param['description'] as string) || '';
const defaultValue = param['default'];
const enumValues = param['enum'] as string[] | undefined;
return (
<Box key={name} flexDirection="column" marginTop={1}>
<Box>
<Text color={theme.text.primary}> {name}</Text>
{isRequired && (
<Text color={theme.status.error}> ({t('required')})</Text>
)}
</Box>
<Box marginLeft={2}>
<Text color={theme.text.secondary}>{t('Type')}: </Text>
<Text color={theme.status.success}>{type}</Text>
</Box>
{description && (
<Box marginLeft={2}>
<Text color={theme.text.secondary} wrap="wrap">
{truncate(description, 80)}
</Text>
</Box>
)}
{enumValues && enumValues.length > 0 && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
{t('Enum')}: {enumValues.join(', ')}
</Text>
</Box>
)}
{defaultValue !== undefined && (
<Box marginLeft={2}>
<Text color={theme.text.secondary}>
{t('Default')}:{' '}
{typeof defaultValue === 'string'
? `"${truncate(defaultValue, 30)}"`
: String(defaultValue)}
</Text>
</Box>
)}
</Box>
);
};
/**
*
*/
const ParametersList: React.FC<{
properties: Record<string, unknown>;
required: string[];
}> = ({ properties, required }) => {
const requiredSet = new Set(required);
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>{t('Parameters')}:</Text>
<Box marginLeft={2} flexDirection="column">
{Object.entries(properties).map(([name, param]) =>
renderParameter(
name,
param as Record<string, unknown>,
requiredSet.has(name),
),
)}
</Box>
</Box>
);
};
/**
* schema的关键信息使
*/
const SchemaSummary: React.FC<{ schema: object }> = ({ schema }) => {
const obj = schema as Record<string, unknown>;
const properties = obj['properties'] as Record<string, unknown> | undefined;
const required = (obj['required'] as string[]) || [];
return (
<Box flexDirection="column">
{/* 参数列表 */}
{properties && Object.keys(properties).length > 0 && (
<ParametersList properties={properties} required={required} />
)}
</Box>
);
};
export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
tool,
onBack,
}) => {
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
}
},
{ isActive: true },
);
if (!tool) {
return (
<Box>
<Text color={theme.status.error}>{t('No tool selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
{/* 无效工具警告 */}
{!tool.isValid && (
<Box flexDirection="column" marginBottom={1}>
<Text color={theme.status.error} bold>
{t('Warning: This tool cannot be called by the LLM')}
</Text>
<Text color={theme.status.error}>
{t('Reason')}: {tool.invalidReason || t('unknown')}
</Text>
<Text color={theme.text.secondary}>
{t(
'Tools must have both name and description to be used by the LLM.',
)}
</Text>
</Box>
)}
{/* 工具描述 */}
{tool.description && (
<Box>
<Text wrap="wrap">{tool.description}</Text>
</Box>
)}
{/* 工具注解 */}
{tool.annotations && (
<Box flexDirection="column" marginTop={1}>
<Text color={theme.text.secondary}>{t('Annotations')}:</Text>
<Box marginLeft={2} flexDirection="column">
{tool.annotations.title && (
<Text color={theme.text.secondary}>
{t('Title')}: {tool.annotations.title}
</Text>
)}
{tool.annotations.readOnlyHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Read Only')}:{' '}
{tool.annotations.readOnlyHint ? t('Yes') : t('No')}
</Text>
)}
{tool.annotations.destructiveHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Destructive')}:{' '}
{tool.annotations.destructiveHint ? t('Yes') : t('No')}
</Text>
)}
{tool.annotations.idempotentHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Idempotent')}:{' '}
{tool.annotations.idempotentHint ? t('Yes') : t('No')}
</Text>
)}
{tool.annotations.openWorldHint !== undefined && (
<Text color={theme.text.secondary}>
{t('Open World')}:{' '}
{tool.annotations.openWorldHint ? t('Yes') : t('No')}
</Text>
)}
</Box>
</Box>
)}
{/* Schema */}
{tool.schema && (
<Box flexDirection="column" marginTop={1}>
<SchemaSummary schema={tool.schema} />
</Box>
)}
{/* 所属服务器 */}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Server')}: {tool.serverName}
</Text>
</Box>
</Box>
);
};

View file

@ -0,0 +1,157 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
import { t } from '../../../../i18n/index.js';
import type { ToolListStepProps, MCPToolDisplayInfo } from '../types.js';
import { VISIBLE_TOOLS_COUNT } from '../constants.js';
export const ToolListStep: React.FC<ToolListStepProps> = ({
tools,
serverName,
onSelect,
onBack,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0);
// 动态计算工具名称列的最大宽度(基于实际内容)
const toolNameWidth = useMemo(() => {
if (tools.length === 0) return 30;
const maxLength = Math.max(...tools.map((t) => t.name.length));
// 最小 30最大 50留一些余量
return Math.min(Math.max(maxLength + 2, 30), 50);
}, [tools]);
// 计算可视区域的起始索引(滚动窗口)
const scrollOffset = useMemo(() => {
if (tools.length <= VISIBLE_TOOLS_COUNT) {
return 0;
}
// 确保选中项在可视区域内
if (selectedIndex < VISIBLE_TOOLS_COUNT - 1) {
return 0;
}
return Math.min(
selectedIndex - VISIBLE_TOOLS_COUNT + 1,
tools.length - VISIBLE_TOOLS_COUNT,
);
}, [selectedIndex, tools.length]);
// 当前可视的工具列表
const displayTools = useMemo(
() => tools.slice(scrollOffset, scrollOffset + VISIBLE_TOOLS_COUNT),
[tools, scrollOffset],
);
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
} else if (key.name === 'up') {
setSelectedIndex((prev) => Math.max(0, prev - 1));
} else if (key.name === 'down') {
setSelectedIndex((prev) => Math.min(tools.length - 1, prev + 1));
} else if (key.name === 'return') {
if (tools[selectedIndex]) {
onSelect(tools[selectedIndex]);
}
}
},
{ isActive: true },
);
if (tools.length === 0) {
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>
{t('No tools available for this server.')}
</Text>
</Box>
);
}
const getToolAnnotations = (tool: MCPToolDisplayInfo): string => {
const hints: string[] = [];
if (tool.annotations?.destructiveHint) hints.push(t('destructive'));
if (tool.annotations?.readOnlyHint) hints.push(t('read-only'));
if (tool.annotations?.openWorldHint) hints.push(t('open-world'));
if (tool.annotations?.idempotentHint) hints.push(t('idempotent'));
return hints.join(', ');
};
return (
<Box flexDirection="column">
{/* 标题 */}
<Box marginBottom={1}>
<Text bold>{t('Tools for {{name}}', { name: serverName })}</Text>
<Text color={theme.text.secondary}>
{' '}
({tools.length} {tools.length === 1 ? t('tool') : t('tools')})
</Text>
</Box>
{/* 工具列表 */}
<Box flexDirection="column">
{displayTools.map((tool, index) => {
const actualIndex = scrollOffset + index;
const isSelected = actualIndex === selectedIndex;
const annotations = getToolAnnotations(tool);
return (
<Box key={tool.name}>
{/* 选择器和序号 */}
<Box minWidth={4}>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
>
{isSelected ? '' : ' '}
</Text>
<Text color={theme.text.secondary}>{actualIndex + 1}.</Text>
</Box>
{/* 工具名称 - 固定宽度 */}
<Box width={toolNameWidth}>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
wrap="truncate"
>
{tool.name}
</Text>
</Box>
{/* 显示无效工具警告 */}
{!tool.isValid && (
<Text color={theme.status.warning}>
{t('invalid: {{reason}}', {
reason: tool.invalidReason || t('unknown'),
})}
</Text>
)}
{annotations && tool.isValid && (
<Text color={theme.text.secondary}>{annotations}</Text>
)}
</Box>
);
})}
</Box>
{/* 滚动提示 */}
{tools.length > VISIBLE_TOOLS_COUNT && (
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{scrollOffset > 0 ? '↑ ' : ' '}
{t('{{current}}/{{total}}', {
current: (selectedIndex + 1).toString(),
total: tools.length.toString(),
})}
{scrollOffset + VISIBLE_TOOLS_COUNT < tools.length ? ' ↓' : ''}
</Text>
</Box>
)}
</Box>
);
};

View file

@ -0,0 +1,180 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type {
MCPServerConfig,
MCPServerStatus,
} from '@qwen-code/qwen-code-core';
/**
* MCP管理步骤定义
*/
export const MCP_MANAGEMENT_STEPS = {
SERVER_LIST: 'server-list',
SERVER_DETAIL: 'server-detail',
DISABLE_SCOPE_SELECT: 'disable-scope-select',
TOOL_LIST: 'tool-list',
TOOL_DETAIL: 'tool-detail',
} as const;
export type MCPManagementStep =
(typeof MCP_MANAGEMENT_STEPS)[keyof typeof MCP_MANAGEMENT_STEPS];
/**
* MCP服务器显示信息
*/
export interface MCPServerDisplayInfo {
/** 服务器名称 */
name: string;
/** 连接状态 */
status: MCPServerStatus;
/** 来源类型 */
source: 'user' | 'project' | 'extension';
/** 配置所在的 scope */
scope: 'user' | 'workspace' | 'extension';
/** 配置文件路径 */
configPath?: string;
/** 服务器配置 */
config: MCPServerConfig;
/** 工具数量 */
toolCount: number;
/** 无效工具数量缺少name或description */
invalidToolCount?: number;
/** Prompt数量 */
promptCount: number;
/** 错误信息 */
errorMessage?: string;
/** 是否被禁用(在排除列表中) */
isDisabled: boolean;
}
/**
* MCP工具显示信息
*/
export interface MCPToolDisplayInfo {
/** 工具名称 */
name: string;
/** 工具描述 */
description?: string;
/** 所属服务器 */
serverName: string;
/** 工具schema */
schema?: object;
/** 工具注解 */
annotations?: {
title?: string;
readOnlyHint?: boolean;
destructiveHint?: boolean;
idempotentHint?: boolean;
openWorldHint?: boolean;
};
/** 工具是否有效有name和description才能被LLM调用 */
isValid: boolean;
/** 无效原因当isValid为false时 */
invalidReason?: string;
}
/**
* MCP Prompt显示信息
*/
export interface MCPPromptDisplayInfo {
/** Prompt名称 */
name: string;
/** Prompt描述 */
description?: string;
/** 所属服务器 */
serverName: string;
/** 参数定义 */
arguments?: Array<{
name: string;
description?: string;
required?: boolean;
}>;
}
/**
*
*/
export interface GroupedServers {
/** 来源标识 */
source: string;
/** 来源显示名称 */
displayName: string;
/** 配置文件路径 */
configPath?: string;
/** 服务器列表 */
servers: MCPServerDisplayInfo[];
}
/**
* ServerListStep组件属性
*/
export interface ServerListStepProps {
/** 服务器列表 */
servers: MCPServerDisplayInfo[];
/** 选择回调 */
onSelect: (index: number) => void;
}
/**
* ServerDetailStep组件属性
*/
export interface ServerDetailStepProps {
/** 选中的服务器 */
server: MCPServerDisplayInfo | null;
/** 查看工具列表回调 */
onViewTools: () => void;
/** 重新连接回调 */
onReconnect?: () => void;
/** 禁用服务器回调 */
onDisable?: () => void;
/** 返回回调 */
onBack: () => void;
}
/**
* DisableScopeSelectStep组件属性
*/
export interface DisableScopeSelectStepProps {
/** 选中的服务器 */
server: MCPServerDisplayInfo | null;
/** 选择 scope 回调 */
onSelectScope: (scope: 'user' | 'workspace') => void;
/** 返回回调 */
onBack: () => void;
}
/**
* ToolListStep组件属性
*/
export interface ToolListStepProps {
/** 工具列表 */
tools: MCPToolDisplayInfo[];
/** 服务器名称 */
serverName: string;
/** 选择回调 */
onSelect: (tool: MCPToolDisplayInfo) => void;
/** 返回回调 */
onBack: () => void;
}
/**
* ToolDetailStep组件属性
*/
export interface ToolDetailStepProps {
/** 工具信息 */
tool: MCPToolDisplayInfo | null;
/** 返回回调 */
onBack: () => void;
}
/**
* MCP管理对话框属性
*/
export interface MCPManagementDialogProps {
/** 关闭回调 */
onClose: () => void;
}

View file

@ -0,0 +1,159 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import {
groupServersBySource,
getStatusColor,
getStatusIcon,
truncateText,
formatServerCommand,
isToolValid,
getToolInvalidReasons,
} from './utils.js';
import type { MCPServerDisplayInfo } from './types.js';
import { MCPServerStatus } from '@qwen-code/qwen-code-core';
describe('MCP utils', () => {
describe('groupServersBySource', () => {
it('should group servers by source', () => {
const servers: MCPServerDisplayInfo[] = [
{
name: 'server1',
status: MCPServerStatus.CONNECTED,
source: 'user',
scope: 'user',
config: { command: 'cmd1' },
toolCount: 1,
promptCount: 0,
isDisabled: false,
},
{
name: 'server2',
status: MCPServerStatus.CONNECTED,
source: 'extension',
scope: 'extension',
config: { command: 'cmd2' },
toolCount: 2,
promptCount: 0,
isDisabled: false,
},
];
const result = groupServersBySource(servers);
expect(result).toHaveLength(2);
expect(result[0].source).toBe('user');
expect(result[0].servers).toHaveLength(1);
expect(result[1].source).toBe('extension');
});
});
describe('getStatusColor', () => {
it('should return correct colors for each status', () => {
expect(getStatusColor(MCPServerStatus.CONNECTED)).toBe('green');
expect(getStatusColor(MCPServerStatus.CONNECTING)).toBe('yellow');
expect(getStatusColor(MCPServerStatus.DISCONNECTED)).toBe('red');
expect(getStatusColor('unknown' as MCPServerStatus)).toBe('gray');
});
});
describe('getStatusIcon', () => {
it('should return correct icons for each status', () => {
expect(getStatusIcon(MCPServerStatus.CONNECTED)).toBe('✓');
expect(getStatusIcon(MCPServerStatus.CONNECTING)).toBe('…');
expect(getStatusIcon(MCPServerStatus.DISCONNECTED)).toBe('✗');
expect(getStatusIcon('unknown' as MCPServerStatus)).toBe('?');
});
});
describe('truncateText', () => {
it('should truncate text longer than maxLength', () => {
expect(truncateText('hello world', 8)).toBe('hello...');
});
it('should not truncate text shorter than maxLength', () => {
expect(truncateText('hello', 10)).toBe('hello');
});
});
describe('formatServerCommand', () => {
it('should format http URL', () => {
const server = {
config: { httpUrl: 'http://localhost:3000' },
} as MCPServerDisplayInfo;
expect(formatServerCommand(server)).toBe('http://localhost:3000 (http)');
});
it('should format stdio command', () => {
const server = {
config: { command: 'node', args: ['server.js'] },
} as MCPServerDisplayInfo;
expect(formatServerCommand(server)).toBe('node server.js (stdio)');
});
it('should return Unknown for empty config', () => {
const server = { config: {} } as MCPServerDisplayInfo;
expect(formatServerCommand(server)).toBe('Unknown');
});
});
describe('isToolValid', () => {
it('should return true for valid tool with name and description', () => {
expect(isToolValid('toolName', 'A description')).toBe(true);
});
it('should return false for tool without name', () => {
expect(isToolValid(undefined, 'A description')).toBe(false);
expect(isToolValid('', 'A description')).toBe(false);
});
it('should return false for tool without description', () => {
expect(isToolValid('toolName', undefined)).toBe(false);
expect(isToolValid('toolName', '')).toBe(false);
});
it('should return false for tool without both name and description', () => {
expect(isToolValid(undefined, undefined)).toBe(false);
expect(isToolValid('', '')).toBe(false);
});
});
describe('getToolInvalidReasons', () => {
it('should return empty array for valid tool', () => {
expect(getToolInvalidReasons('toolName', 'A description')).toEqual([]);
});
it('should return missing name reason', () => {
expect(getToolInvalidReasons(undefined, 'A description')).toEqual([
'missing name',
]);
expect(getToolInvalidReasons('', 'A description')).toEqual([
'missing name',
]);
});
it('should return missing description reason', () => {
expect(getToolInvalidReasons('toolName', undefined)).toEqual([
'missing description',
]);
expect(getToolInvalidReasons('toolName', '')).toEqual([
'missing description',
]);
});
it('should return both reasons when both are missing', () => {
expect(getToolInvalidReasons(undefined, undefined)).toEqual([
'missing name',
'missing description',
]);
expect(getToolInvalidReasons('', '')).toEqual([
'missing name',
'missing description',
]);
});
});
});

View file

@ -0,0 +1,129 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import type { MCPServerDisplayInfo, GroupedServers } from './types.js';
import { SOURCE_DISPLAY_NAMES } from './constants.js';
/**
*
*/
export function groupServersBySource(
servers: MCPServerDisplayInfo[],
): GroupedServers[] {
const groups = new Map<string, MCPServerDisplayInfo[]>();
for (const server of servers) {
const existing = groups.get(server.source);
if (existing) {
existing.push(server);
} else {
groups.set(server.source, [server]);
}
}
// 按优先级排序: user > project > extension
const sourceOrder = ['user', 'project', 'extension'];
const result: GroupedServers[] = [];
for (const source of sourceOrder) {
const servers = groups.get(source);
if (servers && servers.length > 0) {
result.push({
source,
displayName: SOURCE_DISPLAY_NAMES[source] || source,
servers,
});
}
}
return result;
}
/**
*
*/
export function getStatusColor(
status: string,
): 'green' | 'yellow' | 'red' | 'gray' {
switch (status) {
case 'connected':
return 'green';
case 'connecting':
return 'yellow';
case 'disconnected':
return 'red';
default:
return 'gray';
}
}
/**
*
*/
export function getStatusIcon(status: string): string {
switch (status) {
case 'connected':
return '✓';
case 'connecting':
return '…';
case 'disconnected':
return '✗';
default:
return '?';
}
}
/**
*
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.substring(0, maxLength - 3) + '...';
}
/**
*
*/
export function formatServerCommand(server: MCPServerDisplayInfo): string {
const config = server.config;
if (config.httpUrl) {
return `${config.httpUrl} (http)`;
}
if (config.url) {
return `${config.url} (sse)`;
}
if (config.command) {
const args = config.args?.join(' ') || '';
return `${config.command} ${args} (stdio)`.trim();
}
return 'Unknown';
}
/**
* Check if a tool is valid (has both name and description required by LLM)
* @param name - Tool name
* @param description - Tool description
* @returns boolean indicating if the tool is valid
*/
export function isToolValid(name?: string, description?: string): boolean {
return !!name && !!description;
}
/**
* Get the reason why a tool is invalid
* @param name - Tool name
* @param description - Tool description
* @returns Array of missing fields
*/
export function getToolInvalidReasons(
name?: string,
description?: string,
): string[] {
const reasons: string[] = [];
if (!name) reasons.push('missing name');
if (!description) reasons.push('missing description');
return reasons;
}

View file

@ -130,7 +130,7 @@ describe('<AskUserQuestionDialog />', () => {
expect(output).toContain('Switch tabs');
});
it('renders multi-select with checkboxes and submit option', () => {
it('renders multi-select with checkboxes', () => {
const details = createConfirmationDetails({
questions: [createSingleQuestion({ multiSelect: true })],
});
@ -145,8 +145,8 @@ describe('<AskUserQuestionDialog />', () => {
const output = lastFrame();
expect(output).toContain('[ ]');
expect(output).toContain('Submit');
expect(output).toContain('Space/Enter: Toggle');
expect(output).toContain('Space: Toggle');
expect(output).toContain('Enter: Confirm');
});
});
@ -322,29 +322,7 @@ describe('<AskUserQuestionDialog />', () => {
unmount();
});
it('toggles options with Enter', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails({
questions: [createSingleQuestion({ multiSelect: true })],
});
const { stdin, lastFrame, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
/>,
);
await wait();
// Enter to toggle first option
stdin.write('\r');
await wait();
expect(lastFrame()).toContain('[✓]');
unmount();
});
it('selects option with Space and submits for multi-select question', async () => {
it('submits multi-select with Space to toggle then Enter to confirm', async () => {
const onConfirm = vi.fn();
const details = createConfirmationDetails({
questions: [createSingleQuestion({ multiSelect: true })],
@ -362,13 +340,7 @@ describe('<AskUserQuestionDialog />', () => {
stdin.write(' ');
await wait();
// Move to "Submit" option (3 options + custom input + submit)
for (let i = 0; i < 4; i++) {
stdin.write('\u001B[B');
await wait();
}
// Space on submit option should submit selected values
// Enter to confirm and submit
stdin.write('\r');
await wait();

View file

@ -57,13 +57,8 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
? null
: confirmationDetails.questions[currentQuestionIndex];
const isMultiSelect = currentQuestion?.multiSelect ?? false;
// Multi-select: options + custom input + submit; Single-select: options + custom input
const totalOptions = currentQuestion
? currentQuestion.options.length + (isMultiSelect ? 2 : 1)
: 2;
const submitOptionIndex = currentQuestion
? currentQuestion.options.length + 1
: -1;
// Options + custom input ("Other")
const totalOptions = currentQuestion ? currentQuestion.options.length + 1 : 2;
// Check if the custom input option is selected
const isCustomInputSelected =
@ -246,9 +241,6 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
}));
}
}
if (selectedIndex === submitOptionIndex) {
handleMultiSelectSubmit();
}
return;
}
@ -266,32 +258,13 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
return;
}
// Handle multi-select
// Handle multi-select: Enter advances to next question / submits
if (isMultiSelect && currentQuestion) {
// Custom input is handled by TextInput's onSubmit
if (selectedIndex === currentQuestion.options.length) {
return;
}
// Submit option
if (selectedIndex === submitOptionIndex) {
handleMultiSelectSubmit();
return;
}
// Toggle predefined option (same as Space)
if (selectedIndex < currentQuestion.options.length) {
const option = currentQuestion.options[selectedIndex];
if (option) {
const current = multiSelectedOptions[currentQuestionIndex] ?? [];
const isChecked = current.includes(option.label);
const updated = isChecked
? current.filter((l) => l !== option.label)
: [...current, option.label];
setMultiSelectedOptions((prev) => ({
...prev,
[currentQuestionIndex]: updated,
}));
}
}
handleMultiSelectSubmit();
return;
}
@ -450,7 +423,7 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
)}
{/* Question */}
<Box marginBottom={1}>
<Box flexDirection="column" marginBottom={1}>
{!hasMultipleQuestions && (
<Box marginBottom={1}>
<Text color={theme.text.accent} bold>
@ -572,23 +545,6 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
</Box>
)}
</Box>
{/* Submit option for multi-select */}
{isMultiSelect && (
<Box>
<Text
color={
selectedIndex === submitOptionIndex
? theme.text.accent
: theme.text.primary
}
bold={selectedIndex === submitOptionIndex}
>
{selectedIndex === submitOptionIndex ? ' ' : ' '}
{submitOptionIndex + 1}. {t('Submit')}
</Text>
</Box>
)}
</Box>
{/* Help text */}
@ -596,11 +552,17 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
<Box>
<Text dimColor>
{hasMultipleQuestions
? t(
'↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel',
)
? isMultiSelect
? t(
'↑/↓: Navigate | ←/→: Switch tabs | Space: Toggle | Enter: Confirm | Esc: Cancel',
)
: t(
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select | Esc: Cancel',
)
: isMultiSelect
? t('↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel')
? t(
'↑/↓: Navigate | Space: Toggle | Enter: Confirm | Esc: Cancel',
)
: t('↑/↓: Navigate | Enter: Select | Esc: Cancel')}
</Text>
</Box>