mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-29 03:44:06 +08:00
Merge branch 'main' into acute-jingle
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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:",
|
||||
},
|
||||
|
||||
+1
-1
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "7.2.1",
|
||||
"version": "7.2.3",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "7.2.1",
|
||||
"version": "7.2.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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**](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.
|
||||
|
||||
- [**STDIO & SSE Transports**](server-transports) - Detailed comparison of local (STDIO) and remote (SSE) transport mechanisms with deployment considerations for each approach.
|
||||
|
||||
@@ -159,6 +159,12 @@ 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. `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#auto-approve-tools).
|
||||
|
||||
## Environment Variables
|
||||
|
||||
Use `{env:VARIABLE_NAME}` syntax in config files to reference environment variables:
|
||||
|
||||
@@ -19,73 +19,30 @@ 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.
|
||||
|
||||
## Auto Frontier
|
||||
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).
|
||||
|
||||
`kilo-auto/frontier` routes to the latest and most capable paid models available, optimizing for performance, capability, and cost.
|
||||
## Tiers
|
||||
|
||||
### 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.
|
||||
|
||||
## 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.
|
||||
|
||||
### 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 |
|
||||
|
||||
**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 %}
|
||||
- **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
|
||||
|
||||
### Simplified Setup
|
||||
|
||||
No need to manually switch models when changing modes. Auto Model handles the routing transparently in the background.
|
||||
|
||||
### 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.
|
||||
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.
|
||||
|
||||
### Best-in-Class Models
|
||||
### No Configuration Required
|
||||
|
||||
Auto Model routes to capable models matched to your task:
|
||||
No need to manually switch models when changing modes. Auto Model handles routing transparently in the background.
|
||||
|
||||
- **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
|
||||
### Flexible Cost Control
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -46,17 +46,17 @@ 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
|
||||
|
||||
**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 GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — across all modes.
|
||||
|
||||
**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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,33 +44,41 @@ 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.
|
||||
|
||||
{% 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.
|
||||
|
||||
### `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,22 +88,30 @@ 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 |
|
||||
| -------------------------------------------------------------- | ---------------------- |
|
||||
| `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` |
|
||||
| `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`
|
||||
|
||||
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 +131,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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -64,6 +64,12 @@ 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 `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#auto-approve-tools).
|
||||
|
||||
## Defaults
|
||||
|
||||
Most tools default to `"*": "allow"` for a smooth out-of-the-box experience. Notable exceptions that prompt by default:
|
||||
@@ -208,6 +214,12 @@ 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 `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#auto-approve-tools).
|
||||
|
||||
## Full Configuration Example
|
||||
|
||||
{% callout type="info" %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.2.1",
|
||||
"version": "7.2.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 {
|
||||
@@ -27,10 +25,13 @@ import {
|
||||
mapSSEEventToWebviewMessage,
|
||||
getErrorMessage,
|
||||
isEventFromForeignProject,
|
||||
MessageConfirmation,
|
||||
runWithMessageConfirmation,
|
||||
loadSessions as loadSessionsUtil,
|
||||
flushPendingSessionRefresh as flushPendingSessionRefreshUtil,
|
||||
resolveContextDirectory,
|
||||
resolveWorkspaceDirectory,
|
||||
mergeFileSearchResults,
|
||||
type SessionRefreshContext,
|
||||
} from "./kilo-provider-utils"
|
||||
import { GitOps } from "./agent-manager/GitOps"
|
||||
@@ -42,6 +43,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"
|
||||
@@ -164,6 +167,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
|
||||
@@ -527,7 +531,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 +554,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 +569,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 +650,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 +665,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...")
|
||||
@@ -837,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<string>()
|
||||
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)
|
||||
@@ -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.
|
||||
@@ -2335,21 +2297,12 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
/** Abort controllers for active retry loops, keyed by session ID */
|
||||
private retryAbortControllers = new Map<string, AbortController>()
|
||||
|
||||
/**
|
||||
* 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<void> {
|
||||
/** 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<void> {
|
||||
const abortController = new AbortController()
|
||||
this.retryAbortControllers.set(sid, abortController)
|
||||
|
||||
@@ -2362,6 +2315,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
|
||||
|
||||
@@ -2390,12 +2344,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<void>((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)
|
||||
@@ -2455,19 +2413,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)
|
||||
@@ -2520,20 +2481,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)
|
||||
@@ -2678,6 +2642,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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2891,6 +2856,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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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<string>()
|
||||
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<string>()
|
||||
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<string>()
|
||||
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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void> | undefined
|
||||
getClient: () => KiloClient
|
||||
git: GitOps
|
||||
post: (msg: AgentManagerOutMessage) => void
|
||||
log: (...args: unknown[]) => void
|
||||
}
|
||||
|
||||
export class WorktreeDiffController {
|
||||
private interval: ReturnType<typeof setInterval> | 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<Status | undefined> {
|
||||
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<void> {
|
||||
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)
|
||||
}
|
||||
@@ -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<WorktreeStateManager["addWorktree"]>
|
||||
|
||||
export interface WorktreeImporterHost {
|
||||
manager(): WorktreeManager | undefined
|
||||
state(): WorktreeStateManager | undefined
|
||||
post(msg: AgentManagerOutMessage): void
|
||||
push(): void
|
||||
setup(path: string, branch?: string, worktreeId?: string): Promise<void>
|
||||
session(path: string, branch: string, worktreeId?: string): Promise<Session | null>
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,81 @@ export function getErrorMessage(error: unknown): string {
|
||||
return String(error)
|
||||
}
|
||||
|
||||
export class MessageConfirmation {
|
||||
private readonly ids = new Map<string, { confirmed: boolean; waits: Set<() => 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<boolean> {
|
||||
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<T>(
|
||||
state: MessageConfirmation,
|
||||
id: string | undefined,
|
||||
label: string,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
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,
|
||||
@@ -371,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<string>
|
||||
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))]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
type Progress = (status: string, detail?: string, error?: string) => void
|
||||
|
||||
type Ctx = {
|
||||
sessionId?: string
|
||||
handler?: (sessionId: string, progress: Progress) => Promise<void>
|
||||
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",
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,11 @@ export interface CloudSessionContext {
|
||||
postMessage(msg: unknown): void
|
||||
getWorkspaceDirectory(sessionId?: string): string
|
||||
gatherEditorContext(): Promise<EditorContext>
|
||||
runWithMessageConfirmation?<T>(
|
||||
messageID: string | undefined,
|
||||
label: string,
|
||||
run: () => Promise<T>,
|
||||
): Promise<T | undefined>
|
||||
}
|
||||
|
||||
/** 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<TextPartInput | FilePartInput> = []
|
||||
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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -59,25 +59,24 @@ function slimEdit(state: Record<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
const next = { ...state }
|
||||
const meta = state.metadata
|
||||
if (isObj(meta) && Array.isArray(meta.files)) {
|
||||
next.metadata = {
|
||||
...meta,
|
||||
files: (meta.files as Record<string, unknown>[]).map((f) => ({
|
||||
if (isObj(meta)) {
|
||||
const slim: Record<string, unknown> = {}
|
||||
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
|
||||
if (Array.isArray(meta.files)) {
|
||||
slim.files = (meta.files as Record<string, unknown>[]).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<string, unknown>).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<string, unknown>): Record<string, unknown> {
|
||||
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<string, unknown>): Record<string, unknown> {
|
||||
const next = { ...state }
|
||||
const meta = state.metadata
|
||||
if (isObj(meta) && Array.isArray(meta.results)) {
|
||||
next.metadata = {
|
||||
...meta,
|
||||
results: (meta.results as Record<string, unknown>[]).map((r) => {
|
||||
const slim: Record<string, unknown> = {}
|
||||
if (r.diagnostics) slim.diagnostics = r.diagnostics
|
||||
if (isObj(meta)) {
|
||||
const slim: Record<string, unknown> = {}
|
||||
if (meta.diagnostics) slim.diagnostics = meta.diagnostics
|
||||
if (Array.isArray(meta.results)) {
|
||||
slim.results = (meta.results as Record<string, unknown>[]).map((r) => {
|
||||
const rs: Record<string, unknown> = {}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,8 +34,10 @@ 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 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 {
|
||||
@@ -168,10 +170,22 @@ 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")
|
||||
}
|
||||
|
||||
function importer(): string {
|
||||
return fs.readFileSync(IMPORTER_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 +205,31 @@ 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("onImportMessage")
|
||||
expect(text).toContain("onDiffMessage")
|
||||
expect(text).not.toContain("agentManager.requestState")
|
||||
})
|
||||
|
||||
// -- onDeleteWorktree invariants -------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -297,21 +320,35 @@ describe("Agent Manager Provider — onMessage routing", () => {
|
||||
* loading skeletons forever.
|
||||
*/
|
||||
it("requestState handler calls pushEmptyState when this.state is falsy", () => {
|
||||
const text = body("onMessage")
|
||||
// Extract the requestState branch
|
||||
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)
|
||||
expect(snippet, "must call pushEmptyState when state is absent").toContain("pushEmptyState")
|
||||
expect(snippet, "must guard on this.state being falsy").toMatch(/!this\.state/)
|
||||
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 this.state is truthy", () => {
|
||||
const text = body("onMessage")
|
||||
const start = text.indexOf('"agentManager.requestState"')
|
||||
const snippet = text.slice(start, start + 600)
|
||||
expect(snippet, "must call pushState for the normal path").toContain("this.pushState()")
|
||||
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", () => {
|
||||
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")
|
||||
})
|
||||
|
||||
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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -536,8 +573,8 @@ const VSCODE_ALLOWED: Record<string, { note: string }> = {
|
||||
*/
|
||||
const MAX_LINES: Record<string, { maxLines: number; note: string }> = {
|
||||
"AgentManagerProvider.ts": {
|
||||
maxLines: 2050,
|
||||
note: "permission recovery wiring is interleaved with panel/session lifecycle; extract more orchestrators next",
|
||||
maxLines: 2000,
|
||||
note: "diff and import workflows are extracted into cohesive domain services; extract more orchestration next",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
mapSSEEventToWebviewMessage,
|
||||
isEventFromForeignProject,
|
||||
mapCloudSessionMessageToWebviewMessage,
|
||||
MessageConfirmation,
|
||||
mergeFileSearchResults,
|
||||
type ProviderInfo,
|
||||
} from "../../src/kilo-provider-utils"
|
||||
import type { CloudSessionMessage } from "../../src/services/cli-backend/types"
|
||||
@@ -93,6 +95,44 @@ function makeAssistantMessage(overrides: Partial<AssistantMessage> = {}): 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())
|
||||
@@ -523,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"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, any>
|
||||
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<string, any>
|
||||
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<string, any>
|
||||
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<string, unknown>),
|
||||
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<string, any>
|
||||
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<string, unknown>),
|
||||
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<string, any>
|
||||
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<string, any>
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -1319,6 +1311,7 @@ const AgentManagerContent: Component = () => {
|
||||
providerID: ev.providerID,
|
||||
modelID: ev.modelID,
|
||||
agent: ev.agent,
|
||||
variant: ev.variant,
|
||||
files: ev.files,
|
||||
})
|
||||
}
|
||||
@@ -1898,9 +1891,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 +2278,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 +2288,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
|
||||
}
|
||||
|
||||
@@ -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<DialogTab>("new")
|
||||
|
||||
@@ -74,7 +77,7 @@ export const NewWorktreeDialog: Component<{ onClose: () => void; defaultBaseBran
|
||||
const cached = vscode.getState<Record<string, unknown>>()
|
||||
const [prompt, setPrompt] = createSignal((cached?.advancedDialogPrompt as string) ?? "")
|
||||
const [versions, setVersions] = createSignal<VersionCount>(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<ModelAllocations>(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<string | undefined>(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
|
||||
</div>
|
||||
<div class="prompt-input-hint">
|
||||
<div class="prompt-input-hint-selectors">
|
||||
<Show when={session.agents().length > 1}>
|
||||
<ModeSwitcherBase agents={session.agents()} value={agent()} onSelect={setAgent} />
|
||||
</Show>
|
||||
<Show when={!compareMode()}>
|
||||
<ModelSelectorBase
|
||||
value={model()}
|
||||
onSelect={(pid, mid) => 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"
|
||||
/>
|
||||
</Show>
|
||||
<Show when={session.agents().length > 1}>
|
||||
<ModeSwitcherBase agents={session.agents()} value={agent()} onSelect={setAgent} />
|
||||
<ThinkingSelectorBase variants={variants()} value={effectiveVariant()} onSelect={setVariant} />
|
||||
<Show when={overridden()}>
|
||||
<Tooltip value={t("prompt.action.resetModel")} placement="top">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="small"
|
||||
onClick={() => setModel(session.selected())}
|
||||
aria-label={t("prompt.action.resetModel")}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M3.72 3.72a.75.75 0 011.06 0L8 6.94l3.22-3.22a.75.75 0 111.06 1.06L9.06 8l3.22 3.22a.75.75 0 11-1.06 1.06L8 9.06l-3.22 3.22a.75.75 0 01-1.06-1.06L6.94 8 3.72 4.78a.75.75 0 010-1.06z" />
|
||||
</svg>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="prompt-input-hint-actions" />
|
||||
|
||||
@@ -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<string[]>,
|
||||
pending: (id: string) => boolean,
|
||||
managed: Accessor<Array<{ id: string }>>,
|
||||
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 })
|
||||
})
|
||||
}
|
||||
@@ -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<string>()
|
||||
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
|
||||
|
||||
|
||||
@@ -217,7 +217,26 @@ export const PromptInput: Component<PromptInputProps> = (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))
|
||||
|
||||
|
||||
@@ -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<ModelSelectorBaseProps> = (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<ModelSelectorBaseProps> = (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 } })))
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
@@ -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<ThinkingSelectorBaseProps> = (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 (
|
||||
<Show when={variants().length > 0}>
|
||||
<Show when={props.variants.length > 0}>
|
||||
<Popover
|
||||
placement="top-start"
|
||||
open={open()}
|
||||
@@ -37,7 +49,7 @@ export const ThinkingSelector: Component = () => {
|
||||
triggerProps={{ variant: "ghost", size: "small" }}
|
||||
trigger={
|
||||
<>
|
||||
<span class="thinking-selector-trigger-label">{triggerLabel()}</span>
|
||||
<span class="thinking-selector-trigger-label">{label()}</span>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" style={{ "flex-shrink": "0" }}>
|
||||
<path d="M8 4l4 5H4l4-5z" />
|
||||
</svg>
|
||||
@@ -45,12 +57,12 @@ export const ThinkingSelector: Component = () => {
|
||||
}
|
||||
>
|
||||
<div class="thinking-selector-list" role="listbox">
|
||||
<For each={variants()}>
|
||||
<For each={props.variants}>
|
||||
{(v) => (
|
||||
<div
|
||||
class={`thinking-selector-item${current() === v ? " selected" : ""}`}
|
||||
class={`thinking-selector-item${props.value === v ? " selected" : ""}`}
|
||||
role="option"
|
||||
aria-selected={current() === v}
|
||||
aria-selected={props.value === v}
|
||||
onClick={() => pick(v)}
|
||||
>
|
||||
<span class="thinking-selector-item-name">{v.charAt(0).toUpperCase() + v.slice(1)}</span>
|
||||
@@ -62,3 +74,19 @@ export const ThinkingSelector: Component = () => {
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chat-specific wrapper (backwards-compatible)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const ThinkingSelector: Component = () => {
|
||||
const session = useSession()
|
||||
|
||||
return (
|
||||
<ThinkingSelectorBase
|
||||
variants={session.variantList()}
|
||||
value={session.currentVariant()}
|
||||
onSelect={(value) => session.selectVariant(value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -117,6 +117,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
|
||||
|
||||
@@ -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"],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"version": "7.2.1",
|
||||
"version": "7.2.3",
|
||||
"scripts": {
|
||||
"test": "bun test"
|
||||
},
|
||||
|
||||
@@ -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<ReturnType<typeof parseVersion>>,
|
||||
b: NonNullable<ReturnType<typeof parseVersion>>,
|
||||
) {
|
||||
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
|
||||
})()
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:"
|
||||
},
|
||||
"version": "7.2.1",
|
||||
"version": "7.2.3",
|
||||
"dependencies": {},
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "7.2.1",
|
||||
"version": "7.2.3",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "7.2.1",
|
||||
"version": "7.2.3",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
+8
-8
@@ -58,14 +58,14 @@ await $`bun install`
|
||||
await import(`../packages/sdk/js/script/build.ts`)
|
||||
|
||||
if (Script.release) {
|
||||
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))
|
||||
}
|
||||
// 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`
|
||||
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
|
||||
|
||||
// kilocode_change start
|
||||
// await import(`../packages/desktop/scripts/finalize-latest-json.ts`)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user