From 6b3dd8ce29b887354eb75c1e0c450be7766d5329 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Wed, 8 Apr 2026 00:22:54 -0400 Subject: [PATCH 01/39] docs(kilo-docs): document ask and deny permission levels for MCP tools The MCP tool permissions section previously only showed "allow". Document all three levels (allow, ask, deny) with examples including wildcard patterns and top-to-bottom evaluation order. --- .../pages/automate/mcp/using-in-cli.md | 31 +++++++++++++++++++ .../pages/automate/mcp/using-in-kilo-code.md | 28 +++++++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md index eae0fb4219..61b0695c11 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md @@ -159,6 +159,37 @@ Add the test MCP server for development: } ``` +## Tool Permissions + +MCP tool calls use the same permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). + +There are three permission levels: + +| Permission | Behavior | +| ---------- | ----------------------------------------------------------------------------------------------------------------- | +| `"allow"` | Tool calls are auto-approved without prompting. | +| `"ask"` | A prompt appears each time the tool is called, requiring manual approval. This is the default if no rule matches. | +| `"deny"` | Tool calls are blocked entirely. | + +Add the tool name (or a wildcard pattern) to the `permission` key in your `kilo.json`: + +```jsonc +{ + "permission": { + // Auto-approve a specific tool + "my_server_safe_read": "allow", + + // Require approval for all other tools on this server + "my_server_*": "ask", + + // Block a dangerous tool entirely + "my_server_delete_all": "deny", + }, +} +``` + +Glob patterns are evaluated top-to-bottom and the first match wins. This lets you allow specific safe tools while requiring approval for everything else on a server. + ## Environment Variables Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variables: diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md index 72f9994d1d..4611c57c48 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md @@ -422,26 +422,42 @@ To set the maximum time to wait for a response after a tool call to the MCP serv {% /tab %} {% /tabs %} -### Auto Approve Tools +### Tool Permissions {% tabs %} {% tab label="VSCode" %} MCP tool calls use the same permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). -**At runtime:** When an MCP tool is called, the Permission Dock shows an approval prompt. Click **Approve Always** to save an allow rule to your config so future calls to that tool are auto-approved. +There are three permission levels: + +| Permission | Behavior | +| ---------- | ----------------------------------------------------------------------------------------------------------------- | +| `"allow"` | Tool calls are auto-approved without prompting. | +| `"ask"` | A prompt appears each time the tool is called, requiring manual approval. This is the default if no rule matches. | +| `"deny"` | Tool calls are blocked entirely. | + +**At runtime:** When an MCP tool is called and no permission rule matches, the Permission Dock shows an approval prompt (equivalent to `"ask"`). Click **Approve Always** to save an `"allow"` rule to your config so future calls to that tool are auto-approved. **In your config file:** Add the tool name (or a wildcard pattern) to the `permission` key in `kilo.jsonc`: -```json +```jsonc { "permission": { - "my_server_do_something": "allow", - "my_server_*": "allow" - } + // Auto-approve a specific tool + "my_server_safe_read": "allow", + + // Require approval for all other tools on this server + "my_server_*": "ask", + + // Block a dangerous tool entirely + "my_server_delete_all": "deny", + }, } ``` +Glob patterns are evaluated top-to-bottom and the first match wins, so you can allow specific safe tools while requiring approval for everything else on a server. + {% /tab %} {% tab label="VSCode (Legacy)" %} From f3ce67fa26d0d2cfb323ed9a42c6450a2e8c8625 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Thu, 9 Apr 2026 11:56:27 -0400 Subject: [PATCH 02/39] docs(kilo-docs): update Auto Balanced docs to reflect Qwen 3.6 Plus Update all three docs files that reference the balanced tier model mappings to reflect the switch from Kimi K2.5 / Minimax M2.7 to Qwen 3.6 Plus. --- .../pages/code-with-ai/agents/auto-model.md | 32 ++++++++----------- .../architecture/auto-model-tiers.md | 2 +- .../pages/gateway/models-and-providers.md | 12 +++---- 3 files changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index 69ec4bdc5f..b338a4d859 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -45,29 +45,23 @@ That's it. No configuration needed. ## Auto Balanced -`kilo-auto/balanced` follows the same mode-based routing structure as Frontier but uses more cost-effective models — Kimi K2.5 for reasoning-heavy modes and Minimax M2.7 for implementation modes. +`kilo-auto/balanced` follows the same mode-based routing structure as Frontier but uses a more cost-effective model — Qwen 3.6 Plus across all modes. ### Mode-to-Model Mapping -| Mode | Model Used | Best For | -| -------------- | ------------ | ---------------------------- | -| `architect` | Kimi K2.5 | System design, planning | -| `orchestrator` | Kimi K2.5 | Multi-step task coordination | -| `ask` | Kimi K2.5 | Questions, explanations | -| `plan` | Kimi K2.5 | Planning, reasoning | -| `general` | Kimi K2.5 | General assistance | -| `debug` | Kimi K2.5 | Debugging and fixing issues | -| `code` | Minimax M2.7 | Writing and editing code | -| `build` | Minimax M2.7 | Implementation tasks | -| `explore` | Minimax M2.7 | Codebase exploration | +| Mode | Model Used | Best For | +| -------------- | ------------- | ---------------------------- | +| `architect` | Qwen 3.6 Plus | System design, planning | +| `orchestrator` | Qwen 3.6 Plus | Multi-step task coordination | +| `ask` | Qwen 3.6 Plus | Questions, explanations | +| `plan` | Qwen 3.6 Plus | Planning, reasoning | +| `general` | Qwen 3.6 Plus | General assistance | +| `debug` | Qwen 3.6 Plus | Debugging and fixing issues | +| `code` | Qwen 3.6 Plus | Writing and editing code | +| `build` | Qwen 3.6 Plus | Implementation tasks | +| `explore` | Qwen 3.6 Plus | Codebase exploration | -**Planning and reasoning tasks** use Kimi K2.5, a strong open-weight reasoning model from Moonshot AI. - -**Implementation tasks** use Minimax M2.7, which provides fast, capable code generation at a fraction of frontier model costs. - -{% callout type="info" title="Image support" %} -Auto Balanced does not support image inputs, since Minimax M2.7 does not have vision capabilities. -{% /callout %} +**All tasks** use Qwen 3.6 Plus, a strong multimodal reasoning model with 1M context window, fast code generation, and vision capabilities — at a fraction of frontier model costs. ## Benefits diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index 962d5af20f..67154ed013 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -52,7 +52,7 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Cost-conscious developers who want better results than free models at a fraction of frontier cost. -**What it does**: Follows the same mode-based routing structure as Frontier but uses cost-effective open-weight models for both reasoning and implementation tasks. +**What it does**: Follows the same mode-based routing structure as Frontier but uses Qwen 3.6 Plus — a cost-effective multimodal model with strong reasoning and coding capabilities — across all modes. **Pricing**: Paid, but significantly cheaper than Frontier. diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 1a902b2c39..1a2473fbd3 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -80,13 +80,13 @@ Routes to the most capable paid models optimizing for cost, performance, and cap ### `kilo-auto/balanced` -Follows the same mode-based routing as Frontier but uses more cost-effective models. +Follows the same mode-based routing as Frontier but uses a more cost-effective model. -| Mode | Resolved Model | -| -------------------------------------------------------------- | ---------------------- | -| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `moonshotai/kimi-k2.5` | -| `build`, `explore`, `code` | `minimax/minimax-m2.7` | -| Default (no mode specified) | `minimax/minimax-m2.7` | +| Mode | Resolved Model | +| -------------------------------------------------------------- | ------------------- | +| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `qwen/qwen3.6-plus` | +| `build`, `explore`, `code` | `qwen/qwen3.6-plus` | +| Default (no mode specified) | `qwen/qwen3.6-plus` | ### `kilo-auto/free` From d7b3760eec637733d88b9f214be91df35c270b5e Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Thu, 9 Apr 2026 19:42:34 +0000 Subject: [PATCH 03/39] docs(kilo-docs): update gateway models and BYOK documentation - Update models-and-providers.md with real model IDs from the API - Remove Providers and Provider routing sections (implementation details) - Remove internal /providers and /models-by-provider API links - Add kilo-auto/small virtual model documentation - Update BYOK provider table with all supported providers including AWS Bedrock, Inception, BytePlus Coding Plan, Kimi Code, Neuralwatt, and Z.AI Coding Plan - Replace BYOK routing section with user-facing description - Note org-level vs personal BYOK key management --- .../kilo-docs/pages/gateway/authentication.md | 42 +++---- .../pages/gateway/models-and-providers.md | 115 +++++------------- 2 files changed, 52 insertions(+), 105 deletions(-) diff --git a/packages/kilo-docs/pages/gateway/authentication.md b/packages/kilo-docs/pages/gateway/authentication.md index 7d1b484e53..2c5862ca93 100644 --- a/packages/kilo-docs/pages/gateway/authentication.md +++ b/packages/kilo-docs/pages/gateway/authentication.md @@ -74,38 +74,36 @@ Free models include models tagged with `:free` in their model ID, such as `minim ## Bring Your Own Key (BYOK) -BYOK lets you use your own provider API keys with the Kilo AI Gateway. When a BYOK key is configured, the gateway routes requests through Vercel AI Gateway using your key. You are billed directly by the provider -- Kilo does not add any markup. +BYOK lets you use your own provider API keys with the Kilo AI Gateway. When a BYOK key is configured, requests are sent to the provider using your key. You are billed directly by the provider -- Kilo does not add any markup. ### Supported BYOK providers -| Provider | BYOK Key ID | -| ---------------- | ----------- | -| Anthropic | `anthropic` | -| OpenAI | `openai` | -| Google AI Studio | `google` | -| Mistral | `mistral` | -| MiniMax | `minimax` | -| xAI | `xai` | -| Z.AI | `zai` | -| Codestral (FIM) | `codestral` | +| Provider | BYOK Key ID | +| -------------------- | ----------------- | +| Anthropic | `anthropic` | +| AWS Bedrock | `bedrock` | +| Google AI Studio | `google` | +| Inception | `inception` | +| OpenAI | `openai` | +| MiniMax | `minimax` | +| Mistral | `mistral` | +| xAI | `xai` | +| Z.AI | `zai` | +| BytePlus Coding Plan | `byteplus-coding` | +| Codestral (FIM) | `codestral` | +| Kimi Code | `kimi-coding` | +| Neuralwatt | `neuralwatt` | +| Z.AI Coding Plan | `zai-coding` | ### How BYOK works -1. Add your provider API key in the Kilo dashboard or through your Kilo Code extension settings -2. Keys are encrypted at rest using AES encryption +1. Add your provider API key in the [Kilo dashboard](https://app.kilo.ai) or through your Kilo Code extension settings +2. Keys are encrypted at rest using AES-256 encryption 3. When you make a request for a model from that provider, the gateway automatically uses your key 4. Usage is tracked but not billed to your Kilo balance (cost is set to $0) 5. If your BYOK key fails, the request will not automatically fall back to Kilo's keys -### BYOK routing - -When a BYOK key is detected, the request is routed through Vercel AI Gateway with your credentials: - -``` -Client → Kilo Gateway → Vercel AI Gateway (with your key) → Provider -``` - -This provides the benefit of Vercel's reliability infrastructure while using your own billing relationship with the provider. +BYOK keys can be configured at the personal level or at the organization level. Organization-level keys apply to all members of the organization and require owner or billing manager access to manage. ## Request headers diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 1a902b2c39..a2ae0bb8fb 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -1,11 +1,11 @@ --- title: "Models & Providers" -description: "Learn about the AI models and providers available through the Kilo AI Gateway, including model IDs, routing behavior, and provider-specific features." +description: "Learn about the AI models available through the Kilo AI Gateway, including model IDs and how to use them." --- # Models & Providers -The Kilo AI Gateway provides access to hundreds of AI models from multiple providers through a single unified API. You can switch between models by changing the model ID string -- no code changes required. +The Kilo AI Gateway provides access to hundreds of AI models through a single unified API. You can switch between models by changing the model ID string -- no code changes required. ## Specifying a model @@ -44,23 +44,27 @@ This returns model information including pricing, context window, and supported | `anthropic/claude-opus-4.6` | Anthropic | Most capable Claude model for complex reasoning | | `anthropic/claude-sonnet-4.6` | Anthropic | Balanced performance and cost | | `anthropic/claude-haiku-4.5` | Anthropic | Fast and cost-effective | -| `openai/gpt-5.2` | OpenAI | Latest GPT model | -| `google/gemini-3-pro-preview` | Google | Advanced reasoning with 1M context | -| `google/gemini-3-flash-preview` | Google | Fast and efficient | +| `openai/gpt-5.4` | OpenAI | Latest GPT model | +| `openai/gpt-5.4-mini` | OpenAI | Fast and efficient | +| `google/gemini-3.1-pro-preview` | Google | Advanced reasoning | +| `google/gemini-2.5-flash` | Google | Fast and efficient | +| `x-ai/grok-4` | xAI | Most capable Grok model | | `x-ai/grok-code-fast-1` | xAI | Optimized for code tasks | -| `moonshotai/kimi-k2.5` | Moonshot | Strong multilingual model | +| `deepseek/deepseek-v3.2` | DeepSeek | Strong coding and reasoning model | +| `moonshotai/kimi-k2.5` | Moonshot | Strong coding and multilingual model | +| `minimax/minimax-m2.7` | MiniMax | High-performance MoE model | ### Free models Several models are available at no cost, subject to rate limits: -| Model ID | Description | -| ------------------------------------- | ------------------------- | -| `minimax/minimax-m2.1:free` | MiniMax M2.1 | -| `z-ai/glm-5:free` | Z.AI GLM-5 | -| `giga-potato` | Community model | -| `corethink:free` | CoreThink reasoning model | -| `arcee-ai/trinity-large-preview:free` | Arcee Trinity | +| Model ID | Description | +| ---------------------------------------- | ------------------------------ | +| `bytedance-seed/dola-seed-2.0-pro:free` | ByteDance Dola Seed 2.0 Pro | +| `x-ai/grok-code-fast-1:optimized:free` | xAI Grok Code Fast 1 Optimized | +| `nvidia/nemotron-3-super-120b-a12b:free` | NVIDIA Nemotron 3 Super 120B | +| `arcee-ai/trinity-large-thinking:free` | Arcee Trinity Large | +| `openrouter/free` | Best available free model | Free models are available to both authenticated and anonymous users. Anonymous users are rate-limited to 200 requests per hour per IP address. @@ -70,7 +74,7 @@ Kilo Auto virtual models automatically select the best underlying model based on ### `kilo-auto/frontier` -Routes to the most capable paid models optimizing for cost, performance, and capabilities. +Highest performance and capability for any task. | Mode | Resolved Model | | -------------------------------------------------------------- | ----------------------------- | @@ -80,7 +84,7 @@ Routes to the most capable paid models optimizing for cost, performance, and cap ### `kilo-auto/balanced` -Follows the same mode-based routing as Frontier but uses more cost-effective models. +Great balance of price and capability. | Mode | Resolved Model | | -------------------------------------------------------------- | ---------------------- | @@ -90,12 +94,20 @@ Follows the same mode-based routing as Frontier but uses more cost-effective mod ### `kilo-auto/free` -The best available free model for each mode. +Free with limited capability. No credits required. -| Mode | Resolved Model | -| --------------------------- | --------------------------- | -| All modes | `minimax/minimax-m2.5:free` | -| Default (no mode specified) | `minimax/minimax-m2.5:free` | +| Mode | Resolved Model | +| --------- | ---------------------- | +| All modes | `minimax/minimax-m2.5` | + +### `kilo-auto/small` + +Automatically routes to a small, fast model. + +| Mode | Resolved Model | +| ------------- | -------------------- | +| Default | `openai/gpt-5-nano` | +| Free fallback | `openai/gpt-oss-20b` | ### Example usage @@ -115,66 +127,3 @@ curl -X POST "https://api.kilo.ai/api/gateway/chat/completions" \ -H "Content-Type: application/json" \ -d '{"model": "kilo-auto/balanced", "messages": [{"role": "user", "content": "Design a database schema"}]}' ``` - -## Providers - -The gateway routes requests to the appropriate provider based on the model and your configuration: - -| Provider | Slug | Description | -| ----------------- | ------------ | ----------------------------------- | -| OpenRouter | `openrouter` | Primary gateway for most models | -| Vercel AI Gateway | `vercel` | BYOK routing and select A/B testing | -| Mistral | `mistral` | FIM completions (Codestral) | -| xAI | `x-ai` | Grok models (direct) | -| MiniMax | `minimax` | MiniMax models (direct) | -| CoreThink | `corethink` | CoreThink reasoning model | -| Inception | `inception` | InceptionLabs models | -| Martian | `martian` | Optimized xAI models | -| StreamLake | `streamlake` | KAT-Coder models | - -## Provider routing - -The gateway uses the following priority for routing requests: - -1. **BYOK check**: If you have a BYOK key for the model's provider, the request is routed through Vercel AI Gateway using your key -2. **Free model routing**: If the model is a Kilo-hosted free model, it's routed to its designated provider -3. **Default routing**: All other requests go through OpenRouter - -### Preferred inference providers - -For models available through multiple providers, the gateway may use a preferred provider for better performance: - -| Model Family | Preferred Provider | -| ---------------- | -------------------- | -| Anthropic models | Amazon Bedrock | -| MiniMax models | MiniMax (direct) | -| Mistral models | Mistral (direct) | -| Moonshot models | Moonshot AI (direct) | - -These preferences are sent as hints to OpenRouter, which may override them based on availability and load. - -## Listing models - -### Models endpoint - -``` -GET https://api.kilo.ai/api/gateway/models -``` - -Returns an OpenAI-compatible list of all available models with metadata including pricing, context window, and capabilities. - -### Providers endpoint - -``` -GET https://api.kilo.ai/api/gateway/providers -``` - -Returns a list of all available inference providers. - -### Models by provider - -``` -GET https://api.kilo.ai/api/gateway/models-by-provider -``` - -Returns models grouped by their provider, useful for building model selection interfaces. From 235f81c2d106b8e60fed272d73e180a3e56dcf84 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Thu, 9 Apr 2026 17:40:58 -0400 Subject: [PATCH 04/39] docs(kilo-docs): switch Auto Balanced docs to GPT 5.3 Codex --- .../pages/code-with-ai/agents/auto-model.md | 22 +++++++++---------- .../architecture/auto-model-tiers.md | 2 +- .../pages/gateway/models-and-providers.md | 10 ++++----- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index b338a4d859..f6d05b0b7d 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -45,23 +45,23 @@ That's it. No configuration needed. ## Auto Balanced -`kilo-auto/balanced` follows the same mode-based routing structure as Frontier but uses a more cost-effective model — Qwen 3.6 Plus across all modes. +`kilo-auto/balanced` follows the same mode-based routing structure as Frontier but uses a more cost-effective model — GPT 5.3 Codex across all modes. ### Mode-to-Model Mapping | Mode | Model Used | Best For | | -------------- | ------------- | ---------------------------- | -| `architect` | Qwen 3.6 Plus | System design, planning | -| `orchestrator` | Qwen 3.6 Plus | Multi-step task coordination | -| `ask` | Qwen 3.6 Plus | Questions, explanations | -| `plan` | Qwen 3.6 Plus | Planning, reasoning | -| `general` | Qwen 3.6 Plus | General assistance | -| `debug` | Qwen 3.6 Plus | Debugging and fixing issues | -| `code` | Qwen 3.6 Plus | Writing and editing code | -| `build` | Qwen 3.6 Plus | Implementation tasks | -| `explore` | Qwen 3.6 Plus | Codebase exploration | +| `architect` | GPT 5.3 Codex | System design, planning | +| `orchestrator` | GPT 5.3 Codex | Multi-step task coordination | +| `ask` | GPT 5.3 Codex | Questions, explanations | +| `plan` | GPT 5.3 Codex | Planning, reasoning | +| `general` | GPT 5.3 Codex | General assistance | +| `debug` | GPT 5.3 Codex | Debugging and fixing issues | +| `code` | GPT 5.3 Codex | Writing and editing code | +| `build` | GPT 5.3 Codex | Implementation tasks | +| `explore` | GPT 5.3 Codex | Codebase exploration | -**All tasks** use Qwen 3.6 Plus, a strong multimodal reasoning model with 1M context window, fast code generation, and vision capabilities — at a fraction of frontier model costs. +**All tasks** use GPT 5.3 Codex, providing strong coding and reasoning performance across all modes at a lower cost than frontier routing. ## Benefits diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index 67154ed013..7964695fc8 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -52,7 +52,7 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Cost-conscious developers who want better results than free models at a fraction of frontier cost. -**What it does**: Follows the same mode-based routing structure as Frontier but uses Qwen 3.6 Plus — a cost-effective multimodal model with strong reasoning and coding capabilities — across all modes. +**What it does**: Follows the same mode-based routing structure as Frontier but uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — across all modes. **Pricing**: Paid, but significantly cheaper than Frontier. diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 1a2473fbd3..edf203d308 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -82,11 +82,11 @@ Routes to the most capable paid models optimizing for cost, performance, and cap Follows the same mode-based routing as Frontier but uses a more cost-effective model. -| Mode | Resolved Model | -| -------------------------------------------------------------- | ------------------- | -| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `qwen/qwen3.6-plus` | -| `build`, `explore`, `code` | `qwen/qwen3.6-plus` | -| Default (no mode specified) | `qwen/qwen3.6-plus` | +| Mode | Resolved Model | +| -------------------------------------------------------------- | ---------------------- | +| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `openai/gpt-5.3-codex` | +| `build`, `explore`, `code` | `openai/gpt-5.3-codex` | +| Default (no mode specified) | `openai/gpt-5.3-codex` | ### `kilo-auto/free` From afb20fcf0c89e77567e0227d29b636e12b2aac27 Mon Sep 17 00:00:00 2001 From: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com> Date: Thu, 9 Apr 2026 17:47:13 -0400 Subject: [PATCH 05/39] Update packages/kilo-docs/pages/code-with-ai/agents/auto-model.md --- packages/kilo-docs/pages/code-with-ai/agents/auto-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index f6d05b0b7d..25453f31ae 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -61,7 +61,7 @@ That's it. No configuration needed. | `build` | GPT 5.3 Codex | Implementation tasks | | `explore` | GPT 5.3 Codex | Codebase exploration | -**All tasks** use GPT 5.3 Codex, providing strong coding and reasoning performance across all modes at a lower cost than frontier routing. +**All tasks** use GPT 5.3 Codex (Low), providing strong coding and reasoning performance across all modes at a lower cost than frontier routing. ## Benefits From ce6b3ce569b1e7c38508683863e46f6fda2906ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 9 Apr 2026 23:25:35 -0300 Subject: [PATCH 06/39] fix: align pre-release version publishing --- packages/script/src/index.ts | 54 +++++++++++++++++++++++++++++------- script/publish.ts | 13 ++++++--- 2 files changed, 53 insertions(+), 14 deletions(-) diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 5ea9d757a6..a4c91a3a76 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -36,6 +36,26 @@ const CHANNEL = await (async () => { const IS_PREVIEW = CHANNEL !== "latest" // kilocode_change start - shared helpers for version computation +function parseVersion(input: string) { + const match = input.trim().match(/^v?(\d+)\.(\d+)\.(\d+)$/) + if (!match) return + return { + major: Number(match[1]), + minor: Number(match[2]), + patch: Number(match[3]), + value: `${match[1]}.${match[2]}.${match[3]}`, + } +} + +function compareVersion( + a: NonNullable>, + b: NonNullable>, +) { + if (a.major !== b.major) return a.major - b.major + if (a.minor !== b.minor) return a.minor - b.minor + return a.patch - b.patch +} + async function fetchLatest() { const data: any = await fetch("https://registry.npmjs.org/@kilocode/cli/latest").then((res) => { if (!res.ok) throw new Error(res.statusText) @@ -44,28 +64,42 @@ async function fetchLatest() { return data.version as string } +async function fetchHighest() { + if (!env.KILO_RELEASE || !process.env.GH_REPO) return fetchLatest() + const data: { tagName: string }[] = await $`gh release list --json tagName --limit 100 --repo ${process.env.GH_REPO}` + .json() + .catch(() => []) + const versions = data.flatMap((item) => { + const version = parseVersion(item.tagName) + if (!version) return [] + return [version] + }) + const highest = versions.sort(compareVersion).at(-1) + if (highest) return highest.value + return fetchLatest() +} + function bumpVersion(current: string, type: string) { - const [major, minor, patch] = current.split(".").map((x: string) => Number(x) || 0) - if (type === "major") return `${major + 1}.0.0` - if (type === "minor") return `${major}.${minor + 1}.0` - return `${major}.${minor}.${patch + 1}` + const version = parseVersion(current) + if (!version) throw new Error(`Invalid version: ${current}`) + if (type === "major") return `${version.major + 1}.0.0` + if (type === "minor") return `${version.major}.${version.minor + 1}.0` + return `${version.major}.${version.minor}.${version.patch + 1}` } // kilocode_change end const VERSION = await (async () => { if (env.KILO_VERSION) return env.KILO_VERSION // kilocode_change if (IS_PREVIEW) { - // kilocode_change start - compute semver prerelease for rc channel + // kilocode_change start - rc releases use plain semver required by VS Code Marketplace if (env.KILO_BUMP && env.KILO_PRE_RELEASE === "true") { - const current = await fetchLatest() - const base = bumpVersion(current, env.KILO_BUMP.toLowerCase()) - const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "") - return `${base}-rc.${stamp}` + const current = await fetchHighest() + return bumpVersion(current, env.KILO_BUMP.toLowerCase()) } // kilocode_change end return `0.0.0-${CHANNEL}-${new Date().toISOString().slice(0, 16).replace(/[-:T]/g, "")}` } - const version = await fetchLatest() // kilocode_change + const version = await fetchHighest() // kilocode_change return bumpVersion(version, env.KILO_BUMP?.toLowerCase() ?? "patch") // kilocode_change })() diff --git a/script/publish.ts b/script/publish.ts index 14b75965ec..68e0b70225 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -58,14 +58,19 @@ await $`bun install` await import(`../packages/sdk/js/script/build.ts`) if (Script.release) { + // kilocode_change start - commit and tag both release and rc version bumps + await $`git commit -am "release: v${Script.version}"` + await $`git tag v${Script.version}` + await $`git fetch origin` if (!Script.preview) { - await $`git commit -am "release: v${Script.version}"` - await $`git tag v${Script.version}` - await $`git fetch origin` await $`git cherry-pick HEAD..origin/dev`.nothrow() await $`git push origin HEAD --tags --no-verify --force-with-lease` - await new Promise((resolve) => setTimeout(resolve, 5_000)) } + if (Script.preview) { + await $`git push origin HEAD --tags` + } + await new Promise((resolve) => setTimeout(resolve, 5_000)) + // kilocode_change end // kilocode_change start // await import(`../packages/desktop/scripts/finalize-latest-json.ts`) From aa97827f1d6bf8321764253f9f9f46a78f4c8ca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 9 Apr 2026 23:36:00 -0300 Subject: [PATCH 07/39] fix: use main in publish sync --- script/publish.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/publish.ts b/script/publish.ts index 68e0b70225..e3f03b98f3 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -63,7 +63,7 @@ if (Script.release) { await $`git tag v${Script.version}` await $`git fetch origin` if (!Script.preview) { - await $`git cherry-pick HEAD..origin/dev`.nothrow() + await $`git cherry-pick HEAD..origin/main`.nothrow() await $`git push origin HEAD --tags --no-verify --force-with-lease` } if (Script.preview) { From 3ab06812c4df2b4fd0a2f0d387dca3a42e841531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Catriel=20M=C3=BCller?= Date: Thu, 9 Apr 2026 23:39:30 -0300 Subject: [PATCH 08/39] fix: use release sync for prereleases --- script/publish.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/script/publish.ts b/script/publish.ts index e3f03b98f3..846321e95e 100755 --- a/script/publish.ts +++ b/script/publish.ts @@ -62,13 +62,8 @@ if (Script.release) { await $`git commit -am "release: v${Script.version}"` await $`git tag v${Script.version}` await $`git fetch origin` - if (!Script.preview) { - await $`git cherry-pick HEAD..origin/main`.nothrow() - await $`git push origin HEAD --tags --no-verify --force-with-lease` - } - if (Script.preview) { - await $`git push origin HEAD --tags` - } + await $`git cherry-pick HEAD..origin/main`.nothrow() + await $`git push origin HEAD --tags --no-verify --force-with-lease` await new Promise((resolve) => setTimeout(resolve, 5_000)) // kilocode_change end From 6ba53081bc63487d96e5a824a62a1e77b271b1b0 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Thu, 9 Apr 2026 23:26:39 -0400 Subject: [PATCH 09/39] docs(cli): document how to disable built-in providers --- .../src/kilocode/skills/kilo-config.md | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index 70289d08dc..e9f5bcf152 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -114,6 +114,45 @@ Disable an inherited server: `{ "server-name": { "enabled": false } }`. } ``` +### Disabling Built-in Providers + +Use `disabled_providers` to prevent specific providers from loading. This is useful when you want to exclude providers that are auto-detected via environment variables or that you don't want available in the model picker. + +```jsonc +{ + "$schema": "https://app.kilo.ai/config.json", + "disabled_providers": ["kilo", "openai"], +} +``` + +The provider ID is the lowercase name used in the `provider/model` format (e.g., `kilo`, `openai`, `anthropic`, `google`, `groq`). + +**Common provider IDs:** + +| Provider | ID | Notes | +| --------- | ----------- | ----------------------- | +| Kilo | `kilo` | Default Kilo AI Gateway | +| OpenAI | `openai` | | +| Anthropic | `anthropic` | | +| Google | `google` | | +| Groq | `groq` | | +| Ollama | `ollama` | Local models | +| LM Studio | `lmstudio` | Local models | + +**Interaction with `enabled_providers`:** + +- `disabled_providers` removes specific providers from the auto-loaded set +- `enabled_providers` is more restrictive — when set, ONLY the listed providers will be enabled, ignoring all others +- If both are set, `enabled_providers` takes precedence + +To disable all auto-detected providers except one: + +```jsonc +{ + "enabled_providers": ["anthropic"], +} +``` + ## Skills Additional skill directories and remote URLs: From a014fa3796f7d62e6e6bf4ace41a9fc5b57aa2b0 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Thu, 9 Apr 2026 23:57:30 -0400 Subject: [PATCH 10/39] Revert "docs(cli): document how to disable built-in providers" This reverts commit 6ba53081bc63487d96e5a824a62a1e77b271b1b0. --- .../src/kilocode/skills/kilo-config.md | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index e9f5bcf152..70289d08dc 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -114,45 +114,6 @@ Disable an inherited server: `{ "server-name": { "enabled": false } }`. } ``` -### Disabling Built-in Providers - -Use `disabled_providers` to prevent specific providers from loading. This is useful when you want to exclude providers that are auto-detected via environment variables or that you don't want available in the model picker. - -```jsonc -{ - "$schema": "https://app.kilo.ai/config.json", - "disabled_providers": ["kilo", "openai"], -} -``` - -The provider ID is the lowercase name used in the `provider/model` format (e.g., `kilo`, `openai`, `anthropic`, `google`, `groq`). - -**Common provider IDs:** - -| Provider | ID | Notes | -| --------- | ----------- | ----------------------- | -| Kilo | `kilo` | Default Kilo AI Gateway | -| OpenAI | `openai` | | -| Anthropic | `anthropic` | | -| Google | `google` | | -| Groq | `groq` | | -| Ollama | `ollama` | Local models | -| LM Studio | `lmstudio` | Local models | - -**Interaction with `enabled_providers`:** - -- `disabled_providers` removes specific providers from the auto-loaded set -- `enabled_providers` is more restrictive — when set, ONLY the listed providers will be enabled, ignoring all others -- If both are set, `enabled_providers` takes precedence - -To disable all auto-detected providers except one: - -```jsonc -{ - "enabled_providers": ["anthropic"], -} -``` - ## Skills Additional skill directories and remote URLs: From 10bb4d11eeb3d7b7e94751e0b53af7be3d253e1b Mon Sep 17 00:00:00 2001 From: "kilo-maintainer[bot]" Date: Fri, 10 Apr 2026 04:24:44 +0000 Subject: [PATCH 11/39] release: v7.2.3 --- bun.lock | 32 +++++++++++++------------- package.json | 2 +- packages/app/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/desktop/package.json | 2 +- packages/extensions/zed/extension.toml | 12 +++++----- packages/kilo-docs/package.json | 2 +- packages/kilo-gateway/package.json | 2 +- packages/kilo-i18n/package.json | 2 +- packages/kilo-telemetry/package.json | 2 +- packages/kilo-ui/package.json | 2 +- packages/kilo-vscode/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/script/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/storybook/package.json | 2 +- packages/ui/package.json | 2 +- packages/util/package.json | 2 +- script/upstream/package.json | 2 +- sdks/vscode/package.json | 2 +- 21 files changed, 41 insertions(+), 41 deletions(-) diff --git a/bun.lock b/bun.lock index 1a295a2759..d381043d01 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -79,7 +79,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -112,7 +112,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -142,7 +142,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -171,7 +171,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@ai-sdk/anthropic": "2.0.65", "@ai-sdk/openai": "2.0.101", @@ -206,7 +206,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.1", + "version": "7.2.3", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -219,7 +219,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -239,7 +239,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/util": "workspace:*", @@ -274,7 +274,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -327,7 +327,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.1", + "version": "7.2.3", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -451,7 +451,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@kilocode/sdk": "workspace:*", "zod": "catalog:", @@ -465,14 +465,14 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.1", + "version": "7.2.3", "devDependencies": { "@types/bun": "catalog:", }, }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.1", + "version": "7.2.3", "devDependencies": { "@hey-api/openapi-ts": "0.90.10", "@tsconfig/node22": "catalog:", @@ -483,7 +483,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.1", + "version": "7.2.3", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -506,7 +506,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -553,7 +553,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "7.2.1", + "version": "7.2.3", "dependencies": { "zod": "catalog:", }, diff --git a/package.json b/package.json index 94da05a750..58d70dd493 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,6 @@ "@openrouter/ai-sdk-provider@1.5.4": "patches/@openrouter%2Fai-sdk-provider@1.5.4.patch", "ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch" }, - "version": "7.2.1", + "version": "7.2.3", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 9a2833fd5b..f975e0dcc8 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.1", + "version": "7.2.3", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 1ede57d19c..4cc72842bb 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.2.1", + "version": "7.2.3", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index ef17c1279c..b74f408b65 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.1", + "version": "7.2.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 62ad6a8d4e..94cf6fade3 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.2.1" +version = "7.2.3" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.1/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.3/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.1/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.3/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.1/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.3/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.1/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.3/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.1/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.3/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 1f3e7678f1..88fa3e9578 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.1", + "version": "7.2.3", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 14701b2117..5691509af8 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.2.1", + "version": "7.2.3", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 22f32cbb2a..18c75ae505 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.2.1", + "version": "7.2.3", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 16a6bdea84..046f52041c 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.2.1", + "version": "7.2.3", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index d5bd9cf322..4f99390c20 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.1", + "version": "7.2.3", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 8fcd0ce742..bb4f4ef35a 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.2.1", + "version": "7.2.3", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 8fc16943c4..e1c1341a1a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.1", + "version": "7.2.3", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 3864513dc7..5fcd17fc5a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.1", + "version": "7.2.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index 1ea5b610fd..84bb75bec1 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -8,7 +8,7 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.1", + "version": "7.2.3", "scripts": { "test": "bun test" }, diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 10d0d71f8f..fadcab9b89 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.1", + "version": "7.2.3", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index d0f951d9b6..cad7a5be1e 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.1", + "version": "7.2.3", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 619a9afb70..a378f608d3 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.1", + "version": "7.2.3", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index 12551e0d1a..01cd391f1b 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "7.2.1", + "version": "7.2.3", "private": true, "type": "module", "license": "MIT", diff --git a/script/upstream/package.json b/script/upstream/package.json index d477c1bb14..1bcaa8bc82 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/upstream-merge", - "version": "7.2.1", + "version": "7.2.3", "private": true, "type": "module", "description": "Scripts for automating upstream opencode merges into Kilo", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 67e5943232..2bdd76faab 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "7.2.1", + "version": "7.2.3", "publisher": "sst-dev", "repository": { "type": "git", From a1eaae2b03d43c9b1d0a58370d4e6d16aafaaef0 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Fri, 10 Apr 2026 01:02:45 -0400 Subject: [PATCH 12/39] docs(kilo-docs): consolidate MCP tool permissions into auto-approving-actions page --- .../kilo-docs/pages/automate/mcp/overview.md | 2 + .../pages/automate/mcp/using-in-cli.md | 29 +---------- .../pages/automate/mcp/using-in-kilo-code.md | 40 ++-------------- .../settings/auto-approving-actions.md | 48 +++++++++++++++++++ 4 files changed, 57 insertions(+), 62 deletions(-) diff --git a/packages/kilo-docs/pages/automate/mcp/overview.md b/packages/kilo-docs/pages/automate/mcp/overview.md index 6c04998967..8997d1434a 100644 --- a/packages/kilo-docs/pages/automate/mcp/overview.md +++ b/packages/kilo-docs/pages/automate/mcp/overview.md @@ -13,6 +13,8 @@ This documentation is organized into several sections: - [**Using MCP in Kilo Code**](using-in-kilo-code) - Comprehensive guide to configuring, enabling, and managing MCP servers with Kilo Code. Includes server settings, tool approval, and troubleshooting. +- [**MCP Tool Permissions**](/docs/getting-started/settings/auto-approving-actions#mcp-tool-permissions) - Control which MCP tools auto-approve, prompt, or are blocked entirely using the same `allow` / `ask` / `deny` permission system as built-in tools. + - [**What is MCP?**](what-is-mcp) - Clear explanation of the Model Context Protocol, its client-server architecture, and how it enables AI systems to interact with external tools. - [**STDIO & SSE Transports**](server-transports) - Detailed comparison of local (STDIO) and remote (SSE) transport mechanisms with deployment considerations for each approach. diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md index 61b0695c11..b39f2de56c 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md @@ -161,34 +161,9 @@ Add the test MCP server for development: ## Tool Permissions -MCP tool calls use the same permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). +MCP tools use the same permission system as built-in tools (`allow`, `ask`, `deny`). Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). You can use glob patterns like `my_server_*` for broad rules. -There are three permission levels: - -| Permission | Behavior | -| ---------- | ----------------------------------------------------------------------------------------------------------------- | -| `"allow"` | Tool calls are auto-approved without prompting. | -| `"ask"` | A prompt appears each time the tool is called, requiring manual approval. This is the default if no rule matches. | -| `"deny"` | Tool calls are blocked entirely. | - -Add the tool name (or a wildcard pattern) to the `permission` key in your `kilo.json`: - -```jsonc -{ - "permission": { - // Auto-approve a specific tool - "my_server_safe_read": "allow", - - // Require approval for all other tools on this server - "my_server_*": "ask", - - // Block a dangerous tool entirely - "my_server_delete_all": "deny", - }, -} -``` - -Glob patterns are evaluated top-to-bottom and the first match wins. This lets you allow specific safe tools while requiring approval for everything else on a server. +For full details on configuring MCP tool permissions — including examples with glob patterns and per-tool overrides — see [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions#mcp-tool-permissions). ## Environment Variables diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md index 4611c57c48..c367179619 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md @@ -424,44 +424,14 @@ To set the maximum time to wait for a response after a tool call to the MCP serv ### Tool Permissions +MCP tools use the same permission system as built-in tools (`allow`, `ask`, `deny`). Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). You can use glob patterns like `my_server_*` for broad rules. + +For full details on configuring MCP tool permissions — including examples with glob patterns and per-tool overrides — see [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions#mcp-tool-permissions). + {% tabs %} -{% tab label="VSCode" %} - -MCP tool calls use the same permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). - -There are three permission levels: - -| Permission | Behavior | -| ---------- | ----------------------------------------------------------------------------------------------------------------- | -| `"allow"` | Tool calls are auto-approved without prompting. | -| `"ask"` | A prompt appears each time the tool is called, requiring manual approval. This is the default if no rule matches. | -| `"deny"` | Tool calls are blocked entirely. | - -**At runtime:** When an MCP tool is called and no permission rule matches, the Permission Dock shows an approval prompt (equivalent to `"ask"`). Click **Approve Always** to save an `"allow"` rule to your config so future calls to that tool are auto-approved. - -**In your config file:** Add the tool name (or a wildcard pattern) to the `permission` key in `kilo.jsonc`: - -```jsonc -{ - "permission": { - // Auto-approve a specific tool - "my_server_safe_read": "allow", - - // Require approval for all other tools on this server - "my_server_*": "ask", - - // Block a dangerous tool entirely - "my_server_delete_all": "deny", - }, -} -``` - -Glob patterns are evaluated top-to-bottom and the first match wins, so you can allow specific safe tools while requiring approval for everything else on a server. - -{% /tab %} {% tab label="VSCode (Legacy)" %} -MCP tool auto-approval works on a per-tool basis and is disabled by default. To configure auto-approval: +In the legacy extension, MCP tool auto-approval works on a per-tool basis and is disabled by default. To configure auto-approval: 1. First enable the global "Use MCP servers" auto-approval option in [auto-approving-actions](/docs/getting-started/settings/auto-approving-actions) 2. Navigate to Settings > Agent Behaviour > MCP Servers, then locate the specific tool you want to auto-approve diff --git a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md index 10e4a9d004..741a996fed 100644 --- a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md +++ b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md @@ -64,6 +64,31 @@ When a tool is set to `"ask"`, Kilo pauses and displays a permission prompt with Expand **Manage Auto-Approve Rules** to add commands or patterns to your allowed or denied lists. These rules are then appended to the bottom of the approval rules in settings and the config file. +## MCP Tool Permissions + +MCP tools use the same permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). + +**At runtime:** When an MCP tool is called and no permission rule matches, the Permission Dock shows an approval prompt (equivalent to `"ask"`). Click **Approve Always** to save an `"allow"` rule to your config so future calls to that tool are auto-approved. + +**In your config file:** Add the tool name (or a wildcard pattern) to the `permission` key in `kilo.jsonc`: + +```jsonc +{ + "permission": { + // Auto-approve a specific tool + "my_server_safe_read": "allow", + + // Require approval for all other tools on this server + "my_server_*": "ask", + + // Block a dangerous tool entirely + "my_server_delete_all": "deny", + }, +} +``` + +Glob patterns are evaluated top-to-bottom and the first match wins, so you can allow specific safe tools while requiring approval for everything else on a server. + ## Defaults Most tools default to `"*": "allow"` for a smooth out-of-the-box experience. Notable exceptions that prompt by default: @@ -208,6 +233,29 @@ Most tools default to `"*": "allow"` for a smooth out-of-the-box experience. Not - **`external_directory`** — accessing files outside the project prompts for approval - **`doom_loop`** — prompts when the agent enters a repeated failure cycle +## MCP Tool Permissions + +MCP tools use the same permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). + +Add the tool name (or a wildcard pattern) to the `permission` key in your `kilo.jsonc`: + +```jsonc +{ + "permission": { + // Auto-approve a specific tool + "my_server_safe_read": "allow", + + // Require approval for all other tools on this server + "my_server_*": "ask", + + // Block a dangerous tool entirely + "my_server_delete_all": "deny", + }, +} +``` + +Glob patterns are evaluated top-to-bottom and the first match wins. This lets you allow specific safe tools while requiring approval for everything else on a server. + ## Full Configuration Example {% callout type="info" %} From 738e949fd1a36e40721db2b75673bae46fdaad46 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Fri, 10 Apr 2026 01:28:54 -0400 Subject: [PATCH 13/39] docs(kilo-docs): fix rule precedence (last match wins), use realistic GitHub MCP examples, restore VSCode tab --- .../pages/automate/mcp/using-in-cli.md | 4 +-- .../pages/automate/mcp/using-in-kilo-code.md | 28 +++++++++++++++++-- .../settings/auto-approving-actions.md | 24 ++++++++-------- 3 files changed, 40 insertions(+), 16 deletions(-) diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md index b39f2de56c..d2b2d1be8f 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md @@ -161,9 +161,9 @@ Add the test MCP server for development: ## Tool Permissions -MCP tools use the same permission system as built-in tools (`allow`, `ask`, `deny`). Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). You can use glob patterns like `my_server_*` for broad rules. +MCP tools use the same permission system as built-in tools (`allow`, `ask`, `deny`). Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `github_create_pull_request`). You can use glob patterns like `github_*` for broad rules. -For full details on configuring MCP tool permissions — including examples with glob patterns and per-tool overrides — see [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions#mcp-tool-permissions). +For full details on configuring auto-approval permissions — see [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions). ## Environment Variables diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md index c367179619..901b20e5e8 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md @@ -424,11 +424,35 @@ To set the maximum time to wait for a response after a tool call to the MCP serv ### Tool Permissions -MCP tools use the same permission system as built-in tools (`allow`, `ask`, `deny`). Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). You can use glob patterns like `my_server_*` for broad rules. +MCP tools use the same permission system as built-in tools (`allow`, `ask`, `deny`). Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `github_create_pull_request`). You can use glob patterns like `github_*` for broad rules. -For full details on configuring MCP tool permissions — including examples with glob patterns and per-tool overrides — see [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions#mcp-tool-permissions). +For full details on configuring auto-approval permissions — see [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions). {% tabs %} +{% tab label="VSCode" %} + +**At runtime:** When an MCP tool is called and no permission rule matches, the Permission Dock shows an approval prompt (equivalent to `"ask"`). Click **Approve Always** to save an `"allow"` rule to your config so future calls to that tool are auto-approved. + +**In your config file:** Add the tool name (or a wildcard pattern) to the `permission` key in `kilo.jsonc`: + +```jsonc +{ + "permission": { + // Require approval for all tools on this server by default + "github_*": "ask", + + // Auto-approve a specific safe tool + "github_get_file_contents": "allow", + + // Block a dangerous tool entirely + "github_delete_file": "deny", + }, +} +``` + +Rules are evaluated top-to-bottom and the **last** matching rule wins. Put broad patterns first, then add specific overrides after them. + +{% /tab %} {% tab label="VSCode (Legacy)" %} In the legacy extension, MCP tool auto-approval works on a per-tool basis and is disabled by default. To configure auto-approval: diff --git a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md index 741a996fed..e9452c33a2 100644 --- a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md +++ b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md @@ -75,19 +75,19 @@ MCP tools use the same permission system as built-in tools. Each MCP tool's perm ```jsonc { "permission": { - // Auto-approve a specific tool - "my_server_safe_read": "allow", + // Require approval for all tools on this server by default + "github_*": "ask", - // Require approval for all other tools on this server - "my_server_*": "ask", + // Auto-approve a specific safe tool + "github_get_file_contents": "allow", // Block a dangerous tool entirely - "my_server_delete_all": "deny", + "github_delete_file": "deny", }, } ``` -Glob patterns are evaluated top-to-bottom and the first match wins, so you can allow specific safe tools while requiring approval for everything else on a server. +Rules are evaluated top-to-bottom and the **last** matching rule wins. Put broad patterns first, then add specific overrides after them. ## Defaults @@ -242,19 +242,19 @@ Add the tool name (or a wildcard pattern) to the `permission` key in your `kilo. ```jsonc { "permission": { - // Auto-approve a specific tool - "my_server_safe_read": "allow", + // Require approval for all tools on this server by default + "github_*": "ask", - // Require approval for all other tools on this server - "my_server_*": "ask", + // Auto-approve a specific safe tool + "github_get_file_contents": "allow", // Block a dangerous tool entirely - "my_server_delete_all": "deny", + "github_delete_file": "deny", }, } ``` -Glob patterns are evaluated top-to-bottom and the first match wins. This lets you allow specific safe tools while requiring approval for everything else on a server. +Rules are evaluated top-to-bottom and the **last** matching rule wins. Put broad patterns first, then add specific overrides after them. ## Full Configuration Example From 90c6bf99fb9dad9488b7c6859fed2e5d1056b991 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Fri, 10 Apr 2026 01:42:19 -0400 Subject: [PATCH 14/39] docs: make using-in-kilo-code the source of truth for MCP tool permissions, cross-link from other pages --- .../kilo-docs/pages/automate/mcp/overview.md | 2 +- .../pages/automate/mcp/using-in-cli.md | 2 +- .../settings/auto-approving-actions.md | 44 ++----------------- .../src/kilocode/skills/kilo-config.md | 21 +++++++++ 4 files changed, 27 insertions(+), 42 deletions(-) diff --git a/packages/kilo-docs/pages/automate/mcp/overview.md b/packages/kilo-docs/pages/automate/mcp/overview.md index 8997d1434a..44291ba9d7 100644 --- a/packages/kilo-docs/pages/automate/mcp/overview.md +++ b/packages/kilo-docs/pages/automate/mcp/overview.md @@ -13,7 +13,7 @@ This documentation is organized into several sections: - [**Using MCP in Kilo Code**](using-in-kilo-code) - Comprehensive guide to configuring, enabling, and managing MCP servers with Kilo Code. Includes server settings, tool approval, and troubleshooting. -- [**MCP Tool Permissions**](/docs/getting-started/settings/auto-approving-actions#mcp-tool-permissions) - Control which MCP tools auto-approve, prompt, or are blocked entirely using the same `allow` / `ask` / `deny` permission system as built-in tools. +- [**MCP Tool Permissions**](using-in-kilo-code#tool-permissions) - Control which MCP tools auto-approve, prompt, or are blocked entirely using the same `allow` / `ask` / `deny` permission system as built-in tools. - [**What is MCP?**](what-is-mcp) - Clear explanation of the Model Context Protocol, its client-server architecture, and how it enables AI systems to interact with external tools. diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md index d2b2d1be8f..862adcc66c 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md @@ -163,7 +163,7 @@ Add the test MCP server for development: MCP tools use the same permission system as built-in tools (`allow`, `ask`, `deny`). Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `github_create_pull_request`). You can use glob patterns like `github_*` for broad rules. -For full details on configuring auto-approval permissions — see [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions). +For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#tool-permissions). ## Environment Variables diff --git a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md index e9452c33a2..de97c5e6f0 100644 --- a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md +++ b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md @@ -66,28 +66,9 @@ Expand **Manage Auto-Approve Rules** to add commands or patterns to your allowed ## MCP Tool Permissions -MCP tools use the same permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). +MCP tools use the same `allow` / `ask` / `deny` permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `github_create_pull_request`). You can use glob patterns like `github_*` for broad rules. -**At runtime:** When an MCP tool is called and no permission rule matches, the Permission Dock shows an approval prompt (equivalent to `"ask"`). Click **Approve Always** to save an `"allow"` rule to your config so future calls to that tool are auto-approved. - -**In your config file:** Add the tool name (or a wildcard pattern) to the `permission` key in `kilo.jsonc`: - -```jsonc -{ - "permission": { - // Require approval for all tools on this server by default - "github_*": "ask", - - // Auto-approve a specific safe tool - "github_get_file_contents": "allow", - - // Block a dangerous tool entirely - "github_delete_file": "deny", - }, -} -``` - -Rules are evaluated top-to-bottom and the **last** matching rule wins. Put broad patterns first, then add specific overrides after them. +For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#tool-permissions). ## Defaults @@ -235,26 +216,9 @@ Most tools default to `"*": "allow"` for a smooth out-of-the-box experience. Not ## MCP Tool Permissions -MCP tools use the same permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). +MCP tools use the same `allow` / `ask` / `deny` permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `github_create_pull_request`). You can use glob patterns like `github_*` for broad rules. -Add the tool name (or a wildcard pattern) to the `permission` key in your `kilo.jsonc`: - -```jsonc -{ - "permission": { - // Require approval for all tools on this server by default - "github_*": "ask", - - // Auto-approve a specific safe tool - "github_get_file_contents": "allow", - - // Block a dangerous tool entirely - "github_delete_file": "deny", - }, -} -``` - -Rules are evaluated top-to-bottom and the **last** matching rule wins. Put broad patterns first, then add specific overrides after them. +For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#tool-permissions). ## Full Configuration Example diff --git a/packages/opencode/src/kilocode/skills/kilo-config.md b/packages/opencode/src/kilocode/skills/kilo-config.md index 70289d08dc..644905f5a4 100644 --- a/packages/opencode/src/kilocode/skills/kilo-config.md +++ b/packages/opencode/src/kilocode/skills/kilo-config.md @@ -91,6 +91,27 @@ Tool permissions: `read`, `edit`, `glob`, `grep`, `list`, `bash`, `task`, `webfe Disable an inherited server: `{ "server-name": { "enabled": false } }`. +### MCP Tool Permissions + +MCP tools use the same permission system as built-in tools. Each MCP tool's permission key is `{server}_{tool}` (e.g. `github_create_pull_request`). Glob patterns are supported. + +```jsonc +{ + "permission": { + // Require approval for all tools on this server by default + "github_*": "ask", + + // Auto-approve a specific safe tool + "github_get_file_contents": "allow", + + // Block a dangerous tool entirely + "github_delete_file": "deny", + }, +} +``` + +Rules are evaluated top-to-bottom — the **last** matching rule wins. Put broad patterns first, then specific overrides after. + ## Providers ```jsonc From 22a4c233541ba722de51ab87331407d2d3d3d0c6 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Fri, 10 Apr 2026 01:44:21 -0400 Subject: [PATCH 15/39] docs: revert using-in-kilo-code.md changes, fix cross-link anchors --- .../kilo-docs/pages/automate/mcp/overview.md | 2 +- .../pages/automate/mcp/using-in-cli.md | 2 +- .../pages/automate/mcp/using-in-kilo-code.md | 28 ++++++------------- .../settings/auto-approving-actions.md | 4 +-- 4 files changed, 13 insertions(+), 23 deletions(-) diff --git a/packages/kilo-docs/pages/automate/mcp/overview.md b/packages/kilo-docs/pages/automate/mcp/overview.md index 44291ba9d7..a37ecc442e 100644 --- a/packages/kilo-docs/pages/automate/mcp/overview.md +++ b/packages/kilo-docs/pages/automate/mcp/overview.md @@ -13,7 +13,7 @@ This documentation is organized into several sections: - [**Using MCP in Kilo Code**](using-in-kilo-code) - Comprehensive guide to configuring, enabling, and managing MCP servers with Kilo Code. Includes server settings, tool approval, and troubleshooting. -- [**MCP Tool Permissions**](using-in-kilo-code#tool-permissions) - Control which MCP tools auto-approve, prompt, or are blocked entirely using the same `allow` / `ask` / `deny` permission system as built-in tools. +- [**MCP Tool Permissions**](using-in-kilo-code#auto-approve-tools) - Control which MCP tools auto-approve, prompt, or are blocked entirely using the same `allow` / `ask` / `deny` permission system as built-in tools. - [**What is MCP?**](what-is-mcp) - Clear explanation of the Model Context Protocol, its client-server architecture, and how it enables AI systems to interact with external tools. diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md index 862adcc66c..dc6ea99fcd 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-cli.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-cli.md @@ -163,7 +163,7 @@ Add the test MCP server for development: MCP tools use the same permission system as built-in tools (`allow`, `ask`, `deny`). Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `github_create_pull_request`). You can use glob patterns like `github_*` for broad rules. -For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#tool-permissions). +For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#auto-approve-tools). ## Environment Variables diff --git a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md index 901b20e5e8..72f9994d1d 100644 --- a/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md +++ b/packages/kilo-docs/pages/automate/mcp/using-in-kilo-code.md @@ -422,40 +422,30 @@ To set the maximum time to wait for a response after a tool call to the MCP serv {% /tab %} {% /tabs %} -### Tool Permissions - -MCP tools use the same permission system as built-in tools (`allow`, `ask`, `deny`). Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `github_create_pull_request`). You can use glob patterns like `github_*` for broad rules. - -For full details on configuring auto-approval permissions — see [Auto-Approving Actions](/docs/getting-started/settings/auto-approving-actions). +### Auto Approve Tools {% tabs %} {% tab label="VSCode" %} -**At runtime:** When an MCP tool is called and no permission rule matches, the Permission Dock shows an approval prompt (equivalent to `"ask"`). Click **Approve Always** to save an `"allow"` rule to your config so future calls to that tool are auto-approved. +MCP tool calls use the same permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `my_server_do_something`). + +**At runtime:** When an MCP tool is called, the Permission Dock shows an approval prompt. Click **Approve Always** to save an allow rule to your config so future calls to that tool are auto-approved. **In your config file:** Add the tool name (or a wildcard pattern) to the `permission` key in `kilo.jsonc`: -```jsonc +```json { "permission": { - // Require approval for all tools on this server by default - "github_*": "ask", - - // Auto-approve a specific safe tool - "github_get_file_contents": "allow", - - // Block a dangerous tool entirely - "github_delete_file": "deny", - }, + "my_server_do_something": "allow", + "my_server_*": "allow" + } } ``` -Rules are evaluated top-to-bottom and the **last** matching rule wins. Put broad patterns first, then add specific overrides after them. - {% /tab %} {% tab label="VSCode (Legacy)" %} -In the legacy extension, MCP tool auto-approval works on a per-tool basis and is disabled by default. To configure auto-approval: +MCP tool auto-approval works on a per-tool basis and is disabled by default. To configure auto-approval: 1. First enable the global "Use MCP servers" auto-approval option in [auto-approving-actions](/docs/getting-started/settings/auto-approving-actions) 2. Navigate to Settings > Agent Behaviour > MCP Servers, then locate the specific tool you want to auto-approve diff --git a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md index de97c5e6f0..9a76c5d9b9 100644 --- a/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md +++ b/packages/kilo-docs/pages/getting-started/settings/auto-approving-actions.md @@ -68,7 +68,7 @@ Expand **Manage Auto-Approve Rules** to add commands or patterns to your allowed MCP tools use the same `allow` / `ask` / `deny` permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `github_create_pull_request`). You can use glob patterns like `github_*` for broad rules. -For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#tool-permissions). +For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#auto-approve-tools). ## Defaults @@ -218,7 +218,7 @@ Most tools default to `"*": "allow"` for a smooth out-of-the-box experience. Not MCP tools use the same `allow` / `ask` / `deny` permission system as built-in tools. Each MCP tool's permission key is its namespaced name: `{server}_{tool}` (e.g. `github_create_pull_request`). You can use glob patterns like `github_*` for broad rules. -For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#tool-permissions). +For full details and examples, see [MCP Tool Permissions](/docs/automate/mcp/using-in-kilo-code#auto-approve-tools). ## Full Configuration Example From 648793328d5f79f3c075ed5be3c5f3c889040011 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Fri, 10 Apr 2026 02:11:57 -0400 Subject: [PATCH 16/39] docs(kilo-docs): fix stale references to multi-model balanced routing --- .../pages/code-with-ai/agents/auto-model.md | 2 +- .../contributing/architecture/auto-model-tiers.md | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index 25453f31ae..2413f0b83d 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -71,7 +71,7 @@ No need to manually switch models when changing modes. Auto Model handles the ro ### Cost Optimization -Uses the more economical models for implementation tasks where speed matters, while reserving stronger reasoning models for planning tasks. You get optimal cost-to-capability ratio without thinking about it. +Uses cost-effective models matched to the task — Auto Balanced and Auto Free deliver strong capabilities at a fraction of frontier cost. You get optimal cost-to-capability ratio without thinking about it. ### Best-in-Class Models diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index 7964695fc8..03898d77eb 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -160,12 +160,12 @@ The client-side chain works as follows: ## Risks -| Risk | User impact | Mitigation | -| ------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Free model disappears mid-session | User's next message fails | Fallback chain: primary → secondary → tertiary free model. Graceful error only if all options exhausted. | -| Model quality variance across free/balanced tiers | Inconsistent experience compared to Frontier | Set clear expectations in UI. Curate model lists, don't just pick the cheapest. | -| Cross-family model switching breaks context | Thinking blocks from Model A incompatible with Model B | Strip thinking blocks when the underlying model family changes between turns. Frontier stays within one family so this primarily affects Free and Balanced. | -| Users don't understand the tier differences | Wrong tier selected, poor experience | Clear descriptions in the model picker. Good defaults (Balanced for paid, Free for unpaid) so most users never need to actively choose. | +| Risk | User impact | Mitigation | +| ------------------------------------------------- | ------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Free model disappears mid-session | User's next message fails | Fallback chain: primary → secondary → tertiary free model. Graceful error only if all options exhausted. | +| Model quality variance across free/balanced tiers | Inconsistent experience compared to Frontier | Set clear expectations in UI. Curate model lists, don't just pick the cheapest. | +| Cross-family model switching breaks context | Thinking blocks from Model A incompatible with Model B | Strip thinking blocks when the underlying model family changes between turns. Frontier stays within one family so this primarily affects Free tier (which may switch models). | +| Users don't understand the tier differences | Wrong tier selected, poor experience | Clear descriptions in the model picker. Good defaults (Balanced for paid, Free for unpaid) so most users never need to actively choose. | ## Data and compliance From 1f90d2eab2fef2960f17983cefb8016b55358fdc Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Fri, 10 Apr 2026 02:16:33 -0400 Subject: [PATCH 17/39] docs(kilo-docs): replace inline mode-to-model tables with links to gateway docs --- .../pages/code-with-ai/agents/auto-model.md | 34 ++----------------- 1 file changed, 2 insertions(+), 32 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index 2413f0b83d..26e1c7b1e3 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -25,43 +25,13 @@ That's it. No configuration needed. `kilo-auto/frontier` routes to the latest and most capable paid models available, optimizing for performance, capability, and cost. -### Mode-to-Model Mapping - -| Mode | Model Used | Best For | -| -------------- | ----------------- | ---------------------------- | -| `architect` | Claude Opus 4.6 | System design, planning | -| `orchestrator` | Claude Opus 4.6 | Multi-step task coordination | -| `ask` | Claude Opus 4.6 | Questions, explanations | -| `plan` | Claude Opus 4.6 | Planning, reasoning | -| `general` | Claude Opus 4.6 | General assistance | -| `debug` | Claude Opus 4.6 | Debugging and fixing issues | -| `code` | Claude Sonnet 4.6 | Writing and editing code | -| `build` | Claude Sonnet 4.6 | Implementation tasks | -| `explore` | Claude Sonnet 4.6 | Codebase exploration | - -**Planning and reasoning tasks** use Claude Opus 4.6, which excels at complex reasoning, architectural decisions, and breaking down problems. - -**Implementation tasks** use Claude Sonnet 4.6, which is optimized for fast, accurate code generation and editing. +For the current mode-to-model mappings, see the [Gateway docs](/docs/gateway/models-and-providers#kilo-autofrontier). ## Auto Balanced `kilo-auto/balanced` follows the same mode-based routing structure as Frontier but uses a more cost-effective model — GPT 5.3 Codex across all modes. -### Mode-to-Model Mapping - -| Mode | Model Used | Best For | -| -------------- | ------------- | ---------------------------- | -| `architect` | GPT 5.3 Codex | System design, planning | -| `orchestrator` | GPT 5.3 Codex | Multi-step task coordination | -| `ask` | GPT 5.3 Codex | Questions, explanations | -| `plan` | GPT 5.3 Codex | Planning, reasoning | -| `general` | GPT 5.3 Codex | General assistance | -| `debug` | GPT 5.3 Codex | Debugging and fixing issues | -| `code` | GPT 5.3 Codex | Writing and editing code | -| `build` | GPT 5.3 Codex | Implementation tasks | -| `explore` | GPT 5.3 Codex | Codebase exploration | - -**All tasks** use GPT 5.3 Codex (Low), providing strong coding and reasoning performance across all modes at a lower cost than frontier routing. +For the current mode-to-model mappings, see the [Gateway docs](/docs/gateway/models-and-providers#kilo-autobalanced). ## Benefits From d6fef9f68a59deaa4d30c96c2859b5afa082f818 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Fri, 10 Apr 2026 02:20:14 -0400 Subject: [PATCH 18/39] docs(kilo-docs): add tier goal descriptions, explain free tier, remove redundancy --- .../pages/code-with-ai/agents/auto-model.md | 28 ++++++++----------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index 26e1c7b1e3..e876e39839 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -23,33 +23,27 @@ That's it. No configuration needed. ## Auto Frontier -`kilo-auto/frontier` routes to the latest and most capable paid models available, optimizing for performance, capability, and cost. - -For the current mode-to-model mappings, see the [Gateway docs](/docs/gateway/models-and-providers#kilo-autofrontier). +`kilo-auto/frontier` routes to the latest and most capable paid models available. It uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring) — pairing the right model capability to each type of work. ## Auto Balanced -`kilo-auto/balanced` follows the same mode-based routing structure as Frontier but uses a more cost-effective model — GPT 5.3 Codex across all modes. +`kilo-auto/balanced` follows the same mode-based routing structure as Frontier but uses a more cost-effective model across all modes. It's a good default for most developers who want strong AI assistance without paying frontier prices. -For the current mode-to-model mappings, see the [Gateway docs](/docs/gateway/models-and-providers#kilo-autobalanced). +## Auto Free + +`kilo-auto/free` routes to the best available free model on OpenRouter. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the model may change over time. + +For the current model mappings for all tiers, see the [Gateway docs](/docs/gateway/models-and-providers#kilo-autofrontier). ## Benefits -### Simplified Setup +### No Configuration Required -No need to manually switch models when changing modes. Auto Model handles the routing transparently in the background. +No need to manually switch models when changing modes. Auto Model handles routing transparently in the background. -### Cost Optimization +### Flexible Cost Control -Uses cost-effective models matched to the task — Auto Balanced and Auto Free deliver strong capabilities at a fraction of frontier cost. You get optimal cost-to-capability ratio without thinking about it. - -### Best-in-Class Models - -Auto Model routes to capable models matched to your task: - -- **Auto Frontier** uses the latest and most effective models across all modes -- **Auto Balanced** uses more cost-effective models while still providing strong capabilities -- **Auto Free** uses the best available free models +Pick the tier that fits your budget. Frontier gives you the best models for demanding work; Balanced offers capable models at a fraction of the cost; Free costs nothing. ## Requirements From 31f44137209d9750548b0673ff8dff3373e36817 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Fri, 10 Apr 2026 02:28:52 -0400 Subject: [PATCH 19/39] docs(kilo-docs): move gateway link under how it works, convert tier sections to bullets --- .../pages/code-with-ai/agents/auto-model.md | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index e876e39839..f7d1b893fe 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -19,21 +19,13 @@ Auto Model is a smart model routing system that automatically selects the optima 2. Start working in any mode (Code, Architect, Debug, etc.) 3. The system automatically routes your requests to the best model for that task -That's it. No configuration needed. +That's it. No configuration needed. For the exact model mappings for each tier, see the [Gateway docs](/docs/gateway/models-and-providers#kilo-autofrontier). -## Auto Frontier +## Tiers -`kilo-auto/frontier` routes to the latest and most capable paid models available. It uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring) — pairing the right model capability to each type of work. - -## Auto Balanced - -`kilo-auto/balanced` follows the same mode-based routing structure as Frontier but uses a more cost-effective model across all modes. It's a good default for most developers who want strong AI assistance without paying frontier prices. - -## Auto Free - -`kilo-auto/free` routes to the best available free model on OpenRouter. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the model may change over time. - -For the current model mappings for all tiers, see the [Gateway docs](/docs/gateway/models-and-providers#kilo-autofrontier). +- **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work. +- **Balanced** — Follows the same mode-based routing structure as Frontier but uses a more cost-effective model across all modes. A good default for most developers who want strong AI assistance without paying frontier prices. +- **Free** — Routes to the best available free model on OpenRouter. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the model may change over time. ## Benefits From d94135958f835c5b1aee2255956b6287bae139b9 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Fri, 10 Apr 2026 02:32:23 -0400 Subject: [PATCH 20/39] docs(kilo-docs): mention extension shows resolved model and cost on expand --- packages/kilo-docs/pages/code-with-ai/agents/auto-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index f7d1b893fe..05d541bb3d 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -19,7 +19,7 @@ Auto Model is a smart model routing system that automatically selects the optima 2. Start working in any mode (Code, Architect, Debug, etc.) 3. The system automatically routes your requests to the best model for that task -That's it. No configuration needed. For the exact model mappings for each tier, see the [Gateway docs](/docs/gateway/models-and-providers#kilo-autofrontier). +That's it. No configuration needed. The extension shows which model was used for each request — including cost information — when you expand a message. For the exact model mappings for each tier, see the [Gateway docs](/docs/gateway/models-and-providers#kilo-autofrontier). ## Tiers From f20061ebc2204440cbdc6583f1f333257ebe9a7d Mon Sep 17 00:00:00 2001 From: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:36:24 -0400 Subject: [PATCH 21/39] Apply suggestion from @lambertjosh --- packages/kilo-docs/pages/code-with-ai/agents/auto-model.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index 05d541bb3d..ac912ec473 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -19,7 +19,9 @@ Auto Model is a smart model routing system that automatically selects the optima 2. Start working in any mode (Code, Architect, Debug, etc.) 3. The system automatically routes your requests to the best model for that task -That's it. No configuration needed. The extension shows which model was used for each request — including cost information — when you expand a message. For the exact model mappings for each tier, see the [Gateway docs](/docs/gateway/models-and-providers#kilo-autofrontier). +That's it. No configuration needed. + +You can see which underlying models are used, as well as the cost, in the expanded model picker. Mapping information is also available on the [Gateway Model page](/docs/gateway/models-and-providers#kilo-autofrontier). ## Tiers From e80bd61cc0cbe0dcaff8af937b9dbc96fbf8a247 Mon Sep 17 00:00:00 2001 From: Josh Lambert Date: Fri, 10 Apr 2026 02:37:25 -0400 Subject: [PATCH 22/39] docs(kilo-docs): fix broken auto-model anchors in auto-model-tiers.md --- .../pages/contributing/architecture/auto-model-tiers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index 03898d77eb..e2e13938ce 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -46,7 +46,7 @@ Free models on OpenRouter appear and disappear based on promotional periods. A m **Pricing**: Paid. Uses credits. -For the current mode-to-model mappings, see the [Auto Model user docs](/docs/code-with-ai/agents/auto-model#auto-frontier). +For the current mode-to-model mappings, see the [Auto Model user docs](/docs/code-with-ai/agents/auto-model#tiers). ### Auto: Balanced @@ -56,7 +56,7 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Pricing**: Paid, but significantly cheaper than Frontier. -For the current mode-to-model mappings, see the [Auto Model user docs](/docs/code-with-ai/agents/auto-model#auto-balanced). +For the current mode-to-model mappings, see the [Auto Model user docs](/docs/code-with-ai/agents/auto-model#tiers). ### Auto: Free From afad45f8c9e15d3c62fdd35783cc7c35d9a94d0e Mon Sep 17 00:00:00 2001 From: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:40:14 -0400 Subject: [PATCH 23/39] Apply suggestion from @lambertjosh --- packages/kilo-docs/pages/code-with-ai/agents/auto-model.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index ac912ec473..76be6d9285 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -21,7 +21,7 @@ Auto Model is a smart model routing system that automatically selects the optima That's it. No configuration needed. -You can see which underlying models are used, as well as the cost, in the expanded model picker. Mapping information is also available on the [Gateway Model page](/docs/gateway/models-and-providers#kilo-autofrontier). +You can see which underlying models are used, as well as the cost, in the expanded model picker. Model mapping information is also available on the [Gateway Model page](/docs/gateway/models-and-providers#kilo-autofrontier). ## Tiers From c03945790e997e320347969c9b909edf91bc7155 Mon Sep 17 00:00:00 2001 From: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com> Date: Fri, 10 Apr 2026 02:46:47 -0400 Subject: [PATCH 24/39] Apply suggestion from @lambertjosh --- packages/kilo-docs/pages/code-with-ai/agents/auto-model.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index 76be6d9285..841934f345 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -31,6 +31,11 @@ You can see which underlying models are used, as well as the cost, in the expand ## Benefits + +### Cost Optimization + +Automatically uses the best model for a given task, selecting the best balance of cost and capability for a given task. Uses the more economical models for more straight forward tasks, while reserving stronger reasoning models for planning tasks. You get optimal cost-to-capability ratio without thinking about it. + ### No Configuration Required No need to manually switch models when changing modes. Auto Model handles routing transparently in the background. From 6fa7d3dd0003a2139585180530071260c4387667 Mon Sep 17 00:00:00 2001 From: Ricardo Fiorani <1641075+ricardofiorani@users.noreply.github.com> Date: Fri, 10 Apr 2026 09:00:53 +0200 Subject: [PATCH 25/39] fix: wrong doc config instruction for custom providers baseURL (#8627) * fix: wrong doc config instruction for custom providers baseURL * Update packages/kilo-docs/pages/ai-providers/lmstudio.md Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --------- Co-authored-by: kilo-code-bot[bot] <240665456+kilo-code-bot[bot]@users.noreply.github.com> --- packages/kilo-docs/pages/ai-providers/lmstudio.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/kilo-docs/pages/ai-providers/lmstudio.md b/packages/kilo-docs/pages/ai-providers/lmstudio.md index 3ca78d716f..fdfbba7bd8 100644 --- a/packages/kilo-docs/pages/ai-providers/lmstudio.md +++ b/packages/kilo-docs/pages/ai-providers/lmstudio.md @@ -48,13 +48,15 @@ The extension stores this in your `kilo.json` config file. You can also edit the LM Studio runs locally, so no API key is needed. Configure the base URL if LM Studio is running on a different host or port: -**Config file** (`~/.config/kilo/kilo.json` or `./kilo.json`): +**Config file** (`~/.config/kilo/kilo.jsonc` or `./kilo.jsonc`): ```jsonc { "provider": { "lmstudio": { - "baseURL": "http://localhost:1234/v1", + "options": { + "baseURL": "http://localhost:1234/v1", + } }, }, } From e40a3666cb86bf49d5a14f018f74747fa0321b08 Mon Sep 17 00:00:00 2001 From: "kiloconnect[bot]" <240665456+kiloconnect[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:09:19 +0000 Subject: [PATCH 26/39] docs(kilo-docs): add NVIDIA trial terms disclaimer for free Nemotron model --- packages/kilo-docs/pages/gateway/models-and-providers.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index c6412394e5..455eae7831 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -68,6 +68,10 @@ Several models are available at no cost, subject to rate limits: Free models are available to both authenticated and anonymous users. Anonymous users are rate-limited to 200 requests per hour per IP address. +{% callout type="warning" title="Nemotron 3 Super Free (NVIDIA free endpoints)" %} +Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Trial use only — not for production or sensitive data. Prompts and outputs are logged by NVIDIA to improve its models and services. Do not submit personal or confidential data. +{% /callout %} + ## Auto models Kilo Auto virtual models automatically select the best underlying model based on the task type. The selection is controlled by the `x-kilocode-mode` request header. From f1a3471020f089c9d36d4896b3e0eba0f8c25a0c Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 10 Apr 2026 09:14:30 +0200 Subject: [PATCH 27/39] fix(agent-manager): preserve section ordering state (#8680) --- .../src/agent-manager/AgentManagerProvider.ts | 1 + .../src/agent-manager/WorktreeStateManager.ts | 122 ++++++++++-------- .../tests/unit/section-helpers.test.ts | 27 ++++ .../unit/worktree-state-sections.test.ts | 73 +++++++---- .../agent-manager/AgentManagerApp.tsx | 43 +++--- .../agent-manager/section-helpers.ts | 16 +++ 6 files changed, 172 insertions(+), 110 deletions(-) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 3442100587..fd525dd9bd 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -355,6 +355,7 @@ export class AgentManagerProvider implements Disposable { } if (m.type === "agentManager.setWorktreeOrder") { this.state?.setWorktreeOrder(m.order) + this.pushState() return null } if (m.type === "agentManager.setSessionsCollapsed") { diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index 3b051b3680..eacf044ae5 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -44,7 +44,7 @@ export interface Section { name: string /** Color label (e.g. "Red", "Blue") mapped to VS Code theme CSS vars at render time, or null for default. */ color: string | null - /** Position among top-level sidebar children (interleaved with ungrouped worktrees). */ + /** Position among top-level sidebar children (sections and ungrouped worktrees). */ order: number collapsed: boolean } @@ -179,6 +179,7 @@ export class WorktreeStateManager { if (params.groupId) wt.groupId = params.groupId if (params.label) wt.label = params.label this.worktrees.set(id, wt) + this.setNormalizedWorktreeOrder(this.worktreeOrder) this.log( `Added worktree ${id}: ${params.branch}${params.label ? ` (label=${params.label})` : ""}${params.groupId ? ` (group=${params.groupId})` : ""}`, ) @@ -230,9 +231,7 @@ export class WorktreeStateManager { // Clean up tab order for this worktree delete this.tabOrder[id] - // Remove from worktree order - const idx = this.worktreeOrder.indexOf(id) - if (idx !== -1) this.worktreeOrder.splice(idx, 1) + this.setNormalizedWorktreeOrder(this.worktreeOrder.filter((item) => item !== id)) this.log(`Removed worktree ${id}, removed ${orphaned.length} sessions`) void this.save() @@ -298,26 +297,55 @@ export class WorktreeStateManager { } setWorktreeOrder(order: string[]): void { - const top = new Set() - for (const sec of this.sections.values()) top.add(sec.id) - for (const wt of this.worktrees.values()) { - if (!wt.sectionId) top.add(wt.id) - } - this.worktreeOrder = order.filter((id) => top.has(id)) - // Append any sections/ungrouped worktrees missing from the incoming order - const present = new Set(this.worktreeOrder) - for (const id of top) { - if (!present.has(id)) this.worktreeOrder.push(id) - } + this.setNormalizedWorktreeOrder(order) void this.save() } + private setNormalizedWorktreeOrder(order: string[]): boolean { + const valid = new Set() + for (const sec of this.sections.values()) valid.add(sec.id) + for (const wt of this.worktrees.values()) valid.add(wt.id) + + const result: string[] = [] + const seen = new Set() + const add = (id: string) => { + if (!valid.has(id) || seen.has(id)) return + result.push(id) + seen.add(id) + } + + for (const id of order) add(id) + for (const sec of [...this.sections.values()].sort((a, b) => a.order - b.order)) add(sec.id) + for (const wt of this.worktrees.values()) add(wt.id) + + const changed = + result.length !== this.worktreeOrder.length || result.some((id, idx) => id !== this.worktreeOrder[idx]) + this.worktreeOrder = result + return this.syncSectionOrder() || changed + } + + private syncSectionOrder(): boolean { + const top = this.worktreeOrder.filter((id) => { + if (this.sections.has(id)) return true + const wt = this.worktrees.get(id) + return !!wt && !wt.sectionId + }) + const index = new Map(top.map((id, idx) => [id, idx] as const)) + const changes = [...this.sections.values()].map((sec) => { + const order = index.get(sec.id) + if (order === undefined || sec.order === order) return false + sec.order = order + return true + }) + return changes.some(Boolean) + } + // --------------------------------------------------------------------------- // Sections // --------------------------------------------------------------------------- getSections(): Section[] { - return [...this.sections.values()] + return [...this.sections.values()].sort((a, b) => a.order - b.order) } getSection(id: string): Section | undefined { @@ -325,22 +353,23 @@ export class WorktreeStateManager { } addSection(name: string, color: string | null, worktreeIds?: string[]): Section { + this.setNormalizedWorktreeOrder(this.worktreeOrder) const id = generateId("sec") - const order = this.worktreeOrder.length + const order = this.worktreeOrder.filter((item) => { + if (this.sections.has(item)) return true + const wt = this.worktrees.get(item) + return !!wt && !wt.sectionId + }).length const sec: Section = { id, name, color, order, collapsed: false } this.sections.set(id, sec) this.worktreeOrder.push(id) if (worktreeIds) { for (const wtId of worktreeIds) { const wt = this.worktrees.get(wtId) - if (wt) { - wt.sectionId = id - // Remove from top-level worktreeOrder since it's now inside a section - const idx = this.worktreeOrder.indexOf(wtId) - if (idx !== -1) this.worktreeOrder.splice(idx, 1) - } + if (wt) wt.sectionId = id } } + this.setNormalizedWorktreeOrder(this.worktreeOrder) this.log(`Added section ${id}: "${name}"`) void this.save() return sec @@ -372,24 +401,15 @@ export class WorktreeStateManager { if (!this.sections.delete(id)) return // Ungroup all worktrees in this section — do NOT delete them for (const wt of this.worktrees.values()) { - if (wt.sectionId === id) { - wt.sectionId = undefined - if (!this.worktreeOrder.includes(wt.id)) this.worktreeOrder.push(wt.id) - } + if (wt.sectionId === id) wt.sectionId = undefined } - // Remove from sidebar order - const idx = this.worktreeOrder.indexOf(id) - if (idx !== -1) this.worktreeOrder.splice(idx, 1) + this.setNormalizedWorktreeOrder(this.worktreeOrder.filter((item) => item !== id)) this.log(`Deleted section ${id}, ungrouped its worktrees`) void this.save() } moveSection(id: string, dir: -1 | 1): void { - // Ensure the section is in worktreeOrder (it may be missing if drag-and-drop - // overwrote the order before this section was tracked) - if (this.sections.has(id) && !this.worktreeOrder.includes(id)) { - this.worktreeOrder.push(id) - } + const repaired = this.setNormalizedWorktreeOrder(this.worktreeOrder) const top = this.worktreeOrder.filter((item) => { if (this.sections.has(item)) return true const wt = this.worktrees.get(item) @@ -397,15 +417,21 @@ export class WorktreeStateManager { }) const idx = top.indexOf(id) const next = idx + dir - if (idx === -1 || next < 0 || next >= top.length) return + if (idx === -1 || next < 0 || next >= top.length) { + if (repaired) void this.save() + return + } const target = top[next]! const result = [...this.worktreeOrder] const fi = result.indexOf(id) - if (fi === -1 || result.indexOf(target) === -1) return + if (fi === -1 || result.indexOf(target) === -1) { + if (repaired) void this.save() + return + } result.splice(fi, 1) const insertAt = result.indexOf(target) + (dir === 1 ? 1 : 0) result.splice(insertAt, 0, id) - this.worktreeOrder = result + this.setNormalizedWorktreeOrder(result) void this.save() } @@ -423,13 +449,8 @@ export class WorktreeStateManager { const wt = this.worktrees.get(wtId) if (!wt) continue wt.sectionId = sectionId ?? undefined - if (sectionId) { - const idx = this.worktreeOrder.indexOf(wtId) - if (idx !== -1) this.worktreeOrder.splice(idx, 1) - } else { - if (!this.worktreeOrder.includes(wtId)) this.worktreeOrder.push(wtId) - } } + this.setNormalizedWorktreeOrder(this.worktreeOrder) void this.save() } @@ -519,22 +540,15 @@ export class WorktreeStateManager { if (data.worktreeOrder) { this.worktreeOrder = data.worktreeOrder } - // Normalize: ensure all section IDs and ungrouped worktree IDs are in worktreeOrder - const present = new Set(this.worktreeOrder) - for (const id of this.sections.keys()) { - if (!present.has(id)) this.worktreeOrder.push(id) - } - for (const wt of this.worktrees.values()) { - if (!wt.sectionId && !present.has(wt.id)) this.worktreeOrder.push(wt.id) - } + const repaired = this.setNormalizedWorktreeOrder(this.worktreeOrder) this.collapsed = data.sessionsCollapsed ?? false if (data.reviewDiffStyle === "split") { this.reviewDiffStyle = "split" } this.defaultBase = data.defaultBaseBranch this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`) - if (pruned > 0) { - this.log(`Pruned ${pruned} orphaned sessions`) + if (pruned > 0 || repaired) { + if (pruned > 0) this.log(`Pruned ${pruned} orphaned sessions`) void this.save() } } catch (error) { diff --git a/packages/kilo-vscode/tests/unit/section-helpers.test.ts b/packages/kilo-vscode/tests/unit/section-helpers.test.ts index cd2871660d..597e5bca09 100644 --- a/packages/kilo-vscode/tests/unit/section-helpers.test.ts +++ b/packages/kilo-vscode/tests/unit/section-helpers.test.ts @@ -3,6 +3,7 @@ import { buildTopLevelItems, buildSidebarOrder, buildShortcutMap, + completeSidebarOrder, isGrouped, isGroupStart, isGroupEnd, @@ -64,6 +65,32 @@ describe("buildTopLevelItems", () => { const result = buildTopLevelItems([s1], [w1], [w1], ["s1", "w1", "s1", "w1"]) expect(result).toHaveLength(2) }) + + it("ignores section member ids while placing top-level sections", () => { + const s1 = sec("s1", 0) + const w1 = wt("w1", { sectionId: "s1" }) + const w2 = wt("w2") + const result = buildTopLevelItems([s1], [w2], [w1, w2], ["w1", "s1", "w2"]) + expect(result).toEqual([ + { kind: "section", section: s1 }, + { kind: "worktree", wt: w2 }, + ]) + }) +}) + +describe("completeSidebarOrder", () => { + it("keeps section ids while adding missing worktree ids", () => { + const s1 = sec("s1", 0) + const w1 = wt("w1", { sectionId: "s1" }) + const w2 = wt("w2") + expect(completeSidebarOrder([s1], [w1, w2], ["w2", "s1"])).toEqual(["w2", "s1", "w1"]) + }) + + it("drops stale ids and skips duplicates", () => { + const s1 = sec("s1", 0) + const w1 = wt("w1") + expect(completeSidebarOrder([s1], [w1], ["old", "w1", "w1", "s1"])).toEqual(["w1", "s1"]) + }) }) describe("isGrouped", () => { diff --git a/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts index 7759537e2b..e77052d2de 100644 --- a/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-state-sections.test.ts @@ -44,8 +44,8 @@ describe("WorktreeStateManager sections", () => { const sec = mgr.addSection("Group", "Red", [wt1.id]) expect(mgr.getWorktree(wt1.id)?.sectionId).toBe(sec.id) expect(mgr.getWorktree(wt2.id)?.sectionId).toBeUndefined() - // wt1 removed from top-level order, wt2 remains - expect(mgr.getWorktreeOrder()).not.toContain(wt1.id) + // Worktree ids stay in the persisted order so section member order can be restored. + expect(mgr.getWorktreeOrder()).toContain(wt1.id) expect(mgr.getWorktreeOrder()).toContain(wt2.id) }) }) @@ -122,32 +122,21 @@ describe("WorktreeStateManager sections", () => { }) }) - describe("setWorktreeOrder", () => { - it("preserves sections missing from incoming order", () => { - const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) - const a = mgr.addSection("A", null) - const b = mgr.addSection("B", null) - // Simulate webview sending an order that omits section B - mgr.setWorktreeOrder([wt.id, a.id]) - expect(mgr.getWorktreeOrder()).toContain(b.id) - }) - }) - describe("moveToSection", () => { - it("sets sectionId and removes from worktreeOrder", () => { + it("sets sectionId and keeps worktreeOrder for section member sorting", () => { const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) mgr.setWorktreeOrder([wt.id]) const sec = mgr.addSection("Target", null) mgr.moveToSection([wt.id], sec.id) expect(mgr.getWorktree(wt.id)?.sectionId).toBe(sec.id) - expect(mgr.getWorktreeOrder()).not.toContain(wt.id) + expect(mgr.getWorktreeOrder()).toContain(wt.id) }) - it("ungroups worktrees with null sectionId and adds back to order", () => { + it("ungroups worktrees with null sectionId and keeps them in order", () => { const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) - const sec = mgr.addSection("Temp", null, [wt.id]) - expect(mgr.getWorktreeOrder()).not.toContain(wt.id) + mgr.addSection("Temp", null, [wt.id]) + expect(mgr.getWorktreeOrder()).toContain(wt.id) mgr.moveToSection([wt.id], null) expect(mgr.getWorktree(wt.id)?.sectionId).toBeUndefined() @@ -231,16 +220,6 @@ describe("WorktreeStateManager sections", () => { expect(mgr.getWorktree(wt2.id)?.sectionId).toBe(a.id) }) - it("moves a section that is missing from worktreeOrder", () => { - const a = mgr.addSection("A", null) - const b = mgr.addSection("B", null) - // Simulate a drag-and-drop that lost section B from the order - mgr.setWorktreeOrder([a.id]) - expect(mgr.getWorktreeOrder()).toEqual([a.id, b.id]) - mgr.moveSection(b.id, -1) - expect(mgr.getWorktreeOrder()).toEqual([b.id, a.id]) - }) - it("persists reordered sections across save/load", async () => { const a = mgr.addSection("A", null) const b = mgr.addSection("B", null) @@ -251,6 +230,29 @@ describe("WorktreeStateManager sections", () => { await loaded.load() expect(loaded.getWorktreeOrder()).toEqual([b.id, a.id]) }) + + it("updates section order fields when moving", () => { + const a = mgr.addSection("A", null) + const b = mgr.addSection("B", null) + + mgr.moveSection(b.id, -1) + + expect(mgr.getSection(b.id)?.order).toBe(0) + expect(mgr.getSection(a.id)?.order).toBe(1) + }) + + it("repairs stale orders missing section ids before moving", () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const a = mgr.addSection("A", null) + const b = mgr.addSection("B", null) + + mgr.setWorktreeOrder([wt.id]) + mgr.moveSection(b.id, -1) + + expect(mgr.getWorktreeOrder()).toContain(a.id) + expect(mgr.getWorktreeOrder()).toContain(b.id) + expect(mgr.getWorktreeOrder().indexOf(b.id)).toBeLessThan(mgr.getWorktreeOrder().indexOf(a.id)) + }) }) describe("persistence", () => { @@ -303,5 +305,20 @@ describe("WorktreeStateManager sections", () => { await loaded.load() expect(loaded.getWorktreeOrder()).toContain(wt.id) }) + + it("normalizes worktreeOrder on load to include section member worktrees", async () => { + const wt = mgr.addWorktree({ branch: "a", path: "/tmp/a", parentBranch: "main" }) + const sec = mgr.addSection("S", null, [wt.id]) + await mgr.flush() + await mgr.save() + const file = path.join(root, ".kilo", "agent-manager.json") + const data = JSON.parse(fs.readFileSync(file, "utf-8")) + data.worktreeOrder = [sec.id] + fs.writeFileSync(file, JSON.stringify(data)) + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktreeOrder()).toContain(wt.id) + }) }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 62fa0d4625..1a57644ec9 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -81,7 +81,7 @@ import { NewWorktreeDialog } from "./NewWorktreeDialog" import { LanguageBridge, DataBridge } from "../src/App" import { useLanguage } from "../src/context/language" import { formatRelativeDate } from "../src/utils/date" -import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, restoreLocalSessions, LOCAL } from "./navigate" +import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, LOCAL } from "./navigate" import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order" import { ConstrainDragYAxis, SortableReviewTab, SortableTab } from "./sortable-tab" import { DiffPanel } from "./DiffPanel" @@ -98,6 +98,7 @@ import { buildTopLevelItems, buildSidebarOrder, buildShortcutMap, + completeSidebarOrder, isGrouped, isGroupStart, isGroupEnd, @@ -106,7 +107,6 @@ import { import { sectionAwareDetector } from "./section-dnd" import { ConstrainDragXAxis } from "./constrain-drag-x" import { mergeWorktreeDiffs } from "./diff-state" -import { trackOpenSessions } from "./open-sessions" import "./agent-manager.css" import "./agent-manager-review.css" @@ -672,17 +672,11 @@ const AgentManagerContent: Component = () => { const all = session.sessions() if (all.length === 0) return // sessions not loaded yet const ids = all.map((s) => s.id) - const prev = localSessionIDs() - const valid = prev.filter((lid) => isPending(lid) || validateLocalSession(lid, ids)) - if (valid.length !== prev.length) { - const removed = prev.filter((lid) => !isPending(lid) && !valid.includes(lid)) - for (const id of removed) { - vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id }) - } + const valid = localSessionIDs().filter((lid) => isPending(lid) || validateLocalSession(lid, ids)) + if (valid.length !== localSessionIDs().length) { setLocalSessionIDs(valid) } }) - trackOpenSessions(localSessionIDs, isPending, managedSessions, vscode.postMessage) // Drop in-memory review state for worktrees that no longer exist. createEffect(() => { @@ -1125,7 +1119,6 @@ const AgentManagerContent: Component = () => { setLocalSessionIDs((prev) => [...prev, created.session.id]) setSelection(LOCAL) } - vscode.postMessage({ type: "agentManager.persistSession", sessionId: created.session.id }) session.selectSession(created.session.id) }) @@ -1200,7 +1193,6 @@ const AgentManagerContent: Component = () => { if (idx >= 0) return [...prev.slice(0, idx + 1), ev.sessionId, ...prev.slice(idx + 1)] return [...prev, ev.sessionId] }) - vscode.postMessage({ type: "agentManager.persistSession", sessionId: ev.sessionId }) } session.selectSession(ev.sessionId) } @@ -1239,15 +1231,15 @@ const AgentManagerContent: Component = () => { const ms = state.sessions.find((s) => s.id === current) if (ms?.worktreeId) setSelection(ms.worktreeId) } - // Restore local session IDs from persisted state (sessions with no worktreeId) - const restored = restoreLocalSessions( - state.sessions, - localSessionIDs(), - state.tabOrder?.[LOCAL], - isPending, - applyTabOrder, - ) - if (restored) setLocalSessionIDs(restored) + // Recover local tab order from persisted state + const localOrder = state.tabOrder?.[LOCAL] + if (localOrder && localSessionIDs().length > 0) { + const reordered = applyTabOrder( + localSessionIDs().map((id) => ({ id })), + localOrder, + ).map((item) => item.id) + setLocalSessionIDs(reordered) + } // Recover sessions collapsed state from extension-persisted state if (state.sessionsCollapsed !== undefined) setSessionsCollapsed(state.sessionsCollapsed) // Clear busy state for worktrees that have been removed @@ -1898,9 +1890,6 @@ const AgentManagerContent: Component = () => { } if (pending || localSet().has(sessionId)) { setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId)) - if (!pending) { - vscode.postMessage({ type: "agentManager.forgetSession", sessionId }) - } } else { vscode.postMessage({ type: "agentManager.closeSession", sessionId }) } @@ -2288,10 +2277,7 @@ const AgentManagerContent: Component = () => { if (typeof from !== "string" || typeof to !== "string") return if (secIds().has(to)) return setSidebarWorktreeOrder((prev) => { - const cur = applyTabOrder( - sortedWorktrees().map((w) => ({ id: w.id })), - prev, - ).map((i) => i.id) + const cur = completeSidebarOrder(sections(), sortedWorktrees(), prev) return reorderTabs(cur, from, to) ?? prev }) } @@ -2301,6 +2287,7 @@ const AgentManagerContent: Component = () => { setDraggingWorktree(undefined) document.body.classList.remove("am-wt-dragging-active") if (typeof from === "string" && typeof to === "string" && secIds().has(to)) { + vscode.postMessage({ type: "agentManager.setWorktreeOrder", order: sidebarWorktreeOrder() }) moveToSection([from], to) return } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts index 6b5a2c6d08..3b14ffeaf4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts @@ -8,6 +8,22 @@ export type TopLevelItem = { kind: "section"; section: SectionState } | { kind: export type SidebarItem = { type: "local" | "wt" | "session"; id: string } +/** Build a canonical sidebar order containing section IDs and every worktree ID. */ +export function completeSidebarOrder(secs: SectionState[], all: WorktreeState[], order: string[]): string[] { + const valid = new Set([...secs.map((sec) => sec.id), ...all.map((wt) => wt.id)]) + const result: string[] = [] + const seen = new Set() + const add = (id: string) => { + if (!valid.has(id) || seen.has(id)) return + result.push(id) + seen.add(id) + } + for (const id of order) add(id) + for (const sec of secs) add(sec.id) + for (const wt of all) add(wt.id) + return result +} + /** Check if this worktree is part of a multi-version group. */ export const isGrouped = (wt: WorktreeState) => !!wt.groupId From 8388c1f88dfe068634544d1c4f09e95188a9ad7a Mon Sep 17 00:00:00 2001 From: "kilo-code-bot[bot]" <240665456+kilo-code-bot[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 11:01:47 +0200 Subject: [PATCH 28/39] docs: add concise PR description guidelines to AGENTS.md (#8710) Co-authored-by: kiloconnect[bot] <240665456+kiloconnect[bot]@users.noreply.github.com> --- AGENTS.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 8f3ed22f50..dc8bc73271 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,10 @@ Tests MUST test actual implementation, do not duplicate logic into a test. [Conventional Commits](https://www.conventionalcommits.org/) with scopes matching packages: `vscode`, `cli`, `agent-manager`, `sdk`, `ui`, `i18n`, `kilo-docs`, `gateway`, `telemetry`, `desktop`. Omit scope when spanning multiple packages. +## Pull Requests + +PR descriptions should be 2-3 lines covering **what** changed and **why**. Focus on intent and context a reviewer can't get from the diff — skip file-by-file inventories, test result summaries, and anything obvious from the code itself. + ## Fork Merge Process Kilo CLI is a fork of [opencode](https://github.com/anomalyco/opencode). From 24589dba3a24e4658ae2b04c5d16daf95ba0065d Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 10 Apr 2026 11:34:26 +0200 Subject: [PATCH 29/39] Refactor Agent Manager worktree diff controller (#8716) * refactor(agent-manager): extract worktree diff controller * chore(vscode): remove unused agent manager helper * test(agent-manager): relax provider line cap --- .../src/agent-manager/AgentManagerProvider.ts | 536 +++++------------- .../agent-manager/worktree-diff-controller.ts | 306 ++++++++++ .../tests/unit/agent-manager-arch.test.ts | 62 +- .../webview-ui/agent-manager/open-sessions.ts | 15 - 4 files changed, 480 insertions(+), 439 deletions(-) create mode 100644 packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts delete mode 100644 packages/kilo-vscode/webview-ui/agent-manager/open-sessions.ts diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index fd525dd9bd..7d1e6d2176 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -5,12 +5,12 @@ import type { KiloConnectionService } from "../services/cli-backend" import { getErrorMessage } from "../kilo-provider-utils" import { isAbsolutePath } from "../path-utils" import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager" -import { WorktreeStateManager, remoteRef } from "./WorktreeStateManager" +import { WorktreeStateManager } from "./WorktreeStateManager" import { handleSection } from "./section-handler" import { chooseBaseBranch, normalizeBaseBranch } from "./base-branch" import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller" import { PRStatusBridge } from "./pr-status-bridge" -import { GitOps, type ApplyConflict } from "./GitOps" +import { GitOps } from "./GitOps" import { versionedName } from "./branch-name" import { normalizePath, classifyWorktreeError } from "./git-import" import { SetupScriptService } from "./SetupScriptService" @@ -21,15 +21,14 @@ import { createTerminalHost } from "./terminal-host" import { executeVscodeTask } from "./task-runner" import { forkSession } from "./fork-session" import { continueInWorktree } from "./continue-in-worktree" +import { WorktreeDiffController } from "./worktree-diff-controller" -import { shouldStopDiffPolling } from "./delete-worktree" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" import { Semaphore } from "./semaphore" import { PLATFORM } from "./constants" import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types" -import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils" -import type { Host, PanelContext, OutputHandle, SessionProvider, Disposable } from "./host" +import type { Host, PanelContext, OutputHandle, Disposable } from "./host" /** * AgentManagerProvider opens the Agent Manager panel. @@ -39,8 +38,6 @@ import type { Host, PanelContext, OutputHandle, SessionProvider, Disposable } fr * sections: WORKTREES (top) with managed worktrees + their sessions, and * SESSIONS (bottom) with unassociated local sessions. */ -const LOCAL_DIFF_ID = "local" as const - export class AgentManagerProvider implements Disposable { public static readonly viewType = "kilo-code.new.AgentManagerPanel" @@ -52,17 +49,13 @@ export class AgentManagerProvider implements Disposable { private terminalManager: SessionTerminalManager private stateReady: Promise | undefined private importing = false - private diffInterval: ReturnType | undefined - private diffSessionId: string | undefined - private lastDiffHash: string | undefined private statsPoller: GitStatsPoller private prBridge!: PRStatusBridge private gitOps: GitOps - private cachedDiffTarget: { sessionId: string; directory: string; baseBranch: string } | undefined + private diffs: WorktreeDiffController private staleWorktreeIds = new Set() private cachedWorktreeStats: AgentManagerOutMessage | undefined private cachedLocalStats: AgentManagerOutMessage | undefined - private applyingWorktreeId: string | undefined /** Session ID most recently loaded via a `loadMessages` message from the webview. * Updated synchronously — unlike the session provider's currentSession which depends on @@ -79,6 +72,15 @@ export class AgentManagerProvider implements Disposable { ) const semaphore = new Semaphore(3) this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore }) + this.diffs = new WorktreeDiffController({ + getState: () => this.getStateManager(), + getRoot: () => this.getRoot(), + getStateReady: () => this.stateReady, + getClient: () => this.connectionService.getClient(), + git: this.gitOps, + post: (msg) => this.postToWebview(msg), + log: (...args) => this.log(...args), + }) this.statsPoller = new GitStatsPoller({ getWorktrees: () => this.state?.getWorktrees() ?? [], getWorkspaceRoot: () => this.getRoot(), @@ -171,7 +173,7 @@ export class AgentManagerProvider implements Disposable { this.log("Panel disposed") this.statsPoller.stop() this.prBridge.poller.stop() - this.stopDiffPolling() + this.diffs.stop() this.panel = undefined } ctx.sessions.dispose() @@ -226,47 +228,114 @@ export class AgentManagerProvider implements Disposable { // Message interceptor // --------------------------------------------------------------------------- - // eslint-disable-next-line complexity -- TODO: refactor to reduce complexity and remove this disable private async onMessage(msg: Record): Promise | null> { if (this.prBridge.handleMessage(msg)) return null const m = msg as unknown as AgentManagerInMessage - if (m.type === "agentManager.createWorktree") { - return this.onCreateWorktree(m.baseBranch, m.branchName) - } + const worktree = await this.onWorktreeMessage(m) + if (worktree !== undefined) return worktree + const session = this.onSessionMessage(m, msg) + if (session !== undefined) return session + const ui = this.onUiMessage(m, msg) + if (ui !== undefined) return ui + const state = this.onStateMessage(m) + if (state !== undefined) return state + const imports = this.onImportMessage(m) + if (imports !== undefined) return imports + const diff = this.onDiffMessage(m) + if (diff !== undefined) return diff + const bridge = this.onBridgeMessage(m) + if (bridge !== undefined) return bridge + + return msg + } + + private async onWorktreeMessage(m: AgentManagerInMessage): Promise | null | undefined> { + if (m.type === "agentManager.createWorktree") return this.onCreateWorktree(m.baseBranch, m.branchName) if (m.type === "agentManager.deleteWorktree") return this.onDeleteWorktree(m.worktreeId) if (m.type === "agentManager.removeStaleWorktree") return this.onRemoveStaleWorktree(m.worktreeId) if (m.type === "agentManager.promoteSession") return this.onPromoteSession(m.sessionId) + if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId) + if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId) + if (m.type === "agentManager.closeSession") return this.onCloseSession(m.sessionId) + } + + private onSessionMessage( + m: AgentManagerInMessage, + msg: Record, + ): Record | null | undefined { if (m.type === "agentManager.openLocally") { this.panel?.sessions.clearSessionDirectory(m.sessionId) - const st = this.getStateManager() - if (st?.getSession(m.sessionId)) { - st.moveSession(m.sessionId, null) + const state = this.getStateManager() + if (state?.getSession(m.sessionId)) { + state.moveSession(m.sessionId, null) this.pushState() } return null } + if (m.type === "continueInWorktree") { void this.continueFromSidebar(m.sessionId, (status, detail, error) => { this.panel?.postMessage({ type: "continueInWorktreeProgress", status, detail, error }) }) return null } - if (m.type === "agentManager.addSessionToWorktree") return this.onAddSessionToWorktree(m.worktreeId) - if (m.type === "agentManager.forkSession") return this.onForkSession(m.sessionId, m.worktreeId) - if (m.type === "agentManager.closeSession") return this.onCloseSession(m.sessionId) + if (m.type === "agentManager.persistSession" || m.type === "agentManager.forgetSession") { const persist = m.type === "agentManager.persistSession" void this.stateReady?.then(() => { - const st = this.getStateManager() - if (st) - persist ? !st.getSession(m.sessionId) && st.addSession(m.sessionId, null) : st.removeSession(m.sessionId) + const state = this.getStateManager() + if (state) + persist + ? !state.getSession(m.sessionId) && state.addSession(m.sessionId, null) + : state.removeSession(m.sessionId) }) return null } + if ((m.type === "sendMessage" || m.type === "sendCommand") && m.draftID && !m.sessionID) { this.activeSessionId = m.draftID + return msg } + + if (m.type === "loadMessages") { + this.activeSessionId = m.sessionID + this.connectionService.registerFocused("agent-manager", m.sessionID) + this.terminalManager.syncOnSessionSwitch(m.sessionID) + this.prBridge.poller.setActiveWorktreeId(this.state?.getSession(m.sessionID)?.worktreeId ?? undefined) + return msg + } + + if (m.type === "clearSession") { + this.activeSessionId = undefined + this.connectionService.unregisterFocused("agent-manager") + void Promise.resolve().then(() => { + if (!this.panel || !this.state) return + for (const id of this.state.worktreeSessionIds()) { + this.panel.sessions.trackSession(id) + } + }) + return msg + } + + if (m.type === "abort") { + this.host.capture("Agent Manager Session Stopped", { + source: PLATFORM, + sessionId: m.sessionID, + }) + return msg + } + + if (m.type === "agentManager.openSessions") { + this.connectionService.registerOpen("agent-manager", m.sessionIDs) + return null + } + } + + private onUiMessage( + m: AgentManagerInMessage, + msg: Record, + ): Record | null | undefined { if (m.type === "agentManager.configureSetupScript") { void this.configureSetupScript() return null @@ -287,9 +356,7 @@ export class AgentManagerProvider implements Disposable { this.host.copyToClipboard(m.text) return null } - if (m.type === "previewImage") { - return msg - } + if (m.type === "previewImage") return msg if (m.type === "agentManager.showExistingLocalTerminal") { this.terminalManager.syncLocalOnSessionSwitch() return null @@ -310,27 +377,20 @@ export class AgentManagerProvider implements Disposable { } return null } + } + + private onStateMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type === "agentManager.requestState") { void this.stateReady ?.then(() => { - // When the folder is not a git repo (or has no folder open), - // this.state is never created. pushState() silently returns in that - // case, so re-send the empty/non-git state explicitly. if (!this.state) { this.pushEmptyState() return } this.pushState() - // Re-send cached stats so the webview gets them even if the poller - // already emitted before the webview was ready to receive messages. if (this.cachedWorktreeStats) this.postToWebview(this.cachedWorktreeStats) if (this.cachedLocalStats) this.postToWebview(this.cachedLocalStats) this.prBridge.replay() - // Refresh sessions after pushState so the webview's sessionsLoaded - // handler is guaranteed to be registered (requestState fires from - // onMount). Without this, the initial refreshSessions() in - // initializeState() can race ahead of webview mount, causing - // sessionsLoaded to never flip to true. if (this.state.getSessions().length > 0) { this.panel?.sessions.refreshSessions() } @@ -339,12 +399,13 @@ export class AgentManagerProvider implements Disposable { this.log("initializeState failed, pushing partial state:", err) if (!this.state) { this.pushEmptyState() - } else { - this.pushState() + return } + this.pushState() }) return null } + if (m.type === "agentManager.requestBranches") { void this.onRequestBranches() return null @@ -368,11 +429,13 @@ export class AgentManagerProvider implements Disposable { return null } if (m.type === "agentManager.setDefaultBaseBranch") { - const branch = normalizeBaseBranch(m.branch) - this.state?.setDefaultBaseBranch(branch) + this.state?.setDefaultBaseBranch(normalizeBaseBranch(m.branch)) this.pushState() return null } + } + + private onImportMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type === "agentManager.requestExternalWorktrees") { void this.onRequestExternalWorktrees() return null @@ -393,90 +456,48 @@ export class AgentManagerProvider implements Disposable { void this.onImportAllExternalWorktrees() return null } + } + + private onDiffMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type === "agentManager.requestWorktreeDiff") { - void this.onRequestWorktreeDiff(m.sessionId) + void this.diffs.request(m.sessionId) return null } if (m.type === "agentManager.requestWorktreeDiffFile") { - void this.onRequestWorktreeDiffFile(m.sessionId, m.file) + void this.diffs.requestFile(m.sessionId, m.file) return null } if (m.type === "agentManager.applyWorktreeDiff") { - const selectedFiles = Array.isArray(m.selectedFiles) - ? [ - ...new Set( - m.selectedFiles.filter((file): file is string => typeof file === "string").map((file) => file.trim()), - ), - ].filter((file) => file.length > 0) - : undefined - void this.onApplyWorktreeDiff(m.worktreeId, selectedFiles) + void this.diffs.apply(m.worktreeId, m.selectedFiles) return null } if (m.type === "agentManager.revertWorktreeFile") { - void this.onRevertWorktreeFile(m.sessionId, m.file) + void this.diffs.revert(m.sessionId, m.file) return null } if (m.type === "agentManager.startDiffWatch") { - this.startDiffPolling(m.sessionId) - return null - } - if (m.type === "agentManager.openSessions") { - this.connectionService.registerOpen("agent-manager", m.sessionIDs) + this.diffs.start(m.sessionId) return null } if (m.type === "agentManager.stopDiffWatch") { - this.stopDiffPolling() + this.diffs.stop() return null } if (m.type === "agentManager.openFile") { this.openWorktreeFile(m.sessionId, m.filePath, m.line, m.column) return null } + } - // Intercept generic "openFile" from DataBridge (markdown links, tool subtitle clicks) - // and route through worktree-aware resolution — but only for worktree sessions. - // Local sessions fall through to the session provider which resolves against the repo root. - // Uses activeSessionId (set synchronously by loadMessages) rather than - // the session provider's currentSession which can be stale during rapid tab switches. - if (m.type === "openFile") { - const sessionId = this.activeSessionId - const state = this.getStateManager() - if (sessionId && state?.directoryFor(sessionId)) { - this.openWorktreeFile(sessionId, m.filePath, m.line, m.column) - return null - } + private onBridgeMessage(m: AgentManagerInMessage): Record | null | undefined { + if (m.type !== "openFile") return undefined + + const sessionId = this.activeSessionId + const state = this.getStateManager() + if (sessionId && state?.directoryFor(sessionId)) { + this.openWorktreeFile(sessionId, m.filePath, m.line, m.column) + return null } - - // Track the active session synchronously so worktree-aware file resolution - // uses the correct session even before the session provider's async session.get completes. - if (m.type === "loadMessages") { - this.activeSessionId = m.sessionID - this.connectionService.registerFocused("agent-manager", m.sessionID) - this.terminalManager.syncOnSessionSwitch(m.sessionID) - this.prBridge.poller.setActiveWorktreeId(this.state?.getSession(m.sessionID)?.worktreeId ?? undefined) - } - - // After clearSession, clear active tracking and re-register worktree sessions - if (m.type === "clearSession") { - this.activeSessionId = undefined - this.connectionService.unregisterFocused("agent-manager") - void Promise.resolve().then(() => { - if (!this.panel || !this.state) return - for (const id of this.state.worktreeSessionIds()) { - this.panel.sessions.trackSession(id) - } - }) - } - - // Track when a user stops/cancels a running session in the agent manager - if (m.type === "abort") { - this.host.capture("Agent Manager Session Stopped", { - source: PLATFORM, - sessionId: m.sessionID, - }) - } - - return msg } // --------------------------------------------------------------------------- @@ -720,8 +741,8 @@ export class AgentManagerProvider implements Disposable { this.statsPoller.skipWorktree(worktreeId) this.prBridge.remove(worktreeId) const orphaned = state.removeWorktree(worktreeId) - if (shouldStopDiffPolling(worktree.path, orphaned, this.cachedDiffTarget, this.diffSessionId)) { - this.stopDiffPolling() + if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { + this.diffs.stop() } for (const s of orphaned) this.panel?.sessions.clearSessionDirectory(s.id) this.pushState() @@ -752,8 +773,8 @@ export class AgentManagerProvider implements Disposable { } const orphaned = state.removeWorktree(worktreeId) - if (shouldStopDiffPolling(worktree.path, orphaned, this.cachedDiffTarget, this.diffSessionId)) { - this.stopDiffPolling() + if (this.diffs.shouldStopForWorktree(worktree.path, orphaned)) { + this.diffs.stop() } for (const session of orphaned) { this.panel?.sessions.clearSessionDirectory(session.id) @@ -1598,146 +1619,7 @@ export class AgentManagerProvider implements Disposable { } // --------------------------------------------------------------------------- - // Helpers - // --------------------------------------------------------------------------- - - private postApplyResult( - worktreeId: string, - status: "checking" | "applying" | "success" | "conflict" | "error", - message: string, - conflicts?: ApplyConflict[], - ): void { - this.postToWebview({ - type: "agentManager.applyWorktreeDiffResult", - worktreeId, - status, - message, - conflicts, - }) - } - - private async onApplyWorktreeDiff(worktreeId: string, selectedFiles?: string[]): Promise { - if (this.applyingWorktreeId) { - this.postApplyResult(worktreeId, "error", "Another apply operation is already in progress") - return - } - - if (selectedFiles && selectedFiles.length === 0) { - this.postApplyResult(worktreeId, "error", "Select at least one file to apply") - return - } - - const state = this.getStateManager() - const root = this.getRoot() - if (!state || !root) { - this.postApplyResult(worktreeId, "error", "Open a git repository to apply changes") - return - } - - const worktree = state.getWorktree(worktreeId) - if (!worktree) { - this.postApplyResult(worktreeId, "error", "Worktree not found") - return - } - - this.applyingWorktreeId = worktreeId - - try { - this.postApplyResult(worktreeId, "checking", "Checking for conflicts...") - const patch = await this.gitOps.buildWorktreePatch(worktree.path, remoteRef(worktree), selectedFiles) - - if (!patch.trim()) { - this.postApplyResult(worktreeId, "success", "No changes to apply") - return - } - - const check = await this.gitOps.checkApplyPatch(root, patch) - if (!check.ok) { - this.postApplyResult(worktreeId, "conflict", check.message, check.conflicts) - return - } - - this.postApplyResult(worktreeId, "applying", "Applying changes to local branch...") - const applied = await this.gitOps.applyPatch(root, patch) - if (!applied.ok) { - const conflict = applied.conflicts.length > 0 - const status = conflict ? "conflict" : "error" - this.postApplyResult(worktreeId, status, applied.message, applied.conflicts) - return - } - - this.postApplyResult(worktreeId, "success", "Applied worktree changes to local branch") - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - this.log("Failed to apply worktree diff:", message) - this.postApplyResult(worktreeId, "error", message) - } finally { - this.applyingWorktreeId = undefined - } - } - - /** Revert a single file in a worktree back to the merge-base state. */ - private async onRevertWorktreeFile(sessionId: string, file: string): Promise { - if (!file) return - if (this.stateReady) { - await this.stateReady.catch((err) => this.log("stateReady rejected, continuing revert resolve:", err)) - } - - const target = - this.cachedDiffTarget?.sessionId === sessionId ? this.cachedDiffTarget : await this.resolveDiffTarget(sessionId) - if (!target) { - this.postToWebview({ - type: "agentManager.revertWorktreeFileResult", - sessionId, - file, - status: "error", - message: "Could not resolve diff target", - }) - return - } - - // Look up the file status from the cached diffs so we know if it's added/modified/deleted - let status: "added" | "deleted" | "modified" | undefined - try { - const client = this.connectionService.getClient() - const { data } = await client.worktree.diffFile( - { directory: target.directory, base: target.baseBranch, file }, - { throwOnError: true }, - ) - status = data?.status - } catch (err) { - this.log("Failed to look up file status for revert:", err) - } - - try { - const result = await this.gitOps.revertFile(target.directory, target.baseBranch, file, status) - this.postToWebview({ - type: "agentManager.revertWorktreeFileResult", - sessionId, - file, - status: result.ok ? "success" : "error", - message: result.message, - }) - - // After successful revert, trigger a diff refresh so the UI updates - if (result.ok) { - void this.onRequestWorktreeDiff(sessionId) - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - this.log("Failed to revert worktree file:", message) - this.postToWebview({ - type: "agentManager.revertWorktreeFileResult", - sessionId, - file, - status: "error", - message, - }) - } - } - - // --------------------------------------------------------------------------- - // Diff polling + // Worktree file helpers // --------------------------------------------------------------------------- /** Open a worktree directory directly in VS Code. */ @@ -1784,164 +1666,6 @@ export class AgentManagerProvider implements Disposable { this.host.openFile(resolved, line, column) } - /** Resolve worktree path + parentBranch for a session, or undefined if not applicable. */ - private async resolveDiffTarget(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> { - if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocalDiffTarget() - const state = this.getStateManager() - if (!state) { - this.log(`resolveDiffTarget: no state manager for session ${sessionId}`) - return undefined - } - const session = state.getSession(sessionId) - if (!session) { - this.log( - `resolveDiffTarget: session ${sessionId} not found in state (${state.getSessions().length} total sessions)`, - ) - return undefined - } - if (!session.worktreeId) { - this.log(`resolveDiffTarget: session ${sessionId} has no worktreeId (local session)`) - return undefined - } - const worktree = state.getWorktree(session.worktreeId) - if (!worktree) { - this.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${sessionId}`) - return undefined - } - // Always construct remote-prefixed ref for diff (e.g. "origin/main") - return { directory: worktree.path, baseBranch: remoteRef(worktree) } - } - - /** Resolve diff target for the local repo — diffs against the remote tracking - * branch, falling back to the repo's default branch, and ultimately to HEAD so - * local-only repos (no remote) still show working-tree changes in the diff panel. */ - private async resolveLocalDiffTarget(): Promise<{ directory: string; baseBranch: string } | undefined> { - return await resolveLocalDiffTarget(this.gitOps, (...args) => this.log(...args), this.getRoot()) - } - - /** One-shot diff fetch with loading indicators. Resolves target async, then fetches. */ - private async onRequestWorktreeDiff(sessionId: string): Promise { - // Ensure state is loaded before resolving diff target — avoids race where - // startDiffWatch arrives before initializeState() finishes loading state from disk. - // The .catch() is required: this method is called via `void` (fire-and-forget), - // so an uncaught rejection would become an unhandled promise rejection. On failure - // we log and fall through to resolveDiffTarget which logs the specific reason. - if (this.stateReady) { - await this.stateReady.catch((err) => this.log("stateReady rejected, continuing diff resolve:", err)) - } - - const target = await this.resolveDiffTarget(sessionId) - if (!target) return - - // Cache the resolved target so subsequent polls skip resolution entirely - this.cachedDiffTarget = { sessionId, ...target } - - this.postToWebview({ type: "agentManager.worktreeDiffLoading", sessionId, loading: true }) - try { - const client = this.connectionService.getClient() - const { data: diffs } = await client.worktree.diffSummary( - { directory: target.directory, base: target.baseBranch }, - { throwOnError: true }, - ) - - const files = diffs ?? [] - this.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) - - const hash = hashFileDiffs(files) - this.lastDiffHash = hash - this.diffSessionId = sessionId - - this.postToWebview({ type: "agentManager.worktreeDiff", sessionId, diffs: files }) - } catch (err) { - this.log("Failed to fetch worktree diff:", err) - } finally { - this.postToWebview({ type: "agentManager.worktreeDiffLoading", sessionId, loading: false }) - } - } - - /** Polling diff fetch — uses cached target, no loading state, only pushes when hash changes. */ - private async pollDiff(sessionId: string): Promise { - const target = this.cachedDiffTarget?.sessionId === sessionId ? this.cachedDiffTarget : undefined - if (!target) return - - try { - const client = this.connectionService.getClient() - const { data: diffs } = await client.worktree.diffSummary( - { directory: target.directory, base: target.baseBranch }, - { throwOnError: true }, - ) - - const files = diffs ?? [] - const hash = hashFileDiffs(files) - if (hash === this.lastDiffHash && this.diffSessionId === sessionId) return - this.lastDiffHash = hash - this.diffSessionId = sessionId - - this.postToWebview({ type: "agentManager.worktreeDiff", sessionId, diffs: files }) - } catch (err) { - this.log("Failed to poll worktree diff:", err) - } - } - - private async onRequestWorktreeDiffFile(sessionId: string, file: string): Promise { - if (!file) return - - if (this.stateReady) { - await this.stateReady.catch((err) => this.log("stateReady rejected, continuing diff detail resolve:", err)) - } - - const target = - this.cachedDiffTarget?.sessionId === sessionId ? this.cachedDiffTarget : await this.resolveDiffTarget(sessionId) - if (!target) return - - this.cachedDiffTarget = { sessionId, directory: target.directory, baseBranch: target.baseBranch } - - try { - const client = this.connectionService.getClient() - const { data } = await client.worktree.diffFile( - { directory: target.directory, base: target.baseBranch, file }, - { throwOnError: true }, - ) - this.postToWebview({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: data ?? null }) - } catch (err) { - this.log("Failed to fetch worktree diff file:", err) - this.postToWebview({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null }) - } - } - - private startDiffPolling(sessionId: string): void { - // If already polling the same session, keep the existing interval and cache - // to avoid an unnecessary stop→restart cycle that clears lastDiffHash and - // cachedDiffTarget, creating a flash of empty diff data in the webview. - if (this.diffSessionId === sessionId && this.diffInterval) { - this.log(`Already polling session ${sessionId}, skipping restart`) - return - } - this.stopDiffPolling() - this.diffSessionId = sessionId - this.lastDiffHash = undefined - this.log(`Starting diff polling for session ${sessionId}`) - - // Initial fetch resolves + caches the diff target, then starts interval polling - void this.onRequestWorktreeDiff(sessionId).then(() => { - // Only start interval if still watching the same session (may have been stopped) - if (this.diffSessionId !== sessionId) return - this.diffInterval = setInterval(() => { - void this.pollDiff(sessionId) - }, 2500) - }) - } - - private stopDiffPolling(): void { - if (this.diffInterval) { - clearInterval(this.diffInterval) - this.diffInterval = undefined - } - this.diffSessionId = undefined - this.lastDiffHash = undefined - this.cachedDiffTarget = undefined - } - private postToWebview(message: AgentManagerOutMessage): void { this.panel?.postMessage(message) } @@ -2020,7 +1744,7 @@ export class AgentManagerProvider implements Disposable { public dispose(): void { this.connectionService.unregisterFocused("agent-manager") this.connectionService.registerOpen("agent-manager", []) - this.stopDiffPolling() + this.diffs.stop() this.statsPoller.stop() this.gitOps.dispose() this.prBridge.poller.stop() diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts new file mode 100644 index 0000000000..a4ec1dabbd --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -0,0 +1,306 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils" +import type { ApplyConflict, GitOps } from "./GitOps" +import { shouldStopDiffPolling } from "./delete-worktree" +import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" +import type { AgentManagerOutMessage } from "./types" + +const LOCAL_DIFF_ID = "local" as const + +type Target = { sessionId: string; directory: string; baseBranch: string } +type Status = "added" | "deleted" | "modified" + +export interface WorktreeDiffControllerContext { + getState: () => WorktreeStateManager | undefined + getRoot: () => string | undefined + getStateReady: () => Promise | undefined + getClient: () => KiloClient + git: GitOps + post: (msg: AgentManagerOutMessage) => void + log: (...args: unknown[]) => void +} + +export class WorktreeDiffController { + private interval: ReturnType | undefined + private session: string | undefined + private hash: string | undefined + private target: Target | undefined + private applying: string | undefined + + constructor(private readonly ctx: WorktreeDiffControllerContext) {} + + public shouldStopForWorktree(path: string, sessions: ManagedSession[]): boolean { + return shouldStopDiffPolling(path, sessions, this.target, this.session) + } + + public async apply(worktreeId: string, value?: unknown): Promise { + if (this.applying) { + this.postApplyResult(worktreeId, "error", "Another apply operation is already in progress") + return + } + + const files = selectedDiffFiles(value) + if (files && files.length === 0) { + this.postApplyResult(worktreeId, "error", "Select at least one file to apply") + return + } + + const state = this.ctx.getState() + const root = this.ctx.getRoot() + if (!state || !root) { + this.postApplyResult(worktreeId, "error", "Open a git repository to apply changes") + return + } + + const worktree = state.getWorktree(worktreeId) + if (!worktree) { + this.postApplyResult(worktreeId, "error", "Worktree not found") + return + } + + this.applying = worktreeId + + try { + this.postApplyResult(worktreeId, "checking", "Checking for conflicts...") + const patch = await this.ctx.git.buildWorktreePatch(worktree.path, remoteRef(worktree), files) + + if (!patch.trim()) { + this.postApplyResult(worktreeId, "success", "No changes to apply") + return + } + + const check = await this.ctx.git.checkApplyPatch(root, patch) + if (!check.ok) { + this.postApplyResult(worktreeId, "conflict", check.message, check.conflicts) + return + } + + this.postApplyResult(worktreeId, "applying", "Applying changes to local branch...") + const applied = await this.ctx.git.applyPatch(root, patch) + if (!applied.ok) { + const conflict = applied.conflicts.length > 0 + const status = conflict ? "conflict" : "error" + this.postApplyResult(worktreeId, status, applied.message, applied.conflicts) + return + } + + this.postApplyResult(worktreeId, "success", "Applied worktree changes to local branch") + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + this.ctx.log("Failed to apply worktree diff:", msg) + this.postApplyResult(worktreeId, "error", msg) + } finally { + this.applying = undefined + } + } + + public async revert(sessionId: string, file: string): Promise { + if (!file) return + await this.ready("stateReady rejected, continuing revert resolve:") + + const target = this.target?.sessionId === sessionId ? this.target : await this.resolve(sessionId) + if (!target) { + this.ctx.post({ + type: "agentManager.revertWorktreeFileResult", + sessionId, + file, + status: "error", + message: "Could not resolve diff target", + }) + return + } + + try { + const result = await this.ctx.git.revertFile( + target.directory, + target.baseBranch, + file, + await this.status(target, file), + ) + this.ctx.post({ + type: "agentManager.revertWorktreeFileResult", + sessionId, + file, + status: result.ok ? "success" : "error", + message: result.message, + }) + + if (result.ok) void this.request(sessionId) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + this.ctx.log("Failed to revert worktree file:", msg) + this.ctx.post({ + type: "agentManager.revertWorktreeFileResult", + sessionId, + file, + status: "error", + message: msg, + }) + } + } + + public async request(sessionId: string): Promise { + await this.ready("stateReady rejected, continuing diff resolve:") + + const target = await this.resolve(sessionId) + if (!target) return + + this.target = { sessionId, ...target } + this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: true }) + + try { + const { data } = await this.ctx + .getClient() + .worktree.diffSummary({ directory: target.directory, base: target.baseBranch }, { throwOnError: true }) + + const files = data ?? [] + this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) + this.hash = hashFileDiffs(files) + this.session = sessionId + this.ctx.post({ type: "agentManager.worktreeDiff", sessionId, diffs: files }) + } catch (error) { + this.ctx.log("Failed to fetch worktree diff:", error) + } finally { + this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: false }) + } + } + + public async requestFile(sessionId: string, file: string): Promise { + if (!file) return + await this.ready("stateReady rejected, continuing diff detail resolve:") + + const target = this.target?.sessionId === sessionId ? this.target : await this.resolve(sessionId) + if (!target) return + + this.target = { sessionId, directory: target.directory, baseBranch: target.baseBranch } + + try { + const { data } = await this.ctx + .getClient() + .worktree.diffFile({ directory: target.directory, base: target.baseBranch, file }, { throwOnError: true }) + this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: data ?? null }) + } catch (error) { + this.ctx.log("Failed to fetch worktree diff file:", error) + this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null }) + } + } + + public start(sessionId: string): void { + if (this.session === sessionId && this.interval) { + this.ctx.log(`Already polling session ${sessionId}, skipping restart`) + return + } + + this.stop() + this.session = sessionId + this.hash = undefined + this.ctx.log(`Starting diff polling for session ${sessionId}`) + + void this.request(sessionId).then(() => { + if (this.session !== sessionId) return + this.interval = setInterval(() => { + void this.poll(sessionId) + }, 2500) + }) + } + + public stop(): void { + if (this.interval) { + clearInterval(this.interval) + this.interval = undefined + } + this.session = undefined + this.hash = undefined + this.target = undefined + } + + private async poll(sessionId: string): Promise { + const target = this.target?.sessionId === sessionId ? this.target : undefined + if (!target) return + + try { + const { data } = await this.ctx + .getClient() + .worktree.diffSummary({ directory: target.directory, base: target.baseBranch }, { throwOnError: true }) + + const files = data ?? [] + const hash = hashFileDiffs(files) + if (hash === this.hash && this.session === sessionId) return + this.hash = hash + this.session = sessionId + this.ctx.post({ type: "agentManager.worktreeDiff", sessionId, diffs: files }) + } catch (error) { + this.ctx.log("Failed to poll worktree diff:", error) + } + } + + private async resolve(sessionId: string): Promise<{ directory: string; baseBranch: string } | undefined> { + if (sessionId === LOCAL_DIFF_ID) return await this.resolveLocal() + const state = this.ctx.getState() + if (!state) { + this.ctx.log(`resolveDiffTarget: no state manager for session ${sessionId}`) + return undefined + } + + const session = state.getSession(sessionId) + if (!session) { + this.ctx.log( + `resolveDiffTarget: session ${sessionId} not found in state (${state.getSessions().length} total sessions)`, + ) + return undefined + } + if (!session.worktreeId) { + this.ctx.log(`resolveDiffTarget: session ${sessionId} has no worktreeId (local session)`) + return undefined + } + + const worktree = state.getWorktree(session.worktreeId) + if (!worktree) { + this.ctx.log(`resolveDiffTarget: worktree ${session.worktreeId} not found for session ${sessionId}`) + return undefined + } + return { directory: worktree.path, baseBranch: remoteRef(worktree) } + } + + private async resolveLocal(): Promise<{ directory: string; baseBranch: string } | undefined> { + return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot()) + } + + private async status(target: { directory: string; baseBranch: string }, file: string): Promise { + try { + const { data } = await this.ctx + .getClient() + .worktree.diffFile({ directory: target.directory, base: target.baseBranch, file }, { throwOnError: true }) + return data?.status + } catch (error) { + this.ctx.log("Failed to look up file status for revert:", error) + return undefined + } + } + + private async ready(msg: string): Promise { + await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err)) + } + + private postApplyResult( + worktreeId: string, + status: "checking" | "applying" | "success" | "conflict" | "error", + message: string, + conflicts?: ApplyConflict[], + ): void { + this.ctx.post({ + type: "agentManager.applyWorktreeDiffResult", + worktreeId, + status, + message, + conflicts, + }) + } +} + +function selectedDiffFiles(value: unknown): string[] | undefined { + if (!Array.isArray(value)) return undefined + return [ + ...new Set(value.filter((file): file is string => typeof file === "string").map((file) => file.trim())), + ].filter((file) => file.length > 0) +} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index efcc33f8c3..77ef30bf19 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -34,8 +34,9 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/SectionHeader.tsx"), path.join(ROOT, "webview-ui/diff-virtual/DiffVirtualApp.tsx"), ] -const TSX_FILE = TSX_FILES[0] +const TSX_FILE = TSX_FILES[0]! const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts") +const DIFF_CONTROLLER_FILE = path.join(ROOT, "src/agent-manager/worktree-diff-controller.ts") const SETUP_SCRIPT_RUNNER_FILE = path.join(ROOT, "src/agent-manager/SetupScriptRunner.ts") function readAllCss(): string { @@ -168,10 +169,18 @@ describe("Agent Manager Provider — onMessage routing", () => { return method!.getText() } + function provider(): string { + return fs.readFileSync(PROVIDER_FILE, "utf-8") + } + + function diff(): string { + return fs.readFileSync(DIFF_CONTROLLER_FILE, "utf-8") + } + // -- onMessage dispatches all expected message types ----------------------- - it("onMessage handles all documented agentManager.* message types", () => { - const text = body("onMessage") + it("provider routing handles all documented agentManager.* message types", () => { + const text = provider() const expected = [ "agentManager.createWorktree", "agentManager.deleteWorktree", @@ -191,22 +200,30 @@ describe("Agent Manager Provider — onMessage routing", () => { "agentManager.setDefaultBaseBranch", ] for (const msg of expected) { - expect(text, `onMessage should handle "${msg}"`).toContain(msg) + expect(text, `provider routing should handle "${msg}"`).toContain(msg) } }) - it("onMessage handles loadMessages for terminal switching", () => { - const text = body("onMessage") + it("session routing handles loadMessages for terminal switching", () => { + const text = body("onSessionMessage") expect(text).toContain("loadMessages") expect(text).toContain("syncOnSessionSwitch") }) - it("onMessage handles clearSession for SSE re-registration", () => { - const text = body("onMessage") + it("session routing handles clearSession for SSE re-registration", () => { + const text = body("onSessionMessage") expect(text).toContain("clearSession") expect(text).toContain("trackSession") }) + it("onMessage delegates to cohesive routing groups", () => { + const text = body("onMessage") + expect(text).toContain("onWorktreeMessage") + expect(text).toContain("onSessionMessage") + expect(text).toContain("onDiffMessage") + expect(text).not.toContain("agentManager.requestState") + }) + // -- onDeleteWorktree invariants ------------------------------------------- /** @@ -296,23 +313,32 @@ describe("Agent Manager Provider — onMessage routing", () => { * call pushEmptyState() instead — otherwise the webview stays stuck on * loading skeletons forever. */ - it("requestState handler calls pushEmptyState when this.state is falsy", () => { - const text = body("onMessage") - // Extract the requestState branch + it("requestState handler calls pushEmptyState when state is falsy", () => { + const text = body("onStateMessage") const start = text.indexOf('"agentManager.requestState"') expect(start, "requestState branch must exist").toBeGreaterThan(-1) - // Grab a reasonable window after the match - const snippet = text.slice(start, start + 600) + const snippet = text.slice(start, start + 700) expect(snippet, "must call pushEmptyState when state is absent").toContain("pushEmptyState") expect(snippet, "must guard on this.state being falsy").toMatch(/!this\.state/) }) - it("requestState handler calls pushState when this.state is truthy", () => { - const text = body("onMessage") + it("requestState handler calls pushState when state is truthy", () => { + const text = body("onStateMessage") const start = text.indexOf('"agentManager.requestState"') - const snippet = text.slice(start, start + 600) + const snippet = text.slice(start, start + 700) expect(snippet, "must call pushState for the normal path").toContain("this.pushState()") }) + + it("worktree diff behavior lives in the cohesive diff controller", () => { + const text = diff() + const providerText = body("onDiffMessage") + expect(text).toContain("class WorktreeDiffController") + expect(text).toContain("buildWorktreePatch") + expect(text).toContain("revertFile") + expect(text).toContain("diffSummary") + expect(text).toContain("shouldStopDiffPolling") + expect(providerText).toContain("this.diffs") + }) }) // --------------------------------------------------------------------------- @@ -536,8 +562,8 @@ const VSCODE_ALLOWED: Record = { */ const MAX_LINES: Record = { "AgentManagerProvider.ts": { - maxLines: 2050, - note: "permission recovery wiring is interleaved with panel/session lifecycle; extract more orchestrators next", + maxLines: 2000, + note: "worktree diff orchestration lives in WorktreeDiffController; lower this after the next cohesive extraction", }, } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/open-sessions.ts b/packages/kilo-vscode/webview-ui/agent-manager/open-sessions.ts deleted file mode 100644 index ffa48b8b85..0000000000 --- a/packages/kilo-vscode/webview-ui/agent-manager/open-sessions.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createEffect } from "solid-js" -import type { Accessor } from "solid-js" - -/** Reactive effect: reports open (non-pending) session IDs to the extension for heartbeat. */ -export function trackOpenSessions( - local: Accessor, - pending: (id: string) => boolean, - managed: Accessor>, - post: (msg: { type: "agentManager.openSessions"; sessionIDs: string[] }) => void, -): void { - createEffect(() => { - const ids = [...new Set([...local().filter((id) => !pending(id)), ...managed().map((s) => s.id)])] - post({ type: "agentManager.openSessions", sessionIDs: ids }) - }) -} From 2a5c0793a1f1e8bf6cf658fff57d52849d00f3ea Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 10 Apr 2026 12:00:47 +0200 Subject: [PATCH 30/39] feat(agent-manager): match new worktree dialog prompt pills to sidebar (#8713) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The New Worktree dialog prompt input now mirrors the sidebar chat exactly: mode selector, model selector, thinking effort (reasoning), and a reset button — in the same order with the same defaults. - Extract ThinkingSelectorBase from ThinkingSelector for reuse outside session context (same pattern as ModelSelectorBase/ModeSwitcherBase) - Initialize model/variant/agent from session defaults instead of null - Show reset button only when model differs from config default - Hide model/thinking/reset pills in compare mode (each session uses its model's default variant) - Extract message-files and continue-worktree helpers from KiloProvider to fix pre-existing lint cap violations - Extract onRequestState from AgentManagerProvider.onMessage to fix pre-existing complexity violation - Remove orphaned open-sessions.ts (dead code flagged by knip) --- packages/kilo-vscode/src/KiloProvider.ts | 82 +++++-------------- .../src/agent-manager/AgentManagerProvider.ts | 61 ++++++++------ .../src/agent-manager/multi-version.ts | 3 + .../kilo-vscode/src/agent-manager/types.ts | 2 + .../src/kilo-provider/continue-worktree.ts | 33 ++++++++ .../src/kilo-provider/message-files.ts | 11 +++ .../tests/unit/agent-manager-arch.test.ts | 20 ++--- .../tests/unit/extension-arch.test.ts | 13 +-- .../agent-manager/AgentManagerApp.tsx | 1 + .../agent-manager/NewWorktreeDialog.tsx | 70 ++++++++++++++-- .../components/shared/ThinkingSelector.tsx | 54 +++++++++--- .../webview-ui/src/types/messages.ts | 2 + 12 files changed, 227 insertions(+), 125 deletions(-) create mode 100644 packages/kilo-vscode/src/kilo-provider/continue-worktree.ts create mode 100644 packages/kilo-vscode/src/kilo-provider/message-files.ts diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 23f90af0cf..b08904c432 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,7 +1,5 @@ -/* eslint-disable max-lines -- TODO: refactor to reduce file size and remove this disable */ import * as path from "path" import * as vscode from "vscode" -import { z } from "zod" import { buildPreviewPath, getPreviewCommand, getPreviewDir, parseImage, trimEntries } from "./image-preview" import { isAbsolutePath } from "./path-utils" import type { @@ -42,6 +40,8 @@ import { resolveProjectDirectory } from "./project-directory" import { getBusySessionCount, seedSessionStatuses } from "./session-status" import { retry } from "./services/cli-backend/retry" import { slimPart, slimParts } from "./kilo-provider/slim-metadata" +import { handleContinueInWorktree } from "./kilo-provider/continue-worktree" +import { parseMessageFiles } from "./kilo-provider/message-files" import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session" import { childID } from "./kilo-provider/task-session" import { retryable, backoff, MAX_RETRIES } from "./util/retry" @@ -527,7 +527,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper */ private setupWebviewMessageHandler(webview: vscode.Webview): void { this.webviewMessageDisposable?.dispose() - // eslint-disable-next-line complexity -- TODO: refactor to reduce complexity and remove this disable this.webviewMessageDisposable = webview.onDidReceiveMessage(async (message) => { // Run interceptor if attached (e.g., AgentManagerProvider worktree logic) if (this.onBeforeMessage) { @@ -551,17 +550,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.readyResolvers.splice(0).forEach((r) => r()) break case "sendMessage": { - const files = z - .array( - z.object({ - mime: z.string(), - url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")), - filename: z.string().optional(), - }), - ) - .optional() - .catch(undefined) - .parse(message.files) + const files = parseMessageFiles(message.files) await this.handleSendMessage( message.text, typeof message.messageID === "string" ? message.messageID : undefined, @@ -576,17 +565,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper break } case "sendCommand": { - const files = z - .array( - z.object({ - mime: z.string(), - url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")), - filename: z.string().optional(), - }), - ) - .optional() - .catch(undefined) - .parse(message.files) + const files = parseMessageFiles(message.files) await this.handleSendCommand( message.command, message.arguments, @@ -667,9 +646,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await handleRefreshProfile(this.authCtx) break case "openExternal": - if (message.url) { - vscode.env.openExternal(vscode.Uri.parse(message.url)) - } + this.openExternal(message.url) break case "openSettingsPanel": vscode.commands.executeCommand("kilo-code.new.settingsButtonClicked", message.tab) @@ -684,30 +661,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper vscode.commands.executeCommand("kilo-code.new.showChanges") break case "openDiffVirtual": - if (this.diffVirtualProvider && message.diff) { - this.diffVirtualProvider.open(message.diff) - } + this.openDiffVirtual(message.diff) break case "continueInWorktree": - if (message.sessionId && this.continueInWorktreeHandler) { - this.continueInWorktreeHandler(message.sessionId, (status: string, detail?: string, error?: string) => { - this.postMessage({ type: "continueInWorktreeProgress", status, detail, error }) - }).catch((err: unknown) => { - console.error("[Kilo New] continueInWorktree failed:", err) - this.postMessage({ - type: "continueInWorktreeProgress", - status: "error", - error: err instanceof Error ? err.message : String(err), - }) - }) - } else if (message.sessionId) { - console.error("[Kilo New] continueInWorktree: no handler registered") - this.postMessage({ - type: "continueInWorktreeProgress", - status: "error", - error: "Continue in Worktree is not available", - }) - } + handleContinueInWorktree({ + sessionId: message.sessionId, + handler: this.continueInWorktreeHandler ?? undefined, + post: (msg) => this.postMessage(msg), + }) break case "retryConnection": console.log("[Kilo New] KiloProvider: 🔄 Retrying connection...") @@ -909,16 +870,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper void handleRequestCloudSessionData(this.cloudSessionCtx, message.sessionId) break case "importAndSend": { - const files = z - .array( - z.object({ - mime: z.string(), - url: z.string().refine((u) => u.startsWith("file://") || u.startsWith("data:")), - }), - ) - .optional() - .catch(undefined) - .parse(message.files) + const files = parseMessageFiles(message.files) void handleImportAndSend( this.cloudSessionCtx, message.cloudSessionId, @@ -1069,6 +1021,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) } + private openExternal(url: unknown): void { + if (typeof url !== "string") return + void vscode.env.openExternal(vscode.Uri.parse(url)) + } + + private openDiffVirtual(diff: unknown): void { + if (!this.diffVirtualProvider || !diff) return + this.diffVirtualProvider.open(diff as import("./DiffVirtualProvider").DiffVirtualFile) + } + /** * Initialize connection to the CLI backend server. * Subscribes to the shared KiloConnectionService. diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 7d1e6d2176..8d8c3663a7 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -381,28 +381,7 @@ export class AgentManagerProvider implements Disposable { private onStateMessage(m: AgentManagerInMessage): Record | null | undefined { if (m.type === "agentManager.requestState") { - void this.stateReady - ?.then(() => { - if (!this.state) { - this.pushEmptyState() - return - } - this.pushState() - if (this.cachedWorktreeStats) this.postToWebview(this.cachedWorktreeStats) - if (this.cachedLocalStats) this.postToWebview(this.cachedLocalStats) - this.prBridge.replay() - if (this.state.getSessions().length > 0) { - this.panel?.sessions.refreshSessions() - } - }) - .catch((err) => { - this.log("initializeState failed, pushing partial state:", err) - if (!this.state) { - this.pushEmptyState() - return - } - this.pushState() - }) + this.onRequestState() return null } @@ -416,7 +395,6 @@ export class AgentManagerProvider implements Disposable { } if (m.type === "agentManager.setWorktreeOrder") { this.state?.setWorktreeOrder(m.order) - this.pushState() return null } if (m.type === "agentManager.setSessionsCollapsed") { @@ -500,6 +478,41 @@ export class AgentManagerProvider implements Disposable { } } + private onRequestState(): void { + void this.stateReady + ?.then(() => { + // When the folder is not a git repo (or has no folder open), + // this.state is never created. pushState() silently returns in that + // case, so re-send the empty/non-git state explicitly. + if (!this.state) { + this.pushEmptyState() + return + } + this.pushState() + // Re-send cached stats so the webview gets them even if the poller + // already emitted before the webview was ready to receive messages. + if (this.cachedWorktreeStats) this.postToWebview(this.cachedWorktreeStats) + if (this.cachedLocalStats) this.postToWebview(this.cachedLocalStats) + this.prBridge.replay() + // Refresh sessions after pushState so the webview's sessionsLoaded + // handler is guaranteed to be registered (requestState fires from + // onMount). Without this, the initial refreshSessions() in + // initializeState() can race ahead of webview mount, causing + // sessionsLoaded to never flip to true. + if (this.state.getSessions().length > 0) { + this.panel?.sessions.refreshSessions() + } + }) + .catch((err) => { + this.log("initializeState failed, pushing partial state:", err) + if (!this.state) { + this.pushEmptyState() + return + } + this.pushState() + }) + } + // --------------------------------------------------------------------------- // Shared helpers // --------------------------------------------------------------------------- @@ -1022,7 +1035,7 @@ export class AgentManagerProvider implements Disposable { } // Phase 2: Send the initial prompt to all sessions, or clear busy state if no text. - const messages = buildInitialMessages(created, models, { providerID, modelID }, text, agent, files) + const messages = buildInitialMessages(created, models, { providerID, modelID }, text, agent, msg.variant, files) for (let i = 0; i < messages.length; i++) { const msg = messages[i]! if (text) { diff --git a/packages/kilo-vscode/src/agent-manager/multi-version.ts b/packages/kilo-vscode/src/agent-manager/multi-version.ts index c775010d2a..2ab92ad10e 100644 --- a/packages/kilo-vscode/src/agent-manager/multi-version.ts +++ b/packages/kilo-vscode/src/agent-manager/multi-version.ts @@ -67,6 +67,7 @@ export interface InitialMessage { providerID?: string modelID?: string agent?: string + variant?: string files?: Array<{ mime: string; url: string }> } @@ -81,6 +82,7 @@ export function buildInitialMessages( fallback: { providerID?: string; modelID?: string }, prompt?: string, agent?: string, + variant?: string, files?: Array<{ mime: string; url: string }>, ): InitialMessage[] { return created.map((entry) => { @@ -96,6 +98,7 @@ export function buildInitialMessages( if (prompt) { msg.text = prompt msg.agent = agent + msg.variant = variant msg.files = files } return msg diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 2dc76cc6a0..2088d2d388 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -169,6 +169,7 @@ interface SendInitialMessage { providerID?: string modelID?: string agent?: string + variant?: string files?: Array<{ mime: string; url: string }> } @@ -365,6 +366,7 @@ interface CreateMultiVersionIn { providerID?: string modelID?: string agent?: string + variant?: string files?: Array<{ mime: string; url: string }> baseBranch?: string branchName?: string diff --git a/packages/kilo-vscode/src/kilo-provider/continue-worktree.ts b/packages/kilo-vscode/src/kilo-provider/continue-worktree.ts new file mode 100644 index 0000000000..68a435a480 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/continue-worktree.ts @@ -0,0 +1,33 @@ +type Progress = (status: string, detail?: string, error?: string) => void + +type Ctx = { + sessionId?: string + handler?: (sessionId: string, progress: Progress) => Promise + post: (message: { type: "continueInWorktreeProgress"; status: string; detail?: string; error?: string }) => void +} + +export function handleContinueInWorktree(ctx: Ctx): void { + if (ctx.sessionId && ctx.handler) { + ctx + .handler(ctx.sessionId, (status, detail, error) => { + ctx.post({ type: "continueInWorktreeProgress", status, detail, error }) + }) + .catch((err: unknown) => { + console.error("[Kilo New] continueInWorktree failed:", err) + ctx.post({ + type: "continueInWorktreeProgress", + status: "error", + error: err instanceof Error ? err.message : String(err), + }) + }) + return + } + + if (!ctx.sessionId) return + console.error("[Kilo New] continueInWorktree: no handler registered") + ctx.post({ + type: "continueInWorktreeProgress", + status: "error", + error: "Continue in Worktree is not available", + }) +} diff --git a/packages/kilo-vscode/src/kilo-provider/message-files.ts b/packages/kilo-vscode/src/kilo-provider/message-files.ts new file mode 100644 index 0000000000..35b419d419 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/message-files.ts @@ -0,0 +1,11 @@ +import { z } from "zod" + +const file = z.object({ + mime: z.string(), + url: z.string().refine((url) => url.startsWith("file://") || url.startsWith("data:")), + filename: z.string().optional(), +}) + +export function parseMessageFiles(value: unknown) { + return z.array(file).optional().catch(undefined).parse(value) +} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 77ef30bf19..1ba874cef4 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -313,20 +313,16 @@ describe("Agent Manager Provider — onMessage routing", () => { * call pushEmptyState() instead — otherwise the webview stays stuck on * loading skeletons forever. */ - it("requestState handler calls pushEmptyState when state is falsy", () => { - const text = body("onStateMessage") - const start = text.indexOf('"agentManager.requestState"') - expect(start, "requestState branch must exist").toBeGreaterThan(-1) - const snippet = text.slice(start, start + 700) - expect(snippet, "must call pushEmptyState when state is absent").toContain("pushEmptyState") - expect(snippet, "must guard on this.state being falsy").toMatch(/!this\.state/) + it("requestState handler calls pushEmptyState when this.state is falsy", () => { + // onStateMessage delegates to onRequestState; verify the actual handler + const text = body("onRequestState") + expect(text, "must call pushEmptyState when state is absent").toContain("pushEmptyState") + expect(text, "must guard on this.state being falsy").toMatch(/!this\.state/) }) - it("requestState handler calls pushState when state is truthy", () => { - const text = body("onStateMessage") - const start = text.indexOf('"agentManager.requestState"') - const snippet = text.slice(start, start + 700) - expect(snippet, "must call pushState for the normal path").toContain("this.pushState()") + it("requestState handler calls pushState when this.state is truthy", () => { + const text = body("onRequestState") + expect(text, "must call pushState for the normal path").toContain("this.pushState()") }) it("worktree diff behavior lives in the cohesive diff controller", () => { diff --git a/packages/kilo-vscode/tests/unit/extension-arch.test.ts b/packages/kilo-vscode/tests/unit/extension-arch.test.ts index 233f844bdb..af37e1fbae 100644 --- a/packages/kilo-vscode/tests/unit/extension-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/extension-arch.test.ts @@ -170,16 +170,11 @@ describe("Extension — KiloProvider handler wiring", () => { // --------------------------------------------------------------------------- describe("KiloProvider — continueInWorktree error fallback", () => { - const provider = fs.readFileSync(KILO_PROVIDER_FILE, "utf-8") + const helper = fs.readFileSync(path.join(ROOT, "src/kilo-provider/continue-worktree.ts"), "utf-8") it("sends error progress when handler is missing", () => { - const caseStart = provider.indexOf('case "continueInWorktree"') - expect(caseStart, "continueInWorktree case must exist").toBeGreaterThan(-1) - const caseEnd = provider.indexOf("break", caseStart) - const block = provider.slice(caseStart, caseEnd) - - expect(block, "must have else branch for missing handler").toContain("else if") - expect(block, "must send error status back to webview").toContain('"error"') - expect(block, "must use continueInWorktreeProgress message type").toContain("continueInWorktreeProgress") + expect(helper, "must send error status back to webview").toContain('"error"') + expect(helper, "must use continueInWorktreeProgress message type").toContain("continueInWorktreeProgress") + expect(helper, "must handle missing handler case").toContain("no handler registered") }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 1a57644ec9..e51a7663fd 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1311,6 +1311,7 @@ const AgentManagerContent: Component = () => { providerID: ev.providerID, modelID: ev.modelID, agent: ev.agent, + variant: ev.variant, files: ev.files, }) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 7f23f240e3..13f5692bf6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -12,8 +12,10 @@ import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useVSCode } from "../src/context/vscode" import { useServer } from "../src/context/server" import { useSession } from "../src/context/session" +import { useProvider } from "../src/context/provider" import { ModelSelectorBase } from "../src/components/shared/ModelSelector" import { ModeSwitcherBase } from "../src/components/shared/ModeSwitcher" +import { ThinkingSelectorBase } from "../src/components/shared/ThinkingSelector" import { MultiModelSelector, type ModelAllocations, @@ -60,6 +62,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran const vscode = useVSCode() const server = useServer() const session = useSession() + const provider = useProvider() const [tab, setTab] = createSignal("new") @@ -74,7 +77,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran const cached = vscode.getState>() const [prompt, setPrompt] = createSignal((cached?.advancedDialogPrompt as string) ?? "") const [versions, setVersions] = createSignal(1) - const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(null) + const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(session.selected()) const [compareMode, setCompareMode] = createSignal(false) const [modelAllocations, setModelAllocations] = createSignal(new Map()) const [agent, setAgent] = createSignal(session.selectedAgent()) @@ -85,6 +88,43 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran const [baseBranchOpen, setBaseBranchOpen] = createSignal(false) const [compareOpen, setCompareOpen] = createSignal(false) const [highlightedIndex, setHighlightedIndex] = createSignal(0) + const [variant, setVariant] = createSignal(session.currentVariant()) + + // Variant list for the currently selected model + const variants = createMemo(() => { + const sel = model() + if (!sel) return [] + const found = provider.findModel(sel) + if (!found?.variants) return [] + return Object.keys(found.variants) + }) + + // Current effective variant — falls back to first available if stored value is invalid + const effectiveVariant = createMemo(() => { + const list = variants() + if (list.length === 0) return undefined + const stored = variant() + return stored && list.includes(stored) ? stored : list[0] + }) + + // True when the user has changed the model from the session/config default + const overridden = createMemo(() => { + const sel = model() + const cfg = session.selected() + if (!sel || !cfg) return false + return sel.providerID !== cfg.providerID || sel.modelID !== cfg.modelID + }) + + // Reset variant when model changes and stored variant is not in new list + createEffect(() => { + const list = variants() + if (list.length === 0) { + setVariant(undefined) + return + } + const stored = variant() + if (!stored || !list.includes(stored)) setVariant(list[0]) + }) const imageAttach = useImageAttachments() imageAttach.setFilePathDropHandler((paths) => { @@ -172,6 +212,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran providerID: sel?.providerID, modelID: sel?.modelID, agent: selectedAgent, + variant: isCompare ? undefined : effectiveVariant(), baseBranch: advanced ? (baseBranch() ?? undefined) : undefined, branchName: customBranch, modelAllocations: allocations, @@ -333,17 +374,32 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
+ 1}> + + setModel(pid && mid ? { providerID: pid, modelID: mid } : null)} + onSelect={(pid, mid) => { + if (pid && mid) setModel({ providerID: pid, modelID: mid }) + }} placement="top-start" - allowClear - clearLabel="Default" /> - - 1}> - + + + + + +
diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx index 1e8d380be9..d8cdfe5c62 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ThinkingSelector.tsx @@ -2,6 +2,9 @@ * ThinkingSelector component * Popover-based dropdown for choosing a thinking effort variant. * Only rendered when the selected model supports reasoning variants. + * + * ThinkingSelectorBase — reusable core that accepts variants/value/onSelect props. + * ThinkingSelector — thin wrapper wired to session context for chat usage. */ import { Component, createSignal, For, Show } from "solid-js" @@ -9,26 +12,35 @@ import { Popover } from "@kilocode/kilo-ui/popover" import { Button } from "@kilocode/kilo-ui/button" import { useSession } from "../../context/session" -export const ThinkingSelector: Component = () => { - const session = useSession() +// --------------------------------------------------------------------------- +// Reusable base component +// --------------------------------------------------------------------------- + +export interface ThinkingSelectorBaseProps { + /** Available variant names (e.g. ["low","medium","high"]) */ + variants: string[] + /** Currently selected variant */ + value: string | undefined + /** Called when the user picks a variant */ + onSelect: (value: string) => void +} + +export const ThinkingSelectorBase: Component = (props) => { const [open, setOpen] = createSignal(false) - const variants = () => session.variantList() - const current = () => session.currentVariant() - function pick(value: string) { - session.selectVariant(value) + props.onSelect(value) setOpen(false) requestAnimationFrame(() => window.dispatchEvent(new Event("focusPrompt"))) } - const triggerLabel = () => { - const v = current() + const label = () => { + const v = props.value return v ? v.charAt(0).toUpperCase() + v.slice(1) : "" } return ( - 0}> + 0}> { triggerProps={{ variant: "ghost", size: "small" }} trigger={ <> - {triggerLabel()} + {label()} @@ -45,12 +57,12 @@ export const ThinkingSelector: Component = () => { } >
- + {(v) => (
pick(v)} > {v.charAt(0).toUpperCase() + v.slice(1)} @@ -62,3 +74,19 @@ export const ThinkingSelector: Component = () => { ) } + +// --------------------------------------------------------------------------- +// Chat-specific wrapper (backwards-compatible) +// --------------------------------------------------------------------------- + +export const ThinkingSelector: Component = () => { + const session = useSession() + + return ( + session.selectVariant(value)} + /> + ) +} diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 96619e2f95..44e151d9f6 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -1118,6 +1118,7 @@ export interface AgentManagerSendInitialMessage { providerID?: string modelID?: string agent?: string + variant?: string files?: Array<{ mime: string; url: string }> } @@ -2004,6 +2005,7 @@ export interface CreateMultiVersionRequest { providerID?: string modelID?: string agent?: string + variant?: string files?: FileAttachment[] baseBranch?: string branchName?: string From 0ff74fd52f98e5053a28cc5ac6b2561af044d469 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 10 Apr 2026 12:01:04 +0200 Subject: [PATCH 31/39] fix(vscode): restore prompt focus after model selection (#8717) * fix(vscode): restore prompt focus after model selection * fix(vscode): scope aggressive prompt refocus --- .../src/components/chat/PromptInput.tsx | 21 ++++++++++++++++++- .../src/components/shared/ModelSelector.tsx | 8 ++++++- 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 3326f9872e..3b5738cf61 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -217,7 +217,26 @@ export const PromptInput: Component = (props) => { }) // Focus textarea when any part of the app requests it - const onFocusPrompt = () => textareaRef?.focus() + const onFocusPrompt = (event: Event) => { + const focus = () => { + const ref = textareaRef + if (!ref) return + ref.focus({ preventScroll: true }) + } + focus() + if (!(event instanceof CustomEvent) || !event.detail?.restore) return + const restore = () => { + window.focus() + focus() + } + queueMicrotask(restore) + requestAnimationFrame(() => { + restore() + requestAnimationFrame(restore) + setTimeout(restore, 0) + setTimeout(restore, 50) + }) + } window.addEventListener("focusPrompt", onFocusPrompt) onCleanup(() => window.removeEventListener("focusPrompt", onFocusPrompt)) diff --git a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx index dc7b819136..9c7754a499 100644 --- a/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/shared/ModelSelector.tsx @@ -78,6 +78,8 @@ export interface ModelSelectorBaseProps { value: ModelSelection | null /** Called when the user picks a model */ onSelect: (providerID: string, modelID: string) => void + /** Called after a pick closes the popover */ + onPick?: () => void /** Popover placement — defaults to "top-start" */ placement?: "top-start" | "bottom-start" | "bottom-end" | "top-end" /** Allow clearing the selection (shows a "Not set" option) */ @@ -364,6 +366,7 @@ export const ModelSelectorBase: Component = (props) => { function pick(model: EnrichedModel) { props.onSelect(model.providerID, model.id) setOpen(false) + props.onPick?.() } function pickClear() { @@ -372,6 +375,7 @@ export const ModelSelectorBase: Component = (props) => { setPreviewKey(CLEAR_KEY) props.onSelect("", "") setOpen(false) + props.onPick?.() } function setRow(key: string) { @@ -715,7 +719,9 @@ export const ModelSelector: Component = () => { value={session.selected()} onSelect={(providerID, modelID) => { session.selectModel(providerID, modelID) - requestAnimationFrame(() => window.dispatchEvent(new Event("focusPrompt"))) + }} + onPick={() => { + requestAnimationFrame(() => window.dispatchEvent(new CustomEvent("focusPrompt", { detail: { restore: true } }))) }} /> ) From 161ecd0a5efedbc4580a165304e8e8aa54fa7c05 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 10 Apr 2026 12:29:52 +0200 Subject: [PATCH 32/39] ci(vscode): run lint in extension workflow (#8723) --- .github/workflows/test-vscode.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/test-vscode.yml b/.github/workflows/test-vscode.yml index 272879f2e0..a3e66fff45 100644 --- a/.github/workflows/test-vscode.yml +++ b/.github/workflows/test-vscode.yml @@ -3,16 +3,19 @@ name: test-vscode on: push: branches: + - main - dev paths: - "packages/kilo-vscode/**" - "packages/ui/**" - "packages/kilo-ui/**" + - ".github/workflows/test-vscode.yml" pull_request: paths: - "packages/kilo-vscode/**" - "packages/ui/**" - "packages/kilo-ui/**" + - ".github/workflows/test-vscode.yml" workflow_dispatch: jobs: @@ -35,6 +38,10 @@ jobs: working-directory: packages/kilo-vscode run: bun run test:unit + - name: Check lint (ESLint) + working-directory: packages/kilo-vscode + run: bun run lint + - name: Check formatting (prettier) working-directory: packages/kilo-vscode run: bun run format:check From ed7c990dfd5c75d0a1eb309841c7c96ce459e6b7 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 10 Apr 2026 13:13:13 +0200 Subject: [PATCH 33/39] perf(vscode): fix IPC metadata bloat in slim-metadata slimmers (#8721) slimPatch and slimMultiedit used `...meta` spreads that leaked heavy fields (metadata.diff up to 158KB, files[].before/after ~128KB each) through to the VS Code webview on every session switch. Replace with explicit allowlist construction matching the pattern slimEdit/slimWrite already used. Add size-based guardrail tests that inject unknown heavy fields into each tool's metadata and assert the slimmed output stays under 10KB. This catches future upstream changes regardless of field names. Add a runtime warning in slimPart (non-production) when slimmed state exceeds 50KB so regressions surface during development. --- .../src/kilo-provider/slim-metadata.ts | 39 +-- .../tests/unit/slim-metadata.test.ts | 286 ++++++++++++++++++ 2 files changed, 306 insertions(+), 19 deletions(-) create mode 100644 packages/kilo-vscode/tests/unit/slim-metadata.test.ts diff --git a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts index a7748b5d00..0e55b48d82 100644 --- a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts +++ b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts @@ -59,25 +59,24 @@ function slimEdit(state: Record): Record { return next } -/** apply_patch: strip files[].before/after/diff + input.patchText. */ +/** apply_patch: strip files[].before/after/diff, metadata.diff, + input.patchText. */ function slimPatch(state: Record): Record { const next = { ...state } const meta = state.metadata - if (isObj(meta) && Array.isArray(meta.files)) { - next.metadata = { - ...meta, - files: (meta.files as Record[]).map((f) => ({ + if (isObj(meta)) { + const slim: Record = {} + if (meta.diagnostics) slim.diagnostics = meta.diagnostics + if (Array.isArray(meta.files)) { + slim.files = (meta.files as Record[]).map((f) => ({ filePath: f.filePath, relativePath: f.relativePath, type: f.type, additions: f.additions, deletions: f.deletions, movePath: f.movePath, - })), - } - if (isObj(meta) && meta.diagnostics) { - ;(next.metadata as Record).diagnostics = meta.diagnostics + })) } + next.metadata = slim } // Strip the full patch text from input — only keep files count for title const input = state.input @@ -87,27 +86,29 @@ function slimPatch(state: Record): Record { return next } -/** multiedit: strip nested results (each is a full edit metadata object). */ +/** multiedit: strip nested results (each is a full edit metadata object) and top-level diff. */ function slimMultiedit(state: Record): Record { const next = { ...state } const meta = state.metadata - if (isObj(meta) && Array.isArray(meta.results)) { - next.metadata = { - ...meta, - results: (meta.results as Record[]).map((r) => { - const slim: Record = {} - if (r.diagnostics) slim.diagnostics = r.diagnostics + if (isObj(meta)) { + const slim: Record = {} + if (meta.diagnostics) slim.diagnostics = meta.diagnostics + if (Array.isArray(meta.results)) { + slim.results = (meta.results as Record[]).map((r) => { + const rs: Record = {} + if (r.diagnostics) rs.diagnostics = r.diagnostics const fd = r.filediff if (isObj(fd)) { - slim.filediff = { + rs.filediff = { ...(typeof fd.file === "string" ? { file: fd.file } : {}), additions: typeof fd.additions === "number" ? fd.additions : 0, deletions: typeof fd.deletions === "number" ? fd.deletions : 0, } } - return slim - }), + return rs + }) } + next.metadata = slim } return next } diff --git a/packages/kilo-vscode/tests/unit/slim-metadata.test.ts b/packages/kilo-vscode/tests/unit/slim-metadata.test.ts new file mode 100644 index 0000000000..7435dddb2e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/slim-metadata.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect } from "bun:test" +import { slimPart } from "../../src/kilo-provider/slim-metadata" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function part(tool: string, state: Record) { + return { type: "tool", id: "p1", tool, state } +} + +function bytes(obj: unknown): number { + return JSON.stringify(obj).length +} + +/** + * Hard ceiling per slimmed tool state (JSON bytes). Real slimmed parts + * should be well under this. If a slimmer leaks even one file-content + * field (~50-500 KB each) the test blows past this immediately. + */ +const MAX_SLIM_BYTES = 10_000 + +const BIG = "x".repeat(200_000) // 200 KB — typical file content size +const DIAG = [ + { range: { start: { line: 1, character: 0 }, end: { line: 1, character: 5 } }, message: "err", severity: 1 }, +] + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("slimPart", () => { + it("passes through non-tool parts unchanged", () => { + const text = { type: "text", id: "t1", content: "hello" } + expect(slimPart(text)).toBe(text) + }) + + it("passes through unknown tool types unchanged", () => { + const p = part("some_new_tool", { status: "completed", metadata: { big: BIG } }) + expect(slimPart(p)).toBe(p) + }) + + // ----------------------------------------------------------------------- + // edit + // ----------------------------------------------------------------------- + describe("edit", () => { + const heavy = part("edit", { + status: "completed", + input: { filePath: "/a.ts", oldString: "old", newString: "new" }, + output: "Edit applied successfully.", + metadata: { + diff: BIG, + filediff: { file: "/a.ts", before: BIG, after: BIG, additions: 3, deletions: 1 }, + diagnostics: { "/a.ts": DIAG }, + }, + }) + + it("stays under size cap", () => { + expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES) + }) + + it("keeps filediff counts and diagnostics", () => { + const slim = slimPart(heavy) as Record + const meta = slim.state.metadata + expect(meta.filediff.file).toBe("/a.ts") + expect(meta.filediff.additions).toBe(3) + expect(meta.filediff.deletions).toBe(1) + expect(meta.diagnostics).toEqual({ "/a.ts": DIAG }) + }) + + it("keeps output and input intact", () => { + const slim = slimPart(heavy) as Record + expect(slim.state.output).toBe("Edit applied successfully.") + expect(slim.state.input.filePath).toBe("/a.ts") + }) + + it("drops unknown heavy metadata fields", () => { + const withUnknown = part("edit", { + ...heavy.state, + metadata: { ...(heavy.state.metadata as object), newUpstreamBlob: BIG, otherData: BIG }, + }) + expect(bytes(slimPart(withUnknown))).toBeLessThan(MAX_SLIM_BYTES) + }) + }) + + // ----------------------------------------------------------------------- + // apply_patch + // ----------------------------------------------------------------------- + describe("apply_patch", () => { + const heavy = part("apply_patch", { + status: "completed", + input: { patchText: BIG }, + output: "Success. Updated the following files:\n a.ts", + metadata: { + diff: BIG, + files: [ + { + filePath: "/a.ts", + relativePath: "a.ts", + type: "update", + before: BIG, + after: BIG, + diff: BIG, + additions: 5, + deletions: 2, + }, + { + filePath: "/b.ts", + relativePath: "b.ts", + type: "add", + before: undefined, + after: BIG, + diff: BIG, + additions: 10, + deletions: 0, + movePath: undefined, + }, + ], + diagnostics: { "/a.ts": DIAG }, + }, + }) + + it("stays under size cap", () => { + expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES) + }) + + it("keeps file summary fields and diagnostics", () => { + const slim = slimPart(heavy) as Record + const meta = slim.state.metadata + expect(meta.files[0].filePath).toBe("/a.ts") + expect(meta.files[0].relativePath).toBe("a.ts") + expect(meta.files[0].type).toBe("update") + expect(meta.files[0].additions).toBe(5) + expect(meta.files[1].type).toBe("add") + expect(meta.diagnostics).toEqual({ "/a.ts": DIAG }) + }) + + it("drops unknown heavy metadata fields", () => { + const withUnknown = part("apply_patch", { + ...heavy.state, + metadata: { ...(heavy.state.metadata as object), rawPatch: BIG, snapshot: BIG }, + }) + expect(bytes(slimPart(withUnknown))).toBeLessThan(MAX_SLIM_BYTES) + }) + + it("drops unknown heavy fields inside files[]", () => { + const withUnknown = part("apply_patch", { + ...heavy.state, + metadata: { + ...(heavy.state.metadata as Record), + files: [ + { + filePath: "/a.ts", + relativePath: "a.ts", + type: "update", + additions: 1, + deletions: 0, + newHeavyField: BIG, + anotherBlob: BIG, + }, + ], + }, + }) + expect(bytes(slimPart(withUnknown))).toBeLessThan(MAX_SLIM_BYTES) + }) + }) + + // ----------------------------------------------------------------------- + // multiedit + // ----------------------------------------------------------------------- + describe("multiedit", () => { + const heavy = part("multiedit", { + status: "completed", + input: { edits: [] }, + output: "Applied 2 edits.", + metadata: { + diff: BIG, + diagnostics: { "/a.ts": DIAG }, + results: [ + { + filediff: { file: "/a.ts", before: BIG, after: BIG, additions: 1, deletions: 1 }, + diagnostics: { "/a.ts": DIAG }, + diff: BIG, + }, + { filediff: { file: "/b.ts", before: BIG, after: BIG, additions: 2, deletions: 0 }, diagnostics: {} }, + ], + }, + }) + + it("stays under size cap", () => { + expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES) + }) + + it("keeps filediff counts and per-result diagnostics", () => { + const slim = slimPart(heavy) as Record + const meta = slim.state.metadata + expect(meta.results[0].filediff.file).toBe("/a.ts") + expect(meta.results[0].filediff.additions).toBe(1) + expect(meta.results[0].diagnostics).toEqual({ "/a.ts": DIAG }) + expect(meta.results[1].filediff.file).toBe("/b.ts") + expect(meta.diagnostics).toEqual({ "/a.ts": DIAG }) + }) + + it("drops unknown heavy metadata fields", () => { + const withUnknown = part("multiedit", { + ...heavy.state, + metadata: { ...(heavy.state.metadata as object), rawCombinedDiff: BIG }, + }) + expect(bytes(slimPart(withUnknown))).toBeLessThan(MAX_SLIM_BYTES) + }) + + it("drops unknown heavy fields inside results[]", () => { + const withUnknown = part("multiedit", { + ...heavy.state, + metadata: { + ...(heavy.state.metadata as Record), + results: [{ filediff: { file: "/a.ts", additions: 1, deletions: 0 }, diagnostics: {}, newBlob: BIG }], + }, + }) + expect(bytes(slimPart(withUnknown))).toBeLessThan(MAX_SLIM_BYTES) + }) + }) + + // ----------------------------------------------------------------------- + // write + // ----------------------------------------------------------------------- + describe("write", () => { + const heavy = part("write", { + status: "completed", + input: { filePath: "/a.ts", content: BIG }, + output: "File written.", + metadata: { + filepath: "/a.ts", + exists: true, + diff: BIG, + filediff: { file: "/a.ts", before: BIG, after: BIG, additions: 100, deletions: 0 }, + diagnostics: { "/a.ts": DIAG }, + }, + }) + + it("stays under size cap", () => { + expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES) + }) + + it("keeps filepath, exists, filediff counts, diagnostics", () => { + const slim = slimPart(heavy) as Record + const meta = slim.state.metadata + expect(meta.filepath).toBe("/a.ts") + expect(meta.exists).toBe(true) + expect(meta.filediff.file).toBe("/a.ts") + expect(meta.filediff.additions).toBe(100) + expect(meta.filediff.deletions).toBe(0) + expect(meta.diagnostics).toEqual({ "/a.ts": DIAG }) + }) + + it("drops unknown heavy metadata fields", () => { + const withUnknown = part("write", { + ...heavy.state, + metadata: { ...(heavy.state.metadata as object), compiled: BIG }, + }) + expect(bytes(slimPart(withUnknown))).toBeLessThan(MAX_SLIM_BYTES) + }) + }) + + // ----------------------------------------------------------------------- + // bash + // ----------------------------------------------------------------------- + describe("bash", () => { + const heavy = part("bash", { + status: "completed", + input: { command: "ls" }, + output: BIG, + metadata: { output: BIG }, + }) + + it("truncates metadata.output and state.output", () => { + const slim = slimPart(heavy) as Record + expect(slim.state.metadata.output.length).toBeLessThan(BIG.length) + expect((slim.state.output as string).length).toBeLessThan(BIG.length) + }) + + it("stays under size cap", () => { + expect(bytes(slimPart(heavy))).toBeLessThan(MAX_SLIM_BYTES) + }) + }) +}) From 358263c5a164a67e3fbe430aefa72ba204e1b636 Mon Sep 17 00:00:00 2001 From: Joshua Lambert <25085430+lambertjosh@users.noreply.github.com> Date: Fri, 10 Apr 2026 07:19:50 -0400 Subject: [PATCH 34/39] docs(kilo-docs): document asking the agent to configure kilo.json (#8706) * docs(kilo-docs): document asking the agent to configure kilo.json * Apply suggestion from @lambertjosh * docs: restore original intro line on settings page --- .../kilo-docs/pages/getting-started/index.md | 4 ++++ .../pages/getting-started/settings/index.md | 18 ++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/kilo-docs/pages/getting-started/index.md b/packages/kilo-docs/pages/getting-started/index.md index d08441d9a7..e3a0e8e89d 100644 --- a/packages/kilo-docs/pages/getting-started/index.md +++ b/packages/kilo-docs/pages/getting-started/index.md @@ -31,6 +31,10 @@ Your sessions sync across all of these, so you can start a task on your phone an 2. [Connect an AI provider](/docs/ai-providers) or use Kilo's built-in provider & credits 3. [Run your first task](/docs/getting-started/quickstart) +{% callout type="tip" %} +**The easiest way to configure Kilo is to ask the agent.** Just tell the agent what you want — "add this MCP server", "disable OpenAI", "add my Ollama endpoint". The agent has a built-in skill for reading and updating your `kilo.jsonc` configuration. [Learn more](/docs/getting-started/settings#configuring-with-the-agent) +{% /callout %} + New to AI coding assistants? Before learning what Kilo itself does, you can learn about agentic engineering at [path.kilo.ai](https://path.kilo.ai) Coming from Cursor or Windsurf? See our [migration guide](/docs/getting-started/migrating) diff --git a/packages/kilo-docs/pages/getting-started/settings/index.md b/packages/kilo-docs/pages/getting-started/settings/index.md index acd1bd43ff..1d78324d15 100644 --- a/packages/kilo-docs/pages/getting-started/settings/index.md +++ b/packages/kilo-docs/pages/getting-started/settings/index.md @@ -7,6 +7,24 @@ description: "Configure Kilo Code settings and preferences" The VS Code extension can be configured through the Settings window, opened by pressing the gear icon. Both the CLI and the extension can also be configured through interactions with the agent. The current VS Code extension and CLI share the same underlying settings, so changes in one are reflected in the other. +## Configuring with the Agent + +The fastest way to change your Kilo configuration is to ask the agent to do it for you. The agent has a built-in skill that understands the full `kilo.jsonc` schema and can read, create, and update your config files directly. + +**Examples of things you can ask:** + +- "Switch my default model to Claude Sonnet" +- "Disable the OpenAI and Groq providers" +- "Set up an MCP server for Figma" +- "Auto-approve all read and glob operations" +- "Create a custom agent for code review" + +The agent will edit the appropriate config file (global or project-level) and explain what it changed. This works in both the CLI and VS Code extension. + +{% callout type="tip" %} +This is especially useful for complex configuration like custom model definitions, MCP server setup, or permission patterns — the agent knows the correct syntax and will validate the config for you. +{% /callout %} + ## Managing Settings {% tabs %} From 05474353fd78cfccee1681ae250de03467439552 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 10 Apr 2026 13:37:00 +0200 Subject: [PATCH 35/39] refactor(agent-manager): extract worktree importer (#8725) * refactor(agent-manager): extract worktree importer * test(agent-manager): keep provider line cap --- .../src/agent-manager/AgentManagerProvider.ts | 383 ++---------------- .../src/agent-manager/worktree-importer.ts | 336 +++++++++++++++ .../tests/unit/agent-manager-arch.test.ts | 19 +- 3 files changed, 382 insertions(+), 356 deletions(-) create mode 100644 packages/kilo-vscode/src/agent-manager/worktree-importer.ts diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 8d8c3663a7..94013d3ef0 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -12,7 +12,7 @@ import { GitStatsPoller, type WorktreePresenceResult } from "./GitStatsPoller" import { PRStatusBridge } from "./pr-status-bridge" import { GitOps } from "./GitOps" import { versionedName } from "./branch-name" -import { normalizePath, classifyWorktreeError } from "./git-import" +import { classifyWorktreeError } from "./git-import" import { SetupScriptService } from "./SetupScriptService" import { SetupScriptRunner } from "./SetupScriptRunner" import { copyEnvFiles } from "./env-copy" @@ -22,6 +22,7 @@ import { executeVscodeTask } from "./task-runner" import { forkSession } from "./fork-session" import { continueInWorktree } from "./continue-in-worktree" import { WorktreeDiffController } from "./worktree-diff-controller" +import { WorktreeImporter } from "./worktree-importer" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" @@ -46,9 +47,9 @@ export class AgentManagerProvider implements Disposable { private worktrees: WorktreeManager | undefined private state: WorktreeStateManager | undefined private setupScript: SetupScriptService | undefined + private importer: WorktreeImporter private terminalManager: SessionTerminalManager private stateReady: Promise | undefined - private importing = false private statsPoller: GitStatsPoller private prBridge!: PRStatusBridge private gitOps: GitOps @@ -70,6 +71,17 @@ export class AgentManagerProvider implements Disposable { (msg) => this.outputChannel.appendLine(`[SessionTerminal] ${msg}`), createTerminalHost(), ) + this.importer = new WorktreeImporter({ + manager: () => this.getWorktreeManager(), + state: () => this.getStateManager(), + post: (msg) => this.postToWebview(msg), + push: () => this.pushState(), + setup: (dir, branch, id) => this.runSetupScriptForWorktree(dir, branch, id), + session: (dir, branch, id) => this.createSessionInWorktree(dir, branch, id), + register: (sid, dir) => this.registerWorktreeSession(sid, dir), + ready: (sid, result, id) => this.notifyWorktreeReady(sid, result, id), + log: (...args) => this.log(...args), + }) const semaphore = new Semaphore(3) this.gitOps = new GitOps({ log: (...args) => this.log(...args), semaphore }) this.diffs = new WorktreeDiffController({ @@ -285,10 +297,12 @@ export class AgentManagerProvider implements Disposable { const persist = m.type === "agentManager.persistSession" void this.stateReady?.then(() => { const state = this.getStateManager() - if (state) - persist - ? !state.getSession(m.sessionId) && state.addSession(m.sessionId, null) - : state.removeSession(m.sessionId) + if (!state) return + if (persist) { + if (!state.getSession(m.sessionId)) state.addSession(m.sessionId, null) + return + } + state.removeSession(m.sessionId) }) return null } @@ -384,11 +398,6 @@ export class AgentManagerProvider implements Disposable { this.onRequestState() return null } - - if (m.type === "agentManager.requestBranches") { - void this.onRequestBranches() - return null - } if (m.type === "agentManager.setTabOrder") { this.state?.setTabOrder(m.key, m.order) return null @@ -414,24 +423,28 @@ export class AgentManagerProvider implements Disposable { } private onImportMessage(m: AgentManagerInMessage): Record | null | undefined { + if (m.type === "agentManager.requestBranches") { + void this.importer.branches() + return null + } if (m.type === "agentManager.requestExternalWorktrees") { - void this.onRequestExternalWorktrees() + void this.importer.external() return null } if (m.type === "agentManager.importFromBranch") { - void this.onImportFromBranch(m.branch) + void this.importer.branch(m.branch) return null } if (m.type === "agentManager.importFromPR") { - void this.onImportFromPR(m.url) + void this.importer.pr(m.url) return null } if (m.type === "agentManager.importExternalWorktree") { - void this.onImportExternalWorktree(m.path, m.branch) + void this.importer.path(m.path, m.branch) return null } if (m.type === "agentManager.importAllExternalWorktrees") { - void this.onImportAllExternalWorktrees() + void this.importer.all() return null } } @@ -1064,344 +1077,6 @@ export class AgentManagerProvider implements Disposable { return null } - // --------------------------------------------------------------------------- - // Import - // --------------------------------------------------------------------------- - - private async onRequestBranches(): Promise { - const manager = this.getWorktreeManager() - if (!manager) { - this.postToWebview({ type: "agentManager.branches", branches: [], defaultBranch: "main" }) - return - } - try { - const result = await manager.listBranches() - const checkedOut = await manager.checkedOutBranches() - - // Include isCheckedOut flag on each branch — let the webview decide how to filter - const branches = result.branches.map((b) => ({ - ...b, - isCheckedOut: checkedOut.has(b.name), - })) - - // Validate configured default branch still exists - const state = this.getStateManager() - const configured = state?.getDefaultBaseBranch() - if (configured && !branches.some((b) => b.name === configured)) { - this.clearStaleDefaultBaseBranch(state!, configured) - } - - this.postToWebview({ - type: "agentManager.branches", - branches, - defaultBranch: result.defaultBranch, - }) - } catch (error) { - this.log(`Failed to list branches: ${error}`) - this.postToWebview({ type: "agentManager.branches", branches: [], defaultBranch: "main" }) - } - } - - private async onRequestExternalWorktrees(): Promise { - const manager = this.getWorktreeManager() - const state = this.getStateManager() - if (!manager || !state) { - this.postToWebview({ type: "agentManager.externalWorktrees", worktrees: [] }) - return - } - try { - const managedPaths = new Set(state.getWorktrees().map((wt) => wt.path)) - const worktrees = await manager.listExternalWorktrees(managedPaths) - this.postToWebview({ type: "agentManager.externalWorktrees", worktrees }) - } catch (error) { - this.log(`Failed to list external worktrees: ${error}`) - this.postToWebview({ type: "agentManager.externalWorktrees", worktrees: [] }) - } - } - - private async onImportFromBranch(branch: string): Promise { - const manager = this.getWorktreeManager() - const state = this.getStateManager() - if (!manager || !state) { - this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" }) - return - } - if (this.importing) { - this.postToWebview({ - type: "agentManager.importResult", - success: false, - message: "Another import is already in progress", - }) - return - } - this.importing = true - - try { - this.postToWebview({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Creating worktree from branch...", - }) - const result = await manager.createWorktree({ existingBranch: branch }) - const worktree = state.addWorktree({ - branch: result.branch, - path: result.path, - parentBranch: result.parentBranch, - remote: result.remote, - }) - this.pushState() - - try { - this.postToWebview({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Running setup script...", - branch: result.branch, - worktreeId: worktree.id, - }) - await this.runSetupScriptForWorktree(result.path, result.branch, worktree.id) - - const session = await this.createSessionInWorktree(result.path, result.branch, worktree.id) - if (!session) throw new Error("Failed to create session") - - state.addSession(session.id, worktree.id) - this.registerWorktreeSession(session.id, result.path) - this.notifyWorktreeReady(session.id, result, worktree.id) - this.postToWebview({ type: "agentManager.importResult", success: true, message: `Opened branch ${branch}` }) - this.log(`Imported branch ${branch} as worktree ${worktree.id}`) - } catch (inner) { - state.removeWorktree(worktree.id) - await manager.removeWorktree(result.path) - this.pushState() - throw inner - } - } catch (error) { - const raw = error instanceof Error ? error.message : String(error) - const msg = - raw.includes("already used by worktree") || raw.includes("already checked out") - ? `Branch "${branch}" is already checked out in another worktree` - : raw - const code = classifyWorktreeError(msg) - this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", message: msg, errorCode: code }) - this.postToWebview({ type: "agentManager.importResult", success: false, message: msg, errorCode: code }) - } finally { - this.importing = false - } - } - - private async onImportFromPR(url: string): Promise { - const manager = this.getWorktreeManager() - const state = this.getStateManager() - if (!manager || !state) { - this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" }) - return - } - - if (this.importing) { - this.postToWebview({ - type: "agentManager.importResult", - success: false, - message: "Another import is already in progress", - }) - return - } - this.importing = true - - try { - this.postToWebview({ type: "agentManager.worktreeSetup", status: "creating", message: "Resolving PR..." }) - const result = await manager.createFromPR(url) - const worktree = state.addWorktree({ - branch: result.branch, - path: result.path, - parentBranch: result.parentBranch, - remote: result.remote, - }) - this.pushState() - - try { - this.postToWebview({ - type: "agentManager.worktreeSetup", - status: "creating", - message: "Setting up worktree...", - branch: result.branch, - worktreeId: worktree.id, - }) - await this.runSetupScriptForWorktree(result.path, result.branch, worktree.id) - - const session = await this.createSessionInWorktree(result.path, result.branch, worktree.id) - if (!session) throw new Error("Failed to create session") - - state.addSession(session.id, worktree.id) - this.registerWorktreeSession(session.id, result.path) - this.notifyWorktreeReady(session.id, result, worktree.id) - this.postToWebview({ - type: "agentManager.importResult", - success: true, - message: `Opened PR branch ${result.branch}`, - }) - this.log(`Imported PR ${url} as worktree ${worktree.id}`) - } catch (inner) { - state.removeWorktree(worktree.id) - await manager.removeWorktree(result.path) - this.pushState() - throw inner - } - } catch (error) { - const raw = error instanceof Error ? error.message : String(error) - const msg = - raw.includes("already used by worktree") || raw.includes("already checked out") - ? "This PR's branch is already checked out in another worktree" - : raw - const code = classifyWorktreeError(msg) - this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", message: msg, errorCode: code }) - this.postToWebview({ type: "agentManager.importResult", success: false, message: msg, errorCode: code }) - } finally { - this.importing = false - } - } - - private async onImportExternalWorktree(wtPath: string, branch: string): Promise { - const state = this.getStateManager() - const manager = this.getWorktreeManager() - if (!state || !manager) { - this.postToWebview({ type: "agentManager.importResult", success: false, message: "State not initialized" }) - return - } - - if (this.importing) { - this.postToWebview({ - type: "agentManager.importResult", - success: false, - message: "Another import is already in progress", - }) - return - } - this.importing = true - - let worktree: ReturnType | undefined - try { - const externals = await manager.listExternalWorktrees(new Set(state.getWorktrees().map((wt) => wt.path))) - if (!externals.some((e) => normalizePath(e.path) === normalizePath(wtPath))) { - this.postToWebview({ - type: "agentManager.importResult", - success: false, - message: "Path is not a valid worktree for this repository", - }) - return - } - - const base = await manager.resolveBaseBranch() - worktree = state.addWorktree({ branch, path: wtPath, parentBranch: base.branch, remote: base.remote }) - this.pushState() - - const session = await this.createSessionInWorktree(wtPath, branch, worktree.id) - if (!session) { - state.removeWorktree(worktree.id) - this.pushState() - this.postToWebview({ type: "agentManager.importResult", success: false, message: "Failed to create session" }) - return - } - - state.addSession(session.id, worktree.id) - this.registerWorktreeSession(session.id, wtPath) - this.pushState() - this.postToWebview({ - type: "agentManager.worktreeSetup", - status: "ready", - message: "Worktree imported", - sessionId: session.id, - branch, - worktreeId: worktree.id, - }) - this.postToWebview({ - type: "agentManager.sessionMeta", - sessionId: session.id, - mode: "worktree", - branch, - path: wtPath, - parentBranch: base.branch, - }) - this.postToWebview({ type: "agentManager.importResult", success: true, message: `Imported ${branch}` }) - this.log(`Imported external worktree ${wtPath} (${branch})`) - } catch (error) { - if (worktree) { - state.removeWorktree(worktree.id) - this.pushState() - } - const msg = error instanceof Error ? error.message : String(error) - this.postToWebview({ type: "agentManager.importResult", success: false, message: msg }) - } finally { - this.importing = false - } - } - - private async onImportAllExternalWorktrees(): Promise { - if (this.importing) { - this.postToWebview({ - type: "agentManager.importResult", - success: false, - message: "Another import is already in progress", - }) - return - } - const manager = this.getWorktreeManager() - const state = this.getStateManager() - if (!manager || !state) { - this.postToWebview({ type: "agentManager.importResult", success: false, message: "Not a git repository" }) - return - } - this.importing = true - - try { - const managedPaths = new Set(state.getWorktrees().map((wt) => wt.path)) - const externals = await manager.listExternalWorktrees(managedPaths) - if (externals.length === 0) { - this.postToWebview({ - type: "agentManager.importResult", - success: true, - message: "No external worktrees to import", - }) - return - } - - let imported = 0 - const base = await manager.resolveBaseBranch() - for (const ext of externals) { - try { - const worktree = state.addWorktree({ - branch: ext.branch, - path: ext.path, - parentBranch: base.branch, - remote: base.remote, - }) - const session = await this.createSessionInWorktree(ext.path, ext.branch, worktree.id) - if (session) { - state.addSession(session.id, worktree.id) - this.registerWorktreeSession(session.id, ext.path) - imported++ - } else { - state.removeWorktree(worktree.id) - } - } catch (error) { - this.log(`Failed to import external worktree ${ext.path}: ${error}`) - } - } - - this.pushState() - this.postToWebview({ - type: "agentManager.importResult", - success: true, - message: `Imported ${imported} worktree${imported !== 1 ? "s" : ""}`, - }) - this.log(`Imported ${imported}/${externals.length} external worktrees`) - } catch (error) { - const msg = error instanceof Error ? error.message : String(error) - this.postToWebview({ type: "agentManager.importResult", success: false, message: msg }) - } finally { - this.importing = false - } - } - // --------------------------------------------------------------------------- // Keybindings // --------------------------------------------------------------------------- diff --git a/packages/kilo-vscode/src/agent-manager/worktree-importer.ts b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts new file mode 100644 index 0000000000..eee61c96ce --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/worktree-importer.ts @@ -0,0 +1,336 @@ +import type { Session } from "@kilocode/sdk/v2/client" +import type { AgentManagerOutMessage } from "./types" +import type { WorktreeManager, CreateWorktreeResult } from "./WorktreeManager" +import type { WorktreeStateManager } from "./WorktreeStateManager" +import { classifyWorktreeError, normalizePath } from "./git-import" + +type Worktree = ReturnType + +export interface WorktreeImporterHost { + manager(): WorktreeManager | undefined + state(): WorktreeStateManager | undefined + post(msg: AgentManagerOutMessage): void + push(): void + setup(path: string, branch?: string, worktreeId?: string): Promise + session(path: string, branch: string, worktreeId?: string): Promise + register(sessionId: string, directory: string): void + ready(sessionId: string, result: CreateWorktreeResult, worktreeId?: string): void + log(...args: unknown[]): void +} + +export class WorktreeImporter { + private importing = false + + constructor(private readonly host: WorktreeImporterHost) {} + + async branches(): Promise { + const manager = this.host.manager() + if (!manager) { + this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" }) + return + } + + try { + const result = await manager.listBranches() + const checked = await manager.checkedOutBranches() + const branches = result.branches.map((branch) => ({ + ...branch, + isCheckedOut: checked.has(branch.name), + })) + + const state = this.host.state() + const configured = state?.getDefaultBaseBranch() + if (state && configured && !branches.some((branch) => branch.name === configured)) { + this.host.log(`Default base branch "${configured}" no longer exists, clearing`) + state.setDefaultBaseBranch(undefined) + this.host.push() + } + + this.host.post({ + type: "agentManager.branches", + branches, + defaultBranch: result.defaultBranch, + }) + } catch (error) { + this.host.log(`Failed to list branches: ${error}`) + this.host.post({ type: "agentManager.branches", branches: [], defaultBranch: "main" }) + } + } + + async external(): Promise { + const manager = this.host.manager() + const state = this.host.state() + if (!manager || !state) { + this.host.post({ type: "agentManager.externalWorktrees", worktrees: [] }) + return + } + + try { + const paths = new Set(state.getWorktrees().map((worktree) => worktree.path)) + const worktrees = await manager.listExternalWorktrees(paths) + this.host.post({ type: "agentManager.externalWorktrees", worktrees }) + } catch (error) { + this.host.log(`Failed to list external worktrees: ${error}`) + this.host.post({ type: "agentManager.externalWorktrees", worktrees: [] }) + } + } + + async branch(branch: string): Promise { + const manager = this.host.manager() + const state = this.host.state() + if (!manager || !state) { + this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" }) + return + } + if (this.busy()) return + + this.importing = true + try { + this.host.post({ + type: "agentManager.worktreeSetup", + status: "creating", + message: "Creating worktree from branch...", + }) + const result = await manager.createWorktree({ existingBranch: branch }) + const worktree = state.addWorktree({ + branch: result.branch, + path: result.path, + parentBranch: result.parentBranch, + remote: result.remote, + }) + this.host.push() + + try { + this.host.post({ + type: "agentManager.worktreeSetup", + status: "creating", + message: "Running setup script...", + branch: result.branch, + worktreeId: worktree.id, + }) + await this.host.setup(result.path, result.branch, worktree.id) + + const session = await this.host.session(result.path, result.branch, worktree.id) + if (!session) throw new Error("Failed to create session") + + state.addSession(session.id, worktree.id) + this.host.register(session.id, result.path) + this.host.ready(session.id, result, worktree.id) + this.host.post({ type: "agentManager.importResult", success: true, message: `Opened branch ${branch}` }) + this.host.log(`Imported branch ${branch} as worktree ${worktree.id}`) + } catch (error) { + state.removeWorktree(worktree.id) + await manager.removeWorktree(result.path) + this.host.push() + throw error + } + } catch (error) { + this.importError(error, `Branch "${branch}" is already checked out in another worktree`) + } finally { + this.importing = false + } + } + + async pr(url: string): Promise { + const manager = this.host.manager() + const state = this.host.state() + if (!manager || !state) { + this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" }) + return + } + if (this.busy()) return + + this.importing = true + try { + this.host.post({ type: "agentManager.worktreeSetup", status: "creating", message: "Resolving PR..." }) + const result = await manager.createFromPR(url) + const worktree = state.addWorktree({ + branch: result.branch, + path: result.path, + parentBranch: result.parentBranch, + remote: result.remote, + }) + this.host.push() + + try { + this.host.post({ + type: "agentManager.worktreeSetup", + status: "creating", + message: "Setting up worktree...", + branch: result.branch, + worktreeId: worktree.id, + }) + await this.host.setup(result.path, result.branch, worktree.id) + + const session = await this.host.session(result.path, result.branch, worktree.id) + if (!session) throw new Error("Failed to create session") + + state.addSession(session.id, worktree.id) + this.host.register(session.id, result.path) + this.host.ready(session.id, result, worktree.id) + this.host.post({ + type: "agentManager.importResult", + success: true, + message: `Opened PR branch ${result.branch}`, + }) + this.host.log(`Imported PR ${url} as worktree ${worktree.id}`) + } catch (error) { + state.removeWorktree(worktree.id) + await manager.removeWorktree(result.path) + this.host.push() + throw error + } + } catch (error) { + this.importError(error, "This PR's branch is already checked out in another worktree") + } finally { + this.importing = false + } + } + + async path(path: string, branch: string): Promise { + const state = this.host.state() + const manager = this.host.manager() + if (!state || !manager) { + this.host.post({ type: "agentManager.importResult", success: false, message: "State not initialized" }) + return + } + if (this.busy()) return + + this.importing = true + let worktree: Worktree | undefined + try { + const paths = new Set(state.getWorktrees().map((worktree) => worktree.path)) + const externals = await manager.listExternalWorktrees(paths) + if (!externals.some((worktree) => normalizePath(worktree.path) === normalizePath(path))) { + this.host.post({ + type: "agentManager.importResult", + success: false, + message: "Path is not a valid worktree for this repository", + }) + return + } + + const base = await manager.resolveBaseBranch() + worktree = state.addWorktree({ branch, path, parentBranch: base.branch, remote: base.remote }) + this.host.push() + + const session = await this.host.session(path, branch, worktree.id) + if (!session) { + state.removeWorktree(worktree.id) + this.host.push() + this.host.post({ type: "agentManager.importResult", success: false, message: "Failed to create session" }) + return + } + + state.addSession(session.id, worktree.id) + this.host.register(session.id, path) + this.host.push() + this.host.post({ + type: "agentManager.worktreeSetup", + status: "ready", + message: "Worktree imported", + sessionId: session.id, + branch, + worktreeId: worktree.id, + }) + this.host.post({ + type: "agentManager.sessionMeta", + sessionId: session.id, + mode: "worktree", + branch, + path, + parentBranch: base.branch, + }) + this.host.post({ type: "agentManager.importResult", success: true, message: `Imported ${branch}` }) + this.host.log(`Imported external worktree ${path} (${branch})`) + } catch (error) { + if (worktree) { + state.removeWorktree(worktree.id) + this.host.push() + } + const message = error instanceof Error ? error.message : String(error) + this.host.post({ type: "agentManager.importResult", success: false, message }) + } finally { + this.importing = false + } + } + + async all(): Promise { + if (this.busy()) return + + const manager = this.host.manager() + const state = this.host.state() + if (!manager || !state) { + this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" }) + return + } + this.importing = true + + try { + const paths = new Set(state.getWorktrees().map((worktree) => worktree.path)) + const externals = await manager.listExternalWorktrees(paths) + if (externals.length === 0) { + this.host.post({ + type: "agentManager.importResult", + success: true, + message: "No external worktrees to import", + }) + return + } + + const imported: string[] = [] + const base = await manager.resolveBaseBranch() + for (const external of externals) { + try { + const worktree = state.addWorktree({ + branch: external.branch, + path: external.path, + parentBranch: base.branch, + remote: base.remote, + }) + const session = await this.host.session(external.path, external.branch, worktree.id) + if (session) { + state.addSession(session.id, worktree.id) + this.host.register(session.id, external.path) + imported.push(worktree.id) + continue + } + state.removeWorktree(worktree.id) + } catch (error) { + this.host.log(`Failed to import external worktree ${external.path}: ${error}`) + } + } + + this.host.push() + this.host.post({ + type: "agentManager.importResult", + success: true, + message: `Imported ${imported.length} worktree${imported.length !== 1 ? "s" : ""}`, + }) + this.host.log(`Imported ${imported.length}/${externals.length} external worktrees`) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + this.host.post({ type: "agentManager.importResult", success: false, message }) + } finally { + this.importing = false + } + } + + private busy(): boolean { + if (!this.importing) return false + this.host.post({ + type: "agentManager.importResult", + success: false, + message: "Another import is already in progress", + }) + return true + } + + private importError(error: unknown, duplicate: string): void { + const raw = error instanceof Error ? error.message : String(error) + const message = raw.includes("already used by worktree") || raw.includes("already checked out") ? duplicate : raw + const code = classifyWorktreeError(message) + this.host.post({ type: "agentManager.worktreeSetup", status: "error", message, errorCode: code }) + this.host.post({ type: "agentManager.importResult", success: false, message, errorCode: code }) + } +} diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index 1ba874cef4..254423454d 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -37,6 +37,7 @@ const TSX_FILES = [ const TSX_FILE = TSX_FILES[0]! const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts") const DIFF_CONTROLLER_FILE = path.join(ROOT, "src/agent-manager/worktree-diff-controller.ts") +const IMPORTER_FILE = path.join(ROOT, "src/agent-manager/worktree-importer.ts") const SETUP_SCRIPT_RUNNER_FILE = path.join(ROOT, "src/agent-manager/SetupScriptRunner.ts") function readAllCss(): string { @@ -177,6 +178,10 @@ describe("Agent Manager Provider — onMessage routing", () => { return fs.readFileSync(DIFF_CONTROLLER_FILE, "utf-8") } + function importer(): string { + return fs.readFileSync(IMPORTER_FILE, "utf-8") + } + // -- onMessage dispatches all expected message types ----------------------- it("provider routing handles all documented agentManager.* message types", () => { @@ -220,6 +225,7 @@ describe("Agent Manager Provider — onMessage routing", () => { const text = body("onMessage") expect(text).toContain("onWorktreeMessage") expect(text).toContain("onSessionMessage") + expect(text).toContain("onImportMessage") expect(text).toContain("onDiffMessage") expect(text).not.toContain("agentManager.requestState") }) @@ -314,7 +320,6 @@ describe("Agent Manager Provider — onMessage routing", () => { * loading skeletons forever. */ it("requestState handler calls pushEmptyState when this.state is falsy", () => { - // onStateMessage delegates to onRequestState; verify the actual handler const text = body("onRequestState") expect(text, "must call pushEmptyState when state is absent").toContain("pushEmptyState") expect(text, "must guard on this.state being falsy").toMatch(/!this\.state/) @@ -335,6 +340,16 @@ describe("Agent Manager Provider — onMessage routing", () => { expect(text).toContain("shouldStopDiffPolling") expect(providerText).toContain("this.diffs") }) + + it("worktree import behavior lives in the cohesive importer", () => { + const text = importer() + const providerText = body("onImportMessage") + expect(text).toContain("class WorktreeImporter") + expect(text).toContain("createFromPR") + expect(text).toContain("listExternalWorktrees") + expect(text).toContain("createWorktree") + expect(providerText).toContain("this.importer") + }) }) // --------------------------------------------------------------------------- @@ -559,7 +574,7 @@ const VSCODE_ALLOWED: Record = { const MAX_LINES: Record = { "AgentManagerProvider.ts": { maxLines: 2000, - note: "worktree diff orchestration lives in WorktreeDiffController; lower this after the next cohesive extraction", + note: "diff and import workflows are extracted into cohesive domain services; extract more orchestration next", }, } From bf8e8f468497e6b1aee69ac4e728a1cf78f25ff2 Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 10 Apr 2026 13:37:33 +0200 Subject: [PATCH 36/39] fix(vscode): ignore confirmed command transport errors (#8722) * fix(vscode): ignore confirmed command transport errors * fix(vscode): scope command confirmations to pending sends * fix(vscode): centralize confirmation cleanup * chore(vscode): keep provider under line cap --- packages/kilo-vscode/src/KiloProvider.ts | 102 ++++++++++-------- .../kilo-vscode/src/kilo-provider-utils.ts | 75 +++++++++++++ .../kilo-provider/handlers/cloud-session.ts | 54 ++++++---- .../services/cli-backend/sdk-sse-adapter.ts | 5 +- .../tests/unit/kilo-provider-utils.test.ts | 39 +++++++ 5 files changed, 206 insertions(+), 69 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index b08904c432..52cbca8224 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -25,6 +25,8 @@ import { mapSSEEventToWebviewMessage, getErrorMessage, isEventFromForeignProject, + MessageConfirmation, + runWithMessageConfirmation, loadSessions as loadSessionsUtil, flushPendingSessionRefresh as flushPendingSessionRefreshUtil, resolveContextDirectory, @@ -164,6 +166,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Set when refreshSessions() is called before the client is ready. * Cleared and retried once the connection transitions to "connected". */ private pendingSessionRefresh = false + private readonly confirmations = new MessageConfirmation() private unsubscribeEvent: (() => void) | null = null private unsubscribeState: (() => void) | null = null /** Cached legacy migration data so migrate() doesn't re-read from disk/SecretStorage. */ // legacy-migration @@ -2297,21 +2300,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper /** Abort controllers for active retry loops, keyed by session ID */ private retryAbortControllers = new Map() - /** - * Execute an SDK call with exponential backoff on HTTP errors. - * Retries on 429, 5xx, and other retryable status codes. - * When the response includes `Retry-After` / `Retry-After-MS` headers, - * the delay honours that value (capped at 5 min). Otherwise uses the - * predefined backoff schedule: 5s -> 10s -> 30s -> 60s -> 300s. - * - * After MAX_RETRIES (5) attempts, automatically throws the error. - * Users can cancel via the cancel button in the UI which sends an abort - * message — this interrupts the backoff delay and stops the retry loop. - * - * The webview receives `sessionStatus` messages with a countdown so the - * user can see that a retry is in progress. - */ - private async withRetry(fn: () => Promise<{ error?: unknown; response: Response }>, sid: string): Promise { + /** Execute an SDK call with visible exponential backoff for retryable HTTP errors. */ + private async withRetry( + fn: () => Promise<{ error?: unknown; response?: Response }>, + sid: string, + messageID?: string, + ): Promise { const abortController = new AbortController() this.retryAbortControllers.set(sid, abortController) @@ -2324,6 +2318,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const result = await fn() if (!result.error) return + if (this.confirmations.has(messageID)) return const status = result.response?.status ?? 0 @@ -2352,12 +2347,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper }) // Wait for delay or until aborted - await new Promise((resolve) => { - const timer = setTimeout(resolve, delay) - abortController.signal.addEventListener("abort", () => { + await new Promise((resolve) => { + const done = () => { clearTimeout(timer) - }) + abortController.signal.removeEventListener("abort", done) + resolve() + } + const timer = setTimeout(done, delay) + abortController.signal.addEventListener("abort", done, { once: true }) }) + if (this.confirmations.has(messageID)) return } } finally { this.retryAbortControllers.delete(sid) @@ -2417,19 +2416,22 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const sid = resolved!.sid const dir = resolved!.dir - await this.withRetry( - () => - this.client!.session.promptAsync({ - sessionID: sid, - directory: dir, - messageID, - parts, - model: providerID && modelID ? { providerID, modelID } : undefined, - agent, - variant, - editorContext, - }), - sid, + await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Message request", () => + this.withRetry( + () => + this.client!.session.promptAsync({ + sessionID: sid, + directory: dir, + messageID, + parts, + model: providerID && modelID ? { providerID, modelID } : undefined, + agent, + variant, + editorContext, + }), + sid, + messageID, + ), ) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to send message:", error) @@ -2482,20 +2484,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const sid = resolved!.sid const dir = resolved!.dir - await this.withRetry( - () => - this.client!.session.command({ - sessionID: sid, - directory: dir, - command, - arguments: args, - messageID, - model: providerID && modelID ? `${providerID}/${modelID}` : undefined, - agent, - variant, - parts, - }), - sid, + await runWithMessageConfirmation(this.confirmations, messageID, "KiloProvider: Command request", () => + this.withRetry( + () => + this.client!.session.command({ + sessionID: sid, + directory: dir, + command, + arguments: args, + messageID, + model: providerID && modelID ? `${providerID}/${modelID}` : undefined, + agent, + variant, + parts, + }), + sid, + messageID, + ), ) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to send command:", error) @@ -2640,6 +2645,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper postMessage: (msg) => this.postMessage(msg), getWorkspaceDirectory: (sid) => this.getWorkspaceDirectory(sid), gatherEditorContext: () => this.gatherEditorContext(), + runWithMessageConfirmation: (id, label, run) => runWithMessageConfirmation(this.confirmations, id, label, run), } } @@ -2853,6 +2859,10 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // let a foreign session through if it was accidentally tracked. if (isEventFromForeignProject(event, this.projectID)) return + if (event.type === "message.updated") { + this.confirmations.confirm(event.properties.info.id) + } + // session.status events pass the onEventFiltered pre-filter for all providers (see line 842), // so this runs on every KiloProvider instance — including the Settings panel which has no // tracked sessions. Update sessionStatusMap and forward to webview before the diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index a7dbc21b80..8470c74918 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -58,6 +58,81 @@ export function getErrorMessage(error: unknown): string { return String(error) } +export class MessageConfirmation { + private readonly ids = new Map void> }>() + + track(id?: string): () => void { + if (!id) return () => {} + const entry = this.ids.get(id) ?? { confirmed: false, waits: new Set<() => void>() } + this.ids.set(id, entry) + return () => { + this.ids.delete(id) + } + } + + confirm(id: string): void { + const entry = this.ids.get(id) + if (!entry) return + entry.confirmed = true + for (const done of [...entry.waits]) { + done() + } + } + + has(id?: string): boolean { + if (!id) return false + return this.ids.get(id)?.confirmed ?? false + } + + wait(id?: string, timeout = 1_500): Promise { + if (!id) return Promise.resolve(false) + const entry = this.ids.get(id) + if (!entry) return Promise.resolve(false) + if (entry.confirmed) return Promise.resolve(true) + + return new Promise((resolve) => { + const timer = setTimeout(() => { + cleanup() + resolve(entry.confirmed) + }, timeout) + + const cleanup = () => { + clearTimeout(timer) + entry.waits.delete(done) + } + + const done = () => { + cleanup() + resolve(true) + } + + entry.waits.add(done) + }) + } +} + +export async function runWithMessageConfirmation( + state: MessageConfirmation, + id: string | undefined, + label: string, + run: () => Promise, +): Promise { + const release = state.track(id) + try { + return await run() + } catch (error) { + if (await state.wait(id)) { + console.warn(`[Kilo New] ${label} ended after server accepted it; ignoring transport error`, { + error: getErrorMessage(error), + }) + return undefined + } + throw error + } finally { + release() + } +} + export function sessionToWebview(session: Session) { return { id: session.id, diff --git a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts index 20f0982818..acb0365991 100644 --- a/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts +++ b/packages/kilo-vscode/src/kilo-provider/handlers/cloud-session.ts @@ -19,6 +19,11 @@ export interface CloudSessionContext { postMessage(msg: unknown): void getWorkspaceDirectory(sessionId?: string): string gatherEditorContext(): Promise + runWithMessageConfirmation?( + messageID: string | undefined, + label: string, + run: () => Promise, + ): Promise } /** Fetch cloud sessions list and send to webview. */ @@ -123,6 +128,7 @@ export async function handleImportAndSend( return } + const client = ctx.client const dir = ctx.getWorkspaceDirectory() // Step 1: Import the cloud session with fresh IDs @@ -163,28 +169,32 @@ export async function handleImportAndSend( }) // Step 2: Send the user's message/command on the new local session + const run = ctx.runWithMessageConfirmation ?? ((_id, _label, fn) => fn()) try { - if (messageID) { - ctx.connectionService.recordMessageSessionId(messageID, session.id) - } + await run(messageID, "Cloud import send", async () => { + if (messageID) { + ctx.connectionService.recordMessageSessionId(messageID, session.id) + } + + if (command) { + const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url })) + await client.session.command( + { + sessionID: session.id, + directory: dir, + command, + arguments: commandArgs ?? "", + messageID, + model: providerID && modelID ? `${providerID}/${modelID}` : undefined, + agent, + variant, + parts, + }, + { throwOnError: true }, + ) + return + } - if (command) { - const parts = files?.map((f) => ({ type: "file" as const, mime: f.mime, url: f.url })) - await ctx.client.session.command( - { - sessionID: session.id, - directory: dir, - command, - arguments: commandArgs ?? "", - messageID, - model: providerID && modelID ? `${providerID}/${modelID}` : undefined, - agent, - variant, - parts, - }, - { throwOnError: true }, - ) - } else { const parts: Array = [] if (files) { for (const f of files) { @@ -194,7 +204,7 @@ export async function handleImportAndSend( parts.push({ type: "text", text }) const editorContext = await ctx.gatherEditorContext() - await ctx.client.session.promptAsync( + await client.session.promptAsync( { sessionID: session.id, directory: dir, @@ -207,7 +217,7 @@ export async function handleImportAndSend( }, { throwOnError: true }, ) - } + }) } catch (err) { console.error("[Kilo New] Failed to send message after cloud import:", err) ctx.postMessage({ diff --git a/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts b/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts index 0021e4e7a9..2b54ac11bb 100644 --- a/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts +++ b/packages/kilo-vscode/src/services/cli-backend/sdk-sse-adapter.ts @@ -175,7 +175,10 @@ export class SdkSSEAdapter { // The SDK yields GlobalEvent = { directory, payload: Event }. const globalEvent = event as GlobalEvent - console.log("[Kilo New] SSE: 📨 Event:", globalEvent.payload.type) + const type = (globalEvent.payload as { type: string }).type + if (type !== "server.heartbeat") { + console.log("[Kilo New] SSE: 📨 Event:", type) + } this.notifyEvent(globalEvent.payload) } diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index 454997536b..f53b5a1d36 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -7,6 +7,7 @@ import { mapSSEEventToWebviewMessage, isEventFromForeignProject, mapCloudSessionMessageToWebviewMessage, + MessageConfirmation, type ProviderInfo, } from "../../src/kilo-provider-utils" import type { CloudSessionMessage } from "../../src/services/cli-backend/types" @@ -93,6 +94,44 @@ function makeAssistantMessage(overrides: Partial = {}): Assist } } +describe("MessageConfirmation", () => { + it("reports tracked confirmed messages", async () => { + const state = new MessageConfirmation() + state.track("msg-1") + state.confirm("msg-1") + + expect(state.has("msg-1")).toBe(true) + expect(await state.wait("msg-1", 1)).toBe(true) + }) + + it("resolves waiters when a message is confirmed", async () => { + const state = new MessageConfirmation() + state.track("msg-1") + const wait = state.wait("msg-1", 50) + + state.confirm("msg-1") + + expect(await wait).toBe(true) + }) + + it("returns false when confirmation does not arrive", async () => { + const state = new MessageConfirmation() + state.track("msg-1") + + expect(await state.wait("msg-1", 1)).toBe(false) + }) + + it("forgets confirmations after release", () => { + const state = new MessageConfirmation() + const release = state.track("msg-1") + state.confirm("msg-1") + + release() + + expect(state.has("msg-1")).toBe(false) + }) +}) + describe("sessionToWebview", () => { it("converts epoch timestamps to ISO strings", () => { const result = sessionToWebview(makeSession()) From 52f315285e4c0850b7092c19b04148ff808bc5ac Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 10 Apr 2026 14:19:14 +0200 Subject: [PATCH 37/39] docs(kilo-docs): document Agent Manager sections feature (#8728) Add user-facing documentation for the worktree sections/folders feature covering creation, assignment, renaming, colors, reordering, collapsing, and deletion. --- .../kilo-docs/pages/automate/agent-manager.md | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index c06f9f2309..a371cbaa8c 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -65,6 +65,43 @@ You can run up to 4 parallel implementations of the same prompt across separate - **From an external worktree:** Import a worktree that already exists on disk - **Continue in Worktree:** From the sidebar chat, promote the current session to a new Agent Manager worktree +## Sections + +Sections let you group worktrees into collapsible, color-coded folders in the sidebar. Use them to organize your workflow however you like — by status ("Review Pending", "In Progress"), by project area ("Frontend", "Backend"), priority, or any other scheme that fits. + +### Creating a Section + +- **Right-click** any worktree and select **New Section** from the context menu +- A new section is created with a random color and enters rename mode immediately — type a name and press `Enter` + +### Assigning Worktrees to Sections + +**Via context menu:** Right-click a worktree, hover **Move to Section**, and pick a section from the list. Select **Ungrouped** to remove it from its current section. + +**Via drag and drop:** Drag a worktree and drop it onto a section header to move it there. + +Multi-version worktrees (created via Multi-Version Mode) are moved together — assigning one version to a section moves all versions in the group. + +### Renaming + +Right-click the section header and select **Rename Section**. An inline text field appears — type the new name and press `Enter` to confirm or `Escape` to cancel. + +### Colors + +Right-click the section header and select **Set Color** to open the color picker. Eight colors are available (Red, Orange, Yellow, Green, Cyan, Blue, Purple, Magenta) plus a **Default** option that uses the standard panel border color. The selected color appears as a left border stripe on the section. + +### Reordering + +Right-click the section header and use **Move Up** / **Move Down** to reposition it in the sidebar. Sections and ungrouped worktrees share the same ordering space. + +### Collapsing + +Click the section header to toggle it open or closed. Collapsed sections hide their worktrees and show only the section name and a member count badge. Collapse state is persisted across reloads. + +### Deleting a Section + +Right-click the section header and select **Delete Section**. The section is removed but its worktrees are preserved — they become ungrouped. + ## Sending Messages, Approvals, and Control - **Continue the conversation:** Send a follow-up message to the running agent From ca1f9a3c18c61ba94848c768a81c282a4d73964f Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 10 Apr 2026 14:20:33 +0200 Subject: [PATCH 38/39] fix(vscode): improve @ file mention results with open tabs and active editor priority (#8727) The @ mention dropdown showed directories instead of files on empty query, limited results to 10, and only reordered (not injected) open tabs. Now requests files-only with a higher limit, injects open editor tabs at the top (active file first), and filters by query. Closes #8709 --- packages/kilo-vscode/src/KiloProvider.ts | 17 ++-- .../kilo-vscode/src/kilo-provider-utils.ts | 23 +++++ .../tests/unit/kilo-provider-utils.test.ts | 96 +++++++++++++++++++ 3 files changed, 126 insertions(+), 10 deletions(-) diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 52cbca8224..a8e7611697 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -31,6 +31,7 @@ import { flushPendingSessionRefresh as flushPendingSessionRefreshUtil, resolveContextDirectory, resolveWorkspaceDirectory, + mergeFileSearchResults, type SessionRefreshContext, } from "./kilo-provider-utils" import { GitOps } from "./agent-manager/GitOps" @@ -801,17 +802,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const dir = this.getWorkspaceDirectory(this.currentSession?.id) const openPaths = dir ? await this.getOpenTabPaths(dir) : new Set() void sdkClient.find - .files({ query: message.query, directory: dir }, { throwOnError: true }) + .files({ query: message.query, directory: dir, type: "file", limit: 50 }, { throwOnError: true }) .then(({ data: paths }) => { - // Prioritize open files: open tabs first, then the rest - const open = paths.filter((p) => openPaths.has(p)) - const rest = paths.filter((p) => !openPaths.has(p)) - this.postMessage({ - type: "fileSearchResult", - paths: [...open, ...rest], - dir, - requestId: message.requestId, - }) + const uri = vscode.window.activeTextEditor?.document.uri + const active = + uri?.scheme === "file" && dir ? path.relative(dir, uri.fsPath).replaceAll("\\", "/") : undefined + const result = mergeFileSearchResults({ query: message.query, backend: paths, open: openPaths, active }) + this.postMessage({ type: "fileSearchResult", paths: result, dir, requestId: message.requestId }) }) .catch((error: unknown) => { console.error("[Kilo New] File search failed:", error) diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts index 8470c74918..b16ded8d8b 100644 --- a/packages/kilo-vscode/src/kilo-provider-utils.ts +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -446,3 +446,26 @@ export function isEventFromForeignProject(event: Event, expectedProjectID: strin } return false } + +/** + * Merge open-tab paths with backend file search results for the @ mention dropdown. + * + * Ordering: active file → other open tabs → backend results (all deduplicated). + * When a query is present, open tabs are filtered to only include matches. + * The `active` path (if provided) is placed first when it exists in `open`. + */ +export function mergeFileSearchResults(input: { + query: string + backend: string[] + open: Set + active?: string +}): string[] { + const query = input.query.trim().toLowerCase() + const ok = (p: string) => !query || p.toLowerCase().includes(query) + const tabs = + input.active && input.open.has(input.active) && ok(input.active) + ? [input.active, ...[...input.open].filter((p) => p !== input.active && ok(p))] + : [...input.open].filter(ok) + const seen = new Set(tabs) + return [...tabs, ...input.backend.filter((p) => !seen.has(p))] +} diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts index f53b5a1d36..5d9f38aae3 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -8,6 +8,7 @@ import { isEventFromForeignProject, mapCloudSessionMessageToWebviewMessage, MessageConfirmation, + mergeFileSearchResults, type ProviderInfo, } from "../../src/kilo-provider-utils" import type { CloudSessionMessage } from "../../src/services/cli-backend/types" @@ -562,3 +563,98 @@ describe("mapCloudSessionMessage", () => { expect(msg.role).toBe("user") }) }) + +describe("mergeFileSearchResults", () => { + it("returns backend results when no open files", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts", "src/b.ts"], + open: new Set(), + }) + expect(result).toEqual(["src/a.ts", "src/b.ts"]) + }) + + it("places open files before backend results", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts", "src/b.ts", "src/c.ts"], + open: new Set(["src/c.ts", "src/d.ts"]), + }) + expect(result).toEqual(["src/c.ts", "src/d.ts", "src/a.ts", "src/b.ts"]) + }) + + it("places active file first among open files", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts"], + open: new Set(["src/b.ts", "src/c.ts"]), + active: "src/c.ts", + }) + expect(result).toEqual(["src/c.ts", "src/b.ts", "src/a.ts"]) + }) + + it("ignores active file when it is not in open set", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts"], + open: new Set(["src/b.ts"]), + active: "src/x.ts", + }) + expect(result).toEqual(["src/b.ts", "src/a.ts"]) + }) + + it("deduplicates open files from backend results", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/a.ts", "src/b.ts"], + open: new Set(["src/a.ts"]), + }) + expect(result).toEqual(["src/a.ts", "src/b.ts"]) + }) + + it("filters open files by query", () => { + const result = mergeFileSearchResults({ + query: "config", + backend: ["src/config.ts", "src/util.ts"], + open: new Set(["src/index.ts", "src/config.ts", "README.md"]), + }) + expect(result).toEqual(["src/config.ts", "src/util.ts"]) + }) + + it("query filtering is case-insensitive", () => { + const result = mergeFileSearchResults({ + query: "READ", + backend: [], + open: new Set(["README.md", "src/index.ts"]), + }) + expect(result).toEqual(["README.md"]) + }) + + it("shows all open files on empty query", () => { + const result = mergeFileSearchResults({ + query: "", + backend: [], + open: new Set(["src/a.ts", "src/b.ts"]), + }) + expect(result).toEqual(["src/a.ts", "src/b.ts"]) + }) + + it("shows all open files on whitespace-only query", () => { + const result = mergeFileSearchResults({ + query: " ", + backend: ["src/x.ts"], + open: new Set(["src/a.ts"]), + }) + expect(result).toEqual(["src/a.ts", "src/x.ts"]) + }) + + it("handles forward-slash paths (Windows-normalized)", () => { + const result = mergeFileSearchResults({ + query: "", + backend: ["src/utils/path.ts"], + open: new Set(["src/utils/path.ts", "src/index.ts"]), + active: "src/utils/path.ts", + }) + expect(result).toEqual(["src/utils/path.ts", "src/index.ts"]) + }) +}) From 5605ea49fbeb185f8eae2ba4382b1358637849fd Mon Sep 17 00:00:00 2001 From: Marius Date: Fri, 10 Apr 2026 14:49:56 +0200 Subject: [PATCH 39/39] fix(cli): default image input to true for OpenAI-compatible providers (#8729) Models fetched via the OpenAI-compatible /v1/models endpoint had image support hardcoded to false because the endpoint provides no capability metadata. This matches the legacy extension behavior (supportsImages: true) for custom providers while leaving all other provider defaults untouched. --- packages/opencode/src/provider/model-cache.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/model-cache.ts b/packages/opencode/src/provider/model-cache.ts index d902a40e88..bd39b227ad 100644 --- a/packages/opencode/src/provider/model-cache.ts +++ b/packages/opencode/src/provider/model-cache.ts @@ -209,7 +209,7 @@ export namespace ModelCache { name: model.id, family: model.owned_by ?? "", release_date: "", - attachment: false, + attachment: true, reasoning: false, temperature: true, tool_call: true, @@ -217,7 +217,7 @@ export namespace ModelCache { limit: { context: 128000, output: 4096 }, options: {}, modalities: { - input: ["text"], + input: ["text", "image"], output: ["text"], }, }