Merge branch 'main' into feature/arena-agent-collaboration

This commit is contained in:
tanzhenxin 2026-03-09 20:43:25 +08:00
commit 4a681f435d
25 changed files with 1570 additions and 554 deletions

View file

@ -22,6 +22,7 @@ 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';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
interface ExtensionsManagerDialogProps {
onClose: () => void;
@ -46,6 +47,8 @@ export function ExtensionsManagerDialog({
const [updateError, setUpdateError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { columns } = useTerminalSize();
const boxWidth = columns - 4;
// Load extensions
const loadExtensions = useCallback(async () => {
@ -362,10 +365,10 @@ export function ExtensionsManagerDialog({
const currentStep = getCurrentStep();
const getNavigationInstructions = () => {
if (currentStep === MANAGEMENT_STEPS.EXTENSION_LIST) {
if (extensions.length === 0) {
if (extensions.length === 0 || successMessage) {
return t('Esc to close');
}
return t('Enter to select, ↑↓ to navigate, Esc to close');
return t('↑↓ to navigate · Enter to select · Esc to close');
}
if (currentStep === MANAGEMENT_STEPS.EXTENSION_DETAIL) {
@ -373,14 +376,14 @@ export function ExtensionsManagerDialog({
}
if (currentStep === MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION) {
return t('Y/Enter to confirm, N/Esc to cancel');
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 t('↑↓ to navigate · Enter to select · Esc to go back');
};
return (
@ -388,7 +391,7 @@ export function ExtensionsManagerDialog({
<Text color={theme.text.secondary}>{getNavigationInstructions()}</Text>
</Box>
);
}, [getCurrentStep, extensions.length, updateInProgress]);
}, [getCurrentStep, extensions.length, updateInProgress, successMessage]);
const renderStepContent = useCallback(() => {
const currentStep = getCurrentStep();
@ -435,7 +438,6 @@ export function ExtensionsManagerDialog({
selectedExtension={selectedExtension}
hasUpdateAvailable={hasUpdateAvailable}
onNavigateToStep={handleNavigateToStep}
onNavigateBack={handleNavigateBack}
onActionSelect={handleActionSelect}
/>
);
@ -447,7 +449,6 @@ export function ExtensionsManagerDialog({
selectedExtension={selectedExtension}
mode="disable"
onScopeSelect={handleDisableExtension}
onNavigateBack={handleNavigateBack}
/>
);
case MANAGEMENT_STEPS.ENABLE_SCOPE_SELECT:
@ -456,7 +457,6 @@ export function ExtensionsManagerDialog({
selectedExtension={selectedExtension}
mode="enable"
onScopeSelect={handleEnableExtension}
onNavigateBack={handleNavigateBack}
/>
);
case MANAGEMENT_STEPS.UNINSTALL_CONFIRMATION:
@ -508,13 +508,14 @@ export function ExtensionsManagerDialog({
]);
return (
<Box flexDirection="column">
<Box flexDirection="column" width={boxWidth}>
<Box
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
paddingLeft={1}
paddingRight={1}
width={boxWidth}
gap={1}
>
{renderStepHeader()}

View file

@ -1,53 +1,45 @@
// 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 │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
"┌──────────────────────────────────────────────────────────────────────────┐
│ 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 │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
"┌──────────────────────────────────────────────────────────────────────────┐
│ 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 │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
"┌──────────────────────────────────────────────────────────────────────────┐
│ 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 │
│ │
└──────────────────────────────────────────────────────────────────────────────────────────────────┘"
"┌──────────────────────────────────────────────────────────────────────────┐
│ Manage Extensions │
│ │
│ No extensions installed. │
│ Use '/extensions install' to install your first extension. │
│ │
│ Esc to close │
└──────────────────────────────────────────────────────────────────────────┘"
`;

View file

@ -15,14 +15,12 @@ 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>(
@ -78,23 +76,11 @@ export const ActionSelectionStep = ({
},
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);
};

View file

@ -160,18 +160,18 @@ export const ExtensionListStep = ({
return (
<Box flexDirection="column">
<Box flexDirection="column" marginBottom={1}>
{extensions.map((extension, index) =>
renderExtensionItem(extension, index, index === selectedIndex),
)}
</Box>
<Box marginTop={1}>
<Box marginBottom={1}>
<Text color={theme.text.secondary}>
{t('{{count}} extensions installed', {
count: extensions.length.toString(),
})}
</Text>
</Box>
<Box flexDirection="column">
{extensions.map((extension, index) =>
renderExtensionItem(extension, index, index === selectedIndex),
)}
</Box>
</Box>
);
};

View file

@ -14,14 +14,12 @@ 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 = [
{
@ -38,20 +36,9 @@ export function ScopeSelectStep({
},
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;
}
const handleSelect = (value: 'user' | 'workspace') => {
onScopeSelect(value);
};
@ -71,7 +58,7 @@ export function ScopeSelectStep({
return (
<Box flexDirection="column" gap={1}>
<Text color={theme.text.primary}>{title}</Text>
<Box marginTop={1}>
<Box>
<RadioButtonSelect
items={scopeItems}
onSelect={handleSelect}

View file

@ -60,9 +60,6 @@ export function UninstallConfirmStep({
<Text color={theme.text.secondary}>
{t('This action cannot be undone.')}
</Text>
<Box marginTop={1}>
<Text>{t('Press Y/Enter to confirm, N/Esc to cancel')}</Text>
</Box>
</Box>
);
}

View file

@ -3,36 +3,31 @@
exports[`ActionSelectionStep Snapshots > should render for active extension without update 1`] = `
"● View Details
Disable Extension
Uninstall Extension
Back"
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render for disabled extension 1`] = `
"● View Details
Enable Extension
Uninstall Extension
Back"
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render for disabled extension with update 1`] = `
"● View Details
Update Extension
Enable Extension
Uninstall Extension
Back"
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render for extension with update available 1`] = `
"● View Details
Update Extension
Disable Extension
Uninstall Extension
Back"
Uninstall Extension"
`;
exports[`ActionSelectionStep Snapshots > should render with no extension selected 1`] = `
"● View Details
Enable Extension
Uninstall Extension
Back"
Uninstall Extension"
`;

View file

@ -6,31 +6,27 @@ 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]
"3 extensions installed
● 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"
update-available v1.0.0 (active) [update available]"
`;
exports[`ExtensionListStep Snapshots > should render list with single extension 1`] = `
"● test-extension v1.0.0 (active)
"1 extensions installed
1 extensions installed"
● test-extension v1.0.0 (active)"
`;
exports[`ExtensionListStep Snapshots > should render with checking status 1`] = `
"● checking-extension v1.0.0 (active) [checking for updates]
"1 extensions installed
1 extensions installed"
● checking-extension v1.0.0 (active) [checking for updates]"
`;
exports[`ExtensionListStep Snapshots > should render with error status 1`] = `
"● error-extension v1.0.0 (active) [error]
"1 extensions installed
1 extensions installed"
● error-extension v1.0.0 (active) [error]"
`;

View file

@ -20,6 +20,7 @@ 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 { AuthenticateStep } from './steps/AuthenticateStep.js';
import { useConfig } from '../../contexts/ConfigContext.js';
import {
getMCPServerStatus,
@ -31,6 +32,7 @@ import {
} from '@qwen-code/qwen-code-core';
import { loadSettings, SettingScope } from '../../../config/settings.js';
import { isToolValid, getToolInvalidReasons } from './utils.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
const debugLogger = createDebugLogger('MCP_DIALOG');
@ -38,6 +40,8 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
onClose,
}) => {
const config = useConfig();
const { columns: width } = useTerminalSize();
const boxWidth = width - 4;
const [servers, setServers] = useState<MCPServerDisplayInfo[]>([]);
const [selectedServerIndex, setSelectedServerIndex] = useState<number>(-1);
@ -225,6 +229,11 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST);
}, [handleNavigateToStep]);
// Authenticate
const handleAuthenticate = useCallback(() => {
handleNavigateToStep(MCP_MANAGEMENT_STEPS.AUTHENTICATE);
}, [handleNavigateToStep]);
// Select tool
const handleSelectTool = useCallback(
(tool: MCPToolDisplayInfo) => {
@ -318,17 +327,68 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
}, [config, selectedServer, reloadServers]);
// Handle disable/enable action
const handleDisable = useCallback(() => {
const handleDisable = useCallback(async () => {
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);
// Automatically determine the scope and disable without showing selection dialog
try {
setIsLoading(true);
const server = selectedServer;
const settings = loadSettings();
// Determine the scope based on server configuration location
let targetScope: 'user' | 'workspace' = 'user';
if (server.scope === 'extension') {
// Extension servers should not be disabled through user/workspace settings
// Show error message and return
debugLogger.warn(
`Cannot disable extension MCP server '${server.name}'`,
);
setIsLoading(false);
return;
} else if (server.scope === 'workspace') {
targetScope = 'workspace';
}
// Get current exclusion list for the target scope
const scopeSettings = settings.forScope(
targetScope === '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(
targetScope === '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();
} catch (error) {
debugLogger.error(
`Error disabling server '${selectedServer.name}':`,
error,
);
} finally {
setIsLoading(false);
}
}
}, [selectedServer, handleEnableServer, handleNavigateToStep]);
}, [selectedServer, handleEnableServer, config, reloadServers]);
// Execute disable after selecting scope
const handleSelectDisableScope = useCallback(
@ -383,36 +443,84 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
// 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>
let headerText = (
<Box flexDirection="column">
<Text color={theme.text.accent} bold>
{headerText}
{t('Manage MCP servers')}
</Text>
<Text color={theme.text.secondary}>
{servers.length} {servers.length === 1 ? t('server') : t('servers')}
</Text>
</Box>
);
}, [getCurrentStep, selectedServer, selectedTool]);
switch (currentStep) {
case MCP_MANAGEMENT_STEPS.SERVER_DETAIL:
headerText = (
<Box>
<Text color={theme.text.accent} bold>
{selectedServer?.name || t('Server Detail')}
</Text>
</Box>
);
break;
case MCP_MANAGEMENT_STEPS.TOOL_LIST:
headerText = (
<Box flexDirection="column">
<Text color={theme.text.accent} bold>
{t('Tools for {{serverName}}', {
serverName: selectedServer?.name || 'Server',
})}
</Text>
<Text color={theme.text.secondary}>
({getServerTools().length}{' '}
{getServerTools().length === 1 ? t('tool') : t('tools')})
</Text>
</Box>
);
break;
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
headerText = (
<Box flexDirection="column">
<Box>
<Text color={theme.text.accent} bold>
{selectedTool?.name || t('Tool Detail')}
</Text>
{selectedTool?.annotations?.destructiveHint && (
<Text color={theme.status.error}>{'[destructive]'}</Text>
)}
{selectedTool?.annotations?.idempotentHint && (
<Text color={theme.status.warning}>{'[idempotent]'}</Text>
)}
{selectedTool?.annotations?.readOnlyHint && (
<Text color={theme.status.success}>{'[read-only]'}</Text>
)}
{selectedTool?.annotations?.openWorldHint && (
<Text color={theme.text.primary}>{'[open-world]'}</Text>
)}
</Box>
<Text color={theme.text.secondary}>
{selectedTool?.serverName || t('Server')}
</Text>
</Box>
);
break;
case MCP_MANAGEMENT_STEPS.AUTHENTICATE:
headerText = (
<Box>
<Text color={theme.text.accent} bold>
{t('OAuth Authentication')}
</Text>
</Box>
);
break;
case MCP_MANAGEMENT_STEPS.SERVER_LIST:
default:
break;
}
return headerText;
}, [getCurrentStep, selectedServer, selectedTool, getServerTools, servers]);
// Render step content
const renderStepContent = useCallback(() => {
@ -435,6 +543,7 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
onViewTools={handleViewTools}
onReconnect={handleReconnect}
onDisable={handleDisable}
onAuthenticate={handleAuthenticate}
onBack={handleNavigateBack}
/>
);
@ -463,6 +572,17 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
<ToolDetailStep tool={selectedTool} onBack={handleNavigateBack} />
);
case MCP_MANAGEMENT_STEPS.AUTHENTICATE:
return (
<AuthenticateStep
server={selectedServer}
onSuccess={() => {
void reloadServers();
}}
onBack={handleNavigateBack}
/>
);
default:
return (
<Box>
@ -480,10 +600,12 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
handleViewTools,
handleReconnect,
handleDisable,
handleAuthenticate,
handleNavigateBack,
handleSelectTool,
handleSelectDisableScope,
getServerTools,
reloadServers,
]);
// Render step footer
@ -511,6 +633,9 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
case MCP_MANAGEMENT_STEPS.TOOL_DETAIL:
footerText = t('Esc to back');
break;
case MCP_MANAGEMENT_STEPS.AUTHENTICATE:
footerText = t('Esc to go back');
break;
default:
footerText = t('Esc to close');
}
@ -536,14 +661,15 @@ export const MCPManagementDialog: React.FC<MCPManagementDialogProps> = ({
);
return (
<Box flexDirection="column">
<Box flexDirection="column" width={boxWidth}>
<Box
borderStyle="single"
borderColor={theme.border.default}
flexDirection="column"
padding={1}
width="100%"
width={boxWidth}
gap={1}
paddingLeft={1}
paddingRight={1}
>
{renderStepHeader()}
{renderStepContent()}

View file

@ -0,0 +1,164 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useCallback, useRef, useEffect } 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 { AuthenticateStepProps } from '../types.js';
import { useConfig } from '../../../contexts/ConfigContext.js';
import {
MCPOAuthProvider,
MCPOAuthTokenStorage,
getErrorMessage,
} from '@qwen-code/qwen-code-core';
import { appEvents, AppEvent } from '../../../../utils/events.js';
type AuthState = 'idle' | 'authenticating' | 'success' | 'error';
export const AuthenticateStep: React.FC<AuthenticateStepProps> = ({
server,
onSuccess,
onBack,
}) => {
const config = useConfig();
const [authState, setAuthState] = useState<AuthState>('idle');
const [messages, setMessages] = useState<string[]>([]);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const isRunning = useRef(false);
const runAuthentication = useCallback(async () => {
if (!server || !config || isRunning.current) return;
isRunning.current = true;
setAuthState('authenticating');
setMessages([]);
setErrorMessage(null);
// Listen for OAuth display messages (same as mcpCommand.ts)
const displayListener = (message: string) => {
setMessages((prev) => [...prev, message]);
};
appEvents.on(AppEvent.OauthDisplayMessage, displayListener);
try {
setMessages([
t("Starting OAuth authentication for MCP server '{{name}}'...", {
name: server.name,
}),
]);
let oauthConfig = server.config.oauth;
if (!oauthConfig) {
oauthConfig = { enabled: false };
}
const mcpServerUrl = server.config.httpUrl || server.config.url;
const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage());
await authProvider.authenticate(
server.name,
oauthConfig,
mcpServerUrl,
appEvents,
);
setMessages((prev) => [
...prev,
t("Successfully authenticated and refreshed tools for '{{name}}'.", {
name: server.name,
}),
]);
// Trigger tool re-discovery to pick up authenticated server
const toolRegistry = config.getToolRegistry();
if (toolRegistry) {
setMessages((prev) => [
...prev,
t("Re-discovering tools from '{{name}}'...", {
name: server.name,
}),
]);
await toolRegistry.discoverToolsForServer(server.name);
}
// Update the client with the new tools
const geminiClient = config.getGeminiClient();
if (geminiClient) {
await geminiClient.setTools();
}
setAuthState('success');
onSuccess?.();
} catch (error) {
setErrorMessage(getErrorMessage(error));
setAuthState('error');
} finally {
isRunning.current = false;
appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener);
}
}, [server, config, onSuccess]);
useEffect(() => {
runAuthentication();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useKeypress(
(key) => {
if (key.name === 'escape') {
onBack();
}
},
{ isActive: true },
);
if (!server) {
return (
<Box>
<Text color={theme.status.error}>{t('No server selected')}</Text>
</Box>
);
}
return (
<Box flexDirection="column" gap={1}>
{/* Server info */}
<Box>
<Text color={theme.text.secondary}>
{t('Server:')} {server.name}
</Text>
</Box>
{/* Progress messages */}
{messages.length > 0 && (
<Box flexDirection="column">
{messages.map((msg, i) => (
<Text key={i} color={theme.text.secondary}>
{msg}
</Text>
))}
</Box>
)}
{/* Error message */}
{authState === 'error' && errorMessage && (
<Box>
<Text color={theme.status.error}>{errorMessage}</Text>
</Box>
)}
{/* Action hints */}
<Box>
{authState === 'authenticating' && (
<Text color={theme.text.secondary}>
{t('Authenticating... Please complete the login in your browser.')}
</Text>
)}
</Box>
</Box>
);
};

View file

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useState } from 'react';
import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../../../semantic-colors.js';
import { useKeypress } from '../../../hooks/useKeypress.js';
@ -20,62 +20,79 @@ import {
// 标签列宽度
const LABEL_WIDTH = 15;
type ServerAction = 'view-tools' | 'reconnect' | 'toggle-disable';
type ServerAction =
| 'view-tools'
| 'reconnect'
| 'toggle-disable'
| 'authenticate';
export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
server,
onViewTools,
onReconnect,
onDisable,
onAuthenticate,
onBack,
}) => {
const [selectedAction, setSelectedAction] =
useState<ServerAction>('view-tools');
const statusColor = server
? server.isDisabled
? 'yellow'
: getStatusColor(server.status)
: 'gray';
const statusColor = server ? getStatusColor(server.status) : 'gray';
// 根据服务器状态动态生成可用操作
const actions = useMemo(() => {
const result: Array<{
key: string;
label: string;
value: ServerAction;
}> = [];
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,
},
{
if (!server) {
return result;
}
// 只在服务器未禁用且有工具时显示"查看工具"选项
if (!server.isDisabled && (server.toolCount ?? 0) > 0) {
result.push({
key: 'view-tools',
label: t('View tools'),
value: 'view-tools',
});
}
// 只在服务器未禁用且已断开连接时显示"重新连接"选项
if (!server.isDisabled && server.status === 'disconnected') {
result.push({
key: 'reconnect',
label: t('Reconnect'),
value: 'reconnect',
});
}
// 始终显示启用/禁用选项
result.push({
key: 'toggle-disable',
get label() {
return server?.isDisabled ? t('Enable') : t('Disable');
},
value: 'toggle-disable' as const,
},
];
label: server?.isDisabled ? t('Enable') : t('Disable'),
value: 'toggle-disable',
});
// 待补充准确的认证判断方案,暂时全部开放
if (!server.isDisabled) {
result.push({
key: 'authenticate',
label: t('Authenticate'),
value: 'authenticate',
});
}
return result;
}, [server]);
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 },
@ -107,10 +124,8 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
: theme.status.error
}
>
{getStatusIcon(server.status)} {t(server.status)}
{server.isDisabled && (
<Text color={theme.status.warning}> {t('(disabled)')}</Text>
)}
{getStatusIcon(server.status)}{' '}
{server.isDisabled ? t('disabled') : t(server.status)}
</Text>
</Box>
</Box>
@ -120,7 +135,7 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
<Text color={theme.text.primary}>{t('Source:')}</Text>
</Box>
<Box>
<Text color={theme.text.secondary}>
<Text color={theme.text.primary}>
{server.scope === 'user'
? t('User Settings')
: server.scope === 'workspace'
@ -150,37 +165,29 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
</Box>
)}
<Box>
<Box width={LABEL_WIDTH}>
<Text color={theme.text.primary}>{t('Capabilities:')}</Text>
</Box>
{!server.isDisabled && (
<Box>
<Text>
{server.toolCount > 0 ? t('tools') : ''}
{server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''}
{server.promptCount > 0 ? t('prompts') : ''}
</Text>
<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>
</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>
@ -200,7 +207,7 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
<Box>
<RadioButtonSelect<ServerAction>
items={actions}
onHighlight={(value: ServerAction) => setSelectedAction(value)}
showNumbers={false}
onSelect={(value: ServerAction) => {
switch (value) {
case 'view-tools':
@ -212,6 +219,9 @@ export const ServerDetailStep: React.FC<ServerDetailStepProps> = ({
case 'toggle-disable':
onDisable?.();
break;
case 'authenticate':
onAuthenticate?.();
break;
default:
break;
}

View file

@ -27,7 +27,6 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
[servers],
);
// 动态计算服务器名称列的最大宽度(基于实际内容)
const serverNameWidth = useMemo(() => {
if (servers.length === 0) return 20;
const maxLength = Math.max(...servers.map((s) => s.name.length));
@ -35,7 +34,6 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
return Math.min(Math.max(maxLength + 2, 20), 35);
}, [servers]);
// 计算扁平化的服务器列表用于导航
const flatServers = useMemo(() => {
const result: MCPServerDisplayInfo[] = [];
for (const group of groupedServers) {
@ -44,7 +42,6 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
return result;
}, [groupedServers]);
// 键盘导航
useKeypress(
(key) => {
if (key.name === 'up') {
@ -71,7 +68,6 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
);
}
// 计算当前选中项在分组中的位置
const getSelectionPosition = (globalIndex: number) => {
let currentIndex = 0;
for (const group of groupedServers) {
@ -90,18 +86,15 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
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}>
<Box
key={group.source}
flexDirection="column"
marginBottom={groupIndex === groupedServers.length - 1 ? 0 : 1}
>
<Text bold color={theme.text.primary}>
{group.displayName}
{` ${group.displayName}`}
{group.servers[0]?.configPath && (
<Text color={theme.text.secondary}>
{' '}
@ -109,12 +102,14 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
</Text>
)}
</Text>
<Box flexDirection="column" marginTop={1}>
<Box flexDirection="column">
{group.servers.map((server, itemIndex) => {
const isSelected =
groupIndex === currentPosition.groupIndex &&
itemIndex === currentPosition.itemIndex;
const statusColor = getStatusColor(server.status);
const statusColor = server.isDisabled
? 'yellow'
: getStatusColor(server.status);
return (
<Box key={server.name}>
@ -149,13 +144,9 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
: theme.status.error
}
>
{getStatusIcon(server.status)} {t(server.status)}
{getStatusIcon(server.status)}{' '}
{server.isDisabled ? t('disabled') : 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}>
@ -173,8 +164,8 @@ export const ServerListStep: React.FC<ServerListStepProps> = ({
))}
{/* 提示信息 */}
{servers.some((s) => s.status === 'disconnected') && (
<Box>
{servers.some((s) => s.status === 'disconnected' && !s.isDisabled) && (
<Box marginTop={1}>
<Text color={theme.status.warning}>
{t('Run qwen --debug to see error logs')}
</Text>

View file

@ -10,14 +10,6 @@ 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) + '...';
};
/**
*
*/
@ -28,45 +20,15 @@ const renderParameter = (
): 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;
// const defaultValue = param['default'];
// const enumValues = param['enum'] as string[] | undefined;
const text = `${name}${isRequired ? t('required') : ''}: ${type} ${description ? `- ${description}` : ''}`;
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 key={name}>
<Text color={theme.text.secondary} wrap="wrap">
{text}
</Text>
</Box>
);
};
@ -82,8 +44,10 @@ const ParametersList: React.FC<{
return (
<Box flexDirection="column">
<Text color={theme.text.secondary}>{t('Parameters')}:</Text>
<Box marginLeft={2} flexDirection="column">
<Text color={theme.text.primary} bold>
{t('Parameters')}:
</Text>
<Box flexDirection="column" marginLeft={1}>
{Object.entries(properties).map(([name, param]) =>
renderParameter(
name,
@ -156,62 +120,20 @@ export const ToolDetailStep: React.FC<ToolDetailStepProps> = ({
{/* 工具描述 */}
{tool.description && (
<Box>
<Box flexDirection="column">
<Text color={theme.text.primary} bold>
{t('Description')}:
</Text>
<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}>
<Box flexDirection="column">
<SchemaSummary schema={tool.schema} />
</Box>
)}
{/* 所属服务器 */}
<Box marginTop={1}>
<Text color={theme.text.secondary}>
{t('Server')}: {tool.serverName}
</Text>
</Box>
</Box>
);
};

View file

@ -14,7 +14,6 @@ import { VISIBLE_TOOLS_COUNT } from '../constants.js';
export const ToolListStep: React.FC<ToolListStepProps> = ({
tools,
serverName,
onSelect,
onBack,
}) => {
@ -78,24 +77,15 @@ export const ToolListStep: React.FC<ToolListStepProps> = ({
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'));
if (tool.annotations?.destructiveHint) hints.push('destructive');
if (tool.annotations?.readOnlyHint) hints.push('read-only');
if (tool.annotations?.openWorldHint) hints.push('open-world');
if (tool.annotations?.idempotentHint) hints.push('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) => {
@ -105,14 +95,13 @@ export const ToolListStep: React.FC<ToolListStepProps> = ({
return (
<Box key={tool.name}>
{/* 选择器和序号 */}
<Box minWidth={4}>
{/* 选择器 */}
<Box minWidth={2}>
<Text
color={isSelected ? theme.text.accent : theme.text.primary}
>
{isSelected ? '' : ' '}
</Text>
<Text color={theme.text.secondary}>{actualIndex + 1}.</Text>
</Box>
{/* 工具名称 - 固定宽度 */}
<Box width={toolNameWidth}>

View file

@ -18,6 +18,7 @@ export const MCP_MANAGEMENT_STEPS = {
DISABLE_SCOPE_SELECT: 'disable-scope-select',
TOOL_LIST: 'tool-list',
TOOL_DETAIL: 'tool-detail',
AUTHENTICATE: 'authenticate', // OAuth 认证步骤
} as const;
export type MCPManagementStep =
@ -120,7 +121,7 @@ export interface ServerListStepProps {
}
/**
* ServerDetailStep组件属
* ServerDetailStep
*/
export interface ServerDetailStepProps {
/** 选中的服务器 */
@ -131,6 +132,8 @@ export interface ServerDetailStepProps {
onReconnect?: () => void;
/** 禁用服务器回调 */
onDisable?: () => void;
/** OAuth 认证回调 */
onAuthenticate?: () => void;
/** 返回回调 */
onBack: () => void;
}
@ -162,7 +165,7 @@ export interface ToolListStepProps {
}
/**
* ToolDetailStep组件属
* ToolDetailStep
*/
export interface ToolDetailStepProps {
/** 工具信息 */
@ -171,6 +174,18 @@ export interface ToolDetailStepProps {
onBack: () => void;
}
/**
* AuthenticateStep
*/
export interface AuthenticateStepProps {
/** 服务器信息 */
server: MCPServerDisplayInfo | null;
/** 认证成功回调 */
onSuccess?: () => void;
/** 返回回调 */
onBack: () => void;
}
/**
* MCP管理对话框属性
*/