mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 12:11:09 +00:00
Merge branch 'main' into feature/arena-agent-collaboration
This commit is contained in:
commit
f9d4fa0a39
292 changed files with 28467 additions and 8155 deletions
|
|
@ -103,7 +103,9 @@ export const Composer = () => {
|
|||
)}
|
||||
|
||||
{/* Exclusive area: only one component visible at a time */}
|
||||
{/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */}
|
||||
{!showSuggestions &&
|
||||
uiState.streamingState !== StreamingState.WaitingForConfirmation &&
|
||||
(showShortcuts ? (
|
||||
<KeyboardShortcuts />
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -38,6 +38,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 {
|
||||
|
|
@ -339,6 +341,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
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ exports[`SettingsDialog > Snapshot Tests > should render default state correctly
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -32,7 +32,7 @@ exports[`SettingsDialog > Snapshot Tests > should render focused on scope select
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -53,7 +53,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with accessibility sett
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode true* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -74,7 +74,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with all boolean settin
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false* │
|
||||
│ ▼ │
|
||||
|
|
@ -95,7 +95,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode (Modified in System) false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -116,7 +116,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with different scope se
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode (Modified in Workspace) false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -137,7 +137,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with file filtering set
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -158,7 +158,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with mixed boolean and
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -179,7 +179,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with tools and security
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode false │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE false │
|
||||
│ ▼ │
|
||||
|
|
@ -200,7 +200,7 @@ exports[`SettingsDialog > Snapshot Tests > should render with various boolean se
|
|||
│ Language: Model auto │
|
||||
│ Theme Qwen Dark │
|
||||
│ Vim Mode true* │
|
||||
│ Interactive Shell (PTY) false │
|
||||
│ Interactive Shell (PTY) true │
|
||||
│ Preferred Editor │
|
||||
│ Auto-connect to IDE true* │
|
||||
│ ▼ │
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
|
||||
`;
|
||||
9
packages/cli/src/ui/components/extensions/index.ts
Normal file
9
packages/cli/src/ui/components/extensions/index.ts
Normal 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';
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
`;
|
||||
|
|
@ -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"
|
||||
`;
|
||||
11
packages/cli/src/ui/components/extensions/steps/index.ts
Normal file
11
packages/cli/src/ui/components/extensions/steps/index.ts
Normal 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';
|
||||
89
packages/cli/src/ui/components/extensions/types.ts
Normal file
89
packages/cli/src/ui/components/extensions/types.ts
Normal 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;
|
||||
}
|
||||
554
packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
Normal file
554
packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
47
packages/cli/src/ui/components/mcp/constants.ts
Normal file
47
packages/cli/src/ui/components/mcp/constants.ts
Normal 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',
|
||||
};
|
||||
30
packages/cli/src/ui/components/mcp/index.ts
Normal file
30
packages/cli/src/ui/components/mcp/index.ts
Normal 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';
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
223
packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
Normal file
223
packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
185
packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx
Normal file
185
packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
217
packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx
Normal file
217
packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
157
packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx
Normal file
157
packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
180
packages/cli/src/ui/components/mcp/types.ts
Normal file
180
packages/cli/src/ui/components/mcp/types.ts
Normal 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;
|
||||
}
|
||||
159
packages/cli/src/ui/components/mcp/utils.test.ts
Normal file
159
packages/cli/src/ui/components/mcp/utils.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
129
packages/cli/src/ui/components/mcp/utils.ts
Normal file
129
packages/cli/src/ui/components/mcp/utils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -0,0 +1,883 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { AskUserQuestionDialog } from './AskUserQuestionDialog.js';
|
||||
import type { ToolAskUserQuestionConfirmationDetails } from '@qwen-code/qwen-code-core';
|
||||
import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core';
|
||||
import { renderWithProviders } from '../../../test-utils/render.js';
|
||||
|
||||
const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
const createSingleQuestion = (
|
||||
overrides: Partial<
|
||||
ToolAskUserQuestionConfirmationDetails['questions'][0]
|
||||
> = {},
|
||||
): ToolAskUserQuestionConfirmationDetails['questions'][0] => ({
|
||||
question: 'What is your favorite color?',
|
||||
header: 'Color',
|
||||
options: [
|
||||
{ label: 'Red', description: 'A warm color' },
|
||||
{ label: 'Blue', description: 'A cool color' },
|
||||
{ label: 'Green', description: '' },
|
||||
],
|
||||
multiSelect: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createConfirmationDetails = (
|
||||
overrides: Partial<ToolAskUserQuestionConfirmationDetails> = {},
|
||||
): ToolAskUserQuestionConfirmationDetails => ({
|
||||
type: 'ask_user_question',
|
||||
title: 'Question',
|
||||
questions: [createSingleQuestion()],
|
||||
onConfirm: vi.fn(),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('<AskUserQuestionDialog />', () => {
|
||||
describe('rendering', () => {
|
||||
it('renders single question with options', () => {
|
||||
const details = createConfirmationDetails();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('What is your favorite color?');
|
||||
expect(output).toContain('Red');
|
||||
expect(output).toContain('Blue');
|
||||
expect(output).toContain('Green');
|
||||
expect(output).toContain('A warm color');
|
||||
expect(output).toContain('A cool color');
|
||||
});
|
||||
|
||||
it('renders header for single question', () => {
|
||||
const details = createConfirmationDetails();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Color');
|
||||
});
|
||||
|
||||
it('renders "Type something..." custom input option', () => {
|
||||
const details = createConfirmationDetails();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Type something...');
|
||||
});
|
||||
|
||||
it('renders help text for single select', () => {
|
||||
const details = createConfirmationDetails();
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(lastFrame()).toContain('Enter: Select');
|
||||
expect(lastFrame()).toContain('Esc: Cancel');
|
||||
expect(lastFrame()).not.toContain('Switch tabs');
|
||||
});
|
||||
|
||||
it('renders tabs for multiple questions', () => {
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({
|
||||
header: 'Q2',
|
||||
question: 'Second question?',
|
||||
}),
|
||||
],
|
||||
});
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Q1');
|
||||
expect(output).toContain('Q2');
|
||||
expect(output).toContain('Submit');
|
||||
expect(output).toContain('Switch tabs');
|
||||
});
|
||||
|
||||
it('renders multi-select with checkboxes', () => {
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
const onConfirm = vi.fn();
|
||||
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('[ ]');
|
||||
expect(output).toContain('Space: Toggle');
|
||||
expect(output).toContain('Enter: Confirm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('single-select interaction', () => {
|
||||
it('selects an option with Enter and submits immediately for single question', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Press Enter to select the first option (Red)
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Red' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('navigates down with arrow key and selects', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate down to "Blue"
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
|
||||
// Press Enter
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Blue' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('navigates with number keys', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Press '2' to select Blue
|
||||
stdin.write('2');
|
||||
await wait();
|
||||
|
||||
// Press Enter
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Blue' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('cancels with Escape', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\u001B'); // Escape
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not navigate above first option', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Try to go up from first option
|
||||
stdin.write('\u001B[A'); // Up arrow
|
||||
await wait();
|
||||
|
||||
// Should still select the first option
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Red' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not navigate below last option', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate way past the last option (3 options + 1 custom input = 4 total)
|
||||
for (let i = 0; i < 10; i++) {
|
||||
stdin.write('\u001B[B'); // Down arrow
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Should still render without crashing
|
||||
expect(lastFrame()).toContain('What is your favorite color?');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-select interaction', () => {
|
||||
it('toggles options with Space', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Space to toggle first option
|
||||
stdin.write(' ');
|
||||
await wait();
|
||||
|
||||
// Should show checked state
|
||||
expect(lastFrame()).toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('submits multi-select with Space to toggle then Enter to confirm', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Space to toggle first option
|
||||
stdin.write(' ');
|
||||
await wait();
|
||||
|
||||
// Enter to confirm and submit
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Red' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows typed custom input text in frame for multi-select question', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Move to "Type something..." input
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B');
|
||||
await wait();
|
||||
}
|
||||
|
||||
stdin.write('Orange');
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('Orange');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('multiple questions', () => {
|
||||
it('auto-advances to next question after selecting an option', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({
|
||||
header: 'Q2',
|
||||
question: 'Second question?',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Select first option in Q1
|
||||
stdin.write('\r');
|
||||
await wait(200); // Wait for auto-advance timeout (150ms)
|
||||
|
||||
// Should now show Q2
|
||||
expect(lastFrame()).toContain('Second question?');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('navigates between tabs with left/right arrows', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({
|
||||
header: 'Q2',
|
||||
question: 'Second question?',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate right to Q2
|
||||
stdin.write('\u001B[C'); // Right arrow
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('Second question?');
|
||||
|
||||
// Navigate left back to Q1
|
||||
stdin.write('\u001B[D'); // Left arrow
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('What is your favorite color?');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows Submit tab for multiple questions', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({ header: 'Q2' }),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to submit tab (right arrow twice: Q1 -> Q2 -> Submit)
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Submit answers');
|
||||
expect(output).toContain('Cancel');
|
||||
expect(output).toContain('Your answers');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('submits all answers from Submit tab', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({
|
||||
header: 'Q2',
|
||||
question: 'Second question?',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Answer Q1
|
||||
stdin.write('\r'); // Select Red
|
||||
await wait(200);
|
||||
|
||||
// Answer Q2
|
||||
stdin.write('\r'); // Select Red
|
||||
await wait(200);
|
||||
|
||||
// Now on Submit tab, press Enter to submit
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(
|
||||
ToolConfirmationOutcome.ProceedOnce,
|
||||
{ answers: { 0: 'Red', 1: 'Red' } },
|
||||
);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('cancels from Submit tab', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({ header: 'Q2' }),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to submit tab
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
|
||||
// Navigate down to Cancel option
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
|
||||
// Press Enter
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('shows unanswered questions as (not answered) in Submit tab', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({ header: 'Q2' }),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate directly to submit tab without answering anything
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
stdin.write('\u001B[C'); // Right
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('(not answered)');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('focus behavior', () => {
|
||||
it('does not respond to keys when isFocused is false', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
isFocused={false}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
stdin.write('\r'); // Enter
|
||||
await wait();
|
||||
stdin.write('\u001B'); // Escape
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('escape from custom input', () => {
|
||||
it('cancels from custom input with Escape', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (3 options, so index 3 is custom input)
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
stdin.write('\u001B[B'); // Down - now at custom input
|
||||
await wait();
|
||||
|
||||
// Press Escape
|
||||
stdin.write('\u001B');
|
||||
await wait();
|
||||
|
||||
expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel);
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('answered question marker', () => {
|
||||
it('shows check mark on answered question tab', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [
|
||||
createSingleQuestion({ header: 'Q1' }),
|
||||
createSingleQuestion({ header: 'Q2' }),
|
||||
],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Answer Q1
|
||||
stdin.write('\r'); // Select Red
|
||||
await wait(200);
|
||||
|
||||
// Q2 is now active; check that Q1 shows ✓
|
||||
expect(lastFrame()).toContain('Q1');
|
||||
expect(lastFrame()).toContain('✓');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom input preserves state', () => {
|
||||
it('preserves typed text when navigating away and back', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails();
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (3 options, index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Type something
|
||||
stdin.write('Purple');
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('Purple');
|
||||
|
||||
// Navigate away (up to first option)
|
||||
stdin.write('\u001B[A'); // Up
|
||||
await wait();
|
||||
stdin.write('\u001B[A'); // Up
|
||||
await wait();
|
||||
stdin.write('\u001B[A'); // Up
|
||||
await wait();
|
||||
|
||||
// Navigate back to custom input
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Text should still be there
|
||||
expect(lastFrame()).toContain('Purple');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('does not auto-check custom input in multi-select when navigating back', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B');
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Type something - auto-checks
|
||||
stdin.write('Custom');
|
||||
await wait();
|
||||
|
||||
expect(lastFrame()).toContain('[✓]');
|
||||
|
||||
// Enter to toggle it off (since auto-check already checked it)
|
||||
stdin.write('\r');
|
||||
await wait();
|
||||
|
||||
// Should be unchecked now - verify on the custom input line specifically
|
||||
const afterToggle = lastFrame()!;
|
||||
const toggledLine = afterToggle
|
||||
.split('\n')
|
||||
.find((l) => l.includes('Custom'));
|
||||
expect(toggledLine).toBeDefined();
|
||||
expect(toggledLine).toContain('[ ]');
|
||||
expect(toggledLine).not.toContain('[✓]');
|
||||
|
||||
// Navigate away
|
||||
stdin.write('\u001B[A'); // Up
|
||||
await wait();
|
||||
|
||||
// Navigate back to custom input
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
|
||||
// Should still be unchecked (not auto-checked on remount)
|
||||
const output = lastFrame()!;
|
||||
const lines = output.split('\n');
|
||||
const customLine = lines.find((l) => l.includes('Custom'));
|
||||
expect(customLine).toBeDefined();
|
||||
expect(customLine).toContain('[ ]');
|
||||
expect(customLine).not.toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('keeps custom input checked when navigating back if user checked it', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B');
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Type something - should auto-check
|
||||
stdin.write('Custom');
|
||||
await wait();
|
||||
|
||||
// Should already be checked (auto-checked on type)
|
||||
expect(lastFrame()).toContain('[✓]');
|
||||
|
||||
// Navigate away
|
||||
stdin.write('\u001B[A'); // Up
|
||||
await wait();
|
||||
|
||||
// Navigate back to custom input
|
||||
stdin.write('\u001B[B'); // Down
|
||||
await wait();
|
||||
|
||||
// Should still be checked
|
||||
const output = lastFrame()!;
|
||||
const lines = output.split('\n');
|
||||
const customLine = lines.find((l) => l.includes('Custom'));
|
||||
expect(customLine).toBeDefined();
|
||||
expect(customLine).toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('auto-checks custom input in multi-select when user types text', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B');
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Type something - should auto-check
|
||||
stdin.write('Hello');
|
||||
await wait();
|
||||
|
||||
const output = lastFrame()!;
|
||||
const lines = output.split('\n');
|
||||
const customLine = lines.find((l) => l.includes('Hello'));
|
||||
expect(customLine).toBeDefined();
|
||||
expect(customLine).toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('auto-unchecks custom input in multi-select when text is cleared', async () => {
|
||||
const onConfirm = vi.fn();
|
||||
const details = createConfirmationDetails({
|
||||
questions: [createSingleQuestion({ multiSelect: true })],
|
||||
});
|
||||
|
||||
const { stdin, lastFrame, unmount } = renderWithProviders(
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={details}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
await wait();
|
||||
|
||||
// Navigate to custom input (index 3)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
stdin.write('\u001B[B');
|
||||
await wait();
|
||||
}
|
||||
|
||||
// Type something - should auto-check
|
||||
stdin.write('Hi');
|
||||
await wait();
|
||||
|
||||
// Verify auto-check on the custom input line
|
||||
const afterType = lastFrame()!;
|
||||
const typedLine = afterType.split('\n').find((l) => l.includes('Hi'));
|
||||
expect(typedLine).toBeDefined();
|
||||
expect(typedLine).toContain('[✓]');
|
||||
|
||||
// Delete all text (backspace twice)
|
||||
stdin.write('\x7f'); // backspace
|
||||
await wait();
|
||||
stdin.write('\x7f'); // backspace
|
||||
await wait();
|
||||
|
||||
// Should be unchecked now - check the custom input line (option 4)
|
||||
const afterClear = lastFrame()!;
|
||||
const clearedLine = afterClear.split('\n').find((l) => l.includes('4.'));
|
||||
expect(clearedLine).toBeDefined();
|
||||
expect(clearedLine).toContain('[ ]');
|
||||
expect(clearedLine).not.toContain('[✓]');
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,572 @@
|
|||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useState } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import {
|
||||
type ToolAskUserQuestionConfirmationDetails,
|
||||
ToolConfirmationOutcome,
|
||||
type ToolConfirmationPayload,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { TextInput } from '../shared/TextInput.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
|
||||
interface AskUserQuestionDialogProps {
|
||||
confirmationDetails: ToolAskUserQuestionConfirmationDetails;
|
||||
isFocused?: boolean;
|
||||
onConfirm: (
|
||||
outcome: ToolConfirmationOutcome,
|
||||
payload?: ToolConfirmationPayload,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
|
||||
confirmationDetails,
|
||||
isFocused = true,
|
||||
onConfirm,
|
||||
}) => {
|
||||
const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0);
|
||||
const [selectedOptions, setSelectedOptions] = useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [customInputValues, setCustomInputValues] = useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [multiSelectedOptions, setMultiSelectedOptions] = useState<
|
||||
Record<number, string[]>
|
||||
>({});
|
||||
const [customInputChecked, setCustomInputChecked] = useState<
|
||||
Record<number, boolean>
|
||||
>({});
|
||||
|
||||
const hasMultipleQuestions = confirmationDetails.questions.length > 1;
|
||||
const totalTabs = hasMultipleQuestions
|
||||
? confirmationDetails.questions.length + 1
|
||||
: confirmationDetails.questions.length; // +1 for Submit tab
|
||||
const isSubmitTab =
|
||||
hasMultipleQuestions && currentQuestionIndex === totalTabs - 1;
|
||||
|
||||
const currentQuestion = isSubmitTab
|
||||
? null
|
||||
: confirmationDetails.questions[currentQuestionIndex];
|
||||
const isMultiSelect = currentQuestion?.multiSelect ?? false;
|
||||
// Options + custom input ("Other")
|
||||
const totalOptions = currentQuestion ? currentQuestion.options.length + 1 : 2;
|
||||
|
||||
// Check if the custom input option is selected
|
||||
const isCustomInputSelected =
|
||||
!isSubmitTab &&
|
||||
currentQuestion &&
|
||||
selectedIndex === currentQuestion.options.length;
|
||||
|
||||
const currentCustomInputValue = customInputValues[currentQuestionIndex] ?? '';
|
||||
const isCustomInputAnswer =
|
||||
!isSubmitTab &&
|
||||
currentQuestion &&
|
||||
!isMultiSelect &&
|
||||
selectedOptions[currentQuestionIndex] !== undefined &&
|
||||
!currentQuestion.options.some(
|
||||
(opt) => opt.label === selectedOptions[currentQuestionIndex],
|
||||
);
|
||||
|
||||
// Compute the current answer for a question, considering multi-select state
|
||||
const getAnswerForQuestion = (idx: number): string | undefined => {
|
||||
const q = confirmationDetails.questions[idx];
|
||||
if (q?.multiSelect) {
|
||||
const selections = [...(multiSelectedOptions[idx] ?? [])];
|
||||
const customValue = (customInputValues[idx] ?? '').trim();
|
||||
if (customInputChecked[idx] && customValue) {
|
||||
selections.push(customValue);
|
||||
}
|
||||
return selections.length > 0 ? selections.join(', ') : undefined;
|
||||
}
|
||||
return selectedOptions[idx];
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const answers: Record<string, string> = {};
|
||||
confirmationDetails.questions.forEach((_, idx) => {
|
||||
const answer = getAnswerForQuestion(idx);
|
||||
if (answer !== undefined) {
|
||||
answers[idx] = answer;
|
||||
}
|
||||
});
|
||||
|
||||
await onConfirm(ToolConfirmationOutcome.ProceedOnce, { answers });
|
||||
};
|
||||
|
||||
const handleMultiSelectSubmit = () => {
|
||||
if (!currentQuestion) return;
|
||||
const selections = [...(multiSelectedOptions[currentQuestionIndex] ?? [])];
|
||||
const customValue = currentCustomInputValue.trim();
|
||||
if (customInputChecked[currentQuestionIndex] && customValue) {
|
||||
selections.push(customValue);
|
||||
}
|
||||
if (selections.length === 0) return;
|
||||
|
||||
const value = selections.join(', ');
|
||||
const updated = { ...selectedOptions, [currentQuestionIndex]: value };
|
||||
setSelectedOptions(updated);
|
||||
|
||||
if (!hasMultipleQuestions) {
|
||||
void onConfirm(ToolConfirmationOutcome.ProceedOnce, {
|
||||
answers: { [currentQuestionIndex]: value },
|
||||
});
|
||||
} else {
|
||||
if (currentQuestionIndex < totalTabs - 1) {
|
||||
setTimeout(() => {
|
||||
setCurrentQuestionIndex((prev) => Math.min(prev + 1, totalTabs - 1));
|
||||
setSelectedIndex(0);
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleCustomInputSubmit = () => {
|
||||
const trimmedValue = currentCustomInputValue.trim();
|
||||
|
||||
if (isMultiSelect) {
|
||||
// Toggle custom input checked state
|
||||
if (!trimmedValue) return;
|
||||
setCustomInputChecked((prev) => ({
|
||||
...prev,
|
||||
[currentQuestionIndex]: !prev[currentQuestionIndex],
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!trimmedValue) return;
|
||||
|
||||
const updated = {
|
||||
...selectedOptions,
|
||||
[currentQuestionIndex]: trimmedValue,
|
||||
};
|
||||
setSelectedOptions(updated);
|
||||
|
||||
// If single question, submit immediately
|
||||
if (!hasMultipleQuestions) {
|
||||
void onConfirm(ToolConfirmationOutcome.ProceedOnce, {
|
||||
answers: {
|
||||
[currentQuestionIndex]: trimmedValue,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Auto-advance to next tab
|
||||
if (currentQuestionIndex < totalTabs - 1) {
|
||||
setTimeout(() => {
|
||||
setCurrentQuestionIndex((prev) => Math.min(prev + 1, totalTabs - 1));
|
||||
setSelectedIndex(0);
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle navigation and selection
|
||||
useKeypress(
|
||||
(key) => {
|
||||
if (!isFocused) return;
|
||||
|
||||
// When custom input is focused, still allow up/down navigation, tab switch and escape
|
||||
if (isCustomInputSelected) {
|
||||
if (key.name === 'up') {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1));
|
||||
return;
|
||||
}
|
||||
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
void onConfirm(ToolConfirmationOutcome.Cancel);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const input = key.sequence;
|
||||
|
||||
// Tab navigation (left/right arrows)
|
||||
if (key.name === 'left' && hasMultipleQuestions) {
|
||||
if (currentQuestionIndex > 0) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex - 1);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (key.name === 'right' && hasMultipleQuestions) {
|
||||
if (currentQuestionIndex < totalTabs - 1) {
|
||||
setCurrentQuestionIndex(currentQuestionIndex + 1);
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Option navigation (up/down arrows)
|
||||
if (key.name === 'up') {
|
||||
setSelectedIndex(Math.max(0, selectedIndex - 1));
|
||||
return;
|
||||
}
|
||||
if (key.name === 'down') {
|
||||
setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1));
|
||||
return;
|
||||
}
|
||||
|
||||
// Number key selection
|
||||
const numKey = parseInt(input || '', 10);
|
||||
if (!isNaN(numKey) && numKey >= 1 && numKey <= totalOptions) {
|
||||
setSelectedIndex(numKey - 1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Space to toggle multi-select
|
||||
if (key.name === 'space' && isMultiSelect && currentQuestion) {
|
||||
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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (key.name === 'return') {
|
||||
// Handle Submit tab
|
||||
if (isSubmitTab) {
|
||||
if (selectedIndex === 0) {
|
||||
// Submit
|
||||
void handleSubmit();
|
||||
} else {
|
||||
// Cancel
|
||||
void onConfirm(ToolConfirmationOutcome.Cancel);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
handleMultiSelectSubmit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle question options (not custom input - that's handled by TextInput)
|
||||
if (currentQuestion && selectedIndex < currentQuestion.options.length) {
|
||||
const option = currentQuestion.options[selectedIndex];
|
||||
if (option) {
|
||||
const updated = {
|
||||
...selectedOptions,
|
||||
[currentQuestionIndex]: option.label,
|
||||
};
|
||||
setSelectedOptions(updated);
|
||||
|
||||
// If single question, submit immediately
|
||||
if (!hasMultipleQuestions) {
|
||||
void onConfirm(ToolConfirmationOutcome.ProceedOnce, {
|
||||
answers: { [currentQuestionIndex]: option.label },
|
||||
});
|
||||
} else {
|
||||
// Auto-advance to next tab after selection
|
||||
if (currentQuestionIndex < totalTabs - 1) {
|
||||
setTimeout(() => {
|
||||
setCurrentQuestionIndex((prev) =>
|
||||
Math.min(prev + 1, totalTabs - 1),
|
||||
);
|
||||
setSelectedIndex(0);
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Cancel
|
||||
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
||||
void onConfirm(ToolConfirmationOutcome.Cancel);
|
||||
return;
|
||||
}
|
||||
},
|
||||
{ isActive: isFocused },
|
||||
);
|
||||
|
||||
// Submit tab (for multiple questions)
|
||||
if (isSubmitTab) {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
{/* Tabs */}
|
||||
<Box marginBottom={1} flexDirection="row" gap={1}>
|
||||
{confirmationDetails.questions.map((q, idx) => {
|
||||
const isAnswered = getAnswerForQuestion(idx) !== undefined;
|
||||
return (
|
||||
<Box key={idx}>
|
||||
<Text dimColor>
|
||||
{isAnswered ? ' ' : ' '}
|
||||
{q.header}
|
||||
{isAnswered ? ' ✓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box>
|
||||
<Text color={theme.text.accent} bold>
|
||||
▸ {t('Submit')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Show selected answers */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
<Text bold>{t('Your answers:')}</Text>
|
||||
{confirmationDetails.questions.map((q, idx) => {
|
||||
const answer = getAnswerForQuestion(idx);
|
||||
return (
|
||||
<Box key={idx} marginLeft={2}>
|
||||
<Text>
|
||||
{q.header}:{' '}
|
||||
{answer ? (
|
||||
<Text color={theme.text.accent}>{answer}</Text>
|
||||
) : (
|
||||
<Text dimColor>{t('(not answered)')}</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1} marginBottom={1}>
|
||||
<Text>{t('Ready to submit your answers?')}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Submit/Cancel options */}
|
||||
<Box flexDirection="column">
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
selectedIndex === 0 ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
bold={selectedIndex === 0}
|
||||
>
|
||||
{selectedIndex === 0 ? '❯ ' : ' '}1. {t('Submit answers')}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
selectedIndex === 1 ? theme.text.accent : theme.text.primary
|
||||
}
|
||||
bold={selectedIndex === 1}
|
||||
>
|
||||
{selectedIndex === 1 ? '❯ ' : ' '}2. {t('Cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box marginTop={1}>
|
||||
<Text dimColor>
|
||||
{t('↑/↓: Navigate | ←/→: Switch tabs | Enter: Select')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Question tab
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
{/* Tabs for multiple questions */}
|
||||
{hasMultipleQuestions && (
|
||||
<Box marginBottom={1} flexDirection="row" gap={1}>
|
||||
{confirmationDetails.questions.map((q, idx) => {
|
||||
const isAnswered = getAnswerForQuestion(idx) !== undefined;
|
||||
return (
|
||||
<Box key={idx}>
|
||||
<Text
|
||||
color={
|
||||
idx === currentQuestionIndex
|
||||
? theme.text.accent
|
||||
: theme.text.primary
|
||||
}
|
||||
bold={idx === currentQuestionIndex}
|
||||
dimColor={idx !== currentQuestionIndex}
|
||||
>
|
||||
{idx === currentQuestionIndex ? '▸ ' : ' '}
|
||||
{q.header}
|
||||
{isAnswered ? ' ✓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
<Box>
|
||||
<Text dimColor> {t('Submit')}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Question */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{!hasMultipleQuestions && (
|
||||
<Box marginBottom={1}>
|
||||
<Text color={theme.text.accent} bold>
|
||||
{currentQuestion!.header}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Text>{currentQuestion!.question}</Text>
|
||||
</Box>
|
||||
|
||||
{/* Options */}
|
||||
<Box flexDirection="column" marginBottom={1}>
|
||||
{currentQuestion!.options.map((opt, index) => {
|
||||
const isSelected = selectedIndex === index;
|
||||
const isMultiChecked =
|
||||
isMultiSelect &&
|
||||
(multiSelectedOptions[currentQuestionIndex] ?? []).includes(
|
||||
opt.label,
|
||||
);
|
||||
const isAnswered =
|
||||
!isMultiSelect &&
|
||||
selectedOptions[currentQuestionIndex] === opt.label;
|
||||
const isHighlighted = isSelected || isAnswered || isMultiChecked;
|
||||
// Calculate prefix width for description alignment:
|
||||
// 2 (cursor) + checkbox (4 if multi) + number + ". " (2)
|
||||
const prefixWidth =
|
||||
2 + (isMultiSelect ? 4 : 0) + String(index + 1).length + 2;
|
||||
return (
|
||||
<Box key={index} flexDirection="column">
|
||||
<Box>
|
||||
<Text
|
||||
color={isHighlighted ? theme.text.accent : theme.text.primary}
|
||||
bold={isHighlighted}
|
||||
>
|
||||
{isSelected ? '❯ ' : ' '}
|
||||
{isMultiSelect ? (isMultiChecked ? '[✓] ' : '[ ] ') : ''}
|
||||
{index + 1}. {opt.label}
|
||||
{isAnswered ? ' ✓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
{opt.description && (
|
||||
<Box marginLeft={prefixWidth}>
|
||||
<Text dimColor>{opt.description}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Type something option/input */}
|
||||
<Box flexDirection="column">
|
||||
{isCustomInputSelected ? (
|
||||
// Inline TextInput replaces the option text
|
||||
<Box>
|
||||
<Text color={theme.text.accent} bold>
|
||||
❯{' '}
|
||||
{isMultiSelect
|
||||
? customInputChecked[currentQuestionIndex]
|
||||
? '[✓] '
|
||||
: '[ ] '
|
||||
: ''}
|
||||
{currentQuestion!.options.length + 1}.{' '}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={currentCustomInputValue}
|
||||
initialCursorOffset={currentCustomInputValue.length}
|
||||
onChange={(value: string) => {
|
||||
const oldValue =
|
||||
customInputValues[currentQuestionIndex] ?? '';
|
||||
if (isMultiSelect && value !== oldValue) {
|
||||
setCustomInputChecked((prevChecked) => ({
|
||||
...prevChecked,
|
||||
[currentQuestionIndex]: value.trim().length > 0,
|
||||
}));
|
||||
}
|
||||
setCustomInputValues((prev) => ({
|
||||
...prev,
|
||||
[currentQuestionIndex]: value,
|
||||
}));
|
||||
}}
|
||||
onSubmit={handleCustomInputSubmit}
|
||||
placeholder={t('Type something...')}
|
||||
isActive={true}
|
||||
inputWidth={50}
|
||||
/>
|
||||
</Box>
|
||||
) : (
|
||||
// Show typed value or placeholder when not selected
|
||||
<Box>
|
||||
<Text
|
||||
color={
|
||||
isCustomInputAnswer ||
|
||||
customInputChecked[currentQuestionIndex]
|
||||
? theme.text.accent
|
||||
: theme.text.primary
|
||||
}
|
||||
bold={
|
||||
!!(
|
||||
isCustomInputAnswer ||
|
||||
customInputChecked[currentQuestionIndex]
|
||||
)
|
||||
}
|
||||
dimColor={
|
||||
!currentCustomInputValue &&
|
||||
!isCustomInputAnswer &&
|
||||
!customInputChecked[currentQuestionIndex]
|
||||
}
|
||||
>
|
||||
{' '}
|
||||
{isMultiSelect
|
||||
? customInputChecked[currentQuestionIndex]
|
||||
? '[✓] '
|
||||
: '[ ] '
|
||||
: ''}
|
||||
{currentQuestion!.options.length + 1}.{' '}
|
||||
{currentCustomInputValue || t('Type something...')}
|
||||
{isCustomInputAnswer ? ' ✓' : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Help text */}
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
<Box>
|
||||
<Text dimColor>
|
||||
{hasMultipleQuestions
|
||||
? isMultiSelect
|
||||
? t(
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Space: Toggle | Enter: Confirm | Esc: Cancel',
|
||||
)
|
||||
: t(
|
||||
'↑/↓: Navigate | ←/→: Switch tabs | Enter: Select | Esc: Cancel',
|
||||
)
|
||||
: isMultiSelect
|
||||
? t(
|
||||
'↑/↓: Navigate | Space: Toggle | Enter: Confirm | Esc: Cancel',
|
||||
)
|
||||
: t('↑/↓: Navigate | Enter: Select | Esc: Cancel')}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
|
@ -56,6 +56,7 @@ index 0000000..e69de29
|
|||
80,
|
||||
undefined,
|
||||
mockSettings,
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -86,6 +87,7 @@ index 0000000..e69de29
|
|||
80,
|
||||
undefined,
|
||||
mockSettings,
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -115,6 +117,7 @@ index 0000000..e69de29
|
|||
80,
|
||||
undefined,
|
||||
mockSettings,
|
||||
4,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -161,6 +161,7 @@ export const DiffRenderer: React.FC<DiffRendererProps> = ({
|
|||
contentWidth,
|
||||
theme,
|
||||
settings,
|
||||
tabWidth,
|
||||
);
|
||||
} else {
|
||||
renderedOutput = renderDiffContent(
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ export const StatusMessage: React.FC<StatusMessageProps> = ({
|
|||
export const InfoMessage: React.FC<StatusTextProps> = ({ text }) => (
|
||||
<StatusMessage
|
||||
text={text}
|
||||
prefix="•"
|
||||
prefix="●"
|
||||
prefixColor={theme.text.primary}
|
||||
textColor={theme.text.primary}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useKeypress } from '../../hooks/useKeypress.js';
|
|||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { t } from '../../../i18n/index.js';
|
||||
import { AskUserQuestionDialog } from './AskUserQuestionDialog.js';
|
||||
|
||||
export interface ToolConfirmationMessageProps {
|
||||
confirmationDetails: ToolCallConfirmationDetails;
|
||||
|
|
@ -345,6 +346,15 @@ export const ToolConfirmationMessage: React.FC<
|
|||
)}
|
||||
</Box>
|
||||
);
|
||||
} else if (confirmationDetails.type === 'ask_user_question') {
|
||||
// Use dedicated dialog for ask_user_question type
|
||||
return (
|
||||
<AskUserQuestionDialog
|
||||
confirmationDetails={confirmationDetails}
|
||||
isFocused={isFocused}
|
||||
onConfirm={onConfirm}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
// mcp tool confirmation
|
||||
const mcpProps = confirmationDetails as ToolMcpConfirmationDetails;
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ export interface TextInputProps {
|
|||
isActive?: boolean; // when false, ignore keypresses
|
||||
validationErrors?: string[];
|
||||
inputWidth?: number;
|
||||
initialCursorOffset?: number;
|
||||
}
|
||||
|
||||
export function TextInput({
|
||||
|
|
@ -37,6 +38,7 @@ export function TextInput({
|
|||
isActive = true,
|
||||
validationErrors = [],
|
||||
inputWidth = 80,
|
||||
initialCursorOffset,
|
||||
}: TextInputProps) {
|
||||
const allowMultiline = height > 1;
|
||||
|
||||
|
|
@ -51,6 +53,7 @@ export function TextInput({
|
|||
|
||||
const buffer = useTextBuffer({
|
||||
initialText: value || '',
|
||||
initialCursorOffset,
|
||||
viewport: { height, width: inputWidth },
|
||||
isValidPath: () => false,
|
||||
onChange: stableOnChange,
|
||||
|
|
|
|||
|
|
@ -120,45 +120,6 @@ export function AgentCreationWizard({
|
|||
);
|
||||
}, [state.currentStep, state.generationMethod]);
|
||||
|
||||
const renderDebugContent = useCallback(() => {
|
||||
if (process.env['NODE_ENV'] !== 'development') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderStyle="single" borderColor={theme.status.warning} padding={1}>
|
||||
<Box flexDirection="column">
|
||||
<Text color={theme.status.warning} bold>
|
||||
Debug Info:
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>Step: {state.currentStep}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Can Proceed: {state.canProceed ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Generating: {state.isGenerating ? 'Yes' : 'No'}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>Location: {state.location}</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
Method: {state.generationMethod}
|
||||
</Text>
|
||||
{state.validationErrors.length > 0 && (
|
||||
<Text color={theme.status.error}>
|
||||
Errors: {state.validationErrors.join(', ')}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}, [
|
||||
state.currentStep,
|
||||
state.canProceed,
|
||||
state.isGenerating,
|
||||
state.location,
|
||||
state.generationMethod,
|
||||
state.validationErrors,
|
||||
]);
|
||||
|
||||
const renderStepFooter = useCallback(() => {
|
||||
const getNavigationInstructions = () => {
|
||||
// Special case: During generation in description input step, only show cancel option
|
||||
|
|
@ -331,7 +292,6 @@ export function AgentCreationWizard({
|
|||
>
|
||||
{renderStepHeader()}
|
||||
{renderStepContent()}
|
||||
{renderDebugContent()}
|
||||
{renderStepFooter()}
|
||||
</Box>
|
||||
</Box>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue