diff --git a/.gitignore b/.gitignore index 27e0ab904..115964554 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,8 @@ package-lock.json *.iml .cursor .qoder +.claude +CLAUDE.md # OS metadata .DS_Store 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/QWEN.md b/QWEN.md new file mode 100644 index 000000000..3e15e6d6f --- /dev/null +++ b/QWEN.md @@ -0,0 +1,297 @@ +# Qwen Code - Project Context + +## Project Overview + +**Qwen Code** is an open-source AI agent for the terminal, optimized for [Qwen3-Coder](https://github.com/QwenLM/Qwen3-Coder). It helps developers understand large codebases, automate tedious work, and ship faster. + +This project is based on [Google Gemini CLI](https://github.com/google-gemini/gemini-cli) with adaptations to better support Qwen-Coder models. + +### Key Features + +- **OpenAI-compatible, OAuth free tier**: Use an OpenAI-compatible API, or sign in with Qwen OAuth to get 1,000 free requests/day +- **Agentic workflow, feature-rich**: Rich built-in tools (Skills, SubAgents, Plan Mode) for a full agentic workflow +- **Terminal-first, IDE-friendly**: Built for developers who live in the command line, with optional integration for VS Code, Zed, and JetBrains IDEs + +## Technology Stack + +- **Runtime**: Node.js 20+ +- **Language**: TypeScript 5.3+ +- **Package Manager**: npm with workspaces +- **Build Tool**: esbuild +- **Testing**: Vitest +- **Linting**: ESLint + Prettier +- **UI Framework**: Ink (React for CLI) +- **React Version**: 19.x + +## Project Structure + +``` +├── packages/ +│ ├── cli/ # Command-line interface (main entry point) +│ ├── core/ # Core backend logic and tool implementations +│ ├── sdk-java/ # Java SDK +│ ├── sdk-typescript/ # TypeScript SDK +│ ├── test-utils/ # Shared testing utilities +│ ├── vscode-ide-companion/ # VS Code extension companion +│ ├── webui/ # Web UI components +│ └── zed-extension/ # Zed editor extension +├── scripts/ # Build and utility scripts +├── docs/ # Documentation source +├── docs-site/ # Documentation website (Next.js) +├── integration-tests/ # End-to-end integration tests +└── eslint-rules/ # Custom ESLint rules +``` + +### Package Details + +#### `@qwen-code/qwen-code` (packages/cli/) + +The main CLI package providing: + +- Interactive terminal UI using Ink/React +- Non-interactive/headless mode +- Authentication handling (OAuth, API keys) +- Configuration management +- Command system (`/help`, `/clear`, `/compress`, etc.) + +#### `@qwen-code/qwen-code-core` (packages/core/) + +Core library containing: + +- **Tools**: File operations (read, write, edit, glob, grep), shell execution, web fetch, LSP integration, MCP client +- **Subagents**: Task delegation to specialized agents +- **Skills**: Reusable skill system +- **Models**: Model configuration and registry for Qwen and OpenAI-compatible APIs +- **Services**: Git integration, file discovery, session management +- **LSP Support**: Language Server Protocol integration +- **MCP**: Model Context Protocol implementation + +## Building and Running + +### Prerequisites + +- **Node.js**: ~20.19.0 for development (use nvm to manage versions) +- **Git** +- For sandboxing: Docker or Podman (optional but recommended) + +### Setup + +```bash +# Clone and install +git clone https://github.com/QwenLM/qwen-code.git +cd qwen-code +npm install +``` + +### Build Commands + +```bash +# Build all packages +npm run build + +# Build everything including sandbox and VSCode companion +npm run build:all + +# Build only packages +npm run build:packages + +# Development mode with hot reload +npm run dev + +# Bundle for distribution +npm run bundle +``` + +### Running + +```bash +# Start interactive CLI +npm start + +# Or after global installation +qwen + +# Debug mode +npm run debug + +# With environment variables +DEBUG=1 npm start +``` + +### Testing + +```bash +# Run all unit tests +npm run test + +# Run integration tests (no sandbox) +npm run test:e2e + +# Run all integration tests with different sandbox modes +npm run test:integration:all + +# Terminal benchmark tests +npm run test:terminal-bench +``` + +### Code Quality + +```bash +# Run all checks (lint, format, build, test) +npm run preflight + +# Lint only +npm run lint +npm run lint:fix + +# Format only +npm run format + +# Type check +npm run typecheck +``` + +## Development Conventions + +### Code Style + +- **Strict TypeScript**: All strict flags enabled (`strictNullChecks`, `noImplicitAny`, etc.) +- **Module System**: ES modules (`"type": "module"`) +- **Import Style**: Node.js native ESM with `.js` extensions in imports +- **No Relative Imports Between Packages**: ESLint enforces this restriction + +### Key Configuration Files + +- `tsconfig.json`: Base TypeScript configuration with strict settings +- `eslint.config.js`: ESLint flat config with custom rules +- `esbuild.config.js`: Build configuration +- `vitest.config.ts`: Test configuration + +### Import Patterns + +```typescript +// Within a package - use relative paths +import { something } from './utils/something.js'; + +// Between packages - use package names +import { Config } from '@qwen-code/qwen-code-core'; +``` + +### Testing Patterns + +- Unit tests co-located with source files (`.test.ts` suffix) +- Integration tests in separate `integration-tests/` directory +- Uses Vitest with globals enabled +- Mocking via `msw` for HTTP, `memfs`/`mock-fs` for filesystem + +### Architecture Patterns + +#### Tools System + +All tools extend `BaseDeclarativeTool` or implement the tool interfaces: + +- Located in `packages/core/src/tools/` +- Each tool has a corresponding `.test.ts` file +- Tools are registered in the tool registry + +#### Subagents System + +Task delegation framework: + +- Configuration stored as Markdown + YAML frontmatter +- Supports both project-level and user-level subagents +- Event-driven architecture for UI updates + +#### Configuration System + +Hierarchical configuration loading: + +1. Default values +2. User settings (`~/.qwen/settings.json`) +3. Project settings (`.qwen/settings.json`) +4. Environment variables +5. CLI flags + +### Authentication Methods + +1. **Qwen OAuth** (recommended): Browser-based OAuth flow +2. **OpenAI-compatible API**: Via `OPENAI_API_KEY` environment variable + +Environment variables for API mode: + +```bash +export OPENAI_API_KEY="your-api-key" +export OPENAI_BASE_URL="https://api.openai.com/v1" # optional +export OPENAI_MODEL="gpt-4o" # optional +``` + +## Debugging + +### VS Code + +Press `F5` to launch with debugger attached, or: + +```bash +npm run debug # Runs with --inspect-brk +``` + +### React DevTools (for CLI UI) + +```bash +DEV=true npm start +npx react-devtools@4.28.5 +``` + +### Sandbox Debugging + +```bash +DEBUG=1 qwen +``` + +## Documentation + +- User documentation: +- Local docs development: + + ```bash + cd docs-site + npm install + npm run link # Links ../docs to content + npm run dev # http://localhost:3000 + ``` + +## Contributing Guidelines + +See [CONTRIBUTING.md](./CONTRIBUTING.md) for detailed guidelines. Key points: + +1. Link PRs to existing issues +2. Keep PRs small and focused +3. Use Draft PRs for WIP +4. Ensure `npm run preflight` passes +5. Update documentation for user-facing changes +6. Follow Conventional Commits for commit messages + +## Useful Commands Reference + +| Command | Description | +| ------------------- | -------------------------------------------------------------------- | +| `npm start` | Start CLI in interactive mode | +| `npm run dev` | Development mode with hot reload | +| `npm run build` | Build all packages | +| `npm run test` | Run unit tests | +| `npm run test:e2e` | Run integration tests | +| `npm run preflight` | Full CI check (clean, install, format, lint, build, typecheck, test) | +| `npm run lint` | Run ESLint | +| `npm run format` | Run Prettier | +| `npm run clean` | Clean build artifacts | + +## Session Commands (within CLI) + +- `/help` - Display available commands +- `/clear` - Clear conversation history +- `/compress` - Compress history to save tokens +- `/stats` - Show session information +- `/bug` - Submit bug report +- `/exit` or `/quit` - Exit Qwen Code + +--- 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/integration-tests/hook-integration/hooks.test.ts b/integration-tests/hook-integration/hooks.test.ts index bc69ddf31..40ed6f2ba 100644 --- a/integration-tests/hook-integration/hooks.test.ts +++ b/integration-tests/hook-integration/hooks.test.ts @@ -133,7 +133,7 @@ describe('Hooks System Integration', () => { it('should block tool execution when hook returns block and verify no tool was called', async () => { const blockScript = - 'echo \'{"decision": "block", "reason": "File writing blocked by security policy"}\''; + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "File writing blocked by security policy"}\''; await rig.setup('ups-block-tool', { settings: { @@ -162,6 +162,12 @@ describe('Hooks System Integration', () => { ).rejects.toThrow(/block/i); // Tool should not be called due to blocking hook + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(0); // At least one hook call occurred + const toolLogs = rig.readToolLogs(); const writeFileCalls = toolLogs.filter( (t) => @@ -942,8 +948,9 @@ describe('Hooks System Integration', () => { it('should continue execution when hook returns block decision', async () => { // Stop hook's block decision means "block stopping" (i.e., force continuation) // not "block operation and show error" + // Use background process to write count file, ensuring final output is pure JSON const blockStopScript = - 'echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\''; + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\''; await rig.setup('stop-block-decision', { settings: { @@ -967,15 +974,25 @@ describe('Hooks System Integration', () => { }); // When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop) - const result = await rig.run('Say hello', '--max-session-turns', '2'); - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); + const result = await rig.run('Say hello', '--max-session-turns', '3'); + + // Verify that execution completed successfully (not blocked by Stop hook) + // Verify Stop hook was invoked multiple times (indicating multiple rounds) + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); + + const toolLogs = rig.readToolLogs(); + const hasActivity = result.length > 0 || toolLogs.length > 0; + expect(hasActivity).toBe(true); }); it('should continue execution with custom reason', async () => { // Stop hook's block decision means "block stopping" (i.e., force continuation) const blockReasonScript = - 'echo \'{"decision": "block", "reason": "Custom block reason: task incomplete"}\''; + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Custom block reason: task incomplete"}\''; await rig.setup('stop-block-custom-reason', { settings: { @@ -999,46 +1016,19 @@ describe('Hooks System Integration', () => { }); // When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop) - const result = await rig.run('Say goodbye', '--max-session-turns', '2'); - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); - }); - }); + const result = await rig.run('Say goodbye', '--max-session-turns', '3'); - describe('Continue False', () => { - it('should request continue execution when hook returns continue: false', async () => { - const continueScript = - 'echo \'{"continue": false, "stopReason": "More work needed"}\''; + // Verify that execution completed successfully (not blocked by Stop hook) + // This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); - await rig.setup('stop-continue-false', { - settings: { - hooksConfig: { enabled: true }, - hooks: { - Stop: [ - { - hooks: [ - { - type: 'command', - command: continueScript, - name: 'stop-continue-hook', - timeout: 5000, - }, - ], - }, - ], - }, - trusted: true, - }, - }); - - // When continue: false, agent continues execution normally (with max turns to prevent infinite loop) - const result = await rig.run( - 'Say continue', - '--max-session-turns', - '2', - ); - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); + const toolLogs = rig.readToolLogs(); + const hasActivity = result.length > 0 || toolLogs.length > 0; + expect(hasActivity).toBe(true); }); }); @@ -1262,8 +1252,9 @@ describe('Hooks System Integration', () => { // Stop hook's block decision means "block stopping" (i.e., force continuation) const allowScript = 'echo \'{"decision": "allow", "reason": "Stop allowed"}\''; + // Write to a file to count hook invocations, then echo the decision const blockScript = - 'echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\''; + 'echo "hook_called" >> hook_invoke_count.txt; echo \'{"decision": "block", "reason": "Stop blocked by security policy"}\''; await rig.setup('stop-multi-one-blocks', { settings: { @@ -1296,18 +1287,28 @@ describe('Hooks System Integration', () => { const result = await rig.run( 'Say multi stop', '--max-session-turns', - '2', + '3', ); - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); + + // Verify that execution completed successfully (not blocked by Stop hook) + // This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); + + const toolLogs = rig.readToolLogs(); + const hasActivity = result.length > 0 || toolLogs.length > 0; + expect(hasActivity).toBe(true); }); it('should continue execution when first sequential stop hook returns block', async () => { // Stop hook's block decision means "block stopping" (i.e., force continuation) const blockScript = - 'echo \'{"decision": "block", "reason": "First hook blocks stop"}\''; + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "First hook blocks stop"}\''; const allowScript = - 'echo \'{"decision": "allow", "reason": "This should not run"}\''; + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow", "reason": "This should still run"}\''; await rig.setup('stop-seq-first-blocks', { settings: { @@ -1341,18 +1342,28 @@ describe('Hooks System Integration', () => { const result = await rig.run( 'Say sequential stop', '--max-session-turns', - '2', + '3', ); - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); + + // Verify that execution completed successfully (not blocked by Stop hook) + // This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); + + const toolLogs = rig.readToolLogs(); + const hasActivity = result.length > 0 || toolLogs.length > 0; + expect(hasActivity).toBe(true); }); it('should continue execution when second sequential stop hook returns block', async () => { // Stop hook's block decision means "block stopping" (i.e., force continuation) const allowScript = - 'echo \'{"decision": "allow", "reason": "First allows"}\''; + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "allow", "reason": "First allows"}\''; const blockScript = - 'echo \'{"decision": "block", "reason": "Second hook blocks stop"}\''; + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Second hook blocks stop"}\''; await rig.setup('stop-seq-second-blocks', { settings: { @@ -1386,10 +1397,20 @@ describe('Hooks System Integration', () => { const result = await rig.run( 'Say seq second blocks', '--max-session-turns', - '2', + '3', ); - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); + + // Verify that execution completed successfully (not blocked by Stop hook) + // This confirms: 1) Agent could execute after Stop hook blocked, 2) Session terminated normally + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); + + const toolLogs = rig.readToolLogs(); + const hasActivity = result.length > 0 || toolLogs.length > 0; + expect(hasActivity).toBe(true); }); it('should handle multiple stop hooks all returning allow', async () => { @@ -1441,9 +1462,9 @@ describe('Hooks System Integration', () => { it('should handle multiple stop hooks all returning block', async () => { const block1Script = - 'echo \'{"decision": "block", "reason": "First blocks"}\''; + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "First blocks"}\''; const block2Script = - 'echo \'{"decision": "block", "reason": "Second blocks"}\''; + '(echo "hook_called" >> hook_invoke_count.txt &) ; echo \'{"decision": "block", "reason": "Second blocks"}\''; await rig.setup('stop-multi-all-block', { settings: { @@ -1473,138 +1494,18 @@ describe('Hooks System Integration', () => { }); // When Stop hooks block, agent continues execution normally (with max turns to prevent infinite loop) - const result = await rig.run( + const _result = await rig.run( 'Say all block', '--max-session-turns', - '2', + '3', ); - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); - }); - it('should handle multiple continue: false from different stop hooks', async () => { - const continue1Script = - 'echo \'{"continue": false, "stopReason": "First needs more work"}\''; - const continue2Script = - 'echo \'{"continue": false, "stopReason": "Second needs more work"}\''; - - await rig.setup('stop-multi-continue-false', { - settings: { - hooksConfig: { enabled: true }, - hooks: { - Stop: [ - { - hooks: [ - { - type: 'command', - command: continue1Script, - name: 'stop-continue-1', - timeout: 5000, - }, - { - type: 'command', - command: continue2Script, - name: 'stop-continue-2', - timeout: 5000, - }, - ], - }, - ], - }, - trusted: true, - }, - }); - - // When continue: false, agent continues execution normally (with max turns to prevent infinite loop) - const result = await rig.run( - 'Say multi continue', - '--max-session-turns', - '2', - ); - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); - }); - - it('should handle mixed allow and continue: false in stop hooks', async () => { - const allowScript = - 'echo \'{"decision": "allow", "reason": "Allow stop"}\''; - const continueScript = - 'echo \'{"continue": false, "stopReason": "Need more work"}\''; - - await rig.setup('stop-mixed-allow-continue', { - settings: { - hooksConfig: { enabled: true }, - hooks: { - Stop: [ - { - hooks: [ - { - type: 'command', - command: allowScript, - name: 'stop-allow-hook', - timeout: 5000, - }, - { - type: 'command', - command: continueScript, - name: 'stop-continue-hook', - timeout: 5000, - }, - ], - }, - ], - }, - trusted: true, - }, - }); - - // When continue: false, agent continues execution normally (with max turns to prevent infinite loop) - const result = await rig.run('Say mixed', '--max-session-turns', '2'); - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); - }); - - it('should handle block with higher priority than continue: false', async () => { - const blockScript = - 'echo \'{"decision": "block", "reason": "Security block"}\''; - const continueScript = - 'echo \'{"continue": false, "stopReason": "Need more work"}\''; - - await rig.setup('stop-block-vs-continue', { - settings: { - hooksConfig: { enabled: true }, - hooks: { - Stop: [ - { - hooks: [ - { - type: 'command', - command: blockScript, - name: 'stop-block-priority', - timeout: 5000, - }, - { - type: 'command', - command: continueScript, - name: 'stop-continue-lower', - timeout: 5000, - }, - ], - }, - ], - }, - trusted: true, - }, - }); - - // When Stop hook blocks, agent continues execution normally (with max turns to prevent infinite loop) - const result = await rig.run( - 'Say block priority', - '--max-session-turns', - '2', - ); - expect(result).toBeDefined(); - expect(result.length).toBeGreaterThan(0); + // Verify Stop hook was invoked multiple times (indicating multiple rounds) + const hookInvokeCount = rig + .readFile('hook_invoke_count.txt') + .split('\n') + .filter((line) => line.trim() === 'hook_called').length; + expect(hookInvokeCount).toBeGreaterThan(1); }); it('should handle stop hook with error alongside blocking hook', async () => { diff --git a/package-lock.json b/package-lock.json index c0c2bb039..6834e60eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.0", + "version": "0.12.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qwen-code/qwen-code", - "version": "0.12.0", + "version": "0.12.1", "workspaces": [ "packages/*" ], @@ -14293,6 +14293,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -18799,7 +18800,7 @@ }, "packages/cli": { "name": "@qwen-code/qwen-code", - "version": "0.12.0", + "version": "0.12.1", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", "@google/genai": "1.30.0", @@ -19457,7 +19458,7 @@ }, "packages/core": { "name": "@qwen-code/qwen-code-core", - "version": "0.12.0", + "version": "0.12.1", "hasInstallScript": true, "dependencies": { "@anthropic-ai/sdk": "^0.36.1", @@ -22888,7 +22889,7 @@ }, "packages/test-utils": { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.12.0", + "version": "0.12.1", "dev": true, "license": "Apache-2.0", "devDependencies": { @@ -22900,7 +22901,7 @@ }, "packages/vscode-ide-companion": { "name": "qwen-code-vscode-ide-companion", - "version": "0.12.0", + "version": "0.12.1", "license": "LICENSE", "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", @@ -23148,7 +23149,7 @@ }, "packages/web-templates": { "name": "@qwen-code/web-templates", - "version": "0.12.0", + "version": "0.12.1", "devDependencies": { "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -23676,7 +23677,7 @@ }, "packages/webui": { "name": "@qwen-code/webui", - "version": "0.12.0", + "version": "0.12.1", "license": "MIT", "dependencies": { "markdown-it": "^14.1.0" diff --git a/package.json b/package.json index d12e16152..001b2deda 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.0", + "version": "0.12.1", "engines": { "node": ">=20.0.0" }, @@ -13,7 +13,7 @@ "url": "git+https://github.com/QwenLM/qwen-code.git" }, "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.1" }, "scripts": { "start": "cross-env node scripts/start.js", diff --git a/packages/cli/package.json b/packages/cli/package.json index 32073bb5c..11fdb8d96 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code", - "version": "0.12.0", + "version": "0.12.1", "description": "Qwen Code", "repository": { "type": "git", @@ -33,7 +33,7 @@ "dist" ], "config": { - "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.0" + "sandboxImageUri": "ghcr.io/qwenlm/qwen-code:0.12.1" }, "dependencies": { "@agentclientprotocol/sdk": "^0.14.1", diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index 2b93a0256..04b9c7292 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -513,13 +513,27 @@ export class Session implements SessionContext { } const confirmationDetails = - this.config.getApprovalMode() !== ApprovalMode.YOLO - ? await invocation.shouldConfirmExecute(abortSignal) - : false; + await invocation.shouldConfirmExecute(abortSignal); + + // In YOLO mode, auto-approve everything except ask_user_question + // (the user must always have a chance to respond to questions) + const isAskUserQuestionTool = + confirmationDetails && confirmationDetails.type === 'ask_user_question'; + const effectiveConfirmationDetails = + this.config.getApprovalMode() === ApprovalMode.YOLO && + !isAskUserQuestionTool + ? false + : confirmationDetails; // Check for plan mode enforcement - block non-read-only tools + // but allow ask_user_question so users can answer clarification questions const isPlanMode = this.config.getApprovalMode() === ApprovalMode.PLAN; - if (isPlanMode && !isExitPlanModeTool && confirmationDetails) { + if ( + isPlanMode && + !isExitPlanModeTool && + !isAskUserQuestionTool && + effectiveConfirmationDetails + ) { // In plan mode, block any tool that requires confirmation (write operations) return errorResponse( new Error( @@ -529,25 +543,25 @@ export class Session implements SessionContext { ); } - if (confirmationDetails) { + if (effectiveConfirmationDetails) { const content: ToolCallContent[] = []; - if (confirmationDetails.type === 'edit') { + if (effectiveConfirmationDetails.type === 'edit') { content.push({ type: 'diff', - path: confirmationDetails.fileName, - oldText: confirmationDetails.originalContent, - newText: confirmationDetails.newContent, + path: effectiveConfirmationDetails.fileName, + oldText: effectiveConfirmationDetails.originalContent, + newText: effectiveConfirmationDetails.newContent, }); } // Add plan content for exit_plan_mode - if (confirmationDetails.type === 'plan') { + if (effectiveConfirmationDetails.type === 'plan') { content.push({ type: 'content', content: { type: 'text', - text: confirmationDetails.plan, + text: effectiveConfirmationDetails.plan, }, }); } @@ -557,7 +571,7 @@ export class Session implements SessionContext { const params: RequestPermissionRequest = { sessionId: this.sessionId, - options: toPermissionOptions(confirmationDetails), + options: toPermissionOptions(effectiveConfirmationDetails), toolCall: { toolCallId: callId, status: 'pending', @@ -565,10 +579,15 @@ export class Session implements SessionContext { content, locations: invocation.toolLocations(), kind: mappedKind, + rawInput: args, }, }; - const output = await this.client.requestPermission(params); + const output = (await this.client.requestPermission( + params, + )) as RequestPermissionResponse & { + answers?: Record; + }; const outcome = output.outcome.outcome === 'cancelled' ? ToolConfirmationOutcome.Cancel @@ -576,7 +595,9 @@ export class Session implements SessionContext { .nativeEnum(ToolConfirmationOutcome) .parse(output.outcome.optionId); - await confirmationDetails.onConfirm(outcome); + await effectiveConfirmationDetails.onConfirm(outcome, { + answers: output.answers, + }); // After exit_plan_mode confirmation, send current_mode_update notification if (isExitPlanModeTool && outcome !== ToolConfirmationOutcome.Cancel) { @@ -1026,6 +1047,19 @@ function toPermissionOptions( kind: 'reject_once', }, ]; + case 'ask_user_question': + return [ + { + optionId: ToolConfirmationOutcome.ProceedOnce, + name: 'Submit', + kind: 'allow_once', + }, + { + optionId: ToolConfirmationOutcome.Cancel, + name: 'Cancel', + kind: 'reject_once', + }, + ]; default: { const unreachable: never = confirmation; throw new Error(`Unexpected: ${unreachable}`); diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts index 2234c9ea4..9550932c9 100644 --- a/packages/cli/src/config/settings.test.ts +++ b/packages/cli/src/config/settings.test.ts @@ -348,7 +348,7 @@ describe('Settings Loading and Merging', () => { fileName: 'WORKSPACE_CONTEXT.md', }, mcp: { - allowed: ['server1', 'server2'], + allowed: ['server1', 'server2', 'server3', 'server1', 'server2'], }, }); }); @@ -1474,8 +1474,8 @@ describe('Settings Loading and Merging', () => { const settings = loadSettings(MOCK_WORKSPACE_DIR); expect(settings.merged.mcp).toEqual({ - allowed: ['system-allowed'], - excluded: ['workspace-excluded'], + allowed: ['user-allowed', 'workspace-allowed', 'system-allowed'], + excluded: ['user-excluded', 'workspace-excluded'], }); }); diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index fb3701392..663e36dbc 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -998,6 +998,7 @@ const SETTINGS_SCHEMA = { default: undefined as string[] | undefined, description: 'A list of MCP servers to allow.', showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, }, excluded: { type: 'array', @@ -1007,6 +1008,7 @@ const SETTINGS_SCHEMA = { default: undefined as string[] | undefined, description: 'A list of MCP servers to exclude.', showInDialog: false, + mergeStrategy: MergeStrategy.CONCAT, }, }, }, diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index a5af9d471..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 @@ -1539,6 +1551,18 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Fügen Sie Ihren Bailian Coding Plan API-Schlüssel ein und Sie sind bereit!', + Custom: 'Benutzerdefiniert', + 'More instructions about configuring `modelProviders` manually.': + 'Weitere Anweisungen zur manuellen Konfiguration von `modelProviders`.', + 'Select API-KEY configuration mode:': + 'API-KEY-Konfigurationsmodus auswählen:', + '(Press Escape to go back)': '(Escape drücken zum Zurückgehen)', + '(Press Enter to submit, Escape to cancel)': + '(Enter zum Absenden, Escape zum Abbrechen)', + 'More instructions please check:': 'Weitere Anweisungen finden Sie unter:', 'Select Region for Coding Plan': 'Region für Coding Plan auswählen', 'Choose based on where your account is registered': 'Wählen Sie basierend auf dem Registrierungsort Ihres Kontos', @@ -1557,4 +1581,33 @@ export default { 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert.', 'Tip: Use /model to switch between available Coding Plan models.': 'Tipp: Verwenden Sie /model, um zwischen verfügbaren Coding Plan-Modellen zu wechseln.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Bitte beantworten Sie die folgende(n) Frage(n):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Benutzerfragen können im nicht-interaktiven Modus nicht gestellt werden. Bitte führen Sie das Tool im interaktiven Modus aus.', + 'User declined to answer the questions.': + 'Benutzer hat die Beantwortung der Fragen abgelehnt.', + 'User has provided the following answers:': + 'Benutzer hat die folgenden Antworten bereitgestellt:', + 'Failed to process user answers:': + 'Fehler beim Verarbeiten der Benutzerantworten:', + 'Type something...': 'Etwas eingeben...', + Submit: 'Senden', + 'Submit answers': 'Antworten senden', + Cancel: 'Abbrechen', + 'Your answers:': 'Ihre Antworten:', + '(not answered)': '(nicht beantwortet)', + 'Ready to submit your answers?': 'Bereit, Ihre Antworten zu senden?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Navigieren | ←/→: Tabs wechseln | Enter: Auswählen', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigieren | ←/→: Tabs wechseln | Space/Enter: Umschalten | Esc: Abbrechen', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigieren | Space/Enter: Umschalten | Esc: Abbrechen', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Navigieren | Enter: Auswählen | Esc: Abbrechen', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index dedec4b75..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 @@ -1595,6 +1606,16 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + "Paste your api key of Bailian Coding Plan and you're all set!", + Custom: 'Custom', + 'More instructions about configuring `modelProviders` manually.': + 'More instructions about configuring `modelProviders` manually.', + 'Select API-KEY configuration mode:': 'Select API-KEY configuration mode:', + '(Press Escape to go back)': '(Press Escape to go back)', + '(Press Enter to submit, Escape to cancel)': + '(Press Enter to submit, Escape to cancel)', 'Select Region for Coding Plan': 'Select Region for Coding Plan', 'Choose based on where your account is registered': 'Choose based on where your account is registered', @@ -1613,4 +1634,32 @@ export default { 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.', 'Tip: Use /model to switch between available Coding Plan models.': 'Tip: Use /model to switch between available Coding Plan models.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Please answer the following question(s):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.', + 'User declined to answer the questions.': + 'User declined to answer the questions.', + 'User has provided the following answers:': + 'User has provided the following answers:', + 'Failed to process user answers:': 'Failed to process user answers:', + 'Type something...': 'Type something...', + Submit: 'Submit', + 'Submit answers': 'Submit answers', + Cancel: 'Cancel', + 'Your answers:': 'Your answers:', + '(not answered)': '(not answered)', + 'Ready to submit your answers?': 'Ready to submit your answers?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Navigate | Enter: Select | Esc: Cancel', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index b577e2cc1..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 @@ -1046,6 +1058,17 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Bailian Coding PlanのAPIキーを貼り付けるだけで準備完了です!', + Custom: 'カスタム', + 'More instructions about configuring `modelProviders` manually.': + '`modelProviders`を手動で設定する方法の詳細はこちら。', + 'Select API-KEY configuration mode:': 'API-KEY設定モードを選択してください:', + '(Press Escape to go back)': '(Escapeキーで戻る)', + '(Press Enter to submit, Escape to cancel)': + '(Enterで送信、Escapeでキャンセル)', + 'More instructions please check:': '詳細な手順はこちらをご確認ください:', 'Select Region for Coding Plan': 'Coding Planのリージョンを選択', 'Choose based on where your account is registered': 'アカウントの登録先に応じて選択してください', @@ -1064,4 +1087,31 @@ export default { '{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました。', 'Tip: Use /model to switch between available Coding Plan models.': 'ヒント: /model で利用可能な Coding Plan モデルを切り替えられます。', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': '以下の質問に答えてください:', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + '非対話モードではユーザーに質問できません。このツールを使用するには対話モードで実行してください。', + 'User declined to answer the questions.': + 'ユーザーは質問への回答を拒否しました。', + 'User has provided the following answers:': + 'ユーザーは以下の回答を提供しました:', + 'Failed to process user answers:': 'ユーザー回答の処理に失敗しました:', + 'Type something...': '何か入力...', + Submit: '送信', + 'Submit answers': '回答を送信', + Cancel: 'キャンセル', + 'Your answers:': 'あなたの回答:', + '(not answered)': '(未回答)', + 'Ready to submit your answers?': '回答を送信しますか?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: ナビゲート | ←/→: タブ切り替え | Enter: 選択', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: ナビゲート | ←/→: タブ切り替え | Space/Enter: 切り替え | Esc: キャンセル', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: ナビゲート | Space/Enter: 切り替え | Esc: キャンセル', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: ナビゲート | Enter: 選択 | Esc: キャンセル', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index c1503e810..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 @@ -1534,6 +1546,18 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Cole sua chave de API do Bailian Coding Plan e pronto!', + Custom: 'Personalizado', + 'More instructions about configuring `modelProviders` manually.': + 'Mais instruções sobre como configurar `modelProviders` manualmente.', + 'Select API-KEY configuration mode:': + 'Selecione o modo de configuração da API-KEY:', + '(Press Escape to go back)': '(Pressione Escape para voltar)', + '(Press Enter to submit, Escape to cancel)': + '(Pressione Enter para enviar, Escape para cancelar)', + 'More instructions please check:': 'Mais instruções, consulte:', 'Select Region for Coding Plan': 'Selecionar região do Coding Plan', 'Choose based on where your account is registered': 'Escolha com base em onde sua conta está registrada', @@ -1552,4 +1576,33 @@ export default { 'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json.', 'Tip: Use /model to switch between available Coding Plan models.': 'Dica: Use /model para alternar entre os modelos disponíveis do Coding Plan.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Por favor, responda à(s) seguinte(s) pergunta(s):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Não é possível fazer perguntas ao usuário no modo não interativo. Por favor, execute no modo interativo para usar esta ferramenta.', + 'User declined to answer the questions.': + 'O usuário recusou responder às perguntas.', + 'User has provided the following answers:': + 'O usuário forneceu as seguintes respostas:', + 'Failed to process user answers:': + 'Falha ao processar as respostas do usuário:', + 'Type something...': 'Digite algo...', + Submit: 'Enviar', + 'Submit answers': 'Enviar respostas', + Cancel: 'Cancelar', + 'Your answers:': 'Suas respostas:', + '(not answered)': '(não respondido)', + 'Ready to submit your answers?': 'Pronto para enviar suas respostas?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Navegar | ←/→: Alternar abas | Enter: Selecionar', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navegar | ←/→: Alternar abas | Space/Enter: Alternar | Esc: Cancelar', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Navegar | Space/Enter: Alternar | Esc: Cancelar', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Navegar | Enter: Selecionar | Esc: Cancelar', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 60b63880f..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)': '(отключен)', @@ -1478,6 +1481,17 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + 'Вставьте ваш API-ключ Bailian Coding Plan и всё готово!', + Custom: 'Пользовательский', + 'More instructions about configuring `modelProviders` manually.': + 'Дополнительные инструкции по ручной настройке `modelProviders`.', + 'Select API-KEY configuration mode:': 'Выберите режим конфигурации API-KEY:', + '(Press Escape to go back)': '(Нажмите Escape для возврата)', + '(Press Enter to submit, Escape to cancel)': + '(Нажмите Enter для отправки, Escape для отмены)', + 'More instructions please check:': 'Дополнительные инструкции см.:', 'Select Region for Coding Plan': 'Выберите регион Coding Plan', 'Choose based on where your account is registered': 'Выберите в зависимости от места регистрации вашего аккаунта', @@ -1536,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.': 'Для этого сервера нет доступных инструментов.', @@ -1544,6 +1566,7 @@ export default { 'open-world': 'открытый мир', idempotent: 'идемпотентный', 'Tools for {{name}}': 'Инструменты для {{name}}', + 'Tools for {{serverName}}': 'Инструменты для {{serverName}}', '{{current}}/{{total}}': '{{current}}/{{total}}', // MCP Tool Detail @@ -1565,4 +1588,33 @@ export default { 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json.', 'Tip: Use /model to switch between available Coding Plan models.': 'Совет: Используйте /model для переключения между доступными моделями Coding Plan.', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': + 'Пожалуйста, ответьте на следующий(ие) вопрос(ы):', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + 'Невозможно задавать вопросы пользователю в неинтерактивном режиме. Пожалуйста, запустите в интерактивном режиме для использования этого инструмента.', + 'User declined to answer the questions.': + 'Пользователь отказался отвечать на вопросы.', + 'User has provided the following answers:': + 'Пользователь предоставил следующие ответы:', + 'Failed to process user answers:': + 'Не удалось обработать ответы пользователя:', + 'Type something...': 'Введите что-то...', + Submit: 'Отправить', + 'Submit answers': 'Отправить ответы', + Cancel: 'Отмена', + 'Your answers:': 'Ваши ответы:', + '(not answered)': '(не отвечено)', + 'Ready to submit your answers?': 'Готовы отправить свои ответы?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: Навигация | ←/→: Переключение вкладок | Enter: Выбор', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Навигация | ←/→: Переключение вкладок | Space/Enter: Переключить | Esc: Отмена', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: Навигация | Space/Enter: Переключить | Esc: Отмена', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: Навигация | Enter: Выбор | Esc: Отмена', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 8ddff791b..d6f6b2ead 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -138,7 +138,7 @@ export default { '在所选作用域中未找到主题 "{{themeName}}"。', 'Clear conversation history and free up context': '清除对话历史并释放上下文', 'Compresses the context by replacing it with a summary.': - '通过用摘要替换来压缩上下文', + '通过摘要替换来压缩上下文', 'open full Qwen Code documentation in your browser': '在浏览器中打开完整的 Qwen Code 文档', 'Configuration not available.': '配置不可用', @@ -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 @@ -1419,6 +1430,16 @@ export default { // ============================================================================ // Auth Dialog - View Titles and Labels // ============================================================================ + 'API-KEY': 'API-KEY', + 'Coding Plan': 'Coding Plan', + "Paste your api key of Bailian Coding Plan and you're all set!": + '粘贴您的百炼 Coding Plan API Key,即可完成设置!', + Custom: '自定义', + 'More instructions about configuring `modelProviders` manually.': + '关于手动配置 `modelProviders` 的更多说明。', + 'Select API-KEY configuration mode:': '选择 API-KEY 配置模式:', + '(Press Escape to go back)': '(按 Escape 键返回)', + '(Press Enter to submit, Escape to cancel)': '(按 Enter 提交,Escape 取消)', 'Select Region for Coding Plan': '选择 Coding Plan 区域', 'Choose based on where your account is registered': '请根据您的账号注册地区选择', @@ -1436,4 +1457,29 @@ export default { '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。', 'Tip: Use /model to switch between available Coding Plan models.': '提示:使用 /model 切换可用的 Coding Plan 模型。', + + // ============================================================================ + // Ask User Question Tool + // ============================================================================ + 'Please answer the following question(s):': '请回答以下问题:', + 'Cannot ask user questions in non-interactive mode. Please run in interactive mode to use this tool.': + '无法在非交互模式下询问用户问题。请在交互模式下运行以使用此工具。', + 'User declined to answer the questions.': '用户拒绝回答问题。', + 'User has provided the following answers:': '用户提供了以下答案:', + 'Failed to process user answers:': '处理用户答案失败:', + 'Type something...': '输入内容...', + Submit: '提交', + 'Submit answers': '提交答案', + Cancel: '取消', + 'Your answers:': '您的答案:', + '(not answered)': '(未回答)', + 'Ready to submit your answers?': '准备好提交您的答案了吗?', + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select': + '↑/↓: 导航 | ←/→: 切换标签页 | Enter: 选择', + '↑/↓: Navigate | ←/→: Switch tabs | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: 导航 | ←/→: 切换标签页 | Space/Enter: 切换 | Esc: 取消', + '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel': + '↑/↓: 导航 | Space/Enter: 切换 | Esc: 取消', + '↑/↓: Navigate | Enter: Select | Esc: Cancel': + '↑/↓: 导航 | Enter: 选择 | Esc: 取消', }; 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/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 73983c812..193549245 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -103,7 +103,9 @@ export const Composer = () => { )} {/* Exclusive area: only one component visible at a time */} + {/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */} {!showSuggestions && + uiState.streamingState !== StreamingState.WaitingForConfirmation && (showShortcuts ? ( ) : ( 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..ce84814a7 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); @@ -91,16 +95,10 @@ export const MCPManagementDialog: React.FC = ({ let source: 'user' | 'project' | 'extension' = 'user'; if (serverConfig.extensionName) { source = 'extension'; - } - - // Determine the scope of the configuration - let scope: 'user' | 'workspace' | 'extension' = 'user'; - if (serverConfig.extensionName) { - scope = 'extension'; } else if (workspaceSettings.mcpServers?.[name]) { - scope = 'workspace'; + source = 'project'; } else if (userSettings.mcpServers?.[name]) { - scope = 'user'; + source = 'user'; } // Use config.isMcpServerDisabled() to check if server is disabled @@ -115,7 +113,6 @@ export const MCPManagementDialog: React.FC = ({ name, status, source, - scope, config: serverConfig, toolCount: serverTools.length, invalidToolCount, @@ -225,6 +222,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 +320,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.source === '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.source === 'project') { + 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 +436,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 +536,7 @@ export const MCPManagementDialog: React.FC = ({ onViewTools={handleViewTools} onReconnect={handleReconnect} onDisable={handleDisable} + onAuthenticate={handleAuthenticate} onBack={handleNavigateBack} /> ); @@ -463,6 +565,17 @@ export const MCPManagementDialog: React.FC = ({ ); + case MCP_MANAGEMENT_STEPS.AUTHENTICATE: + return ( + { + void reloadServers(); + }} + onBack={handleNavigateBack} + /> + ); + default: return ( @@ -480,10 +593,12 @@ export const MCPManagementDialog: React.FC = ({ handleViewTools, handleReconnect, handleDisable, + handleAuthenticate, handleNavigateBack, handleSelectTool, handleSelectDisableScope, getServerTools, + reloadServers, ]); // Render step footer @@ -511,6 +626,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 +654,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..a4463476f 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,10 +135,10 @@ export const ServerDetailStep: React.FC = ({ {t('Source:')} - - {server.scope === 'user' + + {server.source === 'user' ? t('User Settings') - : server.scope === 'workspace' + : server.source === 'project' ? t('Workspace Settings') : t('Extension')} @@ -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..8812c5f12 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 = @@ -33,8 +34,6 @@ export interface MCPServerDisplayInfo { status: MCPServerStatus; /** 来源类型 */ source: 'user' | 'project' | 'extension'; - /** 配置所在的 scope */ - scope: 'user' | 'workspace' | 'extension'; /** 配置文件路径 */ configPath?: string; /** 服务器配置 */ @@ -120,7 +119,7 @@ export interface ServerListStepProps { } /** - * ServerDetailStep组件属性 + * ServerDetailStep 组件属性 */ export interface ServerDetailStepProps { /** 选中的服务器 */ @@ -131,6 +130,8 @@ export interface ServerDetailStepProps { onReconnect?: () => void; /** 禁用服务器回调 */ onDisable?: () => void; + /** OAuth 认证回调 */ + onAuthenticate?: () => void; /** 返回回调 */ onBack: () => void; } @@ -162,7 +163,7 @@ export interface ToolListStepProps { } /** - * ToolDetailStep组件属性 + * ToolDetailStep 组件属性 */ export interface ToolDetailStepProps { /** 工具信息 */ @@ -171,6 +172,18 @@ export interface ToolDetailStepProps { onBack: () => void; } +/** + * AuthenticateStep 组件属性 + */ +export interface AuthenticateStepProps { + /** 服务器信息 */ + server: MCPServerDisplayInfo | null; + /** 认证成功回调 */ + onSuccess?: () => void; + /** 返回回调 */ + onBack: () => void; +} + /** * MCP管理对话框属性 */ diff --git a/packages/cli/src/ui/components/mcp/utils.test.ts b/packages/cli/src/ui/components/mcp/utils.test.ts index 3b058ba55..155195454 100644 --- a/packages/cli/src/ui/components/mcp/utils.test.ts +++ b/packages/cli/src/ui/components/mcp/utils.test.ts @@ -25,7 +25,6 @@ describe('MCP utils', () => { name: 'server1', status: MCPServerStatus.CONNECTED, source: 'user', - scope: 'user', config: { command: 'cmd1' }, toolCount: 1, promptCount: 0, @@ -35,7 +34,6 @@ describe('MCP utils', () => { name: 'server2', status: MCPServerStatus.CONNECTED, source: 'extension', - scope: 'extension', config: { command: 'cmd2' }, toolCount: 2, promptCount: 0, diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx new file mode 100644 index 000000000..a88b1bb4a --- /dev/null +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx @@ -0,0 +1,456 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { AskUserQuestionDialog } from './AskUserQuestionDialog.js'; +import type { ToolAskUserQuestionConfirmationDetails } from '@qwen-code/qwen-code-core'; +import { ToolConfirmationOutcome } from '@qwen-code/qwen-code-core'; +import { renderWithProviders } from '../../../test-utils/render.js'; + +const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); + +const createSingleQuestion = ( + overrides: Partial< + ToolAskUserQuestionConfirmationDetails['questions'][0] + > = {}, +): ToolAskUserQuestionConfirmationDetails['questions'][0] => ({ + question: 'What is your favorite color?', + header: 'Color', + options: [ + { label: 'Red', description: 'A warm color' }, + { label: 'Blue', description: 'A cool color' }, + { label: 'Green', description: '' }, + ], + multiSelect: false, + ...overrides, +}); + +const createConfirmationDetails = ( + overrides: Partial = {}, +): ToolAskUserQuestionConfirmationDetails => ({ + type: 'ask_user_question', + title: 'Question', + questions: [createSingleQuestion()], + onConfirm: vi.fn(), + ...overrides, +}); + +describe('', () => { + describe('rendering', () => { + it('renders single question with options', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('What is your favorite color?'); + expect(output).toContain('Red'); + expect(output).toContain('Blue'); + expect(output).toContain('Green'); + expect(output).toContain('A warm color'); + expect(output).toContain('A cool color'); + }); + + it('renders header for single question', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Color'); + }); + + it('renders "Type something..." custom input option', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Type something...'); + }); + + it('renders help text for single select', () => { + const details = createConfirmationDetails(); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('Enter: Select'); + expect(lastFrame()).toContain('Esc: Cancel'); + expect(lastFrame()).not.toContain('Switch tabs'); + }); + + it('renders tabs for multiple questions', () => { + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ + header: 'Q2', + question: 'Second question?', + }), + ], + }); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('Q1'); + expect(output).toContain('Q2'); + expect(output).toContain('Submit'); + expect(output).toContain('Switch tabs'); + }); + + it('renders multi-select with checkboxes', () => { + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + const onConfirm = vi.fn(); + + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('[ ]'); + expect(output).toContain('Space: Toggle'); + expect(output).toContain('Enter: Confirm'); + }); + }); + + describe('single-select interaction', () => { + it('selects an option with Enter and submits immediately for single question', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Press Enter to select the first option (Red) + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Red' } }, + ); + unmount(); + }); + + it('navigates down with arrow key and selects', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate down to "Blue" + stdin.write('\u001B[B'); // Down arrow + await wait(); + + // Press Enter + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Blue' } }, + ); + unmount(); + }); + + it('navigates with number keys', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Press '2' to select Blue + stdin.write('2'); + await wait(); + + // Press Enter + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Blue' } }, + ); + unmount(); + }); + + it('cancels with Escape', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\u001B'); // Escape + await wait(); + + expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel); + unmount(); + }); + }); + + describe('multi-select interaction', () => { + it('toggles options with Space', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Space to toggle first option + stdin.write(' '); + await wait(); + + // Should show checked state + expect(lastFrame()).toContain('[✓]'); + unmount(); + }); + + it('submits multi-select with Space to toggle then Enter to confirm', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [createSingleQuestion({ multiSelect: true })], + }); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Space to toggle first option + stdin.write(' '); + await wait(); + + // Enter to confirm and submit + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith( + ToolConfirmationOutcome.ProceedOnce, + { answers: { 0: 'Red' } }, + ); + unmount(); + }); + }); + + describe('multiple questions', () => { + it('navigates between tabs with left/right arrows', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ + header: 'Q2', + question: 'Second question?', + }), + ], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate right to Q2 + stdin.write('\u001B[C'); // Right arrow + await wait(); + + expect(lastFrame()).toContain('Second question?'); + + // Navigate left back to Q1 + stdin.write('\u001B[D'); // Left arrow + await wait(); + + expect(lastFrame()).toContain('What is your favorite color?'); + unmount(); + }); + + it('shows Submit tab for multiple questions', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ header: 'Q2' }), + ], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate to submit tab (right arrow twice: Q1 -> Q2 -> Submit) + stdin.write('\u001B[C'); // Right + await wait(); + stdin.write('\u001B[C'); // Right + await wait(); + + const output = lastFrame(); + expect(output).toContain('Submit answers'); + expect(output).toContain('Cancel'); + expect(output).toContain('Your answers'); + unmount(); + }); + + it('cancels from Submit tab', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ header: 'Q2' }), + ], + }); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate to submit tab + stdin.write('\u001B[C'); // Right + await wait(); + stdin.write('\u001B[C'); // Right + await wait(); + + // Navigate down to Cancel option + stdin.write('\u001B[B'); // Down + await wait(); + + // Press Enter + stdin.write('\r'); + await wait(); + + expect(onConfirm).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel); + unmount(); + }); + + it('shows unanswered questions as (not answered) in Submit tab', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails({ + questions: [ + createSingleQuestion({ header: 'Q1' }), + createSingleQuestion({ header: 'Q2' }), + ], + }); + + const { stdin, lastFrame, unmount } = renderWithProviders( + , + ); + await wait(); + + // Navigate directly to submit tab without answering anything + stdin.write('\u001B[C'); // Right + await wait(); + stdin.write('\u001B[C'); // Right + await wait(); + + expect(lastFrame()).toContain('(not answered)'); + unmount(); + }); + }); + + describe('focus behavior', () => { + it('does not respond to keys when isFocused is false', async () => { + const onConfirm = vi.fn(); + const details = createConfirmationDetails(); + + const { stdin, unmount } = renderWithProviders( + , + ); + await wait(); + + stdin.write('\r'); // Enter + await wait(); + stdin.write('\u001B'); // Escape + await wait(); + + expect(onConfirm).not.toHaveBeenCalled(); + unmount(); + }); + }); +}); diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx new file mode 100644 index 000000000..421ec82c9 --- /dev/null +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx @@ -0,0 +1,572 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useState } from 'react'; +import { Box, Text } from 'ink'; +import { + type ToolAskUserQuestionConfirmationDetails, + ToolConfirmationOutcome, + type ToolConfirmationPayload, +} from '@qwen-code/qwen-code-core'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { TextInput } from '../shared/TextInput.js'; +import { t } from '../../../i18n/index.js'; + +interface AskUserQuestionDialogProps { + confirmationDetails: ToolAskUserQuestionConfirmationDetails; + isFocused?: boolean; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; +} + +export const AskUserQuestionDialog: React.FC = ({ + confirmationDetails, + isFocused = true, + onConfirm, +}) => { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [selectedOptions, setSelectedOptions] = useState< + Record + >({}); + const [customInputValues, setCustomInputValues] = useState< + Record + >({}); + const [selectedIndex, setSelectedIndex] = useState(0); + const [multiSelectedOptions, setMultiSelectedOptions] = useState< + Record + >({}); + const [customInputChecked, setCustomInputChecked] = useState< + Record + >({}); + + const hasMultipleQuestions = confirmationDetails.questions.length > 1; + const totalTabs = hasMultipleQuestions + ? confirmationDetails.questions.length + 1 + : confirmationDetails.questions.length; // +1 for Submit tab + const isSubmitTab = + hasMultipleQuestions && currentQuestionIndex === totalTabs - 1; + + const currentQuestion = isSubmitTab + ? null + : confirmationDetails.questions[currentQuestionIndex]; + const isMultiSelect = currentQuestion?.multiSelect ?? false; + // Options + custom input ("Other") + const totalOptions = currentQuestion ? currentQuestion.options.length + 1 : 2; + + // Check if the custom input option is selected + const isCustomInputSelected = + !isSubmitTab && + currentQuestion && + selectedIndex === currentQuestion.options.length; + + const currentCustomInputValue = customInputValues[currentQuestionIndex] ?? ''; + const isCustomInputAnswer = + !isSubmitTab && + currentQuestion && + !isMultiSelect && + selectedOptions[currentQuestionIndex] !== undefined && + !currentQuestion.options.some( + (opt) => opt.label === selectedOptions[currentQuestionIndex], + ); + + // Compute the current answer for a question, considering multi-select state + const getAnswerForQuestion = (idx: number): string | undefined => { + const q = confirmationDetails.questions[idx]; + if (q?.multiSelect) { + const selections = [...(multiSelectedOptions[idx] ?? [])]; + const customValue = (customInputValues[idx] ?? '').trim(); + if (customInputChecked[idx] && customValue) { + selections.push(customValue); + } + return selections.length > 0 ? selections.join(', ') : undefined; + } + return selectedOptions[idx]; + }; + + const handleSubmit = async () => { + const answers: Record = {}; + confirmationDetails.questions.forEach((_, idx) => { + const answer = getAnswerForQuestion(idx); + if (answer !== undefined) { + answers[idx] = answer; + } + }); + + await onConfirm(ToolConfirmationOutcome.ProceedOnce, { answers }); + }; + + const handleMultiSelectSubmit = () => { + if (!currentQuestion) return; + const selections = [...(multiSelectedOptions[currentQuestionIndex] ?? [])]; + const customValue = currentCustomInputValue.trim(); + if (customInputChecked[currentQuestionIndex] && customValue) { + selections.push(customValue); + } + if (selections.length === 0) return; + + const value = selections.join(', '); + const updated = { ...selectedOptions, [currentQuestionIndex]: value }; + setSelectedOptions(updated); + + if (!hasMultipleQuestions) { + void onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { [currentQuestionIndex]: value }, + }); + } else { + if (currentQuestionIndex < totalTabs - 1) { + setTimeout(() => { + setCurrentQuestionIndex((prev) => Math.min(prev + 1, totalTabs - 1)); + setSelectedIndex(0); + }, 150); + } + } + }; + + const handleCustomInputSubmit = () => { + const trimmedValue = currentCustomInputValue.trim(); + + if (isMultiSelect) { + // Toggle custom input checked state + if (!trimmedValue) return; + setCustomInputChecked((prev) => ({ + ...prev, + [currentQuestionIndex]: !prev[currentQuestionIndex], + })); + return; + } + + if (!trimmedValue) return; + + const updated = { + ...selectedOptions, + [currentQuestionIndex]: trimmedValue, + }; + setSelectedOptions(updated); + + // If single question, submit immediately + if (!hasMultipleQuestions) { + void onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { + [currentQuestionIndex]: trimmedValue, + }, + }); + } else { + // Auto-advance to next tab + if (currentQuestionIndex < totalTabs - 1) { + setTimeout(() => { + setCurrentQuestionIndex((prev) => Math.min(prev + 1, totalTabs - 1)); + setSelectedIndex(0); + }, 150); + } + } + }; + + // Handle navigation and selection + useKeypress( + (key) => { + if (!isFocused) return; + + // When custom input is focused, still allow up/down navigation, tab switch and escape + if (isCustomInputSelected) { + if (key.name === 'up') { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + return; + } + if (key.name === 'down') { + setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1)); + return; + } + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + void onConfirm(ToolConfirmationOutcome.Cancel); + return; + } + return; + } + + const input = key.sequence; + + // Tab navigation (left/right arrows) + if (key.name === 'left' && hasMultipleQuestions) { + if (currentQuestionIndex > 0) { + setCurrentQuestionIndex(currentQuestionIndex - 1); + setSelectedIndex(0); + } + return; + } + if (key.name === 'right' && hasMultipleQuestions) { + if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setSelectedIndex(0); + } + return; + } + + // Option navigation (up/down arrows) + if (key.name === 'up') { + setSelectedIndex(Math.max(0, selectedIndex - 1)); + return; + } + if (key.name === 'down') { + setSelectedIndex(Math.min(totalOptions - 1, selectedIndex + 1)); + return; + } + + // Number key selection + const numKey = parseInt(input || '', 10); + if (!isNaN(numKey) && numKey >= 1 && numKey <= totalOptions) { + setSelectedIndex(numKey - 1); + return; + } + + // Space to toggle multi-select + if (key.name === 'space' && isMultiSelect && currentQuestion) { + if (selectedIndex < currentQuestion.options.length) { + const option = currentQuestion.options[selectedIndex]; + if (option) { + const current = multiSelectedOptions[currentQuestionIndex] ?? []; + const isChecked = current.includes(option.label); + const updated = isChecked + ? current.filter((l) => l !== option.label) + : [...current, option.label]; + setMultiSelectedOptions((prev) => ({ + ...prev, + [currentQuestionIndex]: updated, + })); + } + } + return; + } + + // Enter to select + if (key.name === 'return') { + // Handle Submit tab + if (isSubmitTab) { + if (selectedIndex === 0) { + // Submit + void handleSubmit(); + } else { + // Cancel + void onConfirm(ToolConfirmationOutcome.Cancel); + } + return; + } + + // Handle multi-select: Enter advances to next question / submits + if (isMultiSelect && currentQuestion) { + // Custom input is handled by TextInput's onSubmit + if (selectedIndex === currentQuestion.options.length) { + return; + } + handleMultiSelectSubmit(); + return; + } + + // Handle question options (not custom input - that's handled by TextInput) + if (currentQuestion && selectedIndex < currentQuestion.options.length) { + const option = currentQuestion.options[selectedIndex]; + if (option) { + const updated = { + ...selectedOptions, + [currentQuestionIndex]: option.label, + }; + setSelectedOptions(updated); + + // If single question, submit immediately + if (!hasMultipleQuestions) { + void onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { [currentQuestionIndex]: option.label }, + }); + } else { + // Auto-advance to next tab after selection + if (currentQuestionIndex < totalTabs - 1) { + setTimeout(() => { + setCurrentQuestionIndex((prev) => + Math.min(prev + 1, totalTabs - 1), + ); + setSelectedIndex(0); + }, 150); + } + } + } + } + return; + } + + // Cancel + if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { + void onConfirm(ToolConfirmationOutcome.Cancel); + return; + } + }, + { isActive: isFocused }, + ); + + // Submit tab (for multiple questions) + if (isSubmitTab) { + return ( + + {/* Tabs */} + + {confirmationDetails.questions.map((q, idx) => { + const isAnswered = getAnswerForQuestion(idx) !== undefined; + return ( + + + {isAnswered ? ' ' : ' '} + {q.header} + {isAnswered ? ' ✓' : ''} + + + ); + })} + + + ▸ {t('Submit')} + + + + + {/* Show selected answers */} + + {t('Your answers:')} + {confirmationDetails.questions.map((q, idx) => { + const answer = getAnswerForQuestion(idx); + return ( + + + {q.header}:{' '} + {answer ? ( + {answer} + ) : ( + {t('(not answered)')} + )} + + + ); + })} + + + + {t('Ready to submit your answers?')} + + + {/* Submit/Cancel options */} + + + + {selectedIndex === 0 ? '❯ ' : ' '}1. {t('Submit answers')} + + + + + {selectedIndex === 1 ? '❯ ' : ' '}2. {t('Cancel')} + + + + + + + {t('↑/↓: Navigate | ←/→: Switch tabs | Enter: Select')} + + + + ); + } + + // Question tab + return ( + + {/* Tabs for multiple questions */} + {hasMultipleQuestions && ( + + {confirmationDetails.questions.map((q, idx) => { + const isAnswered = getAnswerForQuestion(idx) !== undefined; + return ( + + + {idx === currentQuestionIndex ? '▸ ' : ' '} + {q.header} + {isAnswered ? ' ✓' : ''} + + + ); + })} + + {t('Submit')} + + + )} + + {/* Question */} + + {!hasMultipleQuestions && ( + + + {currentQuestion!.header} + + + )} + {currentQuestion!.question} + + + {/* Options */} + + {currentQuestion!.options.map((opt, index) => { + const isSelected = selectedIndex === index; + const isMultiChecked = + isMultiSelect && + (multiSelectedOptions[currentQuestionIndex] ?? []).includes( + opt.label, + ); + const isAnswered = + !isMultiSelect && + selectedOptions[currentQuestionIndex] === opt.label; + const isHighlighted = isSelected || isAnswered || isMultiChecked; + // Calculate prefix width for description alignment: + // 2 (cursor) + checkbox (4 if multi) + number + ". " (2) + const prefixWidth = + 2 + (isMultiSelect ? 4 : 0) + String(index + 1).length + 2; + return ( + + + + {isSelected ? '❯ ' : ' '} + {isMultiSelect ? (isMultiChecked ? '[✓] ' : '[ ] ') : ''} + {index + 1}. {opt.label} + {isAnswered ? ' ✓' : ''} + + + {opt.description && ( + + {opt.description} + + )} + + ); + })} + + {/* Type something option/input */} + + {isCustomInputSelected ? ( + // Inline TextInput replaces the option text + + + ❯{' '} + {isMultiSelect + ? customInputChecked[currentQuestionIndex] + ? '[✓] ' + : '[ ] ' + : ''} + {currentQuestion!.options.length + 1}.{' '} + + { + const oldValue = + customInputValues[currentQuestionIndex] ?? ''; + if (isMultiSelect && value !== oldValue) { + setCustomInputChecked((prevChecked) => ({ + ...prevChecked, + [currentQuestionIndex]: value.trim().length > 0, + })); + } + setCustomInputValues((prev) => ({ + ...prev, + [currentQuestionIndex]: value, + })); + }} + onSubmit={handleCustomInputSubmit} + placeholder={t('Type something...')} + isActive={true} + inputWidth={50} + /> + + ) : ( + // Show typed value or placeholder when not selected + + + {' '} + {isMultiSelect + ? customInputChecked[currentQuestionIndex] + ? '[✓] ' + : '[ ] ' + : ''} + {currentQuestion!.options.length + 1}.{' '} + {currentCustomInputValue || t('Type something...')} + {isCustomInputAnswer ? ' ✓' : ''} + + + )} + + + + {/* Help text */} + + + + {hasMultipleQuestions + ? isMultiSelect + ? t( + '↑/↓: Navigate | ←/→: Switch tabs | Space: Toggle | Enter: Confirm | Esc: Cancel', + ) + : t( + '↑/↓: Navigate | ←/→: Switch tabs | Enter: Select | Esc: Cancel', + ) + : isMultiSelect + ? t( + '↑/↓: Navigate | Space: Toggle | Enter: Confirm | Esc: Cancel', + ) + : t('↑/↓: Navigate | Enter: Select | Esc: Cancel')} + + + + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx index b285b0a35..34eb34cac 100644 --- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx +++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx @@ -25,6 +25,7 @@ import { useKeypress } from '../../hooks/useKeypress.js'; import { useSettings } from '../../contexts/SettingsContext.js'; import { theme } from '../../semantic-colors.js'; import { t } from '../../../i18n/index.js'; +import { AskUserQuestionDialog } from './AskUserQuestionDialog.js'; export interface ToolConfirmationMessageProps { confirmationDetails: ToolCallConfirmationDetails; @@ -345,6 +346,15 @@ export const ToolConfirmationMessage: React.FC< )} ); + } else if (confirmationDetails.type === 'ask_user_question') { + // Use dedicated dialog for ask_user_question type + return ( + + ); } else { // mcp tool confirmation const mcpProps = confirmationDetails as ToolMcpConfirmationDetails; diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 40d471296..01ebc2fa0 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -26,6 +26,7 @@ export interface TextInputProps { isActive?: boolean; // when false, ignore keypresses validationErrors?: string[]; inputWidth?: number; + initialCursorOffset?: number; } export function TextInput({ @@ -37,6 +38,7 @@ export function TextInput({ isActive = true, validationErrors = [], inputWidth = 80, + initialCursorOffset, }: TextInputProps) { const allowMultiline = height > 1; @@ -51,6 +53,7 @@ export function TextInput({ const buffer = useTextBuffer({ initialText: value || '', + initialCursorOffset, viewport: { height, width: inputWidth }, isValidPath: () => false, onChange: stableOnChange, diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index 42f28f5e2..c4a5a6117 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -2526,6 +2526,77 @@ describe('useGeminiStream', () => { expect.any(String), ); }); + + it('should clear static error when starting a new query', async () => { + // First, mock a stream that yields an error (static error without countdown) + mockSendMessageStream.mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.Error, + value: { error: { message: 'First error' } }, + }; + })(), + ); + + const { result } = renderHook(() => + useGeminiStream( + new MockedGeminiClientClass(mockConfig), + [], + mockAddItem, + mockConfig, + mockLoadedSettings, + mockOnDebugMessage, + mockHandleSlashCommand, + false, + () => 'vscode' as EditorType, + () => {}, + () => Promise.resolve(), + false, + () => {}, + () => {}, + () => {}, + () => {}, + 80, + 24, + ), + ); + + // Submit first query that will fail + await act(async () => { + await result.current.submitQuery('First query'); + }); + + // Verify error appears in pending history items + await waitFor(() => { + const errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === 'error', + ); + expect(errorItem).toBeDefined(); + }); + + // Now mock a successful stream for the second query + mockSendMessageStream.mockReturnValueOnce( + (async function* () { + yield { + type: ServerGeminiEventType.Text, + value: 'Success response', + }; + })(), + ); + + // Submit second query + await act(async () => { + await result.current.submitQuery('Second query'); + }); + + // Verify the error is cleared (no longer in pending history items) + await waitFor(() => { + const errorItem = result.current.pendingHistoryItems.find( + (item) => item.type === 'error', + ); + expect(errorItem).toBeUndefined(); + }); + }); }); describe('Concurrent Execution Prevention', () => { diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 173065f41..1d0851501 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -1107,8 +1107,13 @@ export const useGeminiStream = ( if (!options?.isContinuation) { setModelSwitchedFromQuotaError(false); // Commit any pending retry error to history (without hint) since the - // user is starting a new conversation turn - if (pendingRetryCountdownItemRef.current) { + // user is starting a new conversation turn. + // Clear both countdown-based errors AND static errors (those without + // an active countdown timer, e.g. "Press Ctrl+Y to retry"). + if ( + pendingRetryCountdownItemRef.current || + pendingRetryErrorItemRef.current + ) { clearRetryCountdown(); } } @@ -1203,7 +1208,8 @@ export const useGeminiStream = ( } // Only clear auto-retry countdown errors (those with an active timer). // Do NOT clear static error+hint from handleErrorEvent — those should - // remain visible until the user presses Ctrl+Y to retry. + // remain visible until the user presses Ctrl+Y to retry or starts + // a new conversation turn (cleared in submitQuery). if (retryCountdownTimerRef.current) { clearRetryCountdown(); } @@ -1250,6 +1256,7 @@ export const useGeminiStream = ( handleLoopDetectedEvent, clearRetryCountdown, pendingRetryCountdownItemRef, + pendingRetryErrorItemRef, setPendingRetryErrorItem, ], ); diff --git a/packages/core/package.json b/packages/core/package.json index 43219cbcc..daa01de83 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-core", - "version": "0.12.0", + "version": "0.12.1", "description": "Qwen Code Core", "repository": { "type": "git", diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 6aa0f5d97..bfacde2a0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -42,6 +42,7 @@ import { import { GitService } from '../services/gitService.js'; // Tools +import { AskUserQuestionTool } from '../tools/askUserQuestion.js'; import { EditTool } from '../tools/edit.js'; import { ExitPlanModeTool } from '../tools/exitPlanMode.js'; import { GlobTool } from '../tools/glob.js'; @@ -1966,6 +1967,7 @@ export class Config { registerCoreTool(ShellTool, this); registerCoreTool(MemoryTool); registerCoreTool(TodoWriteTool, this); + registerCoreTool(AskUserQuestionTool, this); !this.sdkMode && registerCoreTool(ExitPlanModeTool, this); registerCoreTool(WebFetchTool, this); // Conditionally register web search tool if web search provider is configured diff --git a/packages/core/src/core/__snapshots__/prompts.test.ts.snap b/packages/core/src/core/__snapshots__/prompts.test.ts.snap index 2163ccb0c..504dd7e9e 100644 --- a/packages/core/src/core/__snapshots__/prompts.test.ts.snap +++ b/packages/core/src/core/__snapshots__/prompts.test.ts.snap @@ -63,6 +63,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -84,7 +87,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -282,6 +285,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -303,7 +309,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -511,6 +517,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -532,7 +541,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -725,6 +734,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -746,7 +758,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -939,6 +951,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -960,7 +975,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -1153,6 +1168,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -1174,7 +1192,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -1367,6 +1385,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -1388,7 +1409,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -1581,6 +1602,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -1602,7 +1626,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -1795,6 +1819,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -1816,7 +1843,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -2009,6 +2036,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -2030,7 +2060,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -2246,6 +2276,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -2267,7 +2300,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -2546,6 +2579,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -2567,7 +2603,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -2783,6 +2819,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -2804,7 +2843,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -3079,6 +3118,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -3100,7 +3142,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -3293,6 +3335,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ask_user_question tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -3314,7 +3359,7 @@ IMPORTANT: Always use the todo_write tool to plan and track tasks throughout the **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are 'write_file', 'edit' and 'run_shell_command'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ask_user_question tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index e504dc417..fb1cec38f 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -2576,55 +2576,84 @@ describe('truncateAndSaveToFile', () => { }); }); -describe('CoreToolScheduler PermissionRequest Hook Integration', () => { - it('should allow tool execution when hook grants permission', async () => { - const executeFn = vi.fn().mockResolvedValue({ - llmContent: 'Tool executed', - returnDisplay: 'Tool executed', - }); - const mockTool = new MockTool({ - name: 'mockTool', - execute: executeFn, - shouldConfirmExecute: MOCK_TOOL_SHOULD_CONFIRM_EXECUTE, - }); - const declarativeTool = mockTool; +describe('CoreToolScheduler plan mode with ask_user_question', () => { + function createAskUserQuestionMockTool() { + let wasAnswered = false; + let userAnswers: Record = {}; + return new MockTool({ + name: 'ask_user_question', + shouldConfirmExecute: async () => ({ + type: 'ask_user_question' as const, + title: 'Please answer the following question(s):', + questions: [ + { + question: 'Which approach do you prefer?', + header: 'Approach', + options: [ + { label: 'Option A', description: 'First approach' }, + { label: 'Option B', description: 'Second approach' }, + ], + multiSelect: false, + }, + ], + onConfirm: async ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => { + if ( + outcome === ToolConfirmationOutcome.ProceedOnce || + outcome === ToolConfirmationOutcome.ProceedAlways + ) { + wasAnswered = true; + userAnswers = payload?.answers ?? {}; + } else { + wasAnswered = false; + } + }, + }), + execute: async () => { + if (!wasAnswered) { + return { + llmContent: 'User declined to answer the questions.', + returnDisplay: 'User declined to answer the questions.', + }; + } + const answersContent = Object.entries(userAnswers) + .map(([key, value]) => `**Question ${key}**: ${value}`) + .join('\n'); + return { + llmContent: `User has provided the following answers:\n\n${answersContent}`, + returnDisplay: `User has provided the following answers:\n\n${answersContent}`, + }; + }, + }); + } + + function createPlanModeScheduler( + tool: MockTool, + onAllToolCallsComplete: ReturnType, + onToolCallsUpdate: ReturnType, + ) { const mockToolRegistry = { - getTool: () => declarativeTool, + getTool: () => tool, + getToolByName: () => tool, getFunctionDeclarations: () => [], tools: new Map(), discovery: {}, registerTool: () => {}, - getToolByName: () => declarativeTool, - getToolByDisplayName: () => declarativeTool, + getToolByDisplayName: () => tool, getTools: () => [], discoverTools: async () => {}, getAllTools: () => [], getToolsByServer: () => [], } as unknown as ToolRegistry; - const onAllToolCallsComplete = vi.fn(); - const onToolCallsUpdate = vi.fn(); - - const mockMessageBus = { - request: vi.fn().mockResolvedValue({ - success: true, - output: { - hookSpecificOutput: { - decision: { - behavior: 'allow', - }, - }, - message: 'Tool allowed by hook', - }, - }), - }; - const mockConfig = { getSessionId: () => 'test-session-id', getUsageStatisticsEnabled: () => true, getDebugMode: () => false, - getApprovalMode: () => ApprovalMode.DEFAULT, + getApprovalMode: () => ApprovalMode.PLAN, getAllowedTools: () => [], getContentGeneratorConfig: () => ({ model: 'test-model', @@ -2637,44 +2666,112 @@ describe('CoreToolScheduler PermissionRequest Hook Integration', () => { storage: { getProjectTempDir: () => '/tmp', }, - getToolRegistry: () => mockToolRegistry, getTruncateToolOutputThreshold: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + getToolRegistry: () => mockToolRegistry, getUseModelRouter: () => false, getGeminiClient: () => null, + isInteractive: () => true, + getIdeMode: () => false, + getExperimentalZedIntegration: () => false, getChatRecordingService: () => undefined, - getMessageBus: () => mockMessageBus, - getEnableHooks: () => true, - getHookSystem: vi.fn().mockReturnValue(undefined), - getDebugLogger: vi.fn().mockReturnValue({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - isInteractive: vi.fn().mockReturnValue(true), - getExperimentalZedIntegration: vi.fn().mockReturnValue(false), - getInputFormat: vi.fn().mockReturnValue('text'), } as unknown as Config; - const scheduler = new CoreToolScheduler({ + return new CoreToolScheduler({ config: mockConfig, onAllToolCallsComplete, onToolCallsUpdate, getPreferredEditor: () => 'vscode', onEditorClose: vi.fn(), }); + } + it('should enter awaiting_approval for ask_user_question in plan mode', async () => { + const mockTool = createAskUserQuestionMockTool(); + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + const scheduler = createPlanModeScheduler( + mockTool, + onAllToolCallsComplete, + onToolCallsUpdate, + ); + + const abortController = new AbortController(); const request = { callId: '1', - name: 'mockTool', - args: { param: 'value' }, + name: 'ask_user_question', + args: { + questions: [ + { + question: 'Which approach?', + header: 'Approach', + options: [ + { label: 'A', description: 'First' }, + { label: 'B', description: 'Second' }, + ], + multiSelect: false, + }, + ], + }, isClientInitiated: false, - prompt_id: 'prompt-id', + prompt_id: 'prompt-plan-ask', }; - await scheduler.schedule([request], new AbortController().signal); + await scheduler.schedule([request], abortController.signal); + + // Should enter awaiting_approval, NOT be directly scheduled + const awaitingCall = await waitForStatus( + onToolCallsUpdate, + 'awaiting_approval', + ); + expect(awaitingCall).toBeDefined(); + expect(awaitingCall.status).toBe('awaiting_approval'); + }); + + it('should execute successfully when user answers in plan mode', async () => { + const mockTool = createAskUserQuestionMockTool(); + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + const scheduler = createPlanModeScheduler( + mockTool, + onAllToolCallsComplete, + onToolCallsUpdate, + ); + + const abortController = new AbortController(); + const request = { + callId: '1', + name: 'ask_user_question', + args: { + questions: [ + { + question: 'Which approach?', + header: 'Approach', + options: [ + { label: 'A', description: 'First' }, + { label: 'B', description: 'Second' }, + ], + multiSelect: false, + }, + ], + }, + isClientInitiated: false, + prompt_id: 'prompt-plan-ask-answer', + }; + + await scheduler.schedule([request], abortController.signal); + + const awaitingCall = (await waitForStatus( + onToolCallsUpdate, + 'awaiting_approval', + )) as WaitingToolCall; + + // Simulate user answering the question + await awaitingCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.ProceedOnce, + { answers: { '0': 'Option A' } }, + ); await vi.waitFor(() => { expect(onAllToolCallsComplete).toHaveBeenCalled(); @@ -2683,108 +2780,36 @@ describe('CoreToolScheduler PermissionRequest Hook Integration', () => { const completedCalls = onAllToolCallsComplete.mock .calls[0][0] as ToolCall[]; expect(completedCalls[0].status).toBe('success'); - expect(executeFn).toHaveBeenCalledWith({ param: 'value' }); + if (completedCalls[0].status === 'success') { + expect(completedCalls[0].response.resultDisplay).toContain( + 'User has provided the following answers', + ); + } }); - it('should deny tool execution when hook denies permission', async () => { - const executeFn = vi.fn().mockResolvedValue({ - llmContent: 'Tool executed', - returnDisplay: 'Tool executed', - }); - const mockTool = new MockTool({ - name: 'mockTool', - execute: executeFn, + it('should block non-ask_user_question tools that need confirmation in plan mode', async () => { + const editTool = new MockTool({ + name: 'write_file', shouldConfirmExecute: MOCK_TOOL_SHOULD_CONFIRM_EXECUTE, }); - const declarativeTool = mockTool; - - const mockToolRegistry = { - getTool: () => declarativeTool, - getFunctionDeclarations: () => [], - tools: new Map(), - discovery: {}, - registerTool: () => {}, - getToolByName: () => declarativeTool, - getToolByDisplayName: () => declarativeTool, - getTools: () => [], - discoverTools: async () => {}, - getAllTools: () => [], - getToolsByServer: () => [], - } as unknown as ToolRegistry; - const onAllToolCallsComplete = vi.fn(); const onToolCallsUpdate = vi.fn(); - - const mockMessageBus = { - request: vi.fn().mockResolvedValue({ - success: true, - output: { - hookSpecificOutput: { - decision: { - behavior: 'deny', - message: 'Tool denied by hook', - }, - }, - message: 'Tool denied by hook', - }, - }), - }; - - const mockConfig = { - getSessionId: () => 'test-session-id', - getUsageStatisticsEnabled: () => true, - getDebugMode: () => false, - getApprovalMode: () => ApprovalMode.DEFAULT, - getAllowedTools: () => [], - getContentGeneratorConfig: () => ({ - model: 'test-model', - authType: 'gemini', - }), - getShellExecutionConfig: () => ({ - terminalWidth: 90, - terminalHeight: 30, - }), - storage: { - getProjectTempDir: () => '/tmp', - }, - getToolRegistry: () => mockToolRegistry, - getTruncateToolOutputThreshold: () => - DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - getUseModelRouter: () => false, - getGeminiClient: () => null, - getChatRecordingService: () => undefined, - getMessageBus: () => mockMessageBus, - getEnableHooks: () => true, - getHookSystem: vi.fn().mockReturnValue(undefined), - getDebugLogger: vi.fn().mockReturnValue({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - isInteractive: vi.fn().mockReturnValue(true), - getExperimentalZedIntegration: vi.fn().mockReturnValue(false), - getInputFormat: vi.fn().mockReturnValue('text'), - } as unknown as Config; - - const scheduler = new CoreToolScheduler({ - config: mockConfig, + const scheduler = createPlanModeScheduler( + editTool, onAllToolCallsComplete, onToolCallsUpdate, - getPreferredEditor: () => 'vscode', - onEditorClose: vi.fn(), - }); + ); + const abortController = new AbortController(); const request = { callId: '1', - name: 'mockTool', - args: { param: 'value' }, + name: 'write_file', + args: {}, isClientInitiated: false, - prompt_id: 'prompt-id', + prompt_id: 'prompt-plan-blocked', }; - await scheduler.schedule([request], new AbortController().signal); + await scheduler.schedule([request], abortController.signal); await vi.waitFor(() => { expect(onAllToolCallsComplete).toHaveBeenCalled(); @@ -2794,112 +2819,54 @@ describe('CoreToolScheduler PermissionRequest Hook Integration', () => { .calls[0][0] as ToolCall[]; expect(completedCalls[0].status).toBe('error'); if (completedCalls[0].status === 'error') { - expect(completedCalls[0].response.error?.message).toContain( - 'Tool denied by hook', + expect(completedCalls[0].response.resultDisplay).toBe( + 'Plan mode blocked a non-read-only tool call.', ); } - expect(executeFn).not.toHaveBeenCalled(); }); - it('should apply updated input from hook when permission is granted', async () => { - const executeFn = vi.fn().mockResolvedValue({ - llmContent: 'Tool executed', - returnDisplay: 'Tool executed', - }); - const mockTool = new MockTool({ - name: 'mockTool', - execute: executeFn, - shouldConfirmExecute: MOCK_TOOL_SHOULD_CONFIRM_EXECUTE, - }); - const declarativeTool = mockTool; - - const mockToolRegistry = { - getTool: () => declarativeTool, - getFunctionDeclarations: () => [], - tools: new Map(), - discovery: {}, - registerTool: () => {}, - getToolByName: () => declarativeTool, - getToolByDisplayName: () => declarativeTool, - getTools: () => [], - discoverTools: async () => {}, - getAllTools: () => [], - getToolsByServer: () => [], - } as unknown as ToolRegistry; - + it('should handle user cancellation of ask_user_question in plan mode', async () => { + const mockTool = createAskUserQuestionMockTool(); const onAllToolCallsComplete = vi.fn(); const onToolCallsUpdate = vi.fn(); - - const mockMessageBus = { - request: vi.fn().mockResolvedValue({ - success: true, - output: { - hookSpecificOutput: { - decision: { - behavior: 'allow', - updatedInput: { param: 'updated_value' }, - }, - }, - message: 'Tool allowed with updated input', - }, - }), - }; - - const mockConfig = { - getSessionId: () => 'test-session-id', - getUsageStatisticsEnabled: () => true, - getDebugMode: () => false, - getApprovalMode: () => ApprovalMode.DEFAULT, - getAllowedTools: () => [], - getContentGeneratorConfig: () => ({ - model: 'test-model', - authType: 'gemini', - }), - getShellExecutionConfig: () => ({ - terminalWidth: 90, - terminalHeight: 30, - }), - storage: { - getProjectTempDir: () => '/tmp', - }, - getToolRegistry: () => mockToolRegistry, - getTruncateToolOutputThreshold: () => - DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - getUseModelRouter: () => false, - getGeminiClient: () => null, - getChatRecordingService: () => undefined, - getMessageBus: () => mockMessageBus, - getEnableHooks: () => true, - getHookSystem: vi.fn().mockReturnValue(undefined), - getDebugLogger: vi.fn().mockReturnValue({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - isInteractive: vi.fn().mockReturnValue(true), - getExperimentalZedIntegration: vi.fn().mockReturnValue(false), - getInputFormat: vi.fn().mockReturnValue('text'), - } as unknown as Config; - - const scheduler = new CoreToolScheduler({ - config: mockConfig, + const scheduler = createPlanModeScheduler( + mockTool, onAllToolCallsComplete, onToolCallsUpdate, - getPreferredEditor: () => 'vscode', - onEditorClose: vi.fn(), - }); + ); + const abortController = new AbortController(); const request = { callId: '1', - name: 'mockTool', - args: { param: 'original_value' }, + name: 'ask_user_question', + args: { + questions: [ + { + question: 'Which approach?', + header: 'Approach', + options: [ + { label: 'A', description: 'First' }, + { label: 'B', description: 'Second' }, + ], + multiSelect: false, + }, + ], + }, isClientInitiated: false, - prompt_id: 'prompt-id', + prompt_id: 'prompt-plan-ask-cancel', }; - await scheduler.schedule([request], new AbortController().signal); + await scheduler.schedule([request], abortController.signal); + + const awaitingCall = (await waitForStatus( + onToolCallsUpdate, + 'awaiting_approval', + )) as WaitingToolCall; + + // Simulate user cancelling + await awaitingCall.confirmationDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + ); await vi.waitFor(() => { expect(onAllToolCallsComplete).toHaveBeenCalled(); @@ -2907,194 +2874,6 @@ describe('CoreToolScheduler PermissionRequest Hook Integration', () => { const completedCalls = onAllToolCallsComplete.mock .calls[0][0] as ToolCall[]; - expect(completedCalls[0].status).toBe('success'); - expect(executeFn).toHaveBeenCalledWith({ param: 'updated_value' }); - }); - - it('should skip hook when hooks are disabled', async () => { - const executeFn = vi.fn().mockResolvedValue({ - llmContent: 'Tool executed', - returnDisplay: 'Tool executed', - }); - const mockTool = new MockTool({ - name: 'mockTool', - execute: executeFn, - shouldConfirmExecute: MOCK_TOOL_SHOULD_CONFIRM_EXECUTE, - }); - const declarativeTool = mockTool; - - const mockToolRegistry = { - getTool: () => declarativeTool, - getFunctionDeclarations: () => [], - tools: new Map(), - discovery: {}, - registerTool: () => {}, - getToolByName: () => declarativeTool, - getToolByDisplayName: () => declarativeTool, - getTools: () => [], - discoverTools: async () => {}, - getAllTools: () => [], - getToolsByServer: () => [], - } as unknown as ToolRegistry; - - const onAllToolCallsComplete = vi.fn(); - const onToolCallsUpdate = vi.fn(); - - const mockMessageBus = { - request: vi.fn(), - }; - - const mockConfig = { - getSessionId: () => 'test-session-id', - getUsageStatisticsEnabled: () => true, - getDebugMode: () => false, - getApprovalMode: () => ApprovalMode.DEFAULT, - getAllowedTools: () => [], - getContentGeneratorConfig: () => ({ - model: 'test-model', - authType: 'gemini', - }), - getShellExecutionConfig: () => ({ - terminalWidth: 90, - terminalHeight: 30, - }), - storage: { - getProjectTempDir: () => '/tmp', - }, - getToolRegistry: () => mockToolRegistry, - getTruncateToolOutputThreshold: () => - DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - getUseModelRouter: () => false, - getGeminiClient: () => null, - getChatRecordingService: () => undefined, - getMessageBus: () => mockMessageBus, - getEnableHooks: () => false, - } as unknown as Config; - - const scheduler = new CoreToolScheduler({ - config: mockConfig, - onAllToolCallsComplete, - onToolCallsUpdate, - getPreferredEditor: () => 'vscode', - onEditorClose: vi.fn(), - }); - - const request = { - callId: '1', - name: 'mockTool', - args: { param: 'value' }, - isClientInitiated: false, - prompt_id: 'prompt-id', - }; - - await scheduler.schedule([request], new AbortController().signal); - - await vi.waitFor(() => { - expect(onAllToolCallsComplete).toHaveBeenCalled(); - }); - - expect(mockMessageBus.request).not.toHaveBeenCalled(); - }); - - it('should proceed to approval dialog when hook returns no decision', async () => { - const executeFn = vi.fn().mockResolvedValue({ - llmContent: 'Tool executed', - returnDisplay: 'Tool executed', - }); - const mockTool = new MockTool({ - name: 'mockTool', - execute: executeFn, - shouldConfirmExecute: MOCK_TOOL_SHOULD_CONFIRM_EXECUTE, - }); - const declarativeTool = mockTool; - - const mockToolRegistry = { - getTool: () => declarativeTool, - getFunctionDeclarations: () => [], - tools: new Map(), - discovery: {}, - registerTool: () => {}, - getToolByName: () => declarativeTool, - getToolByDisplayName: () => declarativeTool, - getTools: () => [], - discoverTools: async () => {}, - getAllTools: () => [], - getToolsByServer: () => [], - } as unknown as ToolRegistry; - - const onAllToolCallsComplete = vi.fn(); - const onToolCallsUpdate = vi.fn(); - - const mockMessageBus = { - request: vi.fn().mockResolvedValue({ - success: true, - output: {}, - }), - }; - - const mockConfig = { - getSessionId: () => 'test-session-id', - getUsageStatisticsEnabled: () => true, - getDebugMode: () => false, - getApprovalMode: () => ApprovalMode.DEFAULT, - getAllowedTools: () => [], - getContentGeneratorConfig: () => ({ - model: 'test-model', - authType: 'gemini', - }), - getShellExecutionConfig: () => ({ - terminalWidth: 90, - terminalHeight: 30, - }), - storage: { - getProjectTempDir: () => '/tmp', - }, - getToolRegistry: () => mockToolRegistry, - getTruncateToolOutputThreshold: () => - DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, - getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, - getUseModelRouter: () => false, - getGeminiClient: () => null, - getChatRecordingService: () => undefined, - getMessageBus: () => mockMessageBus, - getEnableHooks: () => true, - getHookSystem: vi.fn().mockReturnValue(undefined), - getDebugLogger: vi.fn().mockReturnValue({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - }), - isInteractive: vi.fn().mockReturnValue(true), - getExperimentalZedIntegration: vi.fn().mockReturnValue(false), - getInputFormat: vi.fn().mockReturnValue('text'), - } as unknown as Config; - - const scheduler = new CoreToolScheduler({ - config: mockConfig, - onAllToolCallsComplete, - onToolCallsUpdate, - getPreferredEditor: () => 'vscode', - onEditorClose: vi.fn(), - }); - - const request = { - callId: '1', - name: 'mockTool', - args: { param: 'value' }, - isClientInitiated: false, - prompt_id: 'prompt-id', - }; - - await scheduler.schedule([request], new AbortController().signal); - - await vi.waitFor(() => { - expect(onToolCallsUpdate).toHaveBeenCalled(); - }); - - const calls = onToolCallsUpdate.mock.calls; - const lastCall = calls[calls.length - 1]?.[0] as ToolCall[]; - expect(lastCall?.[0].status).toBe('awaiting_approval'); + expect(completedCalls[0].status).toBe('cancelled'); }); }); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 318efde95..43657e043 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -884,7 +884,13 @@ export class CoreToolScheduler { this.config.getApprovalMode() === ApprovalMode.PLAN; const isExitPlanModeTool = reqInfo.name === 'exit_plan_mode'; - if (isPlanMode && !isExitPlanModeTool) { + // ask_user_question needs the confirmation flow even in plan mode + // so the user can actually answer the questions + const isAskUserQuestionTool = + confirmationDetails && + confirmationDetails.type === 'ask_user_question'; + + if (isPlanMode && !isExitPlanModeTool && !isAskUserQuestionTool) { if (confirmationDetails) { this.setStatusInternal(reqInfo.callId, 'error', { callId: reqInfo.callId, @@ -901,8 +907,14 @@ export class CoreToolScheduler { this.setStatusInternal(reqInfo.callId, 'scheduled'); } } else if ( - this.config.getApprovalMode() === ApprovalMode.YOLO || - doesToolInvocationMatch(toolCall.tool, invocation, allowedTools) + (this.config.getApprovalMode() === ApprovalMode.YOLO || + doesToolInvocationMatch( + toolCall.tool, + invocation, + allowedTools, + )) && + // Even in YOLO mode, ask_user_question tool requires user confirmation to ensure the user always has a chance to respond to questions + confirmationDetails.type !== 'ask_user_question' ) { this.setToolCallOutcome( reqInfo.callId, diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index 5e13cf208..bdf4c6dc1 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -200,6 +200,9 @@ I've found some existing telemetry code. Let me mark the first todo as in_progre [Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] +# Asking questions as you work + +You have access to the ${ToolNames.ASK_USER_QUESTION} tool to ask the user questions when you need clarification, want to validate assumptions, or need to make a decision you're unsure about. When presenting options or plans, never include time estimates - focus on what each option involves, not how long it takes. # Primary Workflows @@ -221,7 +224,7 @@ IMPORTANT: Always use the ${ToolNames.TODO_WRITE} tool to plan and track tasks t **Goal:** Autonomously implement and deliver a visually appealing, substantially complete, and functional prototype. Utilize all tools at your disposal to implement the application. Some tools you may especially find useful are '${ToolNames.WRITE_FILE}', '${ToolNames.EDIT}' and '${ToolNames.SHELL}'. -1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. +1. **Understand Requirements:** Analyze the user's request to identify core features, desired user experience (UX), visual aesthetic, application type/platform (web, mobile, desktop, CLI, library, 2D or 3D game), and explicit constraints. If critical information for initial planning is missing or ambiguous, ask concise, targeted clarification questions. Use the ${ToolNames.ASK_USER_QUESTION} tool to ask questions, clarify and gather information as needed. 2. **Propose Plan:** Formulate an internal development plan. Present a clear, concise, high-level summary to the user. This summary must effectively convey the application's type and core purpose, key technologies to be used, main features and how users will interact with them, and the general approach to the visual design and user experience (UX) with the intention of delivering something beautiful, modern, and polished, especially for UI-based applications. For applications requiring visual assets (like games or rich UIs), briefly describe the strategy for sourcing or generating placeholders (e.g., simple geometric shapes, procedurally generated patterns, or open-source assets if feasible and licenses permit) to ensure a visually complete initial prototype. Ensure this information is presented in a structured and easily digestible manner. - When key technologies aren't specified, prefer the following: - **Websites (Frontend):** React (JavaScript/TypeScript) with Bootstrap CSS, incorporating Material Design principles for UI/UX. @@ -852,7 +855,7 @@ export function getPlanModeSystemReminder(planOnly = false): string { return ` Plan mode is active. The user indicated that they do not want you to execute yet -- you MUST NOT make any edits, run any non-readonly tools (including changing configs or making commits), or otherwise make any changes to the system. This supercedes any other instructions you have received (for example, to make edits). Instead, you should: 1. Answer the user's query comprehensively -2. When you're done researching, present your plan ${planOnly ? 'directly' : `by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan`}. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. +2. When you're done researching, present your plan ${planOnly ? 'directly' : `by calling the ${ToolNames.EXIT_PLAN_MODE} tool, which will prompt the user to confirm the plan`}. Do NOT make any file changes or run any tools that modify the system state in any way until the user has confirmed the plan. Use ${ToolNames.ASK_USER_QUESTION} if you need to clarify approaches. `; } diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 98639b197..6c333c9aa 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -92,7 +92,7 @@ export interface ClaudeMarketplaceConfig { } const CLAUDE_TOOLS_MAPPING: Record = { - AskUserQuestion: 'None', + AskUserQuestion: 'AskUserQuestion', Bash: 'Shell', BashOutput: 'None', Edit: 'Edit', 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), diff --git a/packages/core/src/mcp/oauth-utils.test.ts b/packages/core/src/mcp/oauth-utils.test.ts index 358213510..2e1bd8984 100644 --- a/packages/core/src/mcp/oauth-utils.test.ts +++ b/packages/core/src/mcp/oauth-utils.test.ts @@ -342,4 +342,85 @@ describe('OAuthUtils', () => { expect(result).toBe('https://mcp.alibaba-inc.com/yuque/mcp'); }); }); + + describe('discoverOAuthConfig', () => { + it('should use scopes from protected resource metadata when available', async () => { + // This test verifies the fix for the issue where scopes from + // protected resource metadata were not being used + const mockResourceMetadata: OAuthProtectedResourceMetadata = { + resource: 'https://www.modelscope.cn/mcp-server', + authorization_servers: ['https://www.modelscope.cn'], + scopes_supported: [ + 'openid', + 'profile', + 'list-operational-mcp', + 'manage-mcp-deployment', + ], + }; + + const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = { + issuer: 'https://www.modelscope.cn', + authorization_endpoint: 'https://www.modelscope.cn/oauth/authorize', + token_endpoint: 'https://www.modelscope.cn/oauth/token', + // Note: scopes_supported is NOT present in auth server metadata + }; + + mockFetch + // First call: fetch protected resource metadata + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResourceMetadata), + }) + // Second call: fetch authorization server metadata + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockAuthServerMetadata), + }); + + const result = await OAuthUtils.discoverOAuthConfig( + 'https://www.modelscope.cn/mcp-server', + ); + + expect(result).not.toBeNull(); + expect(result!.scopes).toEqual([ + 'openid', + 'profile', + 'list-operational-mcp', + 'manage-mcp-deployment', + ]); + }); + + it('should prefer protected resource scopes over auth server scopes', async () => { + const mockResourceMetadata: OAuthProtectedResourceMetadata = { + resource: 'https://example.com/mcp', + authorization_servers: ['https://auth.example.com'], + scopes_supported: ['mcp-read', 'mcp-write'], + }; + + const mockAuthServerMetadata: OAuthAuthorizationServerMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', + scopes_supported: ['read', 'write', 'admin'], // Different scopes + }; + + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockResourceMetadata), + }) + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve(mockAuthServerMetadata), + }); + + const result = await OAuthUtils.discoverOAuthConfig( + 'https://example.com/mcp', + ); + + expect(result).not.toBeNull(); + // Should use protected resource scopes, not auth server scopes + expect(result!.scopes).toEqual(['mcp-read', 'mcp-write']); + }); + }); }); diff --git a/packages/core/src/mcp/oauth-utils.ts b/packages/core/src/mcp/oauth-utils.ts index e5d5f3b74..8f60d4f82 100644 --- a/packages/core/src/mcp/oauth-utils.ts +++ b/packages/core/src/mcp/oauth-utils.ts @@ -38,6 +38,7 @@ export interface OAuthProtectedResourceMetadata { resource_signing_alg_values_supported?: string[]; resource_encryption_alg_values_supported?: string[]; resource_encryption_enc_values_supported?: string[]; + scopes_supported?: string[]; } /** @@ -251,6 +252,11 @@ export class OAuthUtils { if (authServerMetadata) { const config = this.metadataToOAuthConfig(authServerMetadata); + // Merge scopes from protected resource metadata (RFC 9728) + // Protected resource scopes take precedence as they define the specific access requirements + if (resourceMetadata.scopes_supported?.length) { + config.scopes = resourceMetadata.scopes_supported; + } if (authServerMetadata.registration_endpoint) { debugLogger.debug( `Dynamic client registration is supported at: ${authServerMetadata.registration_endpoint}`, @@ -325,7 +331,13 @@ export class OAuthUtils { await this.discoverAuthorizationServerMetadata(authServerUrl); if (authServerMetadata) { - return this.metadataToOAuthConfig(authServerMetadata); + const config = this.metadataToOAuthConfig(authServerMetadata); + // Merge scopes from protected resource metadata (RFC 9728) + // Protected resource scopes take precedence as they define the specific access requirements + if (resourceMetadata.scopes_supported?.length) { + config.scopes = resourceMetadata.scopes_supported; + } + return config; } return null; diff --git a/packages/core/src/tools/askUserQuestion.test.ts b/packages/core/src/tools/askUserQuestion.test.ts new file mode 100644 index 000000000..f9aabc2d9 --- /dev/null +++ b/packages/core/src/tools/askUserQuestion.test.ts @@ -0,0 +1,260 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { AskUserQuestionTool } from './askUserQuestion.js'; +import type { Config } from '../config/config.js'; +import { ApprovalMode } from '../config/config.js'; +import { ToolConfirmationOutcome } from './tools.js'; + +describe('AskUserQuestionTool', () => { + let mockConfig: Config; + let tool: AskUserQuestionTool; + + beforeEach(() => { + mockConfig = { + isInteractive: vi.fn().mockReturnValue(true), + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + getTargetDir: vi.fn().mockReturnValue('/mock/dir'), + getChatRecordingService: vi.fn(), + getExperimentalZedIntegration: vi.fn().mockReturnValue(false), + getInputFormat: vi.fn().mockReturnValue(undefined), + } as unknown as Config; + + tool = new AskUserQuestionTool(mockConfig); + }); + + describe('validateToolParams', () => { + it('should accept valid params with single question', () => { + const params = { + questions: [ + { + question: 'What is your favorite color?', + header: 'Color', + options: [ + { label: 'Red', description: 'The color red' }, + { label: 'Blue', description: 'The color blue' }, + ], + multiSelect: false, + }, + ], + }; + + const result = tool.validateToolParams(params); + expect(result).toBeNull(); + }); + + it('should reject params with too many questions', () => { + const params = { + questions: Array(5).fill({ + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }), + }; + + const result = tool.validateToolParams(params); + expect(result).toContain('between 1 and 4 questions'); + }); + + it('should reject question with header too long', () => { + const params = { + questions: [ + { + question: 'Test question?', + header: 'ThisHeaderIsTooLong', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const result = tool.validateToolParams(params); + expect(result).toContain('12 characters or less'); + }); + + it('should reject question with too few options', () => { + const params = { + questions: [ + { + question: 'Test question?', + header: 'Test', + options: [{ label: 'A', description: 'Only one option' }], + multiSelect: false, + }, + ], + }; + + const result = tool.validateToolParams(params); + expect(result).toContain('between 2 and 4 options'); + }); + }); + + describe('shouldConfirmExecute', () => { + it('should return confirmation details in interactive mode', async () => { + const params = { + questions: [ + { + question: 'Pick a framework?', + header: 'Framework', + options: [ + { label: 'React', description: 'A JavaScript library' }, + { label: 'Vue', description: 'Progressive framework' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(confirmation).not.toBe(false); + if (confirmation && confirmation.type === 'ask_user_question') { + expect(confirmation.type).toBe('ask_user_question'); + expect(confirmation.questions).toEqual(params.questions); + expect(confirmation.onConfirm).toBeDefined(); + } + }); + + it('should return false in non-interactive mode', async () => { + (mockConfig.isInteractive as Mock).mockReturnValue(false); + + const params = { + questions: [ + { + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + expect(confirmation).toBe(false); + }); + }); + + describe('execute', () => { + it('should return error in non-interactive mode', async () => { + (mockConfig.isInteractive as Mock).mockReturnValue(false); + + const params = { + questions: [ + { + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('non-interactive mode'); + expect(result.returnDisplay).toContain('non-interactive mode'); + }); + + it('should return cancellation message when user declines', async () => { + const params = { + questions: [ + { + question: 'Test?', + header: 'Test', + options: [ + { label: 'A', description: 'Option A' }, + { label: 'B', description: 'Option B' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + if (confirmation !== false) { + // Simulate user cancellation + await confirmation.onConfirm(ToolConfirmationOutcome.Cancel); + } + + const result = await invocation.execute(new AbortController().signal); + expect(result.llmContent).toContain('declined to answer'); + }); + + it('should return formatted answers when user provides them', async () => { + const params = { + questions: [ + { + question: 'Pick a framework?', + header: 'Framework', + options: [ + { label: 'React', description: 'A JavaScript library' }, + { label: 'Vue', description: 'Progressive framework' }, + ], + multiSelect: false, + }, + { + question: 'Pick a language?', + header: 'Language', + options: [ + { label: 'TypeScript', description: 'Typed JavaScript' }, + { label: 'JavaScript', description: 'Plain JS' }, + ], + multiSelect: false, + }, + ], + }; + + const invocation = tool.build(params); + const confirmation = await invocation.shouldConfirmExecute( + new AbortController().signal, + ); + + if (confirmation !== false) { + // Simulate user providing answers + await confirmation.onConfirm(ToolConfirmationOutcome.ProceedOnce, { + answers: { + '0': 'React', + '1': 'TypeScript', + }, + }); + } + + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toContain('Framework**: React'); + expect(result.llmContent).toContain('Language**: TypeScript'); + expect(result.returnDisplay).toContain( + 'has provided the following answers:', + ); + }); + }); +}); diff --git a/packages/core/src/tools/askUserQuestion.ts b/packages/core/src/tools/askUserQuestion.ts new file mode 100644 index 000000000..e1c6af26e --- /dev/null +++ b/packages/core/src/tools/askUserQuestion.ts @@ -0,0 +1,352 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + ToolAskUserQuestionConfirmationDetails, + ToolConfirmationPayload, + ToolResult, +} from './tools.js'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + Kind, + ToolConfirmationOutcome, +} from './tools.js'; +import type { FunctionDeclaration } from '@google/genai'; +import type { Config } from '../config/config.js'; +import { ToolDisplayNames, ToolNames } from './tool-names.js'; +import { createDebugLogger } from '../utils/debugLogger.js'; +import { InputFormat } from '../output/types.js'; + +const debugLogger = createDebugLogger('ASK_USER_QUESTION'); + +export interface QuestionOption { + label: string; + description: string; +} + +export interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; +} + +export interface AskUserQuestionParams { + questions: Question[]; + metadata?: { + source?: string; + }; +} + +const askUserQuestionToolDescription = `Use this tool when you need to ask the user questions during execution. This allows you to: +1. Gather user preferences or requirements +2. Clarify ambiguous instructions +3. Get decisions on implementation choices as you work +4. Offer choices to the user about what direction to take. + +Usage notes: +- Users will always be able to select "Other" to provide custom text input +- Use multiSelect: true to allow multiple answers to be selected for a question +- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label + +Plan mode note: In plan mode, use this tool to clarify requirements or choose between approaches BEFORE finalizing your plan. Do NOT use this tool to ask "Is this plan ready?" or "Should I proceed?" - use ExitPlanMode for plan approval. +`; + +const askUserQuestionToolSchemaData: FunctionDeclaration = { + name: 'ask_user_question', + description: askUserQuestionToolDescription, + parametersJsonSchema: { + $schema: 'https://json-schema.org/draft/2020-12/schema', + type: 'object', + properties: { + questions: { + description: 'Questions to ask the user (1-4 questions)', + minItems: 1, + maxItems: 4, + type: 'array', + items: { + type: 'object', + properties: { + question: { + description: + 'The complete question to ask the user. Should be clear, specific, and end with a question mark. Example: "Which library should we use for date formatting?" If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"', + type: 'string', + }, + header: { + description: + 'Very short label displayed as a chip/tag (max 12 chars). Examples: "Auth method", "Library", "Approach".', + type: 'string', + }, + options: { + description: + "The available choices for this question. Must have 2-4 options. Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). There should be no 'Other' option, that will be provided automatically.", + minItems: 2, + maxItems: 4, + type: 'array', + items: { + type: 'object', + properties: { + label: { + description: + 'The display text for this option that the user will see and select. Should be concise (1-5 words) and clearly describe the choice.', + type: 'string', + }, + description: { + description: + 'Explanation of what this option means or what will happen if chosen. Useful for providing context about trade-offs or implications.', + type: 'string', + }, + }, + required: ['label', 'description'], + additionalProperties: false, + }, + }, + multiSelect: { + description: + 'Set to true to allow the user to select multiple options instead of just one. Use when choices are not mutually exclusive.', + default: false, + type: 'boolean', + }, + }, + required: ['question', 'header', 'options', 'multiSelect'], + additionalProperties: false, + }, + }, + metadata: { + description: + 'Optional metadata for tracking and analytics purposes. Not displayed to user.', + type: 'object', + properties: { + source: { + description: + 'Optional identifier for the source of this question (e.g., "remember" for /remember command). Used for analytics tracking.', + type: 'string', + }, + }, + additionalProperties: false, + }, + }, + required: ['questions'], + additionalProperties: false, + }, +}; + +class AskUserQuestionToolInvocation extends BaseToolInvocation< + AskUserQuestionParams, + ToolResult +> { + private userAnswers: Record = {}; + private wasAnswered = false; + + constructor( + private readonly _config: Config, + params: AskUserQuestionParams, + ) { + super(params); + } + + getDescription(): string { + const questionCount = this.params.questions.length; + return `Ask user ${questionCount} question${questionCount > 1 ? 's' : ''}`; + } + + override async shouldConfirmExecute( + _abortSignal: AbortSignal, + ): Promise { + // Check if we're in a mode that supports user interaction + // ACP mode (VSCode extension, etc.) uses non-interactive mode but can still collect user input + const isAcpMode = + this._config.getExperimentalZedIntegration() || + this._config.getInputFormat() === InputFormat.STREAM_JSON; + + if (!this._config.isInteractive() && !isAcpMode) { + // In non-interactive mode without ACP support, we cannot collect user input + return false; + } + + const details: ToolAskUserQuestionConfirmationDetails = { + type: 'ask_user_question', + title: 'Please answer the following question(s):', + questions: this.params.questions, + metadata: this.params.metadata, + onConfirm: async ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => { + switch (outcome) { + case ToolConfirmationOutcome.ProceedOnce: + case ToolConfirmationOutcome.ProceedAlways: + this.wasAnswered = true; + this.userAnswers = payload?.answers ?? {}; + break; + case ToolConfirmationOutcome.Cancel: + this.wasAnswered = false; + break; + default: + this.wasAnswered = true; + this.userAnswers = payload?.answers ?? {}; + break; + } + }, + }; + + return details; + } + + async execute(_signal: AbortSignal): Promise { + try { + // Check if we're in a mode that supports user interaction + // ACP mode (VSCode extension, etc.) uses non-interactive mode but can still collect user input + const isAcpMode = + this._config.getExperimentalZedIntegration() || + this._config.getInputFormat() === InputFormat.STREAM_JSON; + + // In non-interactive mode without ACP support, we cannot collect user input + if (!this._config.isInteractive() && !isAcpMode) { + const errorMessage = + 'Cannot ask user questions in non-interactive mode without ACP support. Please run in interactive mode or enable ACP mode to use this tool.'; + return { + llmContent: errorMessage, + returnDisplay: errorMessage, + }; + } + + if (!this.wasAnswered) { + const cancellationMessage = 'User declined to answer the questions.'; + return { + llmContent: cancellationMessage, + returnDisplay: cancellationMessage, + }; + } + + // Format the answers for LLM consumption + const answersContent = Object.entries(this.userAnswers) + .map(([key, value]) => { + const questionIndex = parseInt(key, 10); + const question = this.params.questions[questionIndex]; + return `**${question?.header || `Question ${questionIndex + 1}`}**: ${value}`; + }) + .join('\n'); + + const llmMessage = `User has provided the following answers:\n\n${answersContent}`; + const displayMessage = `User has provided the following answers:\n\n${answersContent}`; + + return { + llmContent: llmMessage, + returnDisplay: displayMessage, + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + debugLogger.error( + `[AskUserQuestionTool] Error executing ask_user_question: ${errorMessage}`, + ); + + const errorLlmContent = `Failed to process user answers: ${errorMessage}`; + + return { + llmContent: errorLlmContent, + returnDisplay: `Error processing answers: ${errorMessage}`, + }; + } + } +} + +export class AskUserQuestionTool extends BaseDeclarativeTool< + AskUserQuestionParams, + ToolResult +> { + static readonly Name: string = ToolNames.ASK_USER_QUESTION; + + constructor(private readonly config: Config) { + super( + AskUserQuestionTool.Name, + ToolDisplayNames.ASK_USER_QUESTION, + askUserQuestionToolDescription, + Kind.Think, + askUserQuestionToolSchemaData.parametersJsonSchema as Record< + string, + unknown + >, + ); + } + + override validateToolParams(params: AskUserQuestionParams): string | null { + // Validate questions array + if (!Array.isArray(params.questions)) { + return 'Parameter "questions" must be an array.'; + } + + if (params.questions.length < 1 || params.questions.length > 4) { + return 'Parameter "questions" must contain between 1 and 4 questions.'; + } + + // Validate individual questions + for (let i = 0; i < params.questions.length; i++) { + const question = params.questions[i]; + + if ( + !question.question || + typeof question.question !== 'string' || + question.question.trim() === '' + ) { + return `Question ${i + 1}: "question" must be a non-empty string.`; + } + + if ( + !question.header || + typeof question.header !== 'string' || + question.header.trim() === '' + ) { + return `Question ${i + 1}: "header" must be a non-empty string.`; + } + + if (question.header.length > 12) { + return `Question ${i + 1}: "header" must be 12 characters or less.`; + } + + if (!Array.isArray(question.options)) { + return `Question ${i + 1}: "options" must be an array.`; + } + + if (question.options.length < 2 || question.options.length > 4) { + return `Question ${i + 1}: "options" must contain between 2 and 4 options.`; + } + + // Validate options + for (let j = 0; j < question.options.length; j++) { + const option = question.options[j]; + + if ( + !option.label || + typeof option.label !== 'string' || + option.label.trim() === '' + ) { + return `Question ${i + 1}, Option ${j + 1}: "label" must be a non-empty string.`; + } + + if ( + !option.description || + typeof option.description !== 'string' || + option.description.trim() === '' + ) { + return `Question ${i + 1}, Option ${j + 1}: "description" must be a non-empty string.`; + } + } + + if (typeof question.multiSelect !== 'boolean') { + return `Question ${i + 1}: "multiSelect" must be a boolean.`; + } + } + + return null; + } + + protected createInvocation(params: AskUserQuestionParams) { + return new AskUserQuestionToolInvocation(this.config, params); + } +} diff --git a/packages/core/src/tools/exitPlanMode.ts b/packages/core/src/tools/exitPlanMode.ts index d8b3df86f..0f06add54 100644 --- a/packages/core/src/tools/exitPlanMode.ts +++ b/packages/core/src/tools/exitPlanMode.ts @@ -24,11 +24,21 @@ export interface ExitPlanModeParams { } const exitPlanModeToolDescription = `Use this tool when you are in plan mode and have finished presenting your plan and are ready to code. This will prompt the user to exit plan mode. + +## When to Use This Tool IMPORTANT: Only use this tool when the task requires planning the implementation steps of a task that requires writing code. For research tasks where you're gathering information, searching files, reading files or in general trying to understand the codebase - do NOT use this tool. -Eg. +## Before Using This Tool +Ensure your plan is complete and unambiguous: +- If you have unresolved questions about requirements or approach, use AskUserQuestion first (in earlier phases) +- Once your plan is finalized, use THIS tool to request approval + +**Important:** Do NOT use AskUserQuestion to ask "Is this plan okay?" or "Should I proceed?" - that's exactly what THIS tool does. ExitPlanMode inherently requests user approval of your plan. + +## Examples 1. Initial task: "Search for and understand the implementation of vim mode in the codebase" - Do not use the exit plan mode tool because you are not planning the implementation steps of a task. 2. Initial task: "Help me implement yank mode for vim" - Use the exit plan mode tool after you have finished planning the implementation steps of the task. +3. Initial task: "Add a new feature to handle user authentication" - If unsure about auth method (OAuth, JWT, etc.), use AskUserQuestion first, then use exit plan mode tool after clarifying the approach. `; const exitPlanModeToolSchemaData: FunctionDeclaration = { diff --git a/packages/core/src/tools/tool-names.ts b/packages/core/src/tools/tool-names.ts index 3399f7d41..c118bffbd 100644 --- a/packages/core/src/tools/tool-names.ts +++ b/packages/core/src/tools/tool-names.ts @@ -25,6 +25,7 @@ export const ToolNames = { WEB_SEARCH: 'web_search', LS: 'list_directory', LSP: 'lsp', + ASK_USER_QUESTION: 'ask_user_question', } as const; /** @@ -48,6 +49,7 @@ export const ToolDisplayNames = { WEB_SEARCH: 'WebSearch', LS: 'ListFiles', LSP: 'Lsp', + ASK_USER_QUESTION: 'AskUserQuestion', } as const; // Migration from old tool names to new tool names diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 96ae53402..649b0cb4f 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -549,6 +549,8 @@ export interface ToolConfirmationPayload { newContent?: string; // used to provide custom cancellation message when outcome is Cancel cancelMessage?: string; + // used to pass user answers from ask_user_question tool + answers?: Record; } export interface ToolExecuteConfirmationDetails { @@ -587,7 +589,8 @@ export type ToolCallConfirmationDetails = | ToolExecuteConfirmationDetails | ToolMcpConfirmationDetails | ToolInfoConfirmationDetails - | ToolPlanConfirmationDetails; + | ToolPlanConfirmationDetails + | ToolAskUserQuestionConfirmationDetails; export interface ToolPlanConfirmationDetails { type: 'plan'; @@ -596,6 +599,27 @@ export interface ToolPlanConfirmationDetails { onConfirm: (outcome: ToolConfirmationOutcome) => Promise; } +export interface ToolAskUserQuestionConfirmationDetails { + type: 'ask_user_question'; + title: string; + questions: Array<{ + question: string; + header: string; + options: Array<{ + label: string; + description: string; + }>; + multiSelect: boolean; + }>; + metadata?: { + source?: string; + }; + onConfirm: ( + outcome: ToolConfirmationOutcome, + payload?: ToolConfirmationPayload, + ) => Promise; +} + /** * TODO: * 1. support explicit denied outcome diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json index e5f087f3c..b9ac81d5b 100644 --- a/packages/test-utils/package.json +++ b/packages/test-utils/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/qwen-code-test-utils", - "version": "0.12.0", + "version": "0.12.1", "private": true, "main": "src/index.ts", "license": "Apache-2.0", diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json index 79e6193df..179f345e0 100644 --- a/packages/vscode-ide-companion/package.json +++ b/packages/vscode-ide-companion/package.json @@ -2,7 +2,7 @@ "name": "qwen-code-vscode-ide-companion", "displayName": "Qwen Code Companion", "description": "Enable Qwen Code with direct access to your VS Code workspace.", - "version": "0.12.0", + "version": "0.12.1", "publisher": "qwenlm", "icon": "assets/icon.png", "repository": { diff --git a/packages/vscode-ide-companion/src/services/acpConnection.ts b/packages/vscode-ide-companion/src/services/acpConnection.ts index ce05d6d64..8c4994d14 100644 --- a/packages/vscode-ide-companion/src/services/acpConnection.ts +++ b/packages/vscode-ide-companion/src/services/acpConnection.ts @@ -27,7 +27,10 @@ import type { SetSessionModeResponse, SetSessionModelResponse, } from '@agentclientprotocol/sdk'; -import type { AuthenticateUpdateNotification } from '../types/acpTypes.js'; +import type { + AuthenticateUpdateNotification, + AskUserQuestionRequest, +} from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { ChildProcess, SpawnOptions } from 'child_process'; import { spawn } from 'child_process'; @@ -58,6 +61,10 @@ export class AcpConnection { onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void = () => {}; onEndTurn: (reason?: string) => void = () => {}; + onAskUserQuestion: (data: AskUserQuestionRequest) => Promise<{ + optionId: string; + answers?: Record; + }> = () => Promise.resolve({ optionId: 'cancel' }); onInitialized: (init: unknown) => void = () => {}; async connect( @@ -175,6 +182,52 @@ export class AcpConnection { ): Promise { const permissionData = params as unknown as RequestPermissionRequest; try { + // Check if this is an ask_user_question request by inspecting rawInput + const rawInput = permissionData.toolCall?.rawInput as + | Record + | undefined; + const isAskUserQuestion = Array.isArray(rawInput?.questions); + + if (isAskUserQuestion) { + // Handle ask_user_question separately via dedicated callback + const questions = (rawInput?.questions ?? + []) as AskUserQuestionRequest['questions']; + const metadata = + rawInput?.metadata as AskUserQuestionRequest['metadata']; + + const response = await self.onAskUserQuestion({ + sessionId: permissionData.sessionId, + questions, + metadata, + }); + + const optionId = response?.optionId; + const answers = response?.answers; + console.log('[ACP] AskUserQuestion response:', optionId); + + let outcome: 'selected' | 'cancelled'; + if ( + optionId && + (optionId.includes('reject') || optionId === 'cancel') + ) { + outcome = 'cancelled'; + } else { + outcome = 'selected'; + } + + if (outcome === 'cancelled') { + return { outcome: { outcome: 'cancelled' } }; + } + return { + outcome: { + outcome: 'selected', + optionId: optionId || 'proceed_once', + }, + answers, + } as RequestPermissionResponse; + } + + // Handle regular permission request const response = await self.onPermissionRequest(permissionData); const optionId = response?.optionId; console.log('[ACP] Permission request:', optionId); diff --git a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts index 38113dd08..4fb044a73 100644 --- a/packages/vscode-ide-companion/src/services/qwenAgentManager.ts +++ b/packages/vscode-ide-companion/src/services/qwenAgentManager.ts @@ -10,7 +10,10 @@ import type { RequestPermissionRequest, SessionNotification, } from '@agentclientprotocol/sdk'; -import type { AuthenticateUpdateNotification } from '../types/acpTypes.js'; +import type { + AuthenticateUpdateNotification, + AskUserQuestionRequest, +} from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import { QwenSessionReader, type QwenSession } from './qwenSessionReader.js'; import { QwenSessionManager } from './qwenSessionManager.js'; @@ -198,6 +201,16 @@ export class QwenAgentManager { return { optionId: this.resolvePermissionOptionId(data) || '' }; }; + this.connection.onAskUserQuestion = async ( + data: AskUserQuestionRequest, + ) => { + if (this.callbacks.onAskUserQuestion) { + const result = await this.callbacks.onAskUserQuestion(data); + return result; + } + return { optionId: 'cancel' }; + }; + this.connection.onEndTurn = (reason?: string) => { try { if (this.callbacks.onEndTurn) { @@ -1287,6 +1300,20 @@ export class QwenAgentManager { this.sessionUpdateHandler.updateCallbacks(this.callbacks); } + /** + * Register ask user question callback + * + * @param callback - Ask user question callback function + */ + onAskUserQuestion( + callback: ( + request: AskUserQuestionRequest, + ) => Promise<{ optionId: string; answers?: Record }>, + ): void { + this.callbacks.onAskUserQuestion = callback; + this.sessionUpdateHandler.updateCallbacks(this.callbacks); + } + /** * Register end-of-turn callback * diff --git a/packages/vscode-ide-companion/src/types/acpTypes.ts b/packages/vscode-ide-companion/src/types/acpTypes.ts index e22e8a726..9a6495237 100644 --- a/packages/vscode-ide-companion/src/types/acpTypes.ts +++ b/packages/vscode-ide-companion/src/types/acpTypes.ts @@ -45,3 +45,24 @@ export const NEXT_APPROVAL_MODE: { plan: 'yolo', yolo: 'default', }; + +// Ask User Question types +export interface QuestionOption { + label: string; + description: string; +} + +export interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; +} + +export interface AskUserQuestionRequest { + sessionId: string; + questions: Question[]; + metadata?: { + source?: string; + }; +} diff --git a/packages/vscode-ide-companion/src/types/chatTypes.ts b/packages/vscode-ide-companion/src/types/chatTypes.ts index 84c7bb9f8..3f73b2e2d 100644 --- a/packages/vscode-ide-companion/src/types/chatTypes.ts +++ b/packages/vscode-ide-companion/src/types/chatTypes.ts @@ -8,6 +8,7 @@ import type { AvailableCommand, RequestPermissionRequest, } from '@agentclientprotocol/sdk'; +import type { AskUserQuestionRequest } from './acpTypes.js'; import type { ApprovalModeValue } from './approvalModeValueTypes.js'; export interface ChatMessage { @@ -59,6 +60,9 @@ export interface QwenAgentCallbacks { onToolCall?: (update: ToolCallUpdateData) => void; onPlan?: (entries: PlanEntry[]) => void; onPermissionRequest?: (request: RequestPermissionRequest) => Promise; + onAskUserQuestion?: ( + request: AskUserQuestionRequest, + ) => Promise<{ optionId: string; answers?: Record }>; onEndTurn?: (reason?: string) => void; onModeInfo?: (info: { currentModeId?: ApprovalModeValue; diff --git a/packages/vscode-ide-companion/src/types/connectionTypes.ts b/packages/vscode-ide-companion/src/types/connectionTypes.ts index 1f4fec2ae..a20f31406 100644 --- a/packages/vscode-ide-companion/src/types/connectionTypes.ts +++ b/packages/vscode-ide-companion/src/types/connectionTypes.ts @@ -9,7 +9,10 @@ import type { RequestPermissionRequest, SessionNotification, } from '@agentclientprotocol/sdk'; -import type { AuthenticateUpdateNotification } from './acpTypes.js'; +import type { + AuthenticateUpdateNotification, + AskUserQuestionRequest, +} from './acpTypes.js'; export interface PendingRequest { resolve: (value: T) => void; @@ -25,6 +28,10 @@ export interface AcpConnectionCallbacks { }>; onAuthenticateUpdate: (data: AuthenticateUpdateNotification) => void; onEndTurn: (reason?: string) => void; + onAskUserQuestion: (data: AskUserQuestionRequest) => Promise<{ + optionId: string; + answers?: Record; + }>; } export interface AcpConnectionState { diff --git a/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts b/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts index f17f68170..76025b6b1 100644 --- a/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts +++ b/packages/vscode-ide-companion/src/types/webviewMessageTypes.ts @@ -12,3 +12,14 @@ export interface PermissionResponseMessage { type: string; data: PermissionResponsePayload; } + +export interface AskUserQuestionResponsePayload { + optionId?: string; + answers: Record; + cancelled?: boolean; +} + +export interface AskUserQuestionResponseMessage { + type: string; + data: AskUserQuestionResponsePayload; +} diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index cb1409af1..56b81d98c 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -45,10 +45,12 @@ import { ApprovalMode, NEXT_APPROVAL_MODE } from '../types/acpTypes.js'; import type { ApprovalModeValue } from '../types/approvalModeValueTypes.js'; import type { PlanEntry, UsageStatsPayload } from '../types/chatTypes.js'; import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; +import type { Question } from '../types/acpTypes.js'; import { DEFAULT_TOKEN_LIMIT, tokenLimit, } from '@qwen-code/qwen-code-core/src/core/tokenLimits.js'; +import { AskUserQuestionDialog } from '@qwen-code/webui'; export const App: React.FC = () => { const vscode = useVSCode(); @@ -70,6 +72,13 @@ export const App: React.FC = () => { options: PermissionOption[]; toolCall: PermissionToolCall; } | null>(null); + const [askUserQuestionRequest, setAskUserQuestionRequest] = useState<{ + questions: Question[]; + sessionId: string; + metadata?: { + source?: string; + }; + } | null>(null); const [planEntries, setPlanEntries] = useState([]); const [isAuthenticated, setIsAuthenticated] = useState(null); const [isLoading, setIsLoading] = useState(true); // Track if we're still initializing/loading @@ -331,6 +340,7 @@ export const App: React.FC = () => { clearToolCalls, setPlanEntries, handlePermissionRequest: setPermissionRequest, + handleAskUserQuestion: setAskUserQuestionRequest, inputFieldRef, setInputText, setEditMode, @@ -481,6 +491,31 @@ export const App: React.FC = () => { [vscode], ); + // Handle ask user question response + const handleAskUserQuestionResponse = useCallback( + (answers: Record) => { + // Forward answers to extension as ACP permission response + vscode.postMessage({ + type: 'askUserQuestionResponse', + data: { answers }, + }); + + setAskUserQuestionRequest(null); + }, + [vscode], + ); + + // Handle ask user question cancel + const handleAskUserQuestionCancel = useCallback(() => { + // Forward cancel to extension as ACP permission response with cancel option + vscode.postMessage({ + type: 'askUserQuestionResponse', + data: { answers: {}, cancelled: true }, + }); + + setAskUserQuestionRequest(null); + }, [vscode]); + // Handle completion selection const handleCompletionSelect = useCallback( (item: CompletionItem) => { @@ -1012,6 +1047,14 @@ export const App: React.FC = () => { onClose={() => setPermissionRequest(null)} /> )} + + {isAuthenticated && askUserQuestionRequest && ( + + )} ); }; diff --git a/packages/vscode-ide-companion/src/webview/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/MessageHandler.ts index 30b9abe56..b89c8fd86 100644 --- a/packages/vscode-ide-companion/src/webview/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/MessageHandler.ts @@ -6,7 +6,10 @@ import type { QwenAgentManager } from '../services/qwenAgentManager.js'; import type { ConversationStore } from '../services/conversationStore.js'; -import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js'; +import type { + PermissionResponseMessage, + AskUserQuestionResponseMessage, +} from '../types/webviewMessageTypes.js'; import { MessageRouter } from './handlers/MessageRouter.js'; /** @@ -61,6 +64,15 @@ export class MessageHandler { this.router.setPermissionHandler(handler); } + /** + * Set ask user question handler + */ + setAskUserQuestionHandler( + handler: (message: AskUserQuestionResponseMessage) => void, + ): void { + this.router.setAskUserQuestionHandler(handler); + } + /** * Set login handler */ diff --git a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts index eaf71f717..82e7cd415 100644 --- a/packages/vscode-ide-companion/src/webview/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/WebViewProvider.ts @@ -11,7 +11,11 @@ import type { RequestPermissionRequest, ModelInfo, } from '@agentclientprotocol/sdk'; -import type { PermissionResponseMessage } from '../types/webviewMessageTypes.js'; +import type { AskUserQuestionRequest } from '../types/acpTypes.js'; +import type { + PermissionResponseMessage, + AskUserQuestionResponseMessage, +} from '../types/webviewMessageTypes.js'; import { PanelManager } from '../webview/PanelManager.js'; import { MessageHandler } from '../webview/MessageHandler.js'; import { WebViewContent } from '../webview/WebViewContent.js'; @@ -31,6 +35,11 @@ export class WebViewProvider { // a diff, auto-allow read/execute, or auto-reject on cancel). private pendingPermissionRequest: RequestPermissionRequest | null = null; private pendingPermissionResolve: ((optionId: string) => void) | null = null; + // Track a pending ask user question request and its resolver + private pendingAskUserQuestionRequest: AskUserQuestionRequest | null = null; + private pendingAskUserQuestionResolve: + | ((result: { optionId: string; answers?: Record }) => void) + | null = null; // Track current ACP mode id to influence permission/diff behavior private currentModeId: ApprovalModeValue | null = null; private authState: boolean | null = null; @@ -44,7 +53,17 @@ export class WebViewProvider { this.agentManager = new QwenAgentManager(); this.conversationStore = new ConversationStore(context); this.panelManager = new PanelManager(extensionUri, () => { - // Panel dispose callback + // Panel dispose callback — unblock any pending ACP Promises + if (this.pendingPermissionResolve) { + this.pendingPermissionResolve('cancel'); + this.pendingPermissionResolve = null; + this.pendingPermissionRequest = null; + } + if (this.pendingAskUserQuestionResolve) { + this.pendingAskUserQuestionResolve({ optionId: 'cancel' }); + this.pendingAskUserQuestionResolve = null; + this.pendingAskUserQuestionRequest = null; + } this.disposables.forEach((d) => d.dispose()); }); this.messageHandler = new MessageHandler( @@ -409,6 +428,60 @@ export class WebViewProvider { }); }, ); + + this.agentManager.onAskUserQuestion( + async (request: AskUserQuestionRequest) => { + // Send ask user question request to WebView + this.sendMessageToWebView({ + type: 'askUserQuestion', + data: request, + }); + + // Wait for user response + return new Promise<{ + optionId: string; + answers?: Record; + }>((resolve) => { + // Cache the pending request and its resolver + this.pendingAskUserQuestionRequest = request; + this.pendingAskUserQuestionResolve = (result) => { + try { + resolve(result); + } finally { + // Always clear pending state + this.pendingAskUserQuestionRequest = null; + this.pendingAskUserQuestionResolve = null; + // Instruct the webview UI to close the dialog + this.sendMessageToWebView({ + type: 'askUserQuestionResolved', + data: { optionId: result.optionId }, + }); + } + }; + const handler = (message: AskUserQuestionResponseMessage) => { + if (message.type !== 'askUserQuestionResponse') { + return; + } + + const { optionId, answers, cancelled } = message.data; + + // Resolve with the result + if (cancelled) { + this.pendingAskUserQuestionResolve?.({ + optionId: 'cancel', + }); + } else { + this.pendingAskUserQuestionResolve?.({ + optionId: optionId || 'proceed_once', + answers, + }); + } + }; + // Store handler in message handler + this.messageHandler.setAskUserQuestionHandler(handler); + }); + }, + ); } async show(): Promise { @@ -1152,6 +1225,25 @@ export class WebViewProvider { } return; } + // Handle ask user question response + if (message.type === 'askUserQuestionResponse') { + const askUserQuestionMsg = message as AskUserQuestionResponseMessage; + const answers = askUserQuestionMsg.data.answers || {}; + const cancelled = askUserQuestionMsg.data.cancelled || false; + + // Resolve the pending ask user question promise + if (cancelled) { + this.pendingAskUserQuestionResolve?.({ + optionId: 'cancel', + }); + } else { + this.pendingAskUserQuestionResolve?.({ + optionId: 'proceed_once', + answers, + }); + } + return; + } await this.messageHandler.route(message); }, null, @@ -1340,6 +1432,17 @@ export class WebViewProvider { * Dispose the WebView provider and clean up resources */ dispose(): void { + // Unblock any pending ACP Promises before tearing down + if (this.pendingPermissionResolve) { + this.pendingPermissionResolve('cancel'); + this.pendingPermissionResolve = null; + this.pendingPermissionRequest = null; + } + if (this.pendingAskUserQuestionResolve) { + this.pendingAskUserQuestionResolve({ optionId: 'cancel' }); + this.pendingAskUserQuestionResolve = null; + this.pendingAskUserQuestionRequest = null; + } this.panelManager.dispose(); this.agentManager.disconnect(); this.disposables.forEach((d) => d.dispose()); diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts index de23fb1e5..9cb401b43 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -7,7 +7,10 @@ import type { IMessageHandler } from './BaseMessageHandler.js'; import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; import type { ConversationStore } from '../../services/conversationStore.js'; -import type { PermissionResponseMessage } from '../../types/webviewMessageTypes.js'; +import type { + PermissionResponseMessage, + AskUserQuestionResponseMessage, +} from '../../types/webviewMessageTypes.js'; import { SessionMessageHandler } from './SessionMessageHandler.js'; import { FileMessageHandler } from './FileMessageHandler.js'; import { EditorMessageHandler } from './EditorMessageHandler.js'; @@ -25,6 +28,9 @@ export class MessageRouter { private permissionHandler: | ((message: PermissionResponseMessage) => void) | null = null; + private askUserQuestionHandler: + | ((message: AskUserQuestionResponseMessage) => void) + | null = null; constructor( agentManager: QwenAgentManager, @@ -86,6 +92,14 @@ export class MessageRouter { return; } + // Handle ask user question response specially + if (message.type === 'askUserQuestionResponse') { + if (this.askUserQuestionHandler) { + this.askUserQuestionHandler(message as AskUserQuestionResponseMessage); + } + return; + } + // Find appropriate handler const handler = this.handlers.find((h) => h.canHandle(message.type)); @@ -135,6 +149,15 @@ export class MessageRouter { this.permissionHandler = handler; } + /** + * Set ask user question handler + */ + setAskUserQuestionHandler( + handler: (message: AskUserQuestionResponseMessage) => void, + ): void { + this.askUserQuestionHandler = handler; + } + /** * Set login handler */ diff --git a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts index 0658aee20..4400c54b4 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useWebViewMessages.ts @@ -15,6 +15,7 @@ import type { import type { ApprovalModeValue } from '../../types/approvalModeValueTypes.js'; import type { PlanEntry } from '../../types/chatTypes.js'; import type { ModelInfo, AvailableCommand } from '@agentclientprotocol/sdk'; +import type { Question } from '../../types/acpTypes.js'; const FORCE_CLEAR_STREAM_END_REASONS = new Set([ 'user_cancelled', @@ -111,6 +112,17 @@ interface UseWebViewMessagesProps { } | null, ) => void; + // Ask User Question + handleAskUserQuestion: ( + request: { + questions: Question[]; + sessionId: string; + metadata?: { + source?: string; + }; + } | null, + ) => void; + // Input inputFieldRef: React.RefObject; setInputText: (text: string) => void; @@ -140,6 +152,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + handleAskUserQuestion, inputFieldRef, setInputText, setEditMode, @@ -164,6 +177,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + handleAskUserQuestion, setIsAuthenticated, setUsageStats, setModelInfo, @@ -213,6 +227,7 @@ export const useWebViewMessages = ({ clearToolCalls, setPlanEntries, handlePermissionRequest, + handleAskUserQuestion, setIsAuthenticated, setUsageStats, setModelInfo, @@ -627,6 +642,19 @@ export const useWebViewMessages = ({ break; } + case 'askUserQuestion': { + // Handle ask user question request from extension + const questionsData = message.data as { + questions: Question[]; + sessionId: string; + metadata?: { + source?: string; + }; + }; + handlers.handleAskUserQuestion(questionsData); + break; + } + case 'plan': if (message.data.entries && Array.isArray(message.data.entries)) { const entries = message.data.entries as PlanEntry[]; diff --git a/packages/web-templates/package.json b/packages/web-templates/package.json index 740b966b8..c4416be38 100644 --- a/packages/web-templates/package.json +++ b/packages/web-templates/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/web-templates", - "version": "0.12.0", + "version": "0.12.1", "description": "Web templates bundled as embeddable JS/CSS strings", "repository": { "type": "git", diff --git a/packages/webui/package.json b/packages/webui/package.json index f2d26978b..7826ce2b2 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/webui", - "version": "0.12.0", + "version": "0.12.1", "description": "Shared UI components for Qwen Code packages", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/webui/src/components/messages/AskUserQuestionDialog.tsx b/packages/webui/src/components/messages/AskUserQuestionDialog.tsx new file mode 100644 index 000000000..d30926d99 --- /dev/null +++ b/packages/webui/src/components/messages/AskUserQuestionDialog.tsx @@ -0,0 +1,525 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + * + * AskUserQuestionDialog component for displaying questions to the user + * and collecting their responses in the WebView + */ + +import type { FC } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; + +export interface QuestionOption { + label: string; + description: string; +} + +export interface Question { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; +} + +export interface AskUserQuestionDialogProps { + questions: Question[]; + onSubmit: (answers: Record) => void; + onCancel: () => void; +} + +interface AnswerState { + selectedOption?: string; + customInput?: string; + multiSelectedOptions?: string[]; + customInputChecked?: boolean; +} + +export const AskUserQuestionDialog: FC = ({ + questions, + onSubmit, + onCancel, +}) => { + const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); + const [answers, setAnswers] = useState>({}); + const [showCustomInput, setShowCustomInput] = useState(false); + const containerRef = useRef(null); + const customInputRef = useRef(null); + + const hasMultipleQuestions = questions.length > 1; + const totalTabs = hasMultipleQuestions + ? questions.length + 1 + : questions.length; + const isSubmitTab = + hasMultipleQuestions && currentQuestionIndex === totalTabs - 1; + + const currentQuestion = isSubmitTab ? null : questions[currentQuestionIndex]; + const isMultiSelect = currentQuestion?.multiSelect ?? false; + + // Get current answer state + const currentAnswer = answers[currentQuestionIndex] || {}; + + // Get answer for a specific question + const getAnswerForQuestion = useCallback( + (idx: number): string | undefined => { + const q = questions[idx]; + const answerState = answers[idx]; + if (!answerState) { + return undefined; + } + + if (q?.multiSelect) { + const selections = [...(answerState.multiSelectedOptions || [])]; + const customValue = (answerState.customInput || '').trim(); + if (answerState.customInputChecked && customValue) { + selections.push(customValue); + } + return selections.length > 0 ? selections.join(', ') : undefined; + } + + // Check if custom input was used (value doesn't match any option) + if (answerState.customInput && answerState.customInput.trim()) { + const matchesOption = q?.options.some( + (opt) => opt.label === answerState.customInput?.trim(), + ); + if (!matchesOption) { + return answerState.customInput.trim(); + } + } + + return answerState.selectedOption; + }, + [questions, answers], + ); + + // Handle submitting all answers + const handleSubmit = useCallback(() => { + const answersRecord: Record = {}; + questions.forEach((_, idx) => { + const answer = getAnswerForQuestion(idx); + if (answer !== undefined) { + answersRecord[idx] = answer; + } + }); + onSubmit(answersRecord); + }, [questions, onSubmit, getAnswerForQuestion]); + + // Handle confirming multi-select for current question + const handleMultiSelectConfirm = useCallback(() => { + if (!currentQuestion) { + return; + } + + const answerState = answers[currentQuestionIndex] || {}; + const selections = [...(answerState.multiSelectedOptions || [])]; + const customValue = (answerState.customInput || '').trim(); + if (answerState.customInputChecked && customValue) { + selections.push(customValue); + } + if (selections.length === 0) { + return; + } + + const value = selections.join(', '); + + const updatedAnswers = { + ...answers, + [currentQuestionIndex]: { + ...answerState, + selectedOption: value, + }, + }; + setAnswers(updatedAnswers); + + if (!hasMultipleQuestions) { + onSubmit({ [currentQuestionIndex]: value }); + } else if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setShowCustomInput(false); + } + }, [ + currentQuestion, + answers, + currentQuestionIndex, + hasMultipleQuestions, + totalTabs, + onSubmit, + ]); + + // Handle option selection + const handleOptionSelect = useCallback( + (optionIndex: number) => { + if (!currentQuestion) { + return; + } + + if (isMultiSelect) { + const answerState = answers[currentQuestionIndex] || {}; + const current = answerState.multiSelectedOptions || []; + const option = currentQuestion.options[optionIndex]; + const isChecked = current.includes(option.label); + const updated = isChecked + ? current.filter((l) => l !== option.label) + : [...current, option.label]; + + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + multiSelectedOptions: updated, + }, + }); + } else { + const option = currentQuestion.options[optionIndex]; + const answerState = answers[currentQuestionIndex] || {}; + const updated = { + ...answerState, + selectedOption: option.label, + customInput: undefined, + }; + setAnswers({ ...answers, [currentQuestionIndex]: updated }); + + if (!hasMultipleQuestions) { + onSubmit({ [currentQuestionIndex]: option.label }); + } else if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setShowCustomInput(false); + } + } + }, + [ + currentQuestion, + isMultiSelect, + answers, + currentQuestionIndex, + hasMultipleQuestions, + totalTabs, + onSubmit, + ], + ); + + // Handle custom input change + const handleCustomInputChange = (value: string) => { + const answerState = answers[currentQuestionIndex] || {}; + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + customInput: value, + customInputChecked: isMultiSelect && value.trim().length > 0, + }, + }); + }; + + // Handle custom input submit + const handleCustomInputSubmit = () => { + const value = currentAnswer.customInput?.trim() || ''; + if (!value) { + return; + } + + if (isMultiSelect) { + const answerState = answers[currentQuestionIndex] || {}; + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + customInputChecked: !answerState.customInputChecked, + }, + }); + } else { + const answerState = answers[currentQuestionIndex] || {}; + const updated = { + ...answerState, + selectedOption: value, + }; + setAnswers({ ...answers, [currentQuestionIndex]: updated }); + + if (!hasMultipleQuestions) { + onSubmit({ [currentQuestionIndex]: value }); + } else if (currentQuestionIndex < totalTabs - 1) { + setCurrentQuestionIndex(currentQuestionIndex + 1); + setShowCustomInput(false); + } + } + }; + + // Escape to cancel + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [onCancel]); + + // Focus custom input when shown + useEffect(() => { + if (showCustomInput && customInputRef.current) { + customInputRef.current.focus(); + } + }, [showCustomInput]); + + // Reset custom input visibility when switching tabs + useEffect(() => { + setShowCustomInput(false); + }, [currentQuestionIndex]); + + // Shared tab bar renderer + const renderTabs = () => ( +
+ {questions.map((q, idx) => { + const isAnswered = getAnswerForQuestion(idx) !== undefined; + const isActive = idx === currentQuestionIndex; + return ( + + ); + })} + +
+ ); + + // Container style + const containerStyle = { + backgroundColor: 'var(--app-input-secondary-background)', + borderColor: 'var(--app-input-border)', + boxShadow: '0 4px 12px rgba(0, 0, 0, 0.15)', + }; + + // Render submit tab + if (isSubmitTab) { + return ( +
+ {renderTabs()} + + {/* Show selected answers */} +
+
+ Your answers: +
+ {questions.map((q, idx) => { + const answer = getAnswerForQuestion(idx); + return ( +
+ {q.header}:{' '} + {answer ? ( + + {answer} + + ) : ( + (not answered) + )} +
+ ); + })} +
+ + {/* Submit/Cancel buttons */} +
+ + +
+
+ ); + } + + // Render question tab + return ( +
+ {/* Tabs for multiple questions */} + {hasMultipleQuestions && renderTabs()} + + {/* Question */} +
+ {!hasMultipleQuestions && ( +
+ + {currentQuestion!.header} + +
+ )} +
+ {currentQuestion!.question} +
+
+ + {/* Options */} +
+ {currentQuestion!.options.map((opt, index) => { + const isSelected = + !isMultiSelect && currentAnswer.selectedOption === opt.label; + const isMultiChecked = + isMultiSelect && + currentAnswer.multiSelectedOptions?.includes(opt.label); + + return ( +
+ + {opt.description && ( +
+ {opt.description} +
+ )} +
+ ); + })} + + {/* Custom input ("Other") */} +
+ {showCustomInput ? ( +
+ {isMultiSelect && ( + { + const answerState = answers[currentQuestionIndex] || {}; + setAnswers({ + ...answers, + [currentQuestionIndex]: { + ...answerState, + customInputChecked: !answerState.customInputChecked, + }, + }); + }} + > + {currentAnswer.customInputChecked ? '☑' : '☐'} + + )} + handleCustomInputChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + e.stopPropagation(); + handleCustomInputSubmit(); + } + }} + placeholder="Type your answer..." + /> +
+ ) : ( + + )} +
+
+ + {/* Action buttons */} +
+ {isMultiSelect && ( + + )} + +
+
+ ); +}; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 330c0cb6d..39e0a8cbf 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -86,6 +86,12 @@ export type { CollapsibleFileContentProps, ContentSegment, } from './components/messages/CollapsibleFileContent'; +export { AskUserQuestionDialog } from './components/messages/AskUserQuestionDialog'; +export type { + AskUserQuestionDialogProps, + Question, + QuestionOption, +} from './components/messages/AskUserQuestionDialog'; // ChatViewer - standalone chat display component export {