diff --git a/OPTIMIZATION_PLAN.md b/OPTIMIZATION_PLAN.md new file mode 100644 index 000000000..b56e14ea9 --- /dev/null +++ b/OPTIMIZATION_PLAN.md @@ -0,0 +1,962 @@ +# Qwen Code 0.12.0 MCP & Extension Management 优化方案 + +## 问题梳理与解决方案 + +根据钉钉文档《0.12.0 体验反馈》中提出的问题,本文件详细分析了每个问题的根本原因,并提供具体的解决方案和代码修改建议。 + +--- + +## 文档问题概览 + +本文档共包含 **6 个问题** (3 个 P1 + 3 个 P2),分为两个主要部分: + +### Part 1: MCP Management TUI (5 个问题) + +- **P1 级别**: 3 个问题 +- **P2 级别**: 2 个细节问题 (共 10 个小点) + +### Part 2: Extension Management TUI (1 个问题) + +- **P2 级别**: 1 个命令报错问题 + +## 问题 1: 【P1】Auth 属于 manage 的一部分,应该加到 manage 里 + +### 问题描述 + +- **现状**: 当前 MCP Management Dialog 中**没有 OAuth 认证功能**,用户必须使用 `/mcp auth ` 命令进行认证 +- **问题**: + - Auth 功能独立于 Manage Dialog 之外,用户体验割裂 + - 需要记住命令行才能认证,不够直观 + - MCP 管理对话框中只能查看服务器状态和工具,无法进行认证操作 +- **文档建议**: Auth 应该整合到 manage dialog 中,在 UI 界面内完成所有 MCP 管理操作 + +### 根本原因分析 + +#### 当前实现 + +```typescript +// packages/cli/src/ui/commands/mcpCommand.ts +const mcpCommand: SlashCommand = { + name: 'mcp', + subCommands: [manageCommand, authCommand], // auth 作为独立子命令存在 + action: async (): Promise => ({ + type: 'dialog', + dialog: 'mcp', // 默认打开管理对话框 + }), +}; +``` + +#### MCP Management Dialog 现状 + +```typescript +// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +// 当前的步骤类型 +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; + +// ServerDetailStep 中的操作选项 +const actions = [ + { label: 'View tools', value: 'view-tools' }, + { label: 'Reconnect', value: 'reconnect' }, + { label: 'Enable/Disable', value: 'toggle-disable' }, + // ❌ 缺少 'Authenticate' 选项 +]; +``` + +#### 问题分析 + +1. **UI 层面**: MCP Management Dialog 中没有认证相关的 UI 组件和操作入口 +2. **代码层面**: OAuth 认证逻辑只在命令行 handler 中实现 (`mcpCommand.ts` 的 `authCommand`) +3. **体验层面**: 用户需要在 TUI 和 CLI 之间切换,无法在一个界面内完成所有操作 + +### 解决方案 + +#### 方案 A: 在 MCP Dialog 中集成完整的 OAuth 认证功能 (强烈推荐) + +**核心思路**: + +- 在 Server Detail 页面添加 "Authenticate" 操作选项 +- 复用现有的 `MCPOAuthProvider` 和 OAuth 流程 +- 通过事件系统显示认证过程中的提示信息 + +**实现步骤**: + +##### 1. 扩展 MCP_MANAGEMENT_STEPS + +```typescript +// packages/cli/src/ui/components/mcp/types.ts +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', + AUTHENTICATE: 'authenticate', // 新增:认证步骤 +} as const; +``` + +##### 2. 在 ServerDetailStep 中添加认证选项 + +```typescript +// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +type ServerAction = + | 'view-tools' + | 'reconnect' + | 'toggle-disable' + | 'authenticate'; // 新增 + +const actions = useMemo(() => { + const result: Array<{ label: string; value: ServerAction }> = []; + + result.push({ label: t('View Tools'), value: 'view-tools' }); + + if (!server.isDisabled && server.status === MCPServerStatus.DISCONNECTED) { + result.push({ label: t('Reconnect'), value: 'reconnect' }); + } + + // 新增:显示认证选项的场景 + const needsAuth = + server.config.oauth?.enabled || + server.status === MCPServerStatus.DISCONNECTED || + server.errorMessage?.includes('401') || + server.errorMessage?.includes('OAuth'); + + if (needsAuth) { + result.push({ + label: t('Authenticate'), + value: 'authenticate', + icon: '🔐', // 可选:添加图标增强视觉提示 + }); + } + + result.push({ + label: server.isDisabled ? t('Enable') : t('Disable'), + value: 'toggle-disable', + }); + + return result; +}, [server]); +``` + +##### 3. 在 MCPManagementDialog 中实现认证逻辑 + +```typescript +// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +import { MCPOAuthProvider, MCPOAuthConfig } from '@qwen-code/qwen-code-core'; +import { appEvents, AppEvent } from '../../utils/events.js'; + +// 新增:处理认证 +const handleAuthenticate = useCallback(async () => { + if (!config || !selectedServer) return; + + try { + setIsLoading(true); + + // 显示开始认证提示 + context.ui.addItem( + { + type: 'info', + text: t("Starting OAuth authentication for '{{name}}'...", { + name: selectedServer.name, + }), + }, + Date.now() + ); + + // 监听并显示认证过程中的消息 + const displayListener = (message: string) => { + context.ui.addItem({ type: 'info', text: message }, Date.now()); + }; + appEvents.on(AppEvent.OauthDisplayMessage, displayListener); + + // 准备 OAuth 配置 + let oauthConfig: MCPOAuthConfig = selectedServer.config.oauth || { enabled: false }; + + // 执行认证 + const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); + await authProvider.authenticate( + selectedServer.name, + oauthConfig, + selectedServer.config.httpUrl || selectedServer.config.url + ); + + // 认证成功 + context.ui.addItem( + { + type: 'success', + text: t("✓ Authentication successful for '{{name}}'", { + name: selectedServer.name, + }), + }, + Date.now() + ); + + // 移除消息监听器 + appEvents.off(AppEvent.OauthDisplayMessage, displayListener); + + // 重新加载服务器数据以更新状态 + await reloadServers(); + + // 返回上一级 + handleNavigateBack(); + } catch (error) { + debugLogger.error( + `Authentication failed for '${selectedServer.name}':`, + error + ); + context.ui.addItem( + { + type: 'error', + text: t("✗ Authentication failed: {{error}}", { + error: getErrorMessage(error), + }), + }, + Date.now() + ); + } finally { + setIsLoading(false); + } +}, [config, selectedServer, reloadServers, handleNavigateBack, context]); + +// 在 renderStepContent 中添加认证步骤的处理 +case MCP_MANAGEMENT_STEPS.AUTHENTICATE: + // 可以直接执行认证,或者显示一个确认对话框 + void handleAuthenticate(); + return {t('Authenticating...')}; +``` + +##### 4. 更新 i18n 翻译文件 + +```javascript +// packages/cli/src/i18n/locales/en.js +{ + 'Authenticate': 'Authenticate', + 'Authenticate with OAuth': 'Authenticate with OAuth', + "Starting OAuth authentication for '{{name}}'...": "Starting OAuth authentication for '{{name}}'...", + "✓ Authentication successful for '{{name}}'": "✓ Authentication successful for '{{name}}'", + "✗ Authentication failed: {{error}}": "✗ Authentication failed: {{error}}", +} +``` + +**优点**: + +- ✅ 用户体验统一,所有 MCP 管理操作在一个界面完成 +- ✅ 复用现有 OAuth 认证逻辑,开发成本低 +- ✅ 直观的视觉反馈,认证过程透明 +- ✅ 符合现代 UI/UX 设计原则 + +**缺点**: + +- ⚠️ 需要处理浏览器跳转和回调 (已有完善实现,风险低) + +#### 方案 B: 保留命令行但改进引导提示 + +如果某些场景下确实需要命令行认证 (如自动化脚本),可以: + +- 保留 `/mcp auth` 命令 +- 在 Dialog 中提供快速复制的命令模板 +- 添加"Copy Auth Command"按钮 + +但这会增加复杂性,不如方案 A 简洁。 + +--- + +## 问题 2: 【P1】一些异常状态 + +### 2.1 禁用之后还可以点击"查看工具",点进去是空的 + +#### 问题描述 + +- **现象**: MCP Server 被禁用后,仍然可以在 UI 中看到"查看工具"选项,点击进入后显示空列表 +- **期望**: 禁用后的服务器不应该显示"查看工具"选项,或者应该给出明确的提示信息 + +#### 根本原因分析 + +当前代码逻辑: + +```typescript +// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +const actions = useMemo(() => { + const result: Array<{ label: string; value: ServerAction }> = []; + + // 无论服务器是否禁用,都添加"查看工具"选项 + result.push({ label: t('View Tools'), value: 'view-tools' }); + + if (server.status === 'disconnected') { + result.push({ label: t('Reconnect'), value: 'reconnect' }); + } + + result.push({ + label: server.isDisabled ? t('Enable') : t('Disable'), + value: 'toggle-disable', + }); + + return result; +}, [server]); +``` + +问题在于: + +1. 没有根据 `server.isDisabled` 状态过滤操作选项 +2. 禁用服务器的工具列表获取逻辑可能存在问题 +3. 缺少用户友好的提示信息 + +#### 解决方案 + +**方案 A: 禁用时隐藏"查看工具"选项 (推荐)** + +**代码修改**: + +```typescript +// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +const actions = useMemo(() => { + const result: Array<{ label: string; value: ServerAction }> = []; + + // 只在服务器启用且已连接时显示"查看工具"选项 + if (!server.isDisabled && server.status === MCPServerStatus.CONNECTED) { + result.push({ + label: t('View Tools'), + value: 'view-tools', + disabled: server.toolCount === 0, // 可选:工具数量为 0 时禁用 + }); + } + + // 禁用状态下显示提示信息 + if (server.isDisabled) { + result.push({ + label: t('Enable to view tools'), + value: 'toggle-disable', + }); + } else { + if (server.status === MCPServerStatus.DISCONNECTED) { + result.push({ label: t('Reconnect'), value: 'reconnect' }); + } + + result.push({ + label: t('Disable'), + value: 'toggle-disable', + }); + } + + return result; +}, [server]); +``` + +**同时修改 ToolListStep**: + +```typescript +// packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx +export const ToolListStep: React.FC = ({ + tools, + serverName, + onSelect, + onBack, +}) => { + // 添加禁用状态检查 + if (tools.length === 0) { + return ( + + + {t('No tools available for this server.')} + + {/* 添加提示:服务器可能被禁用 */} + + {t('Note: This server may be disabled. Please enable it in the server settings.')} + + + ); + } + // ... 其余代码保持不变 +}; +``` + +**方案 B: 显示友好提示并阻止导航** + +在 `MCPManagementDialog` 中添加拦截逻辑: + +```typescript +// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +const handleViewTools = useCallback(() => { + if (!selectedServer) return; + + // 检查服务器是否禁用 + if (selectedServer.isDisabled) { + // 显示提示信息,不执行导航 + debugLogger.warn( + `Cannot view tools for disabled server '${selectedServer.name}'`, + ); + // 可选:在 UI 上显示临时消息 + return; + } + + // 检查是否有工具 + if (selectedServer.toolCount === 0) { + debugLogger.info(`No tools available for server '${selectedServer.name}'`); + // 仍然可以进入查看,但会显示空状态提示 + } + + handleNavigateToStep(MCP_MANAGEMENT_STEPS.TOOL_LIST); +}, [selectedServer, handleNavigateToStep]); +``` + +#### 推荐方案:方案 A + ToolListStep 的提示增强 + +--- + +### 2.2 禁用之后还能重新连接 + +#### 问题描述 + +- **现象**: MCP Server 被禁用后,仍然可以看到"重新连接"选项 +- **期望**: 禁用之后应该没有"重新连接"入口 +- **文档建议**: 禁用之后应该没有"重新连接"入口 + +#### 根本原因分析 + +当前代码逻辑: + +```typescript +// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +if (server.status === 'disconnected') { + result.push({ label: t('Reconnect'), value: 'reconnect' }); +} +``` + +问题在于: + +1. 只检查了连接状态,没有检查禁用状态 +2. 禁用的服务器不应该允许重新连接操作 +3. 逻辑上矛盾:既然禁用了就不应该尝试连接 + +#### 解决方案 + +**代码修改**: + +```typescript +// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +const actions = useMemo(() => { + const result: Array<{ label: string; value: ServerAction }> = []; + + // View Tools 选项 + if (!server.isDisabled && server.toolCount > 0) { + result.push({ label: t('View Tools'), value: 'view-tools' }); + } + + // Reconnect 选项:只在未禁用且断开连接时显示 + if (!server.isDisabled && server.status === MCPServerStatus.DISCONNECTED) { + result.push({ label: t('Reconnect'), value: 'reconnect' }); + } + + // Enable/Disable 选项 + result.push({ + label: server.isDisabled ? t('Enable Server') : t('Disable Server'), + value: 'toggle-disable', + }); + + return result; +}, [server]); +``` + +**同时在 ServerListStep 中添加视觉提示**: + +```typescript +// packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx +{server.isDisabled && ( + + {' '} + {t('(disabled - no connection possible)')} + +)} +``` + +--- + +### 问题 3: 【P1】禁用有个选择设置的 dialog,有点费解 + +#### 问题描述 + +- **现象**: 禁用服务器时会弹出一个对话框让用户选择禁用范围 (user/workspace) +- **问题**: 这个选择让用户体验困惑,特别是当 MCP server 在项目级配置时,在用户级别禁用就有点费解 +- **文档建议**: MCP server 在哪里,就在哪里禁用(如果 MCP server 在项目级,在用户级别禁用就有点费解) + +#### 根本原因分析 + +当前实现逻辑: + +```typescript +// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +const handleSelectDisableScope = useCallback( + async (scope: 'user' | 'workspace') => { + // 允许用户在 user 或 workspace 层面禁用服务器 + // 即使服务器配置在 workspace 层面,也允许在 user 层面禁用 + }, + [config, selectedServer, handleNavigateBack, reloadServers], +); +``` + +问题在于: + +1. 用户可以跨 scope 禁用服务器,造成配置混乱 +2. 不符合"在哪里配置就在哪里管理"的直觉 +3. 增加了不必要的复杂性 + +#### 解决方案 + +**方案 A: 根据服务器来源自动确定禁用 scope (强烈推荐)** + +**核心思路**: + +- User 级别的配置 → 只能在 User 级别禁用 +- Workspace 级别的配置 → 只能在 Workspace 级别禁用 +- Extension 级别的配置 → 不允许禁用 (只能卸载扩展) + +**代码修改**: + +```typescript +// packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx + +// 修改 handleDisable 函数 +const handleDisable = useCallback(() => { + if (!selectedServer) return; + + // 如果服务器已经被禁用,直接启用 + if (selectedServer.isDisabled) { + void handleEnableServer(); + return; + } + + // Extension 提供的服务器不允许禁用 + if (selectedServer.source === 'extension') { + debugLogger.warn( + `Cannot disable extension-provided server '${selectedServer.name}'`, + ); + // 显示提示信息 + return; + } + + // 根据服务器 scope 直接禁用,不再询问 + const scope = + selectedServer.scope === 'extension' + ? SettingScope.User + : selectedServer.scope === 'workspace' + ? SettingScope.Workspace + : SettingScope.User; + + // 直接执行禁用操作 + void executeDisable(scope); +}, [selectedServer, handleEnableServer]); + +// 新增执行禁用函数 +const executeDisable = useCallback( + async (scope: SettingScope) => { + if (!config || !selectedServer) return; + + try { + setIsLoading(true); + + const settings = loadSettings(); + const scopeSettings = settings.forScope(scope).settings; + const currentExcluded = scopeSettings.mcp?.excluded || []; + + if (!currentExcluded.includes(selectedServer.name)) { + const newExcluded = [...currentExcluded, selectedServer.name]; + settings.setValue(scope, 'mcp.excluded', newExcluded); + } + + const toolRegistry = config.getToolRegistry(); + if (toolRegistry) { + await toolRegistry.disableMcpServer(selectedServer.name); + } + + await reloadServers(); + handleNavigateBack(); + } catch (error) { + debugLogger.error( + `Error disabling server '${selectedServer.name}':`, + error, + ); + } finally { + setIsLoading(false); + } + }, + [config, selectedServer, reloadServers, handleNavigateBack], +); + +// 移除 DisableScopeSelectStep 相关的代码和导航逻辑 +``` + +**同时修改 UI 提示**: + +```typescript +// packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx + + + {t('Scope:')} + + + + {t(server.scope)} + {server.source === 'extension' && ( + + {' '}({t('provided by {{name}}', { name: server.config.extensionName })}) + + )} + + + + +// 禁用按钮文本根据 scope 调整 +{server.isDisabled ? ( + {t('Enable (will remove from exclusion list)')} +) : server.source === 'extension' ? ( + {t('Cannot disable extension server')} +) : ( + {t('Disable (in {{scope}})', { scope: server.scope })} +)} +``` + +**方案 B: 保留选择但改进 UX** + +如果确实需要支持跨 scope 禁用 (考虑到某些特殊场景),至少应该: + +1. 明确显示当前服务器的配置位置 +2. 说明不同选择的影响 +3. 给出推荐选项 + +但这会增加复杂性,不如方案 A 简洁明了。 + +#### 推荐方案:方案 A + +--- + +## 实施计划 + +--- + +## 问题 6: 【P2】Extension Management - /extension manage 报错 + +### 问题描述 + +- **现象**: 使用 `/extension manage` 命令时直接报错 +- **期望**: 应该能正常打开 Extension Management Dialog + +### 根本原因分析 + +#### 可能的原因 + +1. **命令拼写错误** (最可能) + - 正确的命令是 `/extensions manage` (复数形式) + - 用户可能输入了 `/extension manage` (单数形式) +2. **ExtensionManager 未正确初始化** + + ```typescript + // packages/cli/src/ui/commands/extensionsCommand.ts#L103-108 + async function listAction(_context: CommandContext, _args: string) { + const extensionManager = context.services.config?.getExtensionManager(); + if (!(extensionManager instanceof ExtensionManager)) { + debugLogger.error( + `Cannot ${context.invocation?.name} extensions in this environment`, + ); + return; // ❌ 这里直接返回,没有给用户任何提示 + } + // ... + } + ``` + +3. **环境限制** + - 某些环境下无法加载 ExtensionManager + - 沙箱模式可能限制扩展管理功能 + +#### 当前错误处理问题 + +- 如果 `getExtensionManager()` 返回 null 或不是 ExtensionManager 实例 +- 代码只是记录 debug 日志并静默返回 +- **用户看不到任何错误提示**,只会感到困惑 + +### 解决方案 + +#### 方案 A: 改进错误提示 (强烈推荐) + +**代码修改**: + +```typescript +// packages/cli/src/ui/commands/extensionsCommand.ts +async function listAction(context: CommandContext, _args: string) { + const extensionManager = context.services.config?.getExtensionManager(); + + if (!(extensionManager instanceof ExtensionManager)) { + debugLogger.error( + `Cannot ${context.invocation?.name} extensions in this environment`, + ); + + // ✅ 添加用户友好的错误提示 + context.ui.addItem( + { + type: MessageType.ERROR, + text: t( + 'Extension management is not available in the current environment. ' + + 'This feature may not be supported in your current mode or configuration.', + ), + }, + Date.now(), + ); + return; + } + + return { + type: 'dialog' as const, + dialog: 'extensions_manage' as const, + }; +} +``` + +#### 方案 B: 检查命令拼写并给出提示 + +在命令解析层面添加提示: + +```typescript +// packages/cli/src/ui/commands/registry.ts 或相关位置 +// 当检测到用户输入 '/extension'(单数) 时,给出提示 +if (commandName === 'extension') { + context.ui.addItem( + { + type: MessageType.INFO, + text: t('Did you mean "/extensions"? (plural form)'), + }, + Date.now(), + ); +} +``` + +#### 方案 C: 同时支持单复数形式 + +为了用户体验,可以同时支持两种形式: + +```typescript +// packages/cli/src/ui/commands/extensionsCommand.ts +export const extensionsCommand: SlashCommand = { + name: 'extensions', // 主要命令 (复数) + aliases: ['extension'], // ✅ 添加别名 (单数) + get description() { + return t('Manage extensions'); + }, + kind: CommandKind.BUILT_IN, + subCommands: [ + manageExtensionsCommand, + installCommand, + exploreExtensionsCommand, + ], + action: async (context, args) => + manageExtensionsCommand.action!(context, args), +}; +``` + +**注意**: 需要检查 SlashCommand 类型定义是否支持 `aliases` 属性 + +### 推荐方案 + +**采用方案 A + 方案 C**: + +1. 改进错误提示,让用户知道发生了什么 +2. 如果可能,同时支持单复数形式 + +--- + +## 实施计划 + +### Phase 1: 修复异常状态问题 (优先级:高) + +1. **修复问题 2.1**: 禁用后可查看工具 + - 修改 `ServerDetailStep.tsx` 的操作列表逻辑 + - 修改 `ToolListStep.tsx` 添加友好提示 + - 预计工时:2 小时 + +2. **修复问题 2.2**: 禁用后可重新连接 + - 修改 `ServerDetailStep.tsx` 的 reconnect 选项条件 + - 预计工时:1 小时 + +### Phase 2: 在 Dialog 中集成 Auth 功能 (优先级:高) + +3. **修复问题 1**: MCP Dialog 集成 OAuth 认证 + - 扩展 `MCP_MANAGEMENT_STEPS` 添加认证步骤 + - 在 `ServerDetailStep` 中添加"Authenticate"选项 + - 在 `MCPManagementDialog` 中实现认证逻辑 + - 更新 i18n 翻译文件 + - 预计工时:4 小时 + +### Phase 3: 改进禁用体验 (优先级:中) + +4. **修复问题 3**: 简化禁用流程 + - 移除 `DisableScopeSelectStep` + - 实现自动 scope 判断逻辑 + - 更新 UI 提示 + - 预计工时:4 小时 + +### Phase 4: UI 细节优化 (优先级:中) + +5. **修复问题 4**: Dialog 1 细节优化 + - 移除重复的来源显示 + - 优化错误信息显示逻辑 (只在有错误时显示) + - 移除多余的空格 + - 优化布局紧凑度 + - 预计工时:3 小时 + +6. **修复问题 5**: Dialog 2 细节优化 + - 统一来源颜色与其他部分一致 + - 添加功能说明 tooltip + - 统一选中色为 theme.text.accent + - 优化工具标注文案 (如"destructive, open-world") + - 移除不必要的序号 + - 预计工时:3 小时 + +### Phase 5: Extension Management 修复 (优先级:低) + +7. **修复问题 6**: Extension 命令报错 + - 改进错误提示 (方案 A) + - 考虑支持单复数形式 (方案 C) + - 预计工时:2 小时 + +### Phase 6: 测试与验证 (优先级:高) + +8. **回归测试** + - 更新所有相关测试用例 + - 手动测试各个场景 + - 确保没有破坏性变更 + - 预计工时:4 小时 + +**总预计工时**: 约 23 小时 (约 3 个工作日) + +--- + +## 影响评估 + +### 兼容性影响 + +- **Breaking Changes**: 无 +- **Deprecation**: 无 +- **新功能**: MCP Dialog 集成 OAuth 认证功能 + +### 需要更新的文档 + +1. `docs/developers/tools/mcp-server.md` - 更新 MCP 管理对话框使用说明 +2. `docs/users/features/mcp-servers.md` - 更新用户指南 +3. `docs/users/features/extensions.md` - 更新扩展管理说明 +4. 内联帮助文本和 i18n 文件 + +### 需要更新的测试 + +1. `packages/cli/src/ui/commands/mcpCommand.test.ts` +2. `packages/cli/src/ui/components/mcp/MCPManagementDialog.test.tsx` +3. `packages/cli/src/ui/components/mcp/steps/ServerDetailStep.test.tsx` +4. `packages/cli/src/ui/commands/extensionsCommand.test.ts` +5. `packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.test.tsx` + +--- + +## 验收标准 + +### 问题 1 验收标准 + +- [ ] MCP Management Dialog 中显示"Authenticate"选项 (针对需要认证的服务器) +- [ ] 点击认证后能正确启动 OAuth 流程 +- [ ] 认证过程中显示友好的提示信息 +- [ ] 认证成功后自动刷新服务器状态 +- [ ] 认证失败时显示明确的错误信息 +- [ ] 保留 `/mcp auth` 命令作为备选方案 (可选) + +### 问题 2.1 验收标准 + +- [ ] 禁用的服务器不显示"查看工具"选项,或显示友好提示 +- [ ] 工具列表为空时,明确提示原因 +- [ ] 用户不会看到空的工具列表页面 + +### 问题 2.2 验收标准 + +- [ ] 禁用的服务器不显示"重新连接"选项 +- [ ] UI 逻辑自洽,不会出现矛盾的操作选项 +- [ ] 禁用状态下只能看到"启用"选项 + +### 问题 3 验收标准 + +- [ ] 禁用操作一键完成,无需选择 scope +- [ ] 禁用范围自动匹配配置范围 +- [ ] UI 明确显示服务器的配置位置 +- [ ] 用户体验流畅,无困惑点 + +### 问题 4 验收标准 (Dialog 1 细节优化) + +- [ ] 移除重复的来源显示 +- [ ] 只在有错误时显示"运行 qwen --debug..."提示 +- [ ] 没有错误时不显示多余的空格 +- [ ] 布局更加紧凑,接近 claude code 的视觉效果 + +### 问题 5 验收标准 (Dialog 2 细节优化) + +- [ ] 来源颜色与其他部分统一 +- [ ] 添加清晰的功能说明 +- [ ] 统一选中色为 theme.text.accent +- [ ] 工具标注文案更易懂 (如改为"Destructive, Open-world") +- [ ] 移除列表项前的序号 (1、2、3...) + +### 问题 6 验收标准 (Extension Management) + +- [ ] `/extensions manage` 命令能正常工作 +- [ ] 如果 ExtensionManager 不可用,显示明确的错误提示 +- [ ] 考虑支持 `/extension`(单数) 作为别名 (可选) +- [ ] 测试不同环境下的行为 (普通模式、沙箱模式等) + +--- + +## 技术细节补充 + +### 关键文件清单 + +``` +# MCP Management +packages/cli/src/ui/commands/mcpCommand.ts +packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx +packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx +packages/cli/src/ui/components/mcp/types.ts +packages/core/src/tools/mcp-client-manager.ts +packages/core/src/config/config.ts + +# Extension Management +packages/cli/src/ui/commands/extensionsCommand.ts +packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +packages/cli/src/ui/components/extensions/types.ts +packages/core/src/extension/extensionManager.ts +``` + +### 依赖关系 + +- MCP Management Dialog 依赖于 Config、ToolRegistry、PromptRegistry +- 禁用逻辑涉及 Settings 的多 scope 管理 +- 状态跟踪通过 `getMCPServerStatus` 和状态监听器实现 + +### 潜在风险点 + +1. **OAuth 认证流程**: 确保在 Dialog 中集成的认证功能不影响现有命令行认证 +2. **多 Scope 配置**: 确保自动 scope 判断不会误删其他 scope 的配置 +3. **Extension 集成**: 确保扩展提供的服务器正确处理 +4. **环境兼容性**: 确保 Extension Management 在不同环境下都能给出正确的错误提示 + +--- + +## 总结 + +本文档针对 0.12.0 版本体验反馈中提出的 **6 个问题** (3 个 P1 + 3 个 P2) 进行了详细分析,并提供了具体的解决方案。所有修改都遵循以下原则: + +1. **用户体验优先**: 简化操作流程,减少困惑 +2. **逻辑一致性**: 确保 UI 状态和行为逻辑自洽 +3. **向后兼容**: 避免破坏性变更 +4. **代码质量**: 简化代码结构,提高可维护性 +5. **错误友好**: 提供清晰、有帮助的错误信息 + +建议按优先级分阶段实施,确保每个问题都得到妥善解决。 diff --git a/integration-tests/file-system.test.ts b/integration-tests/file-system.test.ts index 95109a9de..f4c60edd7 100644 --- a/integration-tests/file-system.test.ts +++ b/integration-tests/file-system.test.ts @@ -139,7 +139,7 @@ describe('file-system', () => { ).toBeDefined(); const newFileContent = rig.readFile(fileName); - expect(newFileContent).toBe('1.0.1'); + expect(newFileContent).toContain('1.0.1'); }); it.skip('should replace multiple instances of a string', async () => { diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index c40a2dacf..9a007a68f 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -915,6 +915,9 @@ export default { 'Enter zum Bestätigen, Esc zum Abbrechen', Disable: 'Deaktivieren', Enable: 'Aktivieren', + Authenticate: 'Authentifizieren', + disabled: 'deaktiviert', + 'Server:': 'Server:', Reconnect: 'Neu verbinden', 'View tools': 'Werkzeuge anzeigen', 'Status:': 'Status:', @@ -943,6 +946,14 @@ export default { 'Run qwen --debug to see error logs': 'Führen Sie qwen --debug aus, um Fehlerprotokolle anzuzeigen', + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth-Authentifizierung', + 'Press Enter to start authentication, Esc to go back': + 'Drücken Sie Enter, um die Authentifizierung zu starten, Esc zum Zurückgehen', + 'Authenticating... Please complete the login in your browser.': + 'Authentifizierung läuft... Bitte schließen Sie die Anmeldung in Ihrem Browser ab.', + 'Press Enter or Esc to go back': 'Drücken Sie Enter oder Esc zum Zurückgehen', + // MCP Tool List 'No tools available for this server.': 'Keine Werkzeuge für diesen Server verfügbar.', @@ -951,6 +962,7 @@ export default { 'open-world': 'offene Welt', idempotent: 'idempotent', 'Tools for {{name}}': 'Werkzeuge für {{name}}', + 'Tools for {{serverName}}': 'Werkzeuge für {{serverName}}', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 494cbc9fa..768506c06 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -842,6 +842,8 @@ export default { Reconnect: 'Reconnect', Enable: 'Enable', Disable: 'Disable', + Authenticate: 'Authenticate', + 'Server:': 'Server:', 'Command:': 'Command:', 'Working Directory:': 'Working Directory:', 'Capabilities:': 'Capabilities:', @@ -866,6 +868,14 @@ export default { 'Add MCP servers to your settings to get started.', 'Run qwen --debug to see error logs': 'Run qwen --debug to see error logs', + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth Authentication', + 'Press Enter to start authentication, Esc to go back': + 'Press Enter to start authentication, Esc to go back', + 'Authenticating... Please complete the login in your browser.': + 'Authenticating... Please complete the login in your browser.', + 'Press Enter or Esc to go back': 'Press Enter or Esc to go back', + // MCP Tool List 'No tools available for this server.': 'No tools available for this server.', destructive: 'destructive', @@ -873,6 +883,7 @@ export default { 'open-world': 'open-world', idempotent: 'idempotent', 'Tools for {{name}}': 'Tools for {{name}}', + 'Tools for {{serverName}}': 'Tools for {{serverName}}', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index e9e69f7e2..3a1bf21c6 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -654,6 +654,9 @@ export default { 'Press Enter to confirm, Esc to cancel': 'Enter で確認、Esc でキャンセル', Disable: '無効化', Enable: '有効化', + Authenticate: '認証', + disabled: '無効', + 'Server:': 'サーバー:', Reconnect: '再接続', 'View tools': 'ツールを表示', 'Status:': 'ステータス:', @@ -683,6 +686,14 @@ export default { 'Run qwen --debug to see error logs': 'qwen --debug を実行してエラーログを確認してください', + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth 認証', + 'Press Enter to start authentication, Esc to go back': + 'Enter で認証開始、Esc で戻る', + 'Authenticating... Please complete the login in your browser.': + '認証中... ブラウザでログインを完了してください。', + 'Press Enter or Esc to go back': 'Enter または Esc で戻る', + // MCP Tool List 'No tools available for this server.': 'このサーバーには使用可能なツールがありません。', @@ -691,6 +702,7 @@ export default { 'open-world': 'オープンワールド', idempotent: '冪等', 'Tools for {{name}}': '{{name}} のツール', + 'Tools for {{serverName}}': '{{serverName}} のツール', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 97f9655f8..37efeda6f 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -921,6 +921,9 @@ export default { 'Enter para confirmar, Esc para cancelar', Disable: 'Desativar', Enable: 'Ativar', + Authenticate: 'Autenticar', + disabled: 'desativado', + 'Server:': 'Servidor:', Reconnect: 'Reconectar', 'View tools': 'Ver ferramentas', 'Status:': 'Status:', @@ -950,6 +953,14 @@ export default { 'Run qwen --debug to see error logs': 'Execute qwen --debug para ver os logs de erro', + // MCP OAuth Authentication + 'OAuth Authentication': 'Autenticação OAuth', + 'Press Enter to start authentication, Esc to go back': + 'Pressione Enter para iniciar a autenticação, Esc para voltar', + 'Authenticating... Please complete the login in your browser.': + 'Autenticando... Por favor, conclua o login no seu navegador.', + 'Press Enter or Esc to go back': 'Pressione Enter ou Esc para voltar', + // MCP Tool List 'No tools available for this server.': 'Nenhuma ferramenta disponível para este servidor.', @@ -958,6 +969,7 @@ export default { 'open-world': 'mundo aberto', idempotent: 'idempotente', 'Tools for {{name}}': 'Ferramentas para {{name}}', + 'Tools for {{serverName}}': 'Ferramentas para {{serverName}}', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index c4c6e8fb0..eaecb4228 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -899,6 +899,9 @@ export default { // MCP Management - Core translations Disable: 'Отключить', Enable: 'Включить', + Authenticate: 'Аутентификация', + disabled: 'отключен', + 'Server:': 'Сервер:', Reconnect: 'Переподключить', 'View tools': 'Просмотреть инструменты', '(disabled)': '(отключен)', @@ -1547,6 +1550,14 @@ export default { 'Run qwen --debug to see error logs': 'Запустите qwen --debug для просмотра журналов ошибок', + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth-аутентификация', + 'Press Enter to start authentication, Esc to go back': + 'Нажмите Enter для начала аутентификации, Esc для возврата', + 'Authenticating... Please complete the login in your browser.': + 'Аутентификация... Пожалуйста, завершите вход в браузере.', + 'Press Enter or Esc to go back': 'Нажмите Enter или Esc для возврата', + // MCP Tool List 'No tools available for this server.': 'Для этого сервера нет доступных инструментов.', @@ -1555,6 +1566,7 @@ export default { 'open-world': 'открытый мир', idempotent: 'идемпотентный', 'Tools for {{name}}': 'Инструменты для {{name}}', + 'Tools for {{serverName}}': 'Инструменты для {{serverName}}', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 811177b55..2c078e258 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -318,7 +318,6 @@ export default { 'MCP Servers:': 'MCP 服务器:', 'Settings:': '设置:', active: '已启用', - disabled: '已禁用', 'View Details': '查看详情', 'Update failed:': '更新失败:', 'Updating {{name}}...': '正在更新 {{name}}...', @@ -793,6 +792,9 @@ export default { Reconnect: '重新连接', Enable: '启用', Disable: '禁用', + Authenticate: '认证', + disabled: '已禁用', + 'Server:': '服务器:', '(disabled)': '(已禁用)', 'Error:': '错误:', Extension: '扩展', @@ -812,6 +814,14 @@ export default { '请在设置中添加 MCP 服务器以开始使用。', 'Run qwen --debug to see error logs': '运行 qwen --debug 查看错误日志', + // MCP OAuth Authentication + 'OAuth Authentication': 'OAuth 认证', + 'Press Enter to start authentication, Esc to go back': + '按 Enter 开始认证,Esc 返回', + 'Authenticating... Please complete the login in your browser.': + '认证中... 请在浏览器中完成登录。', + 'Press Enter or Esc to go back': '按 Enter 或 Esc 返回', + // MCP Server Detail 'Command:': '命令:', 'Working Directory:': '工作目录:', @@ -824,6 +834,7 @@ export default { 'open-world': '开放世界', idempotent: '幂等', 'Tools for {{name}}': '{{name}} 的工具', + 'Tools for {{serverName}}': '{{serverName}} 的工具', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail diff --git a/packages/cli/src/ui/commands/mcpCommand.ts b/packages/cli/src/ui/commands/mcpCommand.ts index 2a5100577..4ca165e35 100644 --- a/packages/cli/src/ui/commands/mcpCommand.ts +++ b/packages/cli/src/ui/commands/mcpCommand.ts @@ -4,186 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - SlashCommand, - CommandContext, - MessageActionReturn, - OpenDialogActionReturn, -} from './types.js'; +import type { SlashCommand, OpenDialogActionReturn } from './types.js'; import { CommandKind } from './types.js'; -import { - getErrorMessage, - MCPOAuthTokenStorage, - MCPOAuthProvider, -} from '@qwen-code/qwen-code-core'; -import { appEvents, AppEvent } from '../../utils/events.js'; import { t } from '../../i18n/index.js'; -const authCommand: SlashCommand = { - name: 'auth', - get description() { - return t('Authenticate with an OAuth-enabled MCP server'); - }, - kind: CommandKind.BUILT_IN, - action: async ( - context: CommandContext, - args: string, - ): Promise => { - const serverName = args.trim(); - const { config } = context.services; - - if (!config) { - return { - type: 'message', - messageType: 'error', - content: t('Config not loaded.'), - }; - } - - const mcpServers = config.getMcpServers() || {}; - - if (!serverName) { - // List servers that support OAuth - const oauthServers = Object.entries(mcpServers) - .filter(([_, server]) => server.oauth?.enabled) - .map(([name, _]) => name); - - if (oauthServers.length === 0) { - return { - type: 'message', - messageType: 'info', - content: t('No MCP servers configured with OAuth authentication.'), - }; - } - - return { - type: 'message', - messageType: 'info', - content: `${t('MCP servers with OAuth authentication:')}\n${oauthServers.map((s) => ` - ${s}`).join('\n')}\n\n${t('Use /mcp auth to authenticate.')}`, - }; - } - - const server = mcpServers[serverName]; - if (!server) { - return { - type: 'message', - messageType: 'error', - content: t("MCP server '{{name}}' not found.", { name: serverName }), - }; - } - - // Always attempt OAuth authentication, even if not explicitly configured - // The authentication process will discover OAuth requirements automatically - - const displayListener = (message: string) => { - context.ui.addItem({ type: 'info', text: message }, Date.now()); - }; - - appEvents.on(AppEvent.OauthDisplayMessage, displayListener); - - try { - context.ui.addItem( - { - type: 'info', - text: t( - "Starting OAuth authentication for MCP server '{{name}}'...", - { - name: serverName, - }, - ), - }, - Date.now(), - ); - - let oauthConfig = server.oauth; - if (!oauthConfig) { - oauthConfig = { enabled: false }; - } - - const mcpServerUrl = server.httpUrl || server.url; - const authProvider = new MCPOAuthProvider(new MCPOAuthTokenStorage()); - await authProvider.authenticate( - serverName, - oauthConfig, - mcpServerUrl, - appEvents, - ); - - context.ui.addItem( - { - type: 'info', - text: t( - "Successfully authenticated and refreshed tools for '{{name}}'.", - { - name: serverName, - }, - ), - }, - Date.now(), - ); - - // Trigger tool re-discovery to pick up authenticated server - const toolRegistry = config.getToolRegistry(); - if (toolRegistry) { - context.ui.addItem( - { - type: 'info', - text: t("Re-discovering tools from '{{name}}'...", { - name: serverName, - }), - }, - Date.now(), - ); - await toolRegistry.discoverToolsForServer(serverName); - } - // Update the client with the new tools - const geminiClient = config.getGeminiClient(); - if (geminiClient) { - await geminiClient.setTools(); - } - - // Reload the slash commands to reflect the changes. - context.ui.reloadCommands(); - - return { - type: 'message', - messageType: 'info', - content: t( - "Successfully authenticated and refreshed tools for '{{name}}'.", - { - name: serverName, - }, - ), - }; - } catch (error) { - return { - type: 'message', - messageType: 'error', - content: t( - "Failed to authenticate with MCP server '{{name}}': {{error}}", - { - name: serverName, - error: getErrorMessage(error), - }, - ), - }; - } finally { - appEvents.removeListener(AppEvent.OauthDisplayMessage, displayListener); - } - }, - completion: async (context: CommandContext, partialArg: string) => { - const { config } = context.services; - if (!config) return []; - - const mcpServers = config.getMcpServers() || {}; - return Object.keys(mcpServers).filter((name) => - name.startsWith(partialArg), - ); - }, -}; - -const manageCommand: SlashCommand = { - name: 'manage', +export const mcpCommand: SlashCommand = { + name: 'mcp', get description() { return t('Open MCP management dialog'); }, @@ -193,19 +19,3 @@ const manageCommand: SlashCommand = { dialog: 'mcp', }), }; - -export const mcpCommand: SlashCommand = { - name: 'mcp', - get description() { - return t( - 'Open MCP management dialog, or authenticate with OAuth-enabled servers', - ); - }, - kind: CommandKind.BUILT_IN, - subCommands: [manageCommand, authCommand], - // Default action when no subcommand is provided - open dialog - action: async (): Promise => ({ - type: 'dialog', - dialog: 'mcp', - }), -}; diff --git a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx index 8a5a90d01..c06f9c85c 100644 --- a/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx +++ b/packages/cli/src/ui/components/extensions/ExtensionsManagerDialog.tsx @@ -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(null); const [successMessage, setSuccessMessage] = useState(null); const [errorMessage, setErrorMessage] = useState(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({ {getNavigationInstructions()} ); - }, [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 ( - + {renderStepHeader()} diff --git a/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap b/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap index af6ba07c4..e1aeaa43b 100644 --- a/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap +++ b/packages/cli/src/ui/components/extensions/__snapshots__/ExtensionsManagerDialog.test.tsx.snap @@ -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 │ +└──────────────────────────────────────────────────────────────────────────┘" `; diff --git a/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx b/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx index aa4e0cf18..0aa566489 100644 --- a/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx +++ b/packages/cli/src/ui/components/extensions/steps/ActionSelectionStep.tsx @@ -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( @@ -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); }; diff --git a/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx index 103ecf93e..63a73ddb5 100644 --- a/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx +++ b/packages/cli/src/ui/components/extensions/steps/ExtensionListStep.tsx @@ -160,18 +160,18 @@ export const ExtensionListStep = ({ return ( - - {extensions.map((extension, index) => - renderExtensionItem(extension, index, index === selectedIndex), - )} - - + {t('{{count}} extensions installed', { count: extensions.length.toString(), })} + + {extensions.map((extension, index) => + renderExtensionItem(extension, index, index === selectedIndex), + )} + ); }; diff --git a/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx b/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx index 809776a5a..b69ab9a7d 100644 --- a/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx +++ b/packages/cli/src/ui/components/extensions/steps/ScopeSelectStep.tsx @@ -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 ( {title} - + {t('This action cannot be undone.')} - - {t('Press Y/Enter to confirm, N/Esc to cancel')} - ); } diff --git a/packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap index a6635ebf0..a872a8859 100644 --- a/packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap +++ b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ActionSelectionStep.test.tsx.snap @@ -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" `; diff --git a/packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap index 045d84986..f949d4bee 100644 --- a/packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap +++ b/packages/cli/src/ui/components/extensions/steps/__snapshots__/ExtensionListStep.test.tsx.snap @@ -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]" `; diff --git a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx index a79af049b..b9881932d 100644 --- a/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx +++ b/packages/cli/src/ui/components/mcp/MCPManagementDialog.tsx @@ -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 = ({ onClose, }) => { const config = useConfig(); + const { columns: width } = useTerminalSize(); + const boxWidth = width - 4; const [servers, setServers] = useState([]); const [selectedServerIndex, setSelectedServerIndex] = useState(-1); @@ -225,6 +229,11 @@ export const MCPManagementDialog: React.FC = ({ 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 = ({ }, [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 = ({ // 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 ( - + let headerText = ( + - {headerText} + {t('Manage MCP servers')} + + + {servers.length} {servers.length === 1 ? t('server') : t('servers')} ); - }, [getCurrentStep, selectedServer, selectedTool]); + + switch (currentStep) { + case MCP_MANAGEMENT_STEPS.SERVER_DETAIL: + headerText = ( + + + {selectedServer?.name || t('Server Detail')} + + + ); + break; + case MCP_MANAGEMENT_STEPS.TOOL_LIST: + headerText = ( + + + {t('Tools for {{serverName}}', { + serverName: selectedServer?.name || 'Server', + })} + + + ({getServerTools().length}{' '} + {getServerTools().length === 1 ? t('tool') : t('tools')}) + + + ); + break; + case MCP_MANAGEMENT_STEPS.TOOL_DETAIL: + headerText = ( + + + + {selectedTool?.name || t('Tool Detail')} + + {selectedTool?.annotations?.destructiveHint && ( + {'[destructive]'} + )} + {selectedTool?.annotations?.idempotentHint && ( + {'[idempotent]'} + )} + {selectedTool?.annotations?.readOnlyHint && ( + {'[read-only]'} + )} + {selectedTool?.annotations?.openWorldHint && ( + {'[open-world]'} + )} + + + {selectedTool?.serverName || t('Server')} + + + ); + break; + case MCP_MANAGEMENT_STEPS.AUTHENTICATE: + headerText = ( + + + {t('OAuth Authentication')} + + + ); + 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 = ({ onViewTools={handleViewTools} onReconnect={handleReconnect} onDisable={handleDisable} + onAuthenticate={handleAuthenticate} onBack={handleNavigateBack} /> ); @@ -463,6 +572,17 @@ export const MCPManagementDialog: React.FC = ({ ); + case MCP_MANAGEMENT_STEPS.AUTHENTICATE: + return ( + { + void reloadServers(); + }} + onBack={handleNavigateBack} + /> + ); + default: return ( @@ -480,10 +600,12 @@ export const MCPManagementDialog: React.FC = ({ handleViewTools, handleReconnect, handleDisable, + handleAuthenticate, handleNavigateBack, handleSelectTool, handleSelectDisableScope, getServerTools, + reloadServers, ]); // Render step footer @@ -511,6 +633,9 @@ export const MCPManagementDialog: React.FC = ({ 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 = ({ ); return ( - + {renderStepHeader()} {renderStepContent()} diff --git a/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx new file mode 100644 index 000000000..e4d4e373a --- /dev/null +++ b/packages/cli/src/ui/components/mcp/steps/AuthenticateStep.tsx @@ -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 = ({ + server, + onSuccess, + onBack, +}) => { + const config = useConfig(); + const [authState, setAuthState] = useState('idle'); + const [messages, setMessages] = useState([]); + const [errorMessage, setErrorMessage] = useState(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 ( + + {t('No server selected')} + + ); + } + + return ( + + {/* Server info */} + + + {t('Server:')} {server.name} + + + + {/* Progress messages */} + {messages.length > 0 && ( + + {messages.map((msg, i) => ( + + {msg} + + ))} + + )} + + {/* Error message */} + {authState === 'error' && errorMessage && ( + + {errorMessage} + + )} + + {/* Action hints */} + + {authState === 'authenticating' && ( + + {t('Authenticating... Please complete the login in your browser.')} + + )} + + + ); +}; diff --git a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx index 07b8da439..d23dccf87 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerDetailStep.tsx @@ -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 = ({ server, onViewTools, onReconnect, onDisable, + onAuthenticate, onBack, }) => { - const [selectedAction, setSelectedAction] = - useState('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 = ({ : theme.status.error } > - {getStatusIcon(server.status)} {t(server.status)} - {server.isDisabled && ( - {t('(disabled)')} - )} + {getStatusIcon(server.status)}{' '} + {server.isDisabled ? t('disabled') : t(server.status)} @@ -120,7 +135,7 @@ export const ServerDetailStep: React.FC = ({ {t('Source:')} - + {server.scope === 'user' ? t('User Settings') : server.scope === 'workspace' @@ -150,37 +165,29 @@ export const ServerDetailStep: React.FC = ({ )} - - - {t('Capabilities:')} - + {!server.isDisabled && ( - - {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.invalidToolCount && server.invalidToolCount > 0 && ( + + {' '} + ({server.invalidToolCount}{' '} + {server.invalidToolCount === 1 + ? t('invalid') + : t('invalid')} + ) + + )} + + - - - - - {t('Tools:')} - - - - {server.toolCount}{' '} - {server.toolCount === 1 ? t('tool') : t('tools')} - {!!server.invalidToolCount && server.invalidToolCount > 0 && ( - - {' '} - ({server.invalidToolCount}{' '} - {server.invalidToolCount === 1 ? t('invalid') : t('invalid')}) - - )} - - - + )} {server.errorMessage && ( @@ -200,7 +207,7 @@ export const ServerDetailStep: React.FC = ({ 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 = ({ case 'toggle-disable': onDisable?.(); break; + case 'authenticate': + onAuthenticate?.(); + 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 index 35cff6708..bd9c58568 100644 --- a/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ServerListStep.tsx @@ -27,7 +27,6 @@ export const ServerListStep: React.FC = ({ [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 = ({ 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 = ({ return result; }, [groupedServers]); - // 键盘导航 useKeypress( (key) => { if (key.name === 'up') { @@ -71,7 +68,6 @@ export const ServerListStep: React.FC = ({ ); } - // 计算当前选中项在分组中的位置 const getSelectionPosition = (globalIndex: number) => { let currentIndex = 0; for (const group of groupedServers) { @@ -90,18 +86,15 @@ export const ServerListStep: React.FC = ({ return ( - {/* 服务器统计 */} - - - {servers.length} {servers.length === 1 ? t('server') : t('servers')} - - - {/* 分组服务器列表 */} {groupedServers.map((group, groupIndex) => ( - + - {group.displayName} + {` ${group.displayName}`} {group.servers[0]?.configPath && ( {' '} @@ -109,12 +102,14 @@ export const ServerListStep: React.FC = ({ )} - + {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 ( @@ -149,13 +144,9 @@ export const ServerListStep: React.FC = ({ : theme.status.error } > - {getStatusIcon(server.status)} {t(server.status)} + {getStatusIcon(server.status)}{' '} + {server.isDisabled ? t('disabled') : t(server.status)} - {/* 显示 Scope 和禁用状态 */} - [{server.scope}] - {server.isDisabled && ( - {t('(disabled)')} - )} {/* 显示无效工具警告 */} {!!server.invalidToolCount && server.invalidToolCount > 0 && ( @@ -173,8 +164,8 @@ export const ServerListStep: React.FC = ({ ))} {/* 提示信息 */} - {servers.some((s) => s.status === 'disconnected') && ( - + {servers.some((s) => s.status === 'disconnected' && !s.isDisabled) && ( + ※ {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 index 0bf32b860..d864c5732 100644 --- a/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ToolDetailStep.tsx @@ -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 ( - - - • {name} - {isRequired && ( - ({t('required')}) - )} - - - {t('Type')}: - {type} - - {description && ( - - - {truncate(description, 80)} - - - )} - {enumValues && enumValues.length > 0 && ( - - - {t('Enum')}: {enumValues.join(', ')} - - - )} - {defaultValue !== undefined && ( - - - {t('Default')}:{' '} - {typeof defaultValue === 'string' - ? `"${truncate(defaultValue, 30)}"` - : String(defaultValue)} - - - )} + + + {text} + ); }; @@ -82,8 +44,10 @@ const ParametersList: React.FC<{ return ( - {t('Parameters')}: - + + {t('Parameters')}: + + {Object.entries(properties).map(([name, param]) => renderParameter( name, @@ -156,62 +120,20 @@ export const ToolDetailStep: React.FC = ({ {/* 工具描述 */} {tool.description && ( - + + + {t('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 */} {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 index de9f4fa6c..81d8e2f7c 100644 --- a/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx +++ b/packages/cli/src/ui/components/mcp/steps/ToolListStep.tsx @@ -14,7 +14,6 @@ import { VISIBLE_TOOLS_COUNT } from '../constants.js'; export const ToolListStep: React.FC = ({ tools, - serverName, onSelect, onBack, }) => { @@ -78,24 +77,15 @@ export const ToolListStep: React.FC = ({ 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 ( - {/* 标题 */} - - {t('Tools for {{name}}', { name: serverName })} - - {' '} - ({tools.length} {tools.length === 1 ? t('tool') : t('tools')}) - - - {/* 工具列表 */} {displayTools.map((tool, index) => { @@ -105,14 +95,13 @@ export const ToolListStep: React.FC = ({ return ( - {/* 选择器和序号 */} - + {/* 选择器 */} + {isSelected ? '❯' : ' '} - {actualIndex + 1}. {/* 工具名称 - 固定宽度 */} diff --git a/packages/cli/src/ui/components/mcp/types.ts b/packages/cli/src/ui/components/mcp/types.ts index 1133592bb..34374fa23 100644 --- a/packages/cli/src/ui/components/mcp/types.ts +++ b/packages/cli/src/ui/components/mcp/types.ts @@ -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管理对话框属性 */ diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 629de747a..3af573ac7 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -624,7 +624,10 @@ export class ExtensionManager { const extension: Extension = { id: getExtensionId(config, installMetadata), name: config.name, - version: config.version, + version: + config.version || + installMetadata?.marketplaceConfig?.metadata?.version || + '1.0.0', path: effectiveExtensionPath, installMetadata, isActive: this.isEnabled(config.name, this.workspaceDir),