diff --git a/.changeset/agent-manager-terminal-rendering.md b/.changeset/agent-manager-terminal-rendering.md new file mode 100644 index 00000000000..c375a577662 --- /dev/null +++ b/.changeset/agent-manager-terminal-rendering.md @@ -0,0 +1,5 @@ +--- +"kilo-code": minor +--- + +Improved command output rendering in Agent Manager with new CommandExecutionBlock component that displays terminal output with status indicators, collapsible output sections, and proper escape sequence handling. diff --git a/.changeset/green-cobras-dress.md b/.changeset/green-cobras-dress.md new file mode 100644 index 00000000000..83017648a53 --- /dev/null +++ b/.changeset/green-cobras-dress.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix text.startsWith is not a function crash diff --git a/.changeset/tall-rockets-brake.md b/.changeset/tall-rockets-brake.md new file mode 100644 index 00000000000..bb010658435 --- /dev/null +++ b/.changeset/tall-rockets-brake.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Normalize line endings in search and replace tool diff --git a/.kilocode/rules-translate/001-general-rules.md b/.kilocode/rules-translate/001-general-rules.md deleted file mode 100644 index 04000c73a17..00000000000 --- a/.kilocode/rules-translate/001-general-rules.md +++ /dev/null @@ -1,106 +0,0 @@ -# 1. SUPPORTED LANGUAGES AND LOCATION - -- Localize all strings into the following locale files: ar, ca, cs, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, th, tr, uk, vi, zh-CN, zh-TW -- The VSCode extension has two main areas that require localization: - - Core Extension: src/i18n/locales/, src/package.nls.json, src/package.nls..json (extension backend) - - WebView UI: webview-ui/src/i18n/locales/ (user interface) - -# 2. VOICE, STYLE AND TONE - -- Always use informal speech (e.g., "du" instead of "Sie" in German) for all translations -- Maintain a direct and concise style that mirrors the tone of the original text -- Carefully account for colloquialisms and idiomatic expressions in both source and target languages -- Aim for culturally relevant and meaningful translations rather than literal translations -- Preserve the personality and voice of the original content -- Use natural-sounding language that feels native to speakers of the target language -- Don't translate the word "token" as it means something specific in English that all languages will understand -- Don't translate domain-specific words (especially technical terms like "Prompt") that are commonly used in English in the target language - -# 3. CORE EXTENSION LOCALIZATION (src/) - -- Located in src/i18n/locales/ -- NOT ALL strings in core source need internationalization - only user-facing messages -- Internal error messages, debugging logs, and developer-facing messages should remain in English -- The t() function is used with namespaces like 'core:errors.missingToolParameter' -- Be careful when modifying interpolation variables; they must remain consistent across all translations -- Some strings in formatResponse.ts are intentionally not internationalized since they're internal -- When updating strings in core.json, maintain all existing interpolation variables -- Check string usages in the codebase before making changes to ensure you're not breaking functionality - -# 4. WEBVIEW UI LOCALIZATION (webview-ui/src/) - -- Located in webview-ui/src/i18n/locales/ -- Uses standard React i18next patterns with the useTranslation hook -- All user interface strings should be internationalized -- Always use the Trans component with named components for text with embedded components - - example: - -`"changeSettings": "You can always change this at the bottom of the settings",` - -``` - - }} - /> -``` - -# 5. TECHNICAL IMPLEMENTATION - -- Use namespaces to organize translations logically -- Handle pluralization using i18next's built-in capabilities -- Implement proper interpolation for variables using {{variable}} syntax -- Don't include defaultValue. The `en` translations are the fallback -- Always use apply_diff instead of write_to_file when editing existing translation files (much faster and more reliable) -- When using apply_diff, carefully identify the exact JSON structure to edit to avoid syntax errors -- Placeholders (like {{variable}}) must remain exactly identical to the English source to maintain code integration and prevent syntax errors - -# 6. WORKFLOW AND APPROACH - -- First add or modify English strings, then ask for confirmation before translating to all other languages -- Use this process for each localization task: - 1. Identify where the string appears in the UI/codebase - 2. Understand the context and purpose of the string - 3. Update English translation first - 4. Use the `` tool to find JSON keys that are near new keys in English translations but do not yet exist in the other language files for `` SEARCH context - 5. Create appropriate translations for all other supported languages utilizing the `search_files` result using `` without reading every file. - 6. Do not output the translated text into the chat, just modify the files. - 7. Validate your changes with the missing translations script -- Flag or comment if an English source string is incomplete ("please see this...") to avoid truncated or unclear translations -- For UI elements, distinguish between: - - Button labels: Use short imperative commands ("Save", "Cancel") - - Tooltip text: Can be slightly more descriptive -- Preserve the original perspective: If text is a user command directed at the software, ensure the translation maintains this direction, avoiding language that makes it sound like an instruction from the system to the user - -# 7. COMMON PITFALLS TO AVOID - -- Switching between formal and informal addressing styles - always stay informal ("du" not "Sie") -- Translating or altering technical terms and brand names that should remain in English -- Modifying or removing placeholders like {{variable}} - these must remain identical -- Translating domain-specific terms that are commonly used in English in the target language -- Changing the meaning or nuance of instructions or error messages -- Forgetting to maintain consistent terminology throughout the translation - -# 8. QUALITY ASSURANCE - -- Maintain consistent terminology across all translations -- Respect the JSON structure of translation files -- Watch for placeholders and preserve them in translations -- Be mindful of text length in UI elements when translating to languages that might require more characters -- Use context-aware translations when the same string has different meanings -- Always validate your translation work by running the missing translations script: - ``` - node scripts/find-missing-translations.js - ``` -- Address any missing translations identified by the script to ensure complete coverage across all locales - -# 9. TRANSLATOR'S CHECKLIST - -- ✓ Used informal tone consistently ("du" not "Sie") -- ✓ Preserved all placeholders exactly as in the English source -- ✓ Maintained consistent terminology with existing translations -- ✓ Kept technical terms and brand names unchanged where appropriate -- ✓ Preserved the original perspective (user→system vs system→user) -- ✓ Adapted the text appropriately for UI context (buttons vs tooltips) diff --git a/.kilocode/rules-translate/instructions-de.md b/.kilocode/rules-translate/instructions-de.md deleted file mode 100644 index 1268424832c..00000000000 --- a/.kilocode/rules-translate/instructions-de.md +++ /dev/null @@ -1,14 +0,0 @@ -# German (de) Translation Guidelines - -**Key Rule:** Always use informal speech ("du" form) in all German translations without exception. - -## Quick Reference - -| Category | Formal (Avoid) | Informal (Use) | Example | -| ----------- | ------------------------- | ------------------- | ----------------- | -| Pronouns | Sie | du | you | -| Possessives | Ihr/Ihre/Ihrem | dein/deine/deinem | your | -| Verbs | können Sie, müssen Sie | kannst du, musst du | you can, you must | -| Imperatives | Geben Sie ein, Wählen Sie | Gib ein, Wähle | Enter, Choose | - -**Technical terms** like "API", "token", "prompt" should not be translated. diff --git a/.kilocode/rules-translate/instructions-zh-tw.md b/.kilocode/rules-translate/instructions-zh-tw.md deleted file mode 100644 index ee4d07a07a4..00000000000 --- a/.kilocode/rules-translate/instructions-zh-tw.md +++ /dev/null @@ -1,18 +0,0 @@ -# Traditional Chinese (zh-TW) Translation Guidelines - -## Key Terminology - -| English Term | Use (zh-TW) | Avoid (Mainland) | -| ------------- | ----------- | ---------------- | -| file | 檔案 | 文件 | -| task | 工作 | 任務 | -| project | 專案 | 項目 | -| configuration | 設定 | 配置 | -| server | 伺服器 | 服務器 | -| import/export | 匯入/匯出 | 導入/導出 | - -## Formatting Rules - -- Add spaces between Chinese and English/numbers: "AI 驅動" (not "AI驅動") -- Use Traditional Chinese quotation marks: 「範例文字」(not "範例文字") -- Use Taiwanese computing conventions rather than mainland terminology diff --git a/.kilocode/workflows/add-missing-translations.md b/.kilocode/workflows/add-missing-translations.md deleted file mode 100644 index 58ec4807b27..00000000000 --- a/.kilocode/workflows/add-missing-translations.md +++ /dev/null @@ -1,10 +0,0 @@ -# Add missing translations - -This workflow requires Orchestrator mode. - -Execute `node scripts/find-missing-translations.js` in Code mode to find all missing translations. - -For each language that is missing translations: - -- For each JSON file that is missing translations: - - Start a separate subtask in Translate mode for this language and JSON file to add the missing translations. Do not try to process mutliple languages or JSON files in one subtask. diff --git a/.kilocode/rules-translate/instructions-zh-cn.md b/.roo/rules-translate/AGENTS.md similarity index 52% rename from .kilocode/rules-translate/instructions-zh-cn.md rename to .roo/rules-translate/AGENTS.md index 3e8d48649d0..add4a837b8d 100644 --- a/.kilocode/rules-translate/instructions-zh-cn.md +++ b/.roo/rules-translate/AGENTS.md @@ -1,6 +1,151 @@ -# Simplified Chinese (zh-CN) Translation Guidelines +# AGENTS.md -## Key Terminology +This file provides guidance to agents when working with code in this repository in Translate mode. + +## Workflow + +This workflow requires Orchestrator mode. + +Execute `node scripts/find-missing-translations.js` in Code mode to find all missing translations. + +For each language that is missing translations: + +- For each JSON file that is missing translations: + - Start a separate subtask in Translate mode for this language and JSON file to add the missing translations. Do not try to process mutliple languages or JSON files in one subtask. + +--- + +# 1. SUPPORTED LANGUAGES AND LOCATION + +- Localize all strings into the following locale files: ar, ca, cs, de, en, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, ru, th, tr, uk, vi, zh-CN, zh-TW +- The VSCode extension has two main areas that require localization: + - Core Extension: src/i18n/locales/, src/package.nls.json, src/package.nls..json (extension backend) + - WebView UI: webview-ui/src/i18n/locales/ (user interface) + +# 2. VOICE, STYLE AND TONE + +- Always use informal speech (e.g., "du" instead of "Sie" in German) for all translations +- Maintain a direct and concise style that mirrors the tone of the original text +- Carefully account for colloquialisms and idiomatic expressions in both source and target languages +- Aim for culturally relevant and meaningful translations rather than literal translations +- Preserve the personality and voice of the original content +- Use natural-sounding language that feels native to speakers of the target language +- Don't translate the word "token" as it means something specific in English that all languages will understand +- Don't translate domain-specific words (especially technical terms like "Prompt") that are commonly used in English in the target language + +# 3. CORE EXTENSION LOCALIZATION (src/) + +- Located in src/i18n/locales/ +- NOT ALL strings in core source need internationalization - only user-facing messages +- Internal error messages, debugging logs, and developer-facing messages should remain in English +- The t() function is used with namespaces like 'core:errors.missingToolParameter' +- Be careful when modifying interpolation variables; they must remain consistent across all translations +- Some strings in formatResponse.ts are intentionally not internationalized since they're internal +- When updating strings in core.json, maintain all existing interpolation variables +- Check string usages in the codebase before making changes to ensure you're not breaking functionality + +# 4. WEBVIEW UI LOCALIZATION (webview-ui/src/) + +- Located in webview-ui/src/i18n/locales/ +- Uses standard React i18next patterns with the useTranslation hook +- All user interface strings should be internationalized +- Always use the Trans component with named components for text with embedded components + + example: + +`"changeSettings": "You can always change this at the bottom of the settings",` + +``` + + }} + /> +``` + +# 5. TECHNICAL IMPLEMENTATION + +- Use namespaces to organize translations logically +- Handle pluralization using i18next's built-in capabilities +- Implement proper interpolation for variables using {{variable}} syntax +- Don't include defaultValue. The `en` translations are the fallback +- Always use apply_diff instead of write_to_file when editing existing translation files (much faster and more reliable) +- When using apply_diff, carefully identify the exact JSON structure to edit to avoid syntax errors +- Placeholders (like {{variable}}) must remain exactly identical to the English source to maintain code integration and prevent syntax errors + +# 6. WORKFLOW AND APPROACH + +- First add or modify English strings, then ask for confirmation before translating to all other languages +- Use this process for each localization task: + 1. Identify where the string appears in the UI/codebase + 2. Understand the context and purpose of the string + 3. Update English translation first + 4. Use the `` tool to find JSON keys that are near new keys in English translations but do not yet exist in the other language files for `` SEARCH context + 5. Create appropriate translations for all other supported languages utilizing the `search_files` result using `` without reading every file. + 6. Do not output the translated text into the chat, just modify the files. + 7. Validate your changes with the missing translations script +- Flag or comment if an English source string is incomplete ("please see this...") to avoid truncated or unclear translations +- For UI elements, distinguish between: + - Button labels: Use short imperative commands ("Save", "Cancel") + - Tooltip text: Can be slightly more descriptive +- Preserve the original perspective: If text is a user command directed at the software, ensure the translation maintains this direction, avoiding language that makes it sound like an instruction from the system to the user + +# 7. COMMON PITFALLS TO AVOID + +- Switching between formal and informal addressing styles - always stay informal ("du" not "Sie") +- Translating or altering technical terms and brand names that should remain in English +- Modifying or removing placeholders like {{variable}} - these must remain identical +- Translating domain-specific terms that are commonly used in English in the target language +- Changing the meaning or nuance of instructions or error messages +- Forgetting to maintain consistent terminology throughout the translation + +# 8. QUALITY ASSURANCE + +- Maintain consistent terminology across all translations +- Respect the JSON structure of translation files +- Watch for placeholders and preserve them in translations +- Be mindful of text length in UI elements when translating to languages that might require more characters +- Use context-aware translations when the same string has different meanings +- Always validate your translation work by running the missing translations script: + ``` + node scripts/find-missing-translations.js + ``` +- Address any missing translations identified by the script to ensure complete coverage across all locales + +# 9. TRANSLATOR'S CHECKLIST + +- ✓ Used informal tone consistently ("du" not "Sie") +- ✓ Preserved all placeholders exactly as in the English source +- ✓ Maintained consistent terminology with existing translations +- ✓ Kept technical terms and brand names unchanged where appropriate +- ✓ Preserved the original perspective (user→system vs system→user) +- ✓ Adapted the text appropriately for UI context (buttons vs tooltips) + +--- + +# Language-Specific Guidelines + +## German (de) Translation Guidelines + +**Key Rule:** Always use informal speech ("du" form) in all German translations without exception. + +### Quick Reference + +| Category | Formal (Avoid) | Informal (Use) | Example | +| ----------- | ------------------------- | ------------------- | ----------------- | +| Pronouns | Sie | du | you | +| Possessives | Ihr/Ihre/Ihrem | dein/deine/deinem | your | +| Verbs | können Sie, müssen Sie | kannst du, musst du | you can, you must | +| Imperatives | Geben Sie ein, Wählen Sie | Gib ein, Wähle | Enter, Choose | + +**Technical terms** like "API", "token", "prompt" should not be translated. + +--- + +## Simplified Chinese (zh-CN) Translation Guidelines + +### Key Terminology | English Term | Preferred (zh-CN) | Avoid | Context/Notes | | --------------------- | ----------------- | ------------ | ------------- | @@ -28,7 +173,7 @@ | power steering mode | 增强导向模式 | 动力转向模式 | 避免直译 | | Boomerang Tasks | 任务拆分 | 回旋镖任务 | 避免直译 | -## Formatting Rules +### Formatting Rules 1. **中英文混排** @@ -63,7 +208,7 @@ - 保持原格式:`{{variable}}` - 中文说明放在变量外:"Token 使用量: {{used}}" -## UI Element Translation Standards +### UI Element Translation Standards 1. **按钮(Buttons)** @@ -95,7 +240,7 @@ - 正文:分段落说明 - 按钮:使用动词,如"确认删除" -## Contextual Translation Principles +### Contextual Translation Principles 1. **根据UI位置调整** @@ -126,7 +271,7 @@ - "Enabled"→"已启用" - "Disabled"→"已禁用" -## Technical Documentation Guidelines +### Technical Documentation Guidelines 1. **技术术语** @@ -164,46 +309,41 @@ - 使用编号步骤:如"1. 注册Google Cloud账号" - 步骤动词一致:如"安装配置Google Cloud CLI工具" -## Common Patterns +### Common Patterns ```markdown <<<<<<< BEFORE "dragFiles": "按住shift拖动文件" ======= "dragFiles": "Shift+拖拽文件" - -> > > > > > > AFTER +>>>>>>> AFTER <<<<<<< BEFORE "description": "启用后,Kilo Code 将能够与 MCP 服务器交互以获取高级功能。" ======= "description": "启用后 Kilo Code 可与 MCP 服务交互获取高级功能。" - -> > > > > > > AFTER +>>>>>>> AFTER <<<<<<< BEFORE "cannotUndo": "此操作无法撤消。" ======= "cannotUndo": "此操作不可逆。" - -> > > > > > > AFTER +>>>>>>> AFTER <<<<<<< BEFORE "hold shift to drag in files" → "按住shift拖动文件" ======= "hold shift to drag in files" → "Shift+拖拽文件" - -> > > > > > > AFTER +>>>>>>> AFTER <<<<<<< BEFORE "Double click to edit" → "双击进行编辑" ======= "Double click to edit" → "双击编辑" - -> > > > > > > AFTER +>>>>>>> AFTER ``` -## Common Pitfalls +### Common Pitfalls 1. 避免过度直译导致生硬 @@ -238,7 +378,7 @@ ✗ 翻译参数名称导致无法使用 ✓ 保持参数名称英文,仅翻译说明 -## Best Practices +### Best Practices 1. **翻译工作流程** @@ -265,7 +405,7 @@ - 初翻 → 技术审校 → 语言润色 → 最终确认 - 重点关注技术准确性、语言流畅度和UI显示效果 -## Quality Checklist +### Quality Checklist 1. 术语是否全文一致? 2. 是否符合中文技术文档习惯? @@ -276,3 +416,24 @@ 7. 文化表达是否恰当? 8. 是否保持了原文的精确含义? 9. 特殊格式(如变量、代码)是否正确保留? + +--- + +## Traditional Chinese (zh-TW) Translation Guidelines + +### Key Terminology + +| English Term | Use (zh-TW) | Avoid (Mainland) | +| ------------- | ----------- | ---------------- | +| file | 檔案 | 文件 | +| task | 工作 | 任務 | +| project | 專案 | 項目 | +| configuration | 設定 | 配置 | +| server | 伺服器 | 服務器 | +| import/export | 匯入/匯出 | 導入/導出 | + +### Formatting Rules + +- Add spaces between Chinese and English/numbers: "AI 驅動" (not "AI驅動") +- Use Traditional Chinese quotation marks: 「範例文字」(not "範例文字") +- Use Taiwanese computing conventions rather than mainland terminology diff --git a/.kilocode/rules/rules.md b/AGENTS.md similarity index 81% rename from .kilocode/rules/rules.md rename to AGENTS.md index 8449b115f66..c753501c4d6 100644 --- a/.kilocode/rules/rules.md +++ b/AGENTS.md @@ -1,4 +1,14 @@ -# Code Quality Rules +# AGENTS.md + +Kilo Code is an open source AI coding agent for VS Code that generates code from natural language, automates tasks, and supports 500+ AI models. + +## Mode-Specific Rules + +For mode-specific guidance, see the following files: + +- **Translate mode**: `.roo/rules-translate/AGENTS.md` - Translation and localization guidelines + +## Code Quality Rules 1. Test Coverage: diff --git a/src/core/kilocode/agent-manager/AgentManagerProvider.ts b/src/core/kilocode/agent-manager/AgentManagerProvider.ts index 84487777063..9d8016abfe2 100644 --- a/src/core/kilocode/agent-manager/AgentManagerProvider.ts +++ b/src/core/kilocode/agent-manager/AgentManagerProvider.ts @@ -238,6 +238,9 @@ export class AgentManagerProvider implements vscode.Disposable { message.sessionLabel as string | undefined, ) break + case "agentManager.resumeSession": + void this.resumeSession(message.sessionId as string, message.content as string) + break case "agentManager.cancelSession": void this.cancelSession(message.sessionId as string) break @@ -315,7 +318,6 @@ export class AgentManagerProvider implements vscode.Disposable { const config = configs[0] await this.startAgentSession(config.prompt, { parallelMode: config.parallelMode, - autoMode: config.autoMode, labelOverride: config.label, existingBranch: config.existingBranch, }) @@ -332,7 +334,6 @@ export class AgentManagerProvider implements vscode.Disposable { await this.startAgentSession(config.prompt, { parallelMode: config.parallelMode, - autoMode: config.autoMode, labelOverride: config.label, existingBranch: config.existingBranch, }) @@ -407,7 +408,6 @@ export class AgentManagerProvider implements vscode.Disposable { prompt: string, options?: { parallelMode?: boolean - autoMode?: boolean labelOverride?: string existingBranch?: string }, @@ -417,41 +417,78 @@ export class AgentManagerProvider implements vscode.Disposable { return } - // Get workspace folder - require a valid workspace + // Get workspace folder early to fetch git URL before spawning + // Note: we intentionally allow starting parallel mode from within an existing git worktree. + // Git worktrees share a common .git dir, so `git worktree add/remove` still works from a worktree root. const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath - if (!workspaceFolder) { - this.outputChannel.appendLine("ERROR: No workspace folder open") - void vscode.window.showErrorMessage("Please open a folder before starting an agent.") + + // Get git URL for the workspace (used for filtering sessions) + let gitUrl: string | undefined + if (workspaceFolder) { + try { + gitUrl = normalizeGitUrl(await getRemoteUrl(workspaceFolder)) + } catch (error) { + this.outputChannel.appendLine( + `[AgentManager] Could not get git URL: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } + + const onSetupFailed = () => { + if (!workspaceFolder) { + void vscode.window.showErrorMessage("Please open a folder before starting an agent.") + } this.postMessage({ type: "agentManager.startSessionFailed" }) - return } - // Note: we intentionally allow starting parallel mode from within an existing git worktree. - // Git worktrees share a common .git dir, so `git worktree add/remove` still works from a worktree root. + await this.spawnCliWithCommonSetup( + prompt, + { + parallelMode: options?.parallelMode, + label: options?.labelOverride, + gitUrl, + existingBranch: options?.existingBranch, + }, + onSetupFailed, + ) + } + + private async getApiConfigurationForCli(): Promise { + const { apiConfiguration } = await this.provider.getState() + return apiConfiguration + } + + /** + * Common helper to spawn a CLI process with standard setup. + * Handles CLI path lookup, workspace folder validation, API config, and event callback wiring. + * @returns true if process was spawned, false if setup failed + */ + private async spawnCliWithCommonSetup( + prompt: string, + options: { + parallelMode?: boolean + label?: string + gitUrl?: string + existingBranch?: string + sessionId?: string + }, + onSetupFailed?: () => void, + ): Promise { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath + if (!workspaceFolder) { + this.outputChannel.appendLine("ERROR: No workspace folder open") + onSetupFailed?.() + return false + } const cliPath = await findKilocodeCli((msg) => this.outputChannel.appendLine(`[AgentManager] ${msg}`)) if (!cliPath) { this.outputChannel.appendLine("ERROR: kilocode CLI not found") this.showCliNotFoundError() - this.postMessage({ type: "agentManager.startSessionFailed" }) - return - } - - // Determine label override (used for multi-version mode) - const existingLabel = options?.labelOverride - - // Get git URL for the workspace (used for filtering sessions) - let gitUrl: string | undefined - try { - gitUrl = normalizeGitUrl(await getRemoteUrl(workspaceFolder)) - } catch (error) { - this.outputChannel.appendLine( - `[AgentManager] Could not get git URL: ${error instanceof Error ? error.message : String(error)}`, - ) + onSetupFailed?.() + return false } - // Record process start time to filter out replayed history events - // This is set before spawning so any events older than this are from history const processStartTime = Date.now() let apiConfiguration: ProviderSettings | undefined try { @@ -468,27 +505,16 @@ export class AgentManagerProvider implements vscode.Disposable { cliPath, workspaceFolder, prompt, - { - parallelMode: options?.parallelMode, - autoMode: options?.autoMode, - label: existingLabel, - gitUrl, - apiConfiguration, - existingBranch: options?.existingBranch, - }, - (sessionId, event) => { - // For new sessions, set the start time when we first see the session - if (!this.processStartTimes.has(sessionId)) { - this.processStartTimes.set(sessionId, processStartTime) + { ...options, apiConfiguration }, + (sid, event) => { + if (!this.processStartTimes.has(sid)) { + this.processStartTimes.set(sid, processStartTime) } - this.handleCliEvent(sessionId, event) + this.handleCliEvent(sid, event) }, ) - } - private async getApiConfigurationForCli(): Promise { - const { apiConfiguration } = await this.provider.getState() - return apiConfiguration + return true } /** @@ -759,16 +785,6 @@ export class AgentManagerProvider implements vscode.Disposable { * Send a follow-up message to a running agent session via stdin. */ public async sendMessage(sessionId: string, content: string, sessionLabel?: string): Promise { - const session = this.registry.getSession(sessionId) - - // Auto-mode sessions are non-interactive - if (session?.autoMode) { - this.outputChannel.appendLine( - `[AgentManager] Session ${sessionId} is running in auto mode; user input is disabled`, - ) - return - } - if (!this.processHandler.hasStdin(sessionId)) { // Session is not running - ignore the message this.outputChannel.appendLine(`[AgentManager] Session ${sessionId} not running, ignoring follow-up message`) @@ -803,20 +819,10 @@ export class AgentManagerProvider implements vscode.Disposable { } /** - * Validate that a message can be sent (not auto-mode, session running, no other message sending). + * Validate that a message can be sent (session running, no other message sending). * Returns error message if validation fails, undefined if valid. */ private validateMessagePrerequisites(sessionId: string, messageId: string): void | undefined { - // Check auto-mode - const session = this.registry.getSession(sessionId) - if (session?.autoMode) { - this.outputChannel.appendLine( - `[AgentManager] Session ${sessionId} is running in auto mode; user input is disabled`, - ) - this.notifyMessageStatus(sessionId, messageId, "failed", "Session is in auto mode") - return - } - // Check if session is running if (!this.processHandler.hasStdin(sessionId)) { this.outputChannel.appendLine(`[AgentManager] Session ${sessionId} not running, message send failed`) @@ -881,6 +887,31 @@ export class AgentManagerProvider implements vscode.Disposable { }) } + /** + * Resume a completed session by spawning a new CLI process with --session flag. + */ + public async resumeSession(sessionId: string, content: string): Promise { + const session = this.registry.getSession(sessionId) + if (!session) { + this.outputChannel.appendLine(`[AgentManager] Session ${sessionId} not found, cannot resume`) + return + } + + // If session is still running, send as regular message instead + if (this.processHandler.hasStdin(sessionId)) { + await this.sendMessage(sessionId, content) + return + } + + this.outputChannel.appendLine(`[AgentManager] Resuming session ${sessionId} with new prompt`) + + await this.spawnCliWithCommonSetup(content, { + sessionId, // This triggers --session= flag + parallelMode: session.parallelMode?.enabled, + gitUrl: session.gitUrl, + }) + } + /** * Cancel/abort a running agent session via stdin. * Falls back to SIGTERM if stdin write fails. diff --git a/src/core/kilocode/agent-manager/AgentRegistry.ts b/src/core/kilocode/agent-manager/AgentRegistry.ts index a20be26972f..7266e84aa9c 100644 --- a/src/core/kilocode/agent-manager/AgentRegistry.ts +++ b/src/core/kilocode/agent-manager/AgentRegistry.ts @@ -2,7 +2,6 @@ import { AgentSession, AgentStatus, AgentManagerState, PendingSession, ParallelM export interface CreateSessionOptions { parallelMode?: boolean - autoMode?: boolean } const MAX_SESSIONS = 10 @@ -36,7 +35,6 @@ export class AgentRegistry { startTime: Date.now(), parallelMode: options?.parallelMode, gitUrl: options?.gitUrl, - autoMode: options?.autoMode, } return this._pendingSession } @@ -69,7 +67,6 @@ export class AgentRegistry { source: "local", ...(options?.parallelMode && { parallelMode: { enabled: true } }), gitUrl: options?.gitUrl, - ...(options?.autoMode && { autoMode: true }), } this.sessions.set(sessionId, session) @@ -148,7 +145,7 @@ export class AgentRegistry { } /** - * Update the autoMode flag on a session. + * Update parallel mode info on a session. */ public updateParallelModeInfo( id: string, diff --git a/src/core/kilocode/agent-manager/CliArgsBuilder.ts b/src/core/kilocode/agent-manager/CliArgsBuilder.ts index 6a051991801..f37299c2ec3 100644 --- a/src/core/kilocode/agent-manager/CliArgsBuilder.ts +++ b/src/core/kilocode/agent-manager/CliArgsBuilder.ts @@ -1,22 +1,19 @@ export interface BuildCliArgsOptions { parallelMode?: boolean sessionId?: string - autoMode?: boolean existingBranch?: string } /** * Builds CLI arguments for spawning kilocode agent processes. * Uses --json-io for bidirectional communication via stdin/stdout. + * Runs in interactive mode - approvals are handled via the JSON-IO protocol. */ export function buildCliArgs(workspace: string, prompt: string, options?: BuildCliArgsOptions): string[] { - // Always use --json-io for Agent Manager (enables stdin for bidirectional communication) + // --json-io: enables bidirectional JSON communication via stdin/stdout // Note: --json (without -io) exists for CI/CD read-only mode but isn't used here - const args = ["--json-io", `--workspace=${workspace}`] - - if (options?.autoMode) { - args.push("--auto") - } + // --yolo: auto-approve tool uses (file reads, writes, commands, etc.) + const args = ["--json-io", "--yolo", `--workspace=${workspace}`] if (options?.parallelMode) { args.push("--parallel") diff --git a/src/core/kilocode/agent-manager/CliProcessHandler.ts b/src/core/kilocode/agent-manager/CliProcessHandler.ts index d1f770d0295..8ba02ef4778 100644 --- a/src/core/kilocode/agent-manager/CliProcessHandler.ts +++ b/src/core/kilocode/agent-manager/CliProcessHandler.ts @@ -29,7 +29,6 @@ interface PendingProcessInfo { prompt: string startTime: number parallelMode?: boolean - autoMode?: boolean // True if session was started with --auto flag desiredSessionId?: string desiredLabel?: string worktreeBranch?: string // Captured from welcome event before session_created @@ -109,7 +108,6 @@ export class CliProcessHandler { options: | { parallelMode?: boolean - autoMode?: boolean sessionId?: string label?: string gitUrl?: string @@ -142,7 +140,6 @@ export class CliProcessHandler { // New session - create pending session state const pendingSession = this.registry.setPendingSession(prompt, { parallelMode: options?.parallelMode, - autoMode: options?.autoMode, gitUrl: options?.gitUrl, }) this.debugLog(`Pending session created, waiting for CLI session_created event`) @@ -152,7 +149,6 @@ export class CliProcessHandler { // Build CLI command const cliArgs = buildCliArgs(workspace, prompt, { parallelMode: options?.parallelMode, - autoMode: options?.autoMode, sessionId: options?.sessionId, existingBranch: options?.existingBranch, }) @@ -203,7 +199,6 @@ export class CliProcessHandler { prompt, startTime: Date.now(), parallelMode: options?.parallelMode, - autoMode: options?.autoMode, desiredSessionId: options?.sessionId, desiredLabel: options?.label, gitUrl: options?.gitUrl, @@ -433,7 +428,6 @@ export class CliProcessHandler { startTime, parser, parallelMode, - autoMode, worktreeBranch, desiredSessionId, desiredLabel, @@ -457,7 +451,6 @@ export class CliProcessHandler { // Create new session (also sets selectedId) session = this.registry.createSession(sessionId, prompt, startTime, { parallelMode, - autoMode, labelOverride: desiredLabel, gitUrl, }) @@ -534,11 +527,28 @@ export class CliProcessHandler { // Clean up this.activeSessions.delete(sessionId) + // Exit code handling (matching cloud-agent backend behavior): + // - 0: Success + // - 124: CLI timeout exceeded + // - 130 (128+2): SIGINT - user interrupted + // - 137 (128+9): SIGKILL - force killed + // - 143 (128+15): SIGTERM - terminated + // - Other non-zero: Error + const INTERRUPTED_EXIT_CODES = [130, 137, 143] + const TIMEOUT_EXIT_CODE = 124 + if (code === 0) { this.registry.updateSessionStatus(sessionId, "done", code) this.callbacks.onSessionLog(sessionId, "Agent completed") // Notify that session completed successfully (for state machine transition) this.callbacks.onSessionCompleted?.(sessionId, code) + } else if (code === TIMEOUT_EXIT_CODE) { + this.registry.updateSessionStatus(sessionId, "error", code, "CLI timeout exceeded") + this.callbacks.onSessionLog(sessionId, "Agent timed out") + } else if (code !== null && INTERRUPTED_EXIT_CODES.includes(code)) { + // User or system interrupted - not an error, just stopped + this.registry.updateSessionStatus(sessionId, "stopped", code) + this.callbacks.onSessionLog(sessionId, "Agent was interrupted") } else { this.registry.updateSessionStatus(sessionId, "error", code ?? undefined) this.callbacks.onSessionLog( diff --git a/src/core/kilocode/agent-manager/KilocodeEventProcessor.ts b/src/core/kilocode/agent-manager/KilocodeEventProcessor.ts index 2df6f6f0339..375f8cacfeb 100644 --- a/src/core/kilocode/agent-manager/KilocodeEventProcessor.ts +++ b/src/core/kilocode/agent-manager/KilocodeEventProcessor.ts @@ -49,6 +49,7 @@ export class KilocodeEventProcessor { public handle(sessionId: string, event: KilocodeStreamEvent): void { const payload = event.payload const messageType = payload.type === "ask" ? "ask" : payload.type === "say" ? "say" : null + const isCommandOutput = payload.ask === "command_output" || payload.say === "command_output" if (!messageType) { // Unknown payloads (e.g., session_created) are logged but not shown as chat @@ -81,7 +82,7 @@ export class KilocodeEventProcessor { // Skip empty partial messages const rawContent = payload.content || payload.text - if (payload.partial && !rawContent) { + if (payload.partial && !rawContent && !isCommandOutput) { return } @@ -101,6 +102,8 @@ export class KilocodeEventProcessor { const timestamp = (payload.timestamp as number | undefined) ?? (payload as { ts?: number }).ts ?? Date.now() const checkpoint = (payload as { checkpoint?: Record }).checkpoint const text = this.deriveMessageText(payload, checkpoint) + const metadata = payload.metadata as Record | undefined + const message: ClineMessage = { ts: timestamp, type: messageType, @@ -109,7 +112,7 @@ export class KilocodeEventProcessor { text, partial: payload.partial ?? false, isAnswered: payload.isAnswered as boolean | undefined, - metadata: payload.metadata as Record | undefined, + metadata, checkpoint, } @@ -123,13 +126,14 @@ export class KilocodeEventProcessor { if (!message.text && message.type === "ask" && message.ask) { if (message.ask === "tool") { message.text = this.formatToolAskText(payload.metadata) - } else { + } else if (message.ask !== "command_output") { message.text = message.ask } } // Drop empty messages (except checkpoints) - if (!message.text && message.say !== "checkpoint_saved") { + const isCommandOutputMessage = message.ask === "command_output" || message.say === "command_output" + if (!message.text && message.say !== "checkpoint_saved" && !isCommandOutputMessage) { return } @@ -234,6 +238,19 @@ export class KilocodeEventProcessor { return this.formatToolAskText(payload.metadata) || "" } + // command_output messages from the CLI often encode output in `metadata` + // (because the CLI JSON renderer parses `text` JSON into `metadata`). + if ((payload.ask === "command_output" || payload.say === "command_output") && payload.metadata) { + const output = (payload.metadata as { output?: unknown }).output + if (typeof output === "string") { + return output + } + if (output != null) { + return String(output) + } + return "" + } + // Fallback empty return "" } @@ -252,6 +269,14 @@ export class KilocodeEventProcessor { } private getMessageKey(message: ClineMessage): string { + // For command_output, use executionId from metadata for deduplication + // This ensures we don't show duplicate outputs when isAnswered changes from false to true + if (message.ask === "command_output" || message.say === "command_output") { + const executionId = (message.metadata as { executionId?: string } | undefined)?.executionId + if (executionId) { + return `command_output-${executionId}` + } + } return `${message.ts}-${message.type}-${message.say ?? ""}-${message.ask ?? ""}` } } diff --git a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.multiversion.test.ts b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.multiversion.test.ts index cc07a45cdcf..6eb980cb4ca 100644 --- a/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.multiversion.test.ts +++ b/src/core/kilocode/agent-manager/__tests__/AgentManagerProvider.multiversion.test.ts @@ -9,16 +9,18 @@ import { extractSessionConfigs, type SessionConfig } from "../multiVersionUtils" * * 1. Single version (count=1) should spawn once with the original prompt * 2. Multiple versions (count>1) should: - * - Always use parallelMode=true and autoMode=true + * - Always use parallelMode=true for isolated worktrees * - Generate labels with (v1), (v2), etc. suffixes * - Spawn sessions sequentially + * + * All sessions run with --auto flag since Agent Manager has no approval UI. */ describe("Multi-version session spawning", () => { describe("extractSessionConfigs", () => { const prompt = "Build a todo app with React" - it("returns single config for version=1 without auto mode", () => { + it("returns single config for version=1", () => { const configs = extractSessionConfigs({ prompt, versions: 1, @@ -29,7 +31,6 @@ describe("Multi-version session spawning", () => { prompt, label: prompt.slice(0, 50), parallelMode: false, - autoMode: false, }) }) @@ -42,7 +43,6 @@ describe("Multi-version session spawning", () => { expect(configs).toHaveLength(1) expect(configs[0].parallelMode).toBe(true) - expect(configs[0].autoMode).toBe(false) }) it("returns multiple configs for version>1", () => { @@ -66,17 +66,6 @@ describe("Multi-version session spawning", () => { expect(configs[1].parallelMode).toBe(true) }) - it("starts multi-version sessions interactively (autoMode=false)", () => { - const configs = extractSessionConfigs({ - prompt, - versions: 2, - }) - - expect(configs).toHaveLength(2) - expect(configs[0].autoMode).toBe(false) - expect(configs[1].autoMode).toBe(false) - }) - it("uses provided labels for multi-version", () => { const labels = ["Version A", "Version B"] const configs = extractSessionConfigs({ @@ -123,7 +112,6 @@ describe("Multi-version session spawning", () => { expect(configs).toHaveLength(4) configs.forEach((config) => { expect(config.parallelMode).toBe(true) - expect(config.autoMode).toBe(false) }) }) @@ -133,7 +121,7 @@ describe("Multi-version session spawning", () => { }) expect(configs).toHaveLength(1) - expect(configs[0].autoMode).toBe(false) + expect(configs[0].parallelMode).toBe(false) }) it("truncates label to 50 characters", () => { diff --git a/src/core/kilocode/agent-manager/__tests__/AgentRegistry.spec.ts b/src/core/kilocode/agent-manager/__tests__/AgentRegistry.spec.ts index edfbe8d2e40..fb8e097ad1c 100644 --- a/src/core/kilocode/agent-manager/__tests__/AgentRegistry.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/AgentRegistry.spec.ts @@ -440,49 +440,6 @@ describe("AgentRegistry", () => { }) }) - describe("autoMode", () => { - it("creates session without autoMode by default", () => { - const session = registry.createSession("session-1", "no auto mode") - expect(session.autoMode).toBeUndefined() - }) - - it("creates session with autoMode enabled when option is provided", () => { - const session = registry.createSession("session-1", "with auto mode", undefined, { autoMode: true }) - expect(session.autoMode).toBe(true) - }) - - it("creates session without autoMode when option is false", () => { - const session = registry.createSession("session-1", "without auto mode", undefined, { autoMode: false }) - expect(session.autoMode).toBeUndefined() - }) - - it("stores autoMode in pending session when provided", () => { - const pending = registry.setPendingSession("test prompt", { autoMode: true }) - expect(pending.autoMode).toBe(true) - expect(registry.pendingSession?.autoMode).toBe(true) - }) - - it("creates pending session without autoMode when not provided", () => { - const pending = registry.setPendingSession("test prompt") - expect(pending.autoMode).toBeUndefined() - }) - - it("preserves autoMode in getState", () => { - registry.createSession("session-1", "auto mode session", undefined, { autoMode: true }) - const state = registry.getState() - expect(state.sessions[0].autoMode).toBe(true) - }) - - it("combines autoMode with parallelMode", () => { - const session = registry.createSession("session-1", "combined", undefined, { - parallelMode: true, - autoMode: true, - }) - expect(session.autoMode).toBe(true) - expect(session.parallelMode).toEqual({ enabled: true }) - }) - }) - describe("getStateForGitUrl", () => { it("returns state filtered by gitUrl", () => { registry.createSession("session-1", "prompt 1", undefined, { diff --git a/src/core/kilocode/agent-manager/__tests__/CliArgsBuilder.spec.ts b/src/core/kilocode/agent-manager/__tests__/CliArgsBuilder.spec.ts index 1ad8ac89e72..4ef8eae8736 100644 --- a/src/core/kilocode/agent-manager/__tests__/CliArgsBuilder.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/CliArgsBuilder.spec.ts @@ -5,27 +5,27 @@ describe("buildCliArgs", () => { it("always uses --json-io for bidirectional communication", () => { const args = buildCliArgs("/workspace", "hello world") - expect(args[0]).toBe("--json-io") + expect(args).toContain("--json-io") }) it("returns correct args for basic prompt", () => { const args = buildCliArgs("/workspace", "hello world") - expect(args).toEqual(["--json-io", "--workspace=/workspace", "hello world"]) + expect(args).toEqual(["--json-io", "--yolo", "--workspace=/workspace", "hello world"]) }) it("preserves prompt with special characters", () => { const prompt = 'echo "$(whoami)"' const args = buildCliArgs("/tmp", prompt) - expect(args).toHaveLength(3) - expect(args[2]).toBe(prompt) + expect(args).toHaveLength(4) + expect(args[3]).toBe(prompt) }) it("handles workspace paths with spaces", () => { const args = buildCliArgs("/path/with spaces/project", "test") - expect(args[1]).toBe("--workspace=/path/with spaces/project") + expect(args[2]).toBe("--workspace=/path/with spaces/project") }) it("omits empty prompt from args (used for resume without new prompt)", () => { @@ -33,14 +33,14 @@ describe("buildCliArgs", () => { // Empty prompt should not be added to args - this is used when resuming // a session with --session where we don't want to pass a new prompt - expect(args).toEqual(["--json-io", "--workspace=/workspace"]) + expect(args).toEqual(["--json-io", "--yolo", "--workspace=/workspace"]) }) it("handles multiline prompts", () => { const prompt = "line1\nline2\nline3" const args = buildCliArgs("/workspace", prompt) - expect(args[2]).toBe(prompt) + expect(args[3]).toBe(prompt) }) it("includes --parallel flag when parallelMode is true", () => { @@ -61,23 +61,20 @@ describe("buildCliArgs", () => { sessionId: "session-id", }) - expect(args).toEqual(["--json-io", "--workspace=/workspace", "--parallel", "--session=session-id", "prompt"]) + expect(args).toEqual([ + "--json-io", + "--yolo", + "--workspace=/workspace", + "--parallel", + "--session=session-id", + "prompt", + ]) }) - it("includes --auto flag when autoMode is true", () => { - const args = buildCliArgs("/workspace", "prompt", { autoMode: true }) + it("uses --yolo for auto-approval of tool uses", () => { + const args = buildCliArgs("/workspace", "prompt") - expect(args).toContain("--auto") - }) - - it("combines --auto and --parallel flags when both options are set", () => { - const args = buildCliArgs("/workspace", "prompt", { - parallelMode: true, - autoMode: true, - }) - - expect(args).toContain("--parallel") - expect(args).toContain("--auto") - expect(args).toEqual(["--json-io", "--workspace=/workspace", "--auto", "--parallel", "prompt"]) + expect(args).toContain("--yolo") + expect(args).not.toContain("--auto") }) }) diff --git a/src/core/kilocode/agent-manager/__tests__/CliArgsBuilder.test.ts b/src/core/kilocode/agent-manager/__tests__/CliArgsBuilder.test.ts index 1a7bece71bd..40a056910b7 100644 --- a/src/core/kilocode/agent-manager/__tests__/CliArgsBuilder.test.ts +++ b/src/core/kilocode/agent-manager/__tests__/CliArgsBuilder.test.ts @@ -13,12 +13,6 @@ describe("CliArgsBuilder", () => { expect(args).toContain(prompt) }) - it("adds --auto flag when autoMode is true", () => { - const args = buildCliArgs(workspace, prompt, { autoMode: true }) - - expect(args).toContain("--auto") - }) - it("adds --parallel flag when parallelMode is true", () => { const args = buildCliArgs(workspace, prompt, { parallelMode: true }) diff --git a/src/core/kilocode/agent-manager/__tests__/CliProcessHandler.spec.ts b/src/core/kilocode/agent-manager/__tests__/CliProcessHandler.spec.ts index 612594eabb9..626e5ffbcb5 100644 --- a/src/core/kilocode/agent-manager/__tests__/CliProcessHandler.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/CliProcessHandler.spec.ts @@ -83,13 +83,13 @@ describe("CliProcessHandler", () => { }) describe("spawnProcess", () => { - it("spawns a CLI process with correct arguments", () => { + it("spawns a CLI process with correct arguments (yolo mode for testing)", () => { const onCliEvent = vi.fn() handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent) expect(spawnMock).toHaveBeenCalledWith( "/path/to/kilocode", - ["--json-io", "--workspace=/workspace", "test prompt"], + ["--json-io", "--yolo", "--workspace=/workspace", "test prompt"], expect.objectContaining({ cwd: "/workspace", stdio: ["pipe", "pipe", "pipe"], @@ -677,7 +677,7 @@ describe("CliProcessHandler", () => { ) }) - it("handles exit with signal", () => { + it("handles exit with signal (null code)", () => { const onCliEvent = vi.fn() handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent) mockProcess.stdout.emit("data", Buffer.from('{"event":"session_created","sessionId":"session-1"}\n')) @@ -689,6 +689,59 @@ describe("CliProcessHandler", () => { expect(callbacks.onSessionLog).toHaveBeenCalledWith("session-1", expect.stringContaining("signal SIGTERM")) }) + it("handles timeout exit (code 124)", () => { + const onCliEvent = vi.fn() + handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent) + mockProcess.stdout.emit("data", Buffer.from('{"event":"session_created","sessionId":"session-1"}\n')) + + mockProcess.emit("exit", 124, null) + + const session = registry.getSession("session-1") + expect(session?.status).toBe("error") + expect(session?.exitCode).toBe(124) + expect(session?.error).toBe("CLI timeout exceeded") + expect(callbacks.onSessionLog).toHaveBeenCalledWith("session-1", "Agent timed out") + }) + + it("handles SIGINT interrupted exit (code 130)", () => { + const onCliEvent = vi.fn() + handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent) + mockProcess.stdout.emit("data", Buffer.from('{"event":"session_created","sessionId":"session-1"}\n')) + + mockProcess.emit("exit", 130, null) + + const session = registry.getSession("session-1") + expect(session?.status).toBe("stopped") + expect(session?.exitCode).toBe(130) + expect(callbacks.onSessionLog).toHaveBeenCalledWith("session-1", "Agent was interrupted") + }) + + it("handles SIGKILL interrupted exit (code 137)", () => { + const onCliEvent = vi.fn() + handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent) + mockProcess.stdout.emit("data", Buffer.from('{"event":"session_created","sessionId":"session-1"}\n')) + + mockProcess.emit("exit", 137, null) + + const session = registry.getSession("session-1") + expect(session?.status).toBe("stopped") + expect(session?.exitCode).toBe(137) + expect(callbacks.onSessionLog).toHaveBeenCalledWith("session-1", "Agent was interrupted") + }) + + it("handles SIGTERM interrupted exit (code 143)", () => { + const onCliEvent = vi.fn() + handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent) + mockProcess.stdout.emit("data", Buffer.from('{"event":"session_created","sessionId":"session-1"}\n')) + + mockProcess.emit("exit", 143, null) + + const session = registry.getSession("session-1") + expect(session?.status).toBe("stopped") + expect(session?.exitCode).toBe(143) + expect(callbacks.onSessionLog).toHaveBeenCalledWith("session-1", "Agent was interrupted") + }) + it("flushes parser buffer on exit", () => { const onCliEvent = vi.fn() handler.spawnProcess("/path/to/kilocode", "/workspace", "test prompt", undefined, onCliEvent) diff --git a/src/core/kilocode/agent-manager/__tests__/KilocodeEventProcessor.spec.ts b/src/core/kilocode/agent-manager/__tests__/KilocodeEventProcessor.spec.ts index 4cf0419d008..3a4cae86e3d 100644 --- a/src/core/kilocode/agent-manager/__tests__/KilocodeEventProcessor.spec.ts +++ b/src/core/kilocode/agent-manager/__tests__/KilocodeEventProcessor.spec.ts @@ -184,4 +184,94 @@ describe("KilocodeEventProcessor", () => { expect(stored?.[0].say).toBe("checkpoint_saved") expect(stored?.[0].text).toBe("") }) + + it("extracts command_output text from metadata.output", () => { + const deps = createDeps() + deps.firstApiReqStarted.set(sessionId, true) + const processor = new KilocodeEventProcessor(deps) + + const cmdOutputEvent: KilocodeStreamEvent = { + streamEventType: "kilocode", + payload: { + type: "ask", + ask: "command_output", + timestamp: 123, + metadata: { executionId: "exec-1", command: "echo pong", output: "pong\n" }, + }, + } + + processor.handle(sessionId, cmdOutputEvent) + + const stored = deps.sessionMessages.get(sessionId) + expect(stored).toHaveLength(1) + expect(stored?.[0].ask).toBe("command_output") + expect(stored?.[0].text).toBe("pong\n") + }) + + it("keeps empty partial command_output messages (no placeholder text)", () => { + const deps = createDeps() + deps.firstApiReqStarted.set(sessionId, true) + const processor = new KilocodeEventProcessor(deps) + + const cmdOutputStart: KilocodeStreamEvent = { + streamEventType: "kilocode", + payload: { + type: "ask", + ask: "command_output", + partial: true, + timestamp: 124, + metadata: { executionId: "exec-2", command: "echo pong", output: "" }, + }, + } + + processor.handle(sessionId, cmdOutputStart) + + const stored = deps.sessionMessages.get(sessionId) + expect(stored).toHaveLength(1) + expect(stored?.[0].ask).toBe("command_output") + expect(stored?.[0].partial).toBe(true) + expect(stored?.[0].text).toBe("") + }) + + it("deduplicates command_output messages by executionId (not timestamp)", () => { + const deps = createDeps() + deps.firstApiReqStarted.set(sessionId, true) + const processor = new KilocodeEventProcessor(deps) + + // First command_output event with isAnswered=false (waiting for approval) + const cmdOutputPending: KilocodeStreamEvent = { + streamEventType: "kilocode", + payload: { + type: "ask", + ask: "command_output", + partial: false, + isAnswered: false, + timestamp: 1000, + metadata: { executionId: "exec-3", command: "pwd", output: "/home/user\n" }, + }, + } + + // Second command_output event with isAnswered=true (approved) - different timestamp + const cmdOutputApproved: KilocodeStreamEvent = { + streamEventType: "kilocode", + payload: { + type: "ask", + ask: "command_output", + partial: false, + isAnswered: true, + timestamp: 1003, // Different timestamp + metadata: { executionId: "exec-3", command: "pwd", output: "/home/user\n" }, + }, + } + + processor.handle(sessionId, cmdOutputPending) + processor.handle(sessionId, cmdOutputApproved) + + // Should only have one message (deduplicated by executionId) + const stored = deps.sessionMessages.get(sessionId) + expect(stored).toHaveLength(1) + expect(stored?.[0].ask).toBe("command_output") + expect(stored?.[0].isAnswered).toBe(true) // Should be the latest one + expect(stored?.[0].text).toBe("/home/user\n") + }) }) diff --git a/src/core/kilocode/agent-manager/multiVersionUtils.ts b/src/core/kilocode/agent-manager/multiVersionUtils.ts index 6b7cd17a791..9f887fe2bc1 100644 --- a/src/core/kilocode/agent-manager/multiVersionUtils.ts +++ b/src/core/kilocode/agent-manager/multiVersionUtils.ts @@ -9,7 +9,6 @@ export interface SessionConfig { prompt: string label: string parallelMode: boolean - autoMode: boolean existingBranch?: string } @@ -23,14 +22,14 @@ export interface StartSessionMessage { /** * Extract session configurations from a start session message. + * Sessions are always interactive (no --auto flag) - approvals handled via JSON-IO protocol. * * For single version (versions=1 or undefined): * - Returns one config with the user's chosen parallelMode - * - autoMode is false (interactive) * * For multi-version (versions>1): * - Returns multiple configs, one per version - * - Forces parallelMode=true for isolated worktrees, autoMode=false for interactive finishing + * - Forces parallelMode=true for isolated worktrees * - Users can click "Finish to Branch" on each session to commit their changes * - Uses provided labels or generates (v1), (v2) suffixes */ @@ -44,13 +43,12 @@ export function extractSessionConfigs(message: StartSessionMessage): SessionConf prompt, label: prompt.slice(0, 50), parallelMode, - autoMode: false, existingBranch, }, ] } - // Multi-version case: always use parallelMode, but start interactively (autoMode=false) + // Multi-version case: always use parallelMode for isolated worktrees // Users can click the "Finish to Branch" button on individual sessions to commit their changes // Note: existingBranch is not supported in multi-version mode as each version needs isolated branches const effectiveLabels = labels ?? Array.from({ length: versions }, (_, i) => `${prompt.slice(0, 50)} (v${i + 1})`) @@ -59,7 +57,6 @@ export function extractSessionConfigs(message: StartSessionMessage): SessionConf prompt, label, parallelMode: true, - autoMode: false, // existingBranch is deliberately excluded in multi-version mode })) } diff --git a/src/core/kilocode/agent-manager/types.ts b/src/core/kilocode/agent-manager/types.ts index 6729ada7838..6de77b55d7d 100644 --- a/src/core/kilocode/agent-manager/types.ts +++ b/src/core/kilocode/agent-manager/types.ts @@ -31,7 +31,6 @@ export interface AgentSession { source: SessionSource parallelMode?: ParallelModeInfo gitUrl?: string - autoMode?: boolean // True if session was started with --auto flag (non-interactive) } /** @@ -43,7 +42,6 @@ export interface PendingSession { startTime: number parallelMode?: boolean gitUrl?: string - autoMode?: boolean // True if session will be started with --auto flag } // Re-export remote session shape from shared session client for consistency diff --git a/src/core/prompts/tools/native-tools/kilocode/search_and_replace.ts b/src/core/prompts/tools/native-tools/kilocode/search_and_replace.ts deleted file mode 100644 index 84ac708f8b2..00000000000 --- a/src/core/prompts/tools/native-tools/kilocode/search_and_replace.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type OpenAI from "openai" -import z from "zod/v4" - -export const SearchAndReplaceParametersSchema = z.object({ - path: z.string().describe("The path to the file to modify (relative to the current workspace directory)."), - old_str: z - .string() - .describe( - "The text to replace (must match exactly, including whitespace and indentation). Provide enough context to make a unique match.", - ), - new_str: z.string().describe("The new text to insert in place of the old text."), -}) - -export type SearchAndReplaceParameters = z.infer - -export default { - type: "function", - function: { - name: "apply_diff", - description: "Replace a specific string in a file with a new string. This is used for making precise edits.", - strict: true, - parameters: z.toJSONSchema(SearchAndReplaceParametersSchema), - }, -} satisfies OpenAI.Chat.ChatCompletionTool diff --git a/src/core/tools/SearchAndReplaceTool.ts b/src/core/tools/SearchAndReplaceTool.ts index 49d159f4557..1dee5ef0e38 100644 --- a/src/core/tools/SearchAndReplaceTool.ts +++ b/src/core/tools/SearchAndReplaceTool.ts @@ -120,13 +120,18 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { return } + const useCrLf_kilocode = fileContent.includes("\r\n") + // Apply all operations sequentially let newContent = fileContent const errors: string[] = [] for (let i = 0; i < operations.length; i++) { const { search, replace } = operations[i] - const searchPattern = new RegExp(escapeRegExp(search), "g") + const searchPattern = new RegExp( + escapeRegExp(normalizeLineEndings_kilocode(search, useCrLf_kilocode)), + "g", + ) const matchCount = newContent.match(searchPattern)?.length ?? 0 if (matchCount === 0) { @@ -142,7 +147,7 @@ export class SearchAndReplaceTool extends BaseTool<"search_and_replace"> { } // Apply the replacement - newContent = newContent.replace(searchPattern, replace) + newContent = newContent.replace(searchPattern, normalizeLineEndings_kilocode(replace, useCrLf_kilocode)) } // If all operations failed, return error @@ -303,4 +308,8 @@ function escapeRegExp(input: string): string { return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") } +function normalizeLineEndings_kilocode(input: string, useCrLf: boolean): string { + return input.replaceAll(/\r?\n/g, useCrLf ? "\r\n" : "\n") +} + export const searchAndReplaceTool = new SearchAndReplaceTool() diff --git a/webview-ui/src/components/chat/ChatView.tsx b/webview-ui/src/components/chat/ChatView.tsx index c129128f4d5..80d06b3633a 100644 --- a/webview-ui/src/components/chat/ChatView.tsx +++ b/webview-ui/src/components/chat/ChatView.tsx @@ -1052,6 +1052,7 @@ const ChatViewComponent: React.ForwardRefRenderFunction = ({ sessionLabel, isActive = false, showCancel = false, - autoMode = false, showFinishToBranch = false, worktreeBranchName, sessionStatus, @@ -46,33 +44,41 @@ export const ChatInput: React.FC = ({ const trimmedMessage = messageText.trim() const isEmpty = trimmedMessage.length === 0 - const isSessionRunning = sessionStatus === "running" + const isSessionCompleted = sessionStatus === "done" || sessionStatus === "error" || sessionStatus === "stopped" - // Input is disabled when: - // - In auto mode (non-interactive) - // - Session is not in "running" status (done, stopped, error, etc.) - const inputDisabled = autoMode || !isSessionRunning - // Send is disabled when: empty, auto-mode, or session not running - // Note: Users CAN queue multiple messages while one is sending - const sendDisabled = isEmpty || autoMode || !isSessionRunning + // Send is disabled when empty + // Note: Users CAN queue multiple messages while one is sending (for running sessions) + // Note: Users CAN send messages to completed sessions (to resume them) + const sendDisabled = isEmpty const handleSend = () => { - if (isEmpty || autoMode || !isSessionRunning) return + if (isEmpty) return - // Queue the message instead of sending directly - const queuedMsg = addToQueue({ sessionId, content: trimmedMessage }) - - if (queuedMsg) { - // Notify the extension that a message has been queued + if (isSessionCompleted) { + // Resume a completed session with a new message (sent directly, not queued) vscode.postMessage({ - type: "agentManager.messageQueued", + type: "agentManager.resumeSession", sessionId, - messageId: queuedMsg.id, sessionLabel, content: trimmedMessage, }) - setMessageText("") + } else { + // For running sessions, queue the message instead of sending directly + const queuedMsg = addToQueue({ sessionId, content: trimmedMessage }) + + if (queuedMsg) { + // Notify the extension that a message has been queued + vscode.postMessage({ + type: "agentManager.messageQueued", + sessionId, + messageId: queuedMsg.id, + sessionLabel, + content: trimmedMessage, + }) + + setMessageText("") + } } } @@ -123,14 +129,7 @@ export const ChatInput: React.FC = ({ onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} aria-label={t("chatInput.ariaLabel")} - placeholder={ - !isSessionRunning - ? t("chatInput.sessionEnded") - : autoMode - ? t("chatInput.autoMode") - : t("chatInput.placeholderTypeTask") - } - disabled={inputDisabled} + placeholder={t("chatInput.placeholderTypeTask")} minRows={3} maxRows={15} className={cn( @@ -221,10 +220,9 @@ export const ChatInput: React.FC = ({ )} - + + {hasOutput && ( + + )} + + + + {/* Output */} + {hasOutput && ( +
+
+
+								{output}
+							
+
+
+ )} + + ) + }, +) + +CommandExecutionBlock.displayName = "CommandExecutionBlock" + +/** + * Status indicator dot/spinner + */ +function StatusIndicator({ status }: { status: CommandStatus }) { + switch (status) { + case "pending": + return
+ case "running": + return + case "success": + return
+ case "error": + return
+ } +} diff --git a/webview-ui/src/kilocode/agent-manager/components/MessageList.tsx b/webview-ui/src/kilocode/agent-manager/components/MessageList.tsx index 7e87f8c0b2d..43b320b2d6f 100644 --- a/webview-ui/src/kilocode/agent-manager/components/MessageList.tsx +++ b/webview-ui/src/kilocode/agent-manager/components/MessageList.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useCallback } from "react" +import React, { useEffect, useRef, useCallback, useMemo } from "react" import { useAtomValue, useSetAtom } from "jotai" import { useTranslation } from "react-i18next" import { sessionMessagesAtomFamily } from "../state/atoms/messages" @@ -12,8 +12,10 @@ import { import type { QueuedMessage } from "../state/atoms/messageQueue" import type { ClineMessage, SuggestionItem, FollowUpData } from "@roo-code/types" import { safeJsonParse } from "@roo/safeJsonParse" +import { combineCommandSequences } from "@roo/combineCommandSequences" import { SimpleMarkdown } from "./SimpleMarkdown" import { FollowUpSuggestions } from "./FollowUpSuggestions" +import { CommandExecutionBlock } from "./CommandExecutionBlock" import { vscode } from "../utils/vscode" import { MessageCircle, @@ -32,6 +34,25 @@ interface MessageListProps { sessionId: string } +// Parse exit code from various formats (number, string, etc.) +function parseExitCode(raw: unknown): number | undefined { + if (typeof raw === "number") return raw + if (typeof raw === "string" && raw.trim() && !Number.isNaN(Number(raw))) return Number(raw) + return undefined +} + +// Extract execution metadata from command output message +function extractCommandMetadata(msg: ClineMessage): { exitCode?: number; status?: string; isRunning?: boolean } | null { + const metadata = msg.metadata as Record | undefined + if (!metadata) return null + + return { + exitCode: parseExitCode(metadata.exitCode), + status: typeof metadata.status === "string" ? metadata.status : undefined, + isRunning: msg.partial ?? false, + } +} + /** * Displays messages for a session from Jotai state. */ @@ -45,6 +66,33 @@ export function MessageList({ sessionId }: MessageListProps) { const removeFromQueue = useSetAtom(removeFromQueueAtom) const containerRef = useRef(null) + // Combine command and command_output messages into single entries + const combinedMessages = useMemo(() => combineCommandSequences(messages), [messages]) + + const commandExecutionByTs = useMemo(() => { + const info = new Map() + + for (let i = 0; i < messages.length; i++) { + const msg = messages[i] + if (msg.type !== "ask" || msg.ask !== "command") continue + + let data: ReturnType = null + + // Find output messages following this command + for (let j = i + 1; j < messages.length; j++) { + const next = messages[j] + if (next.type === "ask" && next.ask === "command") break + if (next.ask !== "command_output" && next.say !== "command_output") continue + + data = extractCommandMetadata(next) + } + + if (data) info.set(msg.ts, data) + } + + return info + }, [messages]) + // Auto-scroll to bottom when new messages arrive useEffect(() => { if (containerRef.current) { @@ -55,7 +103,7 @@ export function MessageList({ sessionId }: MessageListProps) { } }) } - }, [messages]) + }, [combinedMessages]) const handleSuggestionClick = useCallback( (suggestion: SuggestionItem) => { @@ -101,10 +149,12 @@ export function MessageList({ sessionId }: MessageListProps) { return (
- {messages.map((msg, idx) => ( + {combinedMessages.map((msg, idx) => ( @@ -143,11 +193,13 @@ function extractFollowUpData(message: ClineMessage): { question: string; suggest interface MessageItemProps { message: ClineMessage + isLast: boolean + commandExecutionByTs: Map onSuggestionClick?: (suggestion: SuggestionItem) => void onCopyToInput?: (suggestion: SuggestionItem) => void } -function MessageItem({ message, onSuggestionClick, onCopyToInput }: MessageItemProps) { +function MessageItem({ message, isLast, commandExecutionByTs, onSuggestionClick, onCopyToInput }: MessageItemProps) { const { t } = useTranslation("agentManager") // --- 1. Determine Message Style & Content --- @@ -200,7 +252,8 @@ function MessageItem({ message, onSuggestionClick, onCopyToInput }: MessageItemP } case "api_req_finished": case "checkpoint_saved": - return null // Skip internal messages + case "command_output": + return null // Skip internal messages (command_output is combined with command) default: content = } @@ -224,9 +277,22 @@ function MessageItem({ message, onSuggestionClick, onCopyToInput }: MessageItemP case "command": { icon = title = t("messages.command") - content = + const execInfo = commandExecutionByTs.get(message.ts) + content = ( + + ) break } + case "command_output": { + // Skip standalone command_output - combined with command message + return null + } case "tool": { // Tool info can be in metadata (from CLI) or parsed from text const metadata = message.metadata as { tool?: string; path?: string; todos?: unknown[] } | undefined diff --git a/webview-ui/src/kilocode/agent-manager/components/SessionDetail.tsx b/webview-ui/src/kilocode/agent-manager/components/SessionDetail.tsx index 0c2f23b5a3d..62e9ff3c764 100644 --- a/webview-ui/src/kilocode/agent-manager/components/SessionDetail.tsx +++ b/webview-ui/src/kilocode/agent-manager/components/SessionDetail.tsx @@ -180,7 +180,6 @@ export function SessionDetail() { sessionLabel={selectedSession.label} isActive={isActive} showCancel={isActive} - autoMode={selectedSession.autoMode} showFinishToBranch={canFinishWorktree} worktreeBranchName={branchName} sessionStatus={selectedSession.status} diff --git a/webview-ui/src/kilocode/agent-manager/components/__tests__/CommandExecutionBlock.spec.tsx b/webview-ui/src/kilocode/agent-manager/components/__tests__/CommandExecutionBlock.spec.tsx new file mode 100644 index 00000000000..e5640dbc8dd --- /dev/null +++ b/webview-ui/src/kilocode/agent-manager/components/__tests__/CommandExecutionBlock.spec.tsx @@ -0,0 +1,325 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import { CommandExecutionBlock } from "../CommandExecutionBlock" +import { COMMAND_OUTPUT_STRING } from "@roo/combineCommandSequences" + +// Mock react-i18next +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (key: string) => { + const translations: Record = { + "messages.copyCommand": "Copy command", + "messages.expandOutput": "Expand output", + "messages.collapseOutput": "Collapse output", + } + return translations[key] || key + }, + }), + initReactI18next: { type: "3rdParty", init: () => {} }, +})) + +// Mock clipboard API +const mockClipboard = { + writeText: vi.fn().mockResolvedValue(undefined), +} +Object.assign(navigator, { clipboard: mockClipboard }) + +describe("CommandExecutionBlock", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe("parsing command and output", () => { + it("renders command only when no output", () => { + render() + + expect(screen.getByText("ls -la")).toBeInTheDocument() + }) + + it("renders command and output when both present", () => { + const text = `echo hello${COMMAND_OUTPUT_STRING}hello` + render() + + expect(screen.getByText("echo hello")).toBeInTheDocument() + expect(screen.getByText("hello")).toBeInTheDocument() + }) + + it("handles empty text gracefully", () => { + const { container } = render() + + // Should render without crashing + expect(container.querySelector(".bg-vscode-editor-background")).toBeInTheDocument() + }) + + it("cleans up command_output text from output", () => { + const text = `npm run build${COMMAND_OUTPUT_STRING}command_output\nBuild successful` + render() + + expect(screen.getByText("npm run build")).toBeInTheDocument() + expect(screen.getByText("Build successful")).toBeInTheDocument() + // Should not show the literal "command_output" text + expect(screen.queryByText(/^command_output$/)).not.toBeInTheDocument() + }) + + it("does not render output panel for ANSI-only output", () => { + const text = `cmd${COMMAND_OUTPUT_STRING}\u001b[2J\u001b[H\u001b[2K` + const { container } = render() + + // Output pre has `text-xs` class, command pre does not. + expect(container.querySelector("pre.text-xs")).not.toBeInTheDocument() + }) + }) + + describe("status indicators", () => { + it("shows pending indicator (yellow) when no output and not running", () => { + const { container } = render() + + expect(container.querySelector(".bg-yellow-500\\/70")).toBeInTheDocument() + }) + + it("shows running indicator (spinner) when isRunning and no output", () => { + const { container } = render() + + expect(container.querySelector(".animate-spin")).toBeInTheDocument() + }) + + it("shows running indicator (spinner) when Output marker present and isLast but no output", () => { + const text = `npm install\n${COMMAND_OUTPUT_STRING}` + const { container } = render() + + expect(container.querySelector(".animate-spin")).toBeInTheDocument() + }) + + it("shows error indicator (red) when exitCode is non-zero", () => { + const text = `exit 1\n${COMMAND_OUTPUT_STRING}` + const { container } = render() + + expect(container.querySelector(".bg-red-500")).toBeInTheDocument() + }) + + it("shows success indicator (green) when exitCode is 0", () => { + const text = `echo ok\n${COMMAND_OUTPUT_STRING}` + const { container } = render() + + expect(container.querySelector(".bg-green-500")).toBeInTheDocument() + }) + + it("shows success indicator (green) when Output marker present but not last and no output", () => { + const text = `npm install\n${COMMAND_OUTPUT_STRING}` + const { container } = render() + + expect(container.querySelector(".bg-green-500")).toBeInTheDocument() + }) + + it("shows success indicator (green) when has output without exitCode", () => { + const text = `echo hello${COMMAND_OUTPUT_STRING}hello` + const { container } = render() + + expect(container.querySelector(".bg-green-500")).toBeInTheDocument() + }) + }) + + describe("deterministic exit code based error detection", () => { + it("does not treat error-like text as errors without exit code", () => { + const text = `some-command${COMMAND_OUTPUT_STRING}Error: something went wrong` + const { container } = render() + + // Without exitCode, output text patterns are ignored - should show success (green) + expect(container.querySelector(".bg-green-500")).toBeInTheDocument() + }) + + it("trusts exit code over output content", () => { + const text = `some-command${COMMAND_OUTPUT_STRING}Build successful` + const { container } = render() + + // Even with successful output, non-zero exit code means error (red) + expect(container.querySelector(".bg-red-500")).toBeInTheDocument() + }) + + it("shows green indicator when exitCode is 0 regardless of output", () => { + const text = `some-command${COMMAND_OUTPUT_STRING}Error: something` + const { container } = render() + + // Exit code 0 always means success (green), even with "Error" in output + expect(container.querySelector(".bg-green-500")).toBeInTheDocument() + }) + }) + + describe("copy button", () => { + it("copies command to clipboard when clicked", async () => { + render() + + const copyButton = screen.getByTitle("Copy command") + fireEvent.click(copyButton) + + await waitFor(() => { + expect(mockClipboard.writeText).toHaveBeenCalledWith("npm install") + }) + }) + + it("shows check icon after copying", async () => { + render() + + const copyButton = screen.getByTitle("Copy command") + fireEvent.click(copyButton) + + // Wait for the check icon to appear (indicates copy success) + await waitFor(() => { + expect(mockClipboard.writeText).toHaveBeenCalled() + }) + }) + }) + + describe("expand/collapse output", () => { + it("shows expand button when there is output", () => { + const text = `npm run build${COMMAND_OUTPUT_STRING}Build successful` + render() + + expect(screen.getByTitle("Collapse output")).toBeInTheDocument() + }) + + it("does not show expand button when no output", () => { + render() + + expect(screen.queryByTitle("Expand output")).not.toBeInTheDocument() + expect(screen.queryByTitle("Collapse output")).not.toBeInTheDocument() + }) + + it("toggles output visibility when clicked", () => { + const text = `npm run build${COMMAND_OUTPUT_STRING}Build successful` + render() + + // Initially expanded (shows "Collapse output") + const toggleButton = screen.getByTitle("Collapse output") + fireEvent.click(toggleButton) + + // Now collapsed (shows "Expand output") + expect(screen.getByTitle("Expand output")).toBeInTheDocument() + }) + }) + + describe("output styling", () => { + it("applies red text color when exitCode indicates error", () => { + const text = `npm run build${COMMAND_OUTPUT_STRING}Build output` + const { container } = render() + + // Check that the error output has red text + const outputPre = container.querySelector("pre.text-red-400") + expect(outputPre).toBeInTheDocument() + // Background should match editor background + const outputDiv = container.querySelector(".bg-vscode-editor-background") + expect(outputDiv).toBeInTheDocument() + }) + + it("applies normal text color when command succeeds (exitCode 0)", () => { + const text = `npm run build${COMMAND_OUTPUT_STRING}Build successful` + const { container } = render() + + // Check that successful output has normal text color + const outputPre = container.querySelector("pre.text-vscode-descriptionForeground") + expect(outputPre).toBeInTheDocument() + // Background should match editor background for all outputs + const outputDiv = container.querySelector(".bg-vscode-editor-background") + expect(outputDiv).toBeInTheDocument() + }) + + it("applies normal text color when output exists without exitCode", () => { + const text = `npm run build${COMMAND_OUTPUT_STRING}Build output` + const { container } = render() + + // Without exitCode, it's treated as success and uses normal color + const outputPre = container.querySelector("pre.text-vscode-descriptionForeground") + expect(outputPre).toBeInTheDocument() + }) + }) + + describe("exit code reliability for error detection", () => { + it("shows red indicator for exit code 127 (command not found)", () => { + const text = `nonexistent_command${COMMAND_OUTPUT_STRING}command not found` + const { container } = render() + + expect(container.querySelector(".bg-red-500")).toBeInTheDocument() + }) + + it("shows red indicator for exit code 2 (stderr output)", () => { + const text = `ls /invalid${COMMAND_OUTPUT_STRING}No such file or directory` + const { container } = render() + + expect(container.querySelector(".bg-red-500")).toBeInTheDocument() + }) + + it("shows green indicator for exit code 0 even with 'error' in output text", () => { + const text = `test_check${COMMAND_OUTPUT_STRING}Checked 5 items, 0 errors found` + const { container } = render() + + expect(container.querySelector(".bg-green-500")).toBeInTheDocument() + }) + + it("shows red indicator for exit code 1 even with 'success' in output text", () => { + const text = `failing_script${COMMAND_OUTPUT_STRING}Build failed: success unlikely` + const { container } = render() + + expect(container.querySelector(".bg-red-500")).toBeInTheDocument() + }) + + it("shows error indicator when terminalStatus is timeout even with other factors", () => { + // terminalStatus=timeout overrides other status indicators + const text = `long_cmd${COMMAND_OUTPUT_STRING}Still processing` + const { container } = render() + + // terminalStatus=timeout means error + expect(container.querySelector(".bg-red-500")).toBeInTheDocument() + }) + + it("shows red indicator when terminalStatus is timeout without exitCode", () => { + const text = `long_cmd${COMMAND_OUTPUT_STRING}Still running...` + const { container } = render() + + expect(container.querySelector(".bg-red-500")).toBeInTheDocument() + }) + }) + + describe("metadata extraction from command output messages", () => { + it("correctly extracts and applies exitCode metadata to determine status", () => { + // This test verifies the complete flow: metadata → component prop → status indicator + const text = `failing_cmd${COMMAND_OUTPUT_STRING}Error details` + + // When exitCode metadata is provided, it should result in red indicator + const { container } = render( + , + ) + + // Verify red indicator (error status) + expect(container.querySelector(".bg-red-500")).toBeInTheDocument() + // Verify no green indicator + expect(container.querySelector(".bg-green-500")).not.toBeInTheDocument() + }) + + it("displays red error output text when exitCode indicates failure", () => { + const text = `git_push${COMMAND_OUTPUT_STRING}fatal: Authentication failed` + const { container } = render() + + // Should have red error indicator + expect(container.querySelector(".bg-red-500")).toBeInTheDocument() + // Output should be visible and colored red + const outputPre = container.querySelector("pre.text-red-400") + expect(outputPre).toBeInTheDocument() + expect(outputPre?.textContent).toContain("fatal: Authentication failed") + }) + + it("displays green success output text when exitCode is 0", () => { + const text = `npm_test${COMMAND_OUTPUT_STRING}All tests passed: 42` + const { container } = render() + + // Should have green success indicator + expect(container.querySelector(".bg-green-500")).toBeInTheDocument() + // Output should be visible and not red + const outputPre = container.querySelector("pre.text-vscode-descriptionForeground") + expect(outputPre).toBeInTheDocument() + expect(outputPre?.textContent).toContain("All tests passed") + }) + }) +}) diff --git a/webview-ui/src/kilocode/agent-manager/components/__tests__/MessageList.spec.tsx b/webview-ui/src/kilocode/agent-manager/components/__tests__/MessageList.spec.tsx index ada718ae750..a5e8d627ae1 100644 --- a/webview-ui/src/kilocode/agent-manager/components/__tests__/MessageList.spec.tsx +++ b/webview-ui/src/kilocode/agent-manager/components/__tests__/MessageList.spec.tsx @@ -11,6 +11,10 @@ vi.mock("react-i18next", () => ({ useTranslation: () => ({ t: (key: string) => key, }), + initReactI18next: { + type: "3rdParty", + init: () => {}, + }, })) // Mock vscode postMessage