diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 9c546004c..20c6d670b 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -104,6 +104,7 @@ import { type VisionSwitchOutcome } from './components/ModelSwitchDialog.js'; import { processVisionSwitchOutcome } from './hooks/useVisionAutoSwitch.js'; import { useSubagentCreateDialog } from './hooks/useSubagentCreateDialog.js'; import { useAgentsManagerDialog } from './hooks/useAgentsManagerDialog.js'; +import { useMcpDialog } from './hooks/useMcpDialog.js'; import { useAttentionNotifications } from './hooks/useAttentionNotifications.js'; import { requestConsentInteractive, @@ -495,6 +496,7 @@ export const AppContainer = (props: AppContainerProps) => { openAgentsManagerDialog, closeAgentsManagerDialog, } = useAgentsManagerDialog(); + const { isMcpDialogOpen, openMcpDialog, closeMcpDialog } = useMcpDialog(); // Vision model auto-switch dialog state (must be before slashCommandActions) const [isVisionSwitchDialogOpen, setIsVisionSwitchDialogOpen] = @@ -529,6 +531,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openMcpDialog, openResumeDialog, }), [ @@ -544,6 +547,7 @@ export const AppContainer = (props: AppContainerProps) => { addConfirmUpdateExtensionRequest, openSubagentCreateDialog, openAgentsManagerDialog, + openMcpDialog, openResumeDialog, ], ); @@ -1295,6 +1299,7 @@ export const AppContainer = (props: AppContainerProps) => { showIdeRestartPrompt || isSubagentCreateDialogOpen || isAgentsManagerDialogOpen || + isMcpDialogOpen || isApprovalModeDialogOpen || isResumeDialogOpen; @@ -1408,6 +1413,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs isSubagentCreateDialogOpen, isAgentsManagerDialogOpen, + // MCP dialog + isMcpDialogOpen, // Feedback dialog isFeedbackDialogOpen, }), @@ -1500,6 +1507,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs isSubagentCreateDialogOpen, isAgentsManagerDialogOpen, + // MCP dialog + isMcpDialogOpen, // Feedback dialog isFeedbackDialogOpen, ], @@ -1542,6 +1551,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // MCP dialog + closeMcpDialog, // Resume session dialog openResumeDialog, closeResumeDialog, @@ -1585,6 +1596,8 @@ export const AppContainer = (props: AppContainerProps) => { // Subagent dialogs closeSubagentCreateDialog, closeAgentsManagerDialog, + // MCP dialog + closeMcpDialog, // Resume session dialog openResumeDialog, closeResumeDialog, diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index d8fec7177..ccb1edf65 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -9,6 +9,7 @@ import type { SlashCommandActionReturn, CommandContext, MessageActionReturn, + OpenDialogActionReturn, } from './types.js'; import { CommandKind } from './types.js'; import type { DiscoveredMCPPrompt } from '@qwen-code/qwen-code-core'; @@ -352,6 +353,18 @@ const refreshCommand: SlashCommand = { }, }; +const manageCommand: SlashCommand = { + name: 'manage', + get description() { + return t('Open MCP management dialog'); + }, + kind: CommandKind.BUILT_IN, + action: async (): Promise => ({ + type: 'dialog', + dialog: 'mcp', + }), +}; + export const mcpCommand: SlashCommand = { name: 'mcp', get description() { @@ -360,12 +373,10 @@ export const mcpCommand: SlashCommand = { ); }, kind: CommandKind.BUILT_IN, - subCommands: [listCommand, authCommand, refreshCommand], - // Default action when no subcommand is provided - action: async ( - context: CommandContext, - args: string, - ): Promise => - // If no subcommand, run the list command - listCommand.action!(context, args), + subCommands: [manageCommand, listCommand, authCommand, refreshCommand], + // Default action when no subcommand is provided - open dialog + action: async (): Promise => ({ + type: 'dialog', + dialog: 'mcp', + }), }; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 6c03ec136..4a6f42c80 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -146,7 +146,8 @@ export interface OpenDialogActionReturn { | 'subagent_list' | 'permissions' | 'approval-mode' - | 'resume'; + | 'resume' + | 'mcp'; } /** diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index dbb6f2207..7836ceb6b 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -35,6 +35,7 @@ import { WelcomeBackDialog } from './WelcomeBackDialog.js'; import { ModelSwitchDialog } from './ModelSwitchDialog.js'; import { AgentCreationWizard } from './subagents/create/AgentCreationWizard.js'; import { AgentsManagerDialog } from './subagents/manage/AgentsManagerDialog.js'; +import { MCPManagementDialog } from './mcp/MCPManagementDialog.js'; import { SessionPicker } from './SessionPicker.js'; interface DialogManagerProps { @@ -297,6 +298,10 @@ export const DialogManager = ({ ); } + if (uiState.isMcpDialogOpen) { + return ; + } + if (uiState.isResumeDialogOpen) { return ( = ({ + onClose, +}) => { + const config = useConfig(); + + const [servers, setServers] = useState([]); + const [selectedServerIndex, setSelectedServerIndex] = useState(-1); + const [selectedTool, setSelectedTool] = useState( + null, + ); + const [navigationStack, setNavigationStack] = useState([ + MCP_MANAGEMENT_STEPS.SERVER_LIST, + ]); + const [isLoading, setIsLoading] = useState(true); + + // 加载MCP服务器数据 + useEffect(() => { + const loadServers = async () => { + if (!config) return; + + setIsLoading(true); + try { + const mcpServers = config.getMcpServers() || {}; + const toolRegistry = config.getToolRegistry(); + const promptRegistry = await config.getPromptRegistry(); + + const serverInfos: MCPServerDisplayInfo[] = []; + + for (const [name, serverConfig] of Object.entries(mcpServers) as Array< + [string, MCPServerConfig] + >) { + const status = getMCPServerStatus(name); + + // 获取该服务器的工具 + const allTools: AnyDeclarativeTool[] = + toolRegistry?.getAllTools() || []; + const serverTools = allTools.filter( + (t): t is DiscoveredMCPTool => + t instanceof DiscoveredMCPTool && t.serverName === name, + ); + + // 获取该服务器的prompts + const allPrompts: DiscoveredMCPPrompt[] = + promptRegistry?.getAllPrompts() || []; + const serverPrompts = allPrompts.filter( + (p) => 'serverName' in p && p.serverName === name, + ); + + // 确定来源 + let source: 'user' | 'project' | 'extension' = 'user'; + if (serverConfig.extensionName) { + source = 'extension'; + } + // TODO: 区分user和project来源需要更详细的配置信息 + + serverInfos.push({ + name, + status, + source, + config: serverConfig, + toolCount: serverTools.length, + promptCount: serverPrompts.length, + }); + } + + setServers(serverInfos); + } finally { + setIsLoading(false); + } + }; + + loadServers(); + }, [config]); + + // 选中的服务器 + const selectedServer = useMemo(() => { + if (selectedServerIndex >= 0 && selectedServerIndex < servers.length) { + return servers[selectedServerIndex]; + } + return null; + }, [servers, selectedServerIndex]); + + // 当前步骤 + const getCurrentStep = useCallback( + () => + navigationStack[navigationStack.length - 1] || + MCP_MANAGEMENT_STEPS.SERVER_LIST, + [navigationStack], + ); + + // 导航处理 + 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); + }); + }, []); + + // 选择服务器 + const handleSelectServer = useCallback( + (index: number) => { + setSelectedServerIndex(index); + handleNavigateToStep(MCP_MANAGEMENT_STEPS.SERVER_DETAIL); + }, + [handleNavigateToStep], + ); + + // 获取服务器工具列表 + 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) => ({ + name: tool.name, + description: tool.description, + serverName: tool.serverName, + schema: tool.parameterSchema as object | undefined, + })); + }, [config, selectedServer]); + + // 查看工具列表 + const handleViewTools = useCallback(() => { + handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST); + }, [handleNavigateToStep]); + + // 选择工具 + const handleSelectTool = useCallback( + (tool: MCPToolDisplayInfo) => { + setSelectedTool(tool); + handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_DETAIL); + }, + [handleNavigateToStep], + ); + + // 重新连接服务器 + const handleReconnect = useCallback(async () => { + if (!config || !selectedServer) return; + + try { + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.discoverToolsForServer(selectedServer.name); + } + // 重新加载服务器数据以更新状态 + const loadServers = async () => { + setIsLoading(true); + try { + const mcpServers = config.getMcpServers() || {}; + const toolRegistry = config.getToolRegistry(); + const promptRegistry = await config.getPromptRegistry(); + + const serverInfos: MCPServerDisplayInfo[] = []; + + for (const [name, serverConfig] of Object.entries( + mcpServers, + ) as Array<[string, MCPServerConfig]>) { + const status = getMCPServerStatus(name); + + const allTools: AnyDeclarativeTool[] = + toolRegistry?.getAllTools() || []; + const serverTools = allTools.filter( + (t): t is DiscoveredMCPTool => + t instanceof DiscoveredMCPTool && t.serverName === name, + ); + + const allPrompts: DiscoveredMCPPrompt[] = + promptRegistry?.getAllPrompts() || []; + const serverPrompts = allPrompts.filter( + (p) => 'serverName' in p && p.serverName === name, + ); + + let source: 'user' | 'project' | 'extension' = 'user'; + if (serverConfig.extensionName) { + source = 'extension'; + } + + serverInfos.push({ + name, + status, + source, + config: serverConfig, + toolCount: serverTools.length, + promptCount: serverPrompts.length, + }); + } + + setServers(serverInfos); + } finally { + setIsLoading(false); + } + }; + await loadServers(); + } catch (_error) { + // 错误处理 - 静默失败 + } + }, [config, selectedServer]); + + // 禁用服务器 + const handleDisable = useCallback(async () => { + if (!config || !selectedServer) return; + + // TODO: 实现禁用服务器的逻辑 + // 这需要修改配置文件,暂时返回到服务器列表 + handleNavigateBack(); + }, [config, selectedServer, handleNavigateBack]); + + // 渲染步骤头部 + 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.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 ( + + {headerText} + + ); + }, [getCurrentStep, selectedServer, selectedTool]); + + // 渲染步骤内容 + const renderStepContent = useCallback(() => { + if (isLoading) { + return ( + + {t('Loading...')} + + ); + } + + const currentStep = getCurrentStep(); + + switch (currentStep) { + case MCP_MANAGEMENT_STEPS.SERVER_LIST: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.TOOL_LIST: + return ( + + ); + + case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: + return ( + + ); + + default: + return ( + + {t('Unknown step')} + + ); + } + }, [ + isLoading, + getCurrentStep, + servers, + selectedServer, + selectedTool, + handleSelectServer, + handleViewTools, + handleReconnect, + handleDisable, + handleNavigateBack, + handleSelectTool, + getServerTools, + ]); + + // 渲染步骤底部 + 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.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 ( + + {footerText} + + ); + }, [getCurrentStep, servers.length]); + + // ESC键处理 + useKeypress( + (key) => { + if (key.name === 'escape') { + const currentStep = getCurrentStep(); + if (currentStep === MCP_MANAGEMENT_STEPS.SERVER_LIST) { + onClose(); + } else { + handleNavigateBack(); + } + } + }, + { isActive: true }, + ); + + return ( + + + {renderStepHeader()} + {renderStepContent()} + {renderStepFooter()} + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/constants.ts b/packages/cli/src/ui/components/mcp/constants.ts new file mode 100644 index 000000000..add1d3287 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/constants.ts @@ -0,0 +1,37 @@ +/** + * @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 SOURCE_DISPLAY_NAMES: Record = { + user: 'User MCPs', + project: 'Project MCPs', + extension: 'Extension MCPs', +}; + +/** + * 状态显示文本 + */ +export const STATUS_TEXT: Record = { + connected: 'connected', + connecting: 'connecting', + disconnected: 'failed', +}; diff --git a/packages/cli/src/ui/components/mcp/index.ts b/packages/cli/src/ui/components/mcp/index.ts new file mode 100644 index 000000000..01ebfee8f --- /dev/null +++ b/packages/cli/src/ui/components/mcp/index.ts @@ -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'; diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx new file mode 100644 index 000000000..fc34d1f51 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -0,0 +1,183 @@ +/** + * @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'; + +type ServerAction = 'view-tools' | 'reconnect' | 'disable'; + +export const ServerDetailStep: React.FC = ({ + server, + onViewTools, + onReconnect, + onDisable, + onBack, +}) => { + const [selectedAction, setSelectedAction] = + useState('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: 'disable', + get label() { + return t('Disable'); + }, + value: '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 'disable': + onDisable?.(); + break; + default: + break; + } + } + }, + { isActive: true }, + ); + + if (!server) { + return ( + + {t('No server selected')} + + ); + } + + return ( + + {/* 服务器详情 */} + + + {t('Status:')} + + + {getStatusIcon(server.status)} {t(server.status)} + + + + + + {t('Command:')} + + {formatServerCommand(server)} + + + + {server.config.cwd && ( + + {t('Working Directory:')} + + {server.config.cwd} + + + )} + + + {t('Capabilities:')} + + + {server.toolCount > 0 ? t('tools') : ''} + {server.toolCount > 0 && server.promptCount > 0 ? ', ' : ''} + {server.promptCount > 0 ? t('prompts') : ''} + + + + + + {t('Tools:')} + + + {server.toolCount}{' '} + {server.toolCount === 1 ? t('tool') : t('tools')} + + + + + {server.errorMessage && ( + + {t('Error:')} + + + {server.errorMessage} + + + + )} + + + {/* 操作列表 */} + + + items={actions} + onHighlight={(value: ServerAction) => setSelectedAction(value)} + onSelect={(value: ServerAction) => { + switch (value) { + case 'view-tools': + onViewTools(); + break; + case 'reconnect': + onReconnect?.(); + break; + case 'disable': + onDisable?.(); + break; + default: + break; + } + }} + /> + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx new file mode 100644 index 000000000..53e03ac59 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx @@ -0,0 +1,169 @@ +/** + * @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 = ({ + servers, + onSelect, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + const groupedServers = useMemo( + () => groupServersBySource(servers), + [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 ( + + + {t('No MCP servers configured.')} + + + {t('Add MCP servers to your settings to get started.')} + + + ); + } + + // 计算当前选中项在分组中的位置 + 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 ( + + {/* 服务器统计 */} + + + {servers.length} {servers.length === 1 ? t('server') : t('servers')} + + + + {/* 分组服务器列表 */} + {groupedServers.map((group, groupIndex) => ( + + + {group.displayName} + {group.servers[0]?.configPath && ( + + {' '} + ({group.servers[0].configPath}) + + )} + + + {group.servers.map((server, itemIndex) => { + const isSelected = + groupIndex === currentPosition.groupIndex && + itemIndex === currentPosition.itemIndex; + const statusColor = getStatusColor(server.status); + + return ( + + + + {isSelected ? '❯' : ' '} + + + + {server.name} + + · + + {getStatusIcon(server.status)} + + + {' '} + {t(server.status)} + + + ); + })} + + + ))} + + {/* 提示信息 */} + {servers.some((s) => s.status === 'disconnected') && ( + + + ※ {t('Run qwen --debug to see error logs')} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx new file mode 100644 index 000000000..a83185c53 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx @@ -0,0 +1,116 @@ +/** + * @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'; + +export const ToolDetailStep: React.FC = ({ + tool, + onBack, +}) => { + useKeypress( + (key) => { + if (key.name === 'escape') { + onBack(); + } + }, + { isActive: true }, + ); + + if (!tool) { + return ( + + {t('No tool selected')} + + ); + } + + // 格式化schema显示 + const formatSchema = (schema: object | undefined): string => { + if (!schema) return t('No schema available'); + return JSON.stringify(schema, null, 2); + }; + + return ( + + {/* 工具名称 */} + + {tool.name} + + + {/* 工具描述 */} + {tool.description && ( + + {tool.description} + + )} + + {/* 工具注解 */} + {tool.annotations && ( + + {t('Annotations:')} + + {tool.annotations.title && ( + + • {t('Title')}: {tool.annotations.title} + + )} + {tool.annotations.readOnlyHint !== undefined && ( + + • {t('Read Only')}:{' '} + {tool.annotations.readOnlyHint ? t('Yes') : t('No')} + + )} + {tool.annotations.destructiveHint !== undefined && ( + + • {t('Destructive')}:{' '} + {tool.annotations.destructiveHint ? t('Yes') : t('No')} + + )} + {tool.annotations.idempotentHint !== undefined && ( + + • {t('Idempotent')}:{' '} + {tool.annotations.idempotentHint ? t('Yes') : t('No')} + + )} + {tool.annotations.openWorldHint !== undefined && ( + + • {t('Open World')}:{' '} + {tool.annotations.openWorldHint ? t('Yes') : t('No')} + + )} + + + )} + + {/* Schema */} + + {t('Schema:')} + + + {formatSchema(tool.schema)} + + + + + {/* 所属服务器 */} + + + {t('Server')}: {tool.serverName} + + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx new file mode 100644 index 000000000..cc9ad6919 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx @@ -0,0 +1,142 @@ +/** + * @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'; + +// 可视区域最大显示工具数量 +const VISIBLE_TOOLS_COUNT = 10; + +export const ToolListStep: React.FC = ({ + tools, + serverName, + onSelect, + onBack, +}) => { + const [selectedIndex, setSelectedIndex] = useState(0); + + // 计算可视区域的起始索引(滚动窗口) + 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 ( + + + {t('No tools available for this server.')} + + + ); + } + + 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 ( + + {/* 标题 */} + + {t('Tools for {{name}}', { name: serverName })} + + {' '} + ({tools.length} {tools.length === 1 ? t('tool') : t('tools')}) + + + + {/* 工具列表 */} + + {displayTools.map((tool, index) => { + const actualIndex = scrollOffset + index; + const isSelected = actualIndex === selectedIndex; + const annotations = getToolAnnotations(tool); + + return ( + + + + {isSelected ? '❯' : ' '} + + {actualIndex + 1}. + + + {tool.name} + + {annotations && ( + <> + + {annotations} + + )} + + ); + })} + + + {/* 滚动提示 */} + {tools.length > VISIBLE_TOOLS_COUNT && ( + + + {scrollOffset > 0 ? '↑ ' : ' '} + {t('{{current}}/{{total}}', { + current: (scrollOffset + 1).toString(), + total: tools.length.toString(), + })} + {scrollOffset + VISIBLE_TOOLS_COUNT < tools.length ? ' ↓' : ''} + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/types.ts b/packages/cli/src/ui/components/mcp/types.ts new file mode 100644 index 000000000..a49367711 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/types.ts @@ -0,0 +1,183 @@ +/** + * @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', + TOOL_LIST: 'tool-list', + TOOL_DETAIL: 'tool-detail', + PROMPT_LIST: 'prompt-list', + PROMPT_DETAIL: 'prompt-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'; + /** 配置文件路径 */ + configPath?: string; + /** 服务器配置 */ + config: MCPServerConfig; + /** 工具数量 */ + toolCount: number; + /** Prompt数量 */ + promptCount: number; + /** 错误信息 */ + errorMessage?: string; +} + +/** + * MCP工具显示信息 + */ +export interface MCPToolDisplayInfo { + /** 工具名称 */ + name: string; + /** 工具描述 */ + description?: string; + /** 所属服务器 */ + serverName: string; + /** 工具schema */ + schema?: object; + /** 工具注解 */ + annotations?: { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; + }; +} + +/** + * 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; +} + +/** + * ToolListStep组件属性 + */ +export interface ToolListStepProps { + /** 工具列表 */ + tools: MCPToolDisplayInfo[]; + /** 服务器名称 */ + serverName: string; + /** 选择回调 */ + onSelect: (tool: MCPToolDisplayInfo) => void; + /** 返回回调 */ + onBack: () => void; +} + +/** + * ToolDetailStep组件属性 + */ +export interface ToolDetailStepProps { + /** 工具信息 */ + tool: MCPToolDisplayInfo | null; + /** 返回回调 */ + onBack: () => void; +} + +/** + * PromptListStep组件属性 + */ +export interface PromptListStepProps { + /** Prompt列表 */ + prompts: MCPPromptDisplayInfo[]; + /** 服务器名称 */ + serverName: string; + /** 选择回调 */ + onSelect: (prompt: MCPPromptDisplayInfo) => void; + /** 返回回调 */ + onBack: () => void; +} + +/** + * PromptDetailStep组件属性 + */ +export interface PromptDetailStepProps { + /** Prompt信息 */ + prompt: MCPPromptDisplayInfo | null; + /** 返回回调 */ + onBack: () => void; +} + +/** + * MCP管理对话框属性 + */ +export interface MCPManagementDialogProps { + /** 关闭回调 */ + onClose: () => void; +} diff --git a/packages/cli/src/ui/components/mcp/utils.ts b/packages/cli/src/ui/components/mcp/utils.ts new file mode 100644 index 000000000..709c8f947 --- /dev/null +++ b/packages/cli/src/ui/components/mcp/utils.ts @@ -0,0 +1,103 @@ +/** + * @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(); + + 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'; +} diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index e4cb85003..538765b43 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -72,6 +72,8 @@ export interface UIActions { // Subagent dialogs closeSubagentCreateDialog: () => void; closeAgentsManagerDialog: () => void; + // MCP dialog + closeMcpDialog: () => void; // Resume session dialog openResumeDialog: () => void; closeResumeDialog: () => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index f8d52faa1..e61efd3b1 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -127,6 +127,8 @@ export interface UIState { // Subagent dialogs isSubagentCreateDialogOpen: boolean; isAgentsManagerDialogOpen: boolean; + // MCP dialog + isMcpDialogOpen: boolean; // Feedback dialog isFeedbackDialogOpen: boolean; } diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 59ff06bcf..3079acf65 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -77,6 +77,7 @@ interface SlashCommandProcessorActions { addConfirmUpdateExtensionRequest: (request: ConfirmationRequest) => void; openSubagentCreateDialog: () => void; openAgentsManagerDialog: () => void; + openMcpDialog: () => void; } /** @@ -419,6 +420,9 @@ export const useSlashCommandProcessor = ( case 'subagent_list': actions.openAgentsManagerDialog(); return { type: 'handled' }; + case 'mcp': + actions.openMcpDialog(); + return { type: 'handled' }; case 'approval-mode': actions.openApprovalModeDialog(); return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/useMcpDialog.ts b/packages/cli/src/ui/hooks/useMcpDialog.ts new file mode 100644 index 000000000..3b444297f --- /dev/null +++ b/packages/cli/src/ui/hooks/useMcpDialog.ts @@ -0,0 +1,31 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useCallback } from 'react'; + +export interface UseMcpDialogReturn { + isMcpDialogOpen: boolean; + openMcpDialog: () => void; + closeMcpDialog: () => void; +} + +export const useMcpDialog = (): UseMcpDialogReturn => { + const [isMcpDialogOpen, setIsMcpDialogOpen] = useState(false); + + const openMcpDialog = useCallback(() => { + setIsMcpDialogOpen(true); + }, []); + + const closeMcpDialog = useCallback(() => { + setIsMcpDialogOpen(false); + }, []); + + return { + isMcpDialogOpen, + openMcpDialog, + closeMcpDialog, + }; +};