Merge branch 'dev' into catrielmuller/320-notifications-vscode

This commit is contained in:
Catriel Müller
2026-02-20 10:59:50 -03:00
committed by GitHub
97 changed files with 6420 additions and 519 deletions
+1
View File
@@ -6,6 +6,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
- You may be running in a git worktree. All changes must be made in your current working directory — never modify files in the main repo checkout.
## Build and Dev
+55 -57
View File
@@ -1,18 +1,53 @@
<p align="center">
<a href="https://kilo.ai">
<img src="logo.png" alt="Kilo CLI logo">
</a>
</p>
<p align="center">The open source AI coding agent.</p>
<p align="center">
<a href="https://kilo.ai/discord"><img alt="Discord" src="https://img.shields.io/discord/1391832426048651334?style=flat-square&label=discord" /></a>
<a href="https://x.com/kilocode"><img alt="X" src="https://img.shields.io/badge/X-@kilocode-000000?style=flat-square&logo=x&logoColor=white" /></a>
<a href="https://www.reddit.com/r/kilocode/"><img alt="Reddit" src="https://img.shields.io/badge/Reddit-r%2Fkilocode-FF4500?style=flat-square&logo=reddit&logoColor=white" /></a>
<a href="https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code"><img src="https://img.shields.io/badge/VS_Code_Marketplace-007ACC?style=flat&logo=visualstudiocode&logoColor=white" alt="VS Code Marketplace"></a>
<a href="https://x.com/kilocode"><img src="https://img.shields.io/badge/kilocode-000000?style=flat&logo=x&logoColor=white" alt="X (Twitter)"></a>
<a href="https://blog.kilo.ai"><img src="https://img.shields.io/badge/Blog-555?style=flat&logo=substack&logoColor=white" alt="Substack Blog"></a>
<a href="https://kilo.ai/discord"><img src="https://img.shields.io/badge/Join%20Discord-5865F2?style=flat&logo=discord&logoColor=white" alt="Discord"></a>
<a href="https://www.reddit.com/r/kilocode/"><img src="https://img.shields.io/badge/Join%20r%2Fkilocode-D84315?style=flat&logo=reddit&logoColor=white" alt="Reddit"></a>
</p>
---
# 🚀 Kilo
### Installation
> Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent.
> #1 on OpenRouter. 1.5M+ Kilo Coders. 25T+ tokens processed
- ✨ Generate code from natural language
- ✅ Checks its own work
- 🧪 Run terminal commands
- 🌐 Automate the browser
- ⚡ Inline autocomplete suggestions
- 🤖 Latest AI models
- 🎁 API keys optional
- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5
<p align="center">
<img src="https://media.githubusercontent.com/media/Kilo-Org/kilocode/main/kilo.gif" width="100%" />
</p>
## Quick Links
- [VS Code Marketplace](https://kilo.ai/vscode-marketplace?utm_source=Readme) (download)
- Install CLI: `npm install -g @kilocode/cli`
- [Official Kilo.ai Home page](https://kilo.ai) (learn more)
## Key Features
- **Code Generation:** Kilo can generate code using natural language.
- **Inline Autocomplete:** Get intelligent code completions as you type, powered by AI.
- **Task Automation:** Kilo can automate repetitive coding tasks to save time.
- **Automated Refactoring:** Kilo can refactor and improve existing code efficiently.
- **MCP Server Marketplace**: Kilo can easily find, and use MCP servers to extend the agent capabilities.
- **Multi Mode**: Plan with Architect, Code with Coder, and Debug with Debugger, and make your own custom modes.
## Get Started in Visual Studio Code
1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code).
2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 with transparent pricing that matches provider rates exactly.
3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action:
[![Watch the video](https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg)](https://youtu.be/pqGfYXgrhig)
## Get Started with the CLI
```bash
# npm
@@ -24,19 +59,6 @@ npx @kilocode/cli
Then run `kilo` in any project directory to start.
### Agents
Kilo CLI includes two built-in agents you can switch between using the `Tab` key:
- **build** - Default, full access agent for development work
- **plan** - Read-only agent for analysis and code exploration
- Denies file edits by default
- Asks permission before running bash commands
- Ideal for exploring unfamiliar codebases or planning changes
Also included is a **general** subagent for complex searches and multi-step tasks.
This is used internally and can be invoked using `@general` in messages.
### Autonomous Mode (CI/CD)
Use the `--auto` flag with `kilo run` to enable fully autonomous operation without user interaction. This is ideal for CI/CD pipelines and automated workflows:
@@ -47,44 +69,20 @@ kilo run --auto "run tests and fix any failures"
**Important:** The `--auto` flag disables all permission prompts and allows the agent to execute any action without confirmation. Only use this in trusted environments like CI/CD pipelines.
### Migrating from Kilo Code Extension
## Contributing
If you're coming from the Kilo Code VS Code extension, your configurations are automatically migrated:
We welcome contributions from developers, writers, and enthusiasts!
To get started, please read our [Contributing Guide](/CONTRIBUTING.md). It includes details on setting up your environment, coding standards, types of contribution and how to submit pull requests.
| Kilo Code Feature | Kilo CLI Equivalent |
| -------------------------------------------- | -------------------------------------------- |
| Custom modes | Converted to agents |
| Rules (`.kilocoderules`, `.kilocode/rules/`) | Added to `instructions` array |
| Skills (`.kilocode/skills/`) | Auto-discovered alongside `.opencode/skill/` |
| Workflows (`.kilocode/workflows/`) | Converted to commands |
| MCP servers | Migrated to `mcp` config |
## Code of Conduct
MCP servers are configured in **`~/.config/kilo/opencode.json`** (or `opencode.jsonc`; on Windows the config directory may be under `%USERPROFILE%` depending on your environment). Use a top-level `"mcp"` object: each key is a server name, value is `type: "local"` and `command: ["executable", "arg1", ...]`. Optional per-server: `environment`, `enabled`, `timeout`. Restart the CLI after editing for changes to take effect. (Path from [`packages/opencode/src/global/index.ts`](packages/opencode/src/global/index.ts); schema in [`config.ts`](packages/opencode/src/config/config.ts) `McpLocal`.)
Our community is built on respect, inclusivity, and collaboration. Please review our [Code of Conduct](/CODE_OF_CONDUCT.md) to understand the expectations for all contributors and community members.
**Default mode mappings:**
## License
- `code``build` agent
- `architect``plan` agent
This project is licensed under the MIT License.
Youre free to use, modify, and distribute this code, including for commercial purposes as long as you include proper attribution and license notices. See [License](/LICENSE).
For detailed migration information, see:
- [Migration Overview](packages/opencode/src/kilocode/docs/migration.md)
- [Rules Migration](packages/opencode/src/kilocode/docs/rules-migration.md)
### Documentation
For more info on how to configure Kilo CLI, [**head over to our docs**](https://kilo.ai/docs).
### Contributing
If you're interested in contributing, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request.
### FAQ
#### Where did Kilo CLI come from?
### Where did Kilo CLI come from?
Kilo CLI is a fork of [OpenCode](https://github.com/anomalyco/opencode), enhanced to work within the Kilo agentic engineering platform.
---
**Join our community** [Discord](https://kilo.ai/discord) | [X.com](https://x.com/kilocode) | [Reddit](https://reddit.com/r/kilocode)
+30 -14
View File
@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@kilocode/kilo-i18n": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
@@ -75,7 +75,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -108,7 +108,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "1.0.0",
"version": "1.0.24",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -136,7 +136,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@clack/prompts": "1.0.0-alpha.1",
"@kilocode/plugin": "workspace:*",
@@ -168,7 +168,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "1.0.23",
"version": "1.0.24",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -178,7 +178,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"@opentelemetry/api": "1.9.0",
@@ -198,7 +198,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@kobalte/core": "0.13.11",
},
@@ -221,13 +221,14 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.0.23",
"version": "7.0.24",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-i18n": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "^7.0.0",
"dotenv": "^16.4.7",
"eventsource": "^2.0.2",
@@ -257,13 +258,14 @@
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"qrcode": "^1.5.4",
"ts-morph": "27.0.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
},
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "1.0.23",
"version": "1.0.24",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilocode",
@@ -373,7 +375,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"zod": "catalog:",
@@ -387,14 +389,14 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "1.0.23",
"version": "1.0.24",
"devDependencies": {
"@types/bun": "catalog:",
},
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "1.0.23",
"version": "1.0.24",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -405,7 +407,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -447,7 +449,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"zod": "catalog:",
},
@@ -1582,6 +1584,8 @@
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@ts-morph/common": ["@ts-morph/common@0.28.1", "", { "dependencies": { "minimatch": "^10.0.1", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.14" } }, "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g=="],
"@tsconfig/bun": ["@tsconfig/bun@1.0.9", "", {}, "sha512-4M0/Ivfwcpz325z6CwSifOBZYji3DFOEpY6zEUt0+Xi2qRhzwvmqQN9XAHJh3OVvRJuAqVTLU2abdCplvp6mwQ=="],
"@tsconfig/node22": ["@tsconfig/node22@22.0.2", "", {}, "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA=="],
@@ -1914,6 +1918,8 @@
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -2634,6 +2640,8 @@
"partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -3018,6 +3026,8 @@
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
"ts-morph": ["ts-morph@27.0.2", "", { "dependencies": { "@ts-morph/common": "~0.28.1", "code-block-writer": "^13.0.3" } }, "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
@@ -3418,6 +3428,8 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@ts-morph/common/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@vscode/test-cli/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -3772,6 +3784,8 @@
"@tailwindcss/vite/@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="],
"@vscode/test-cli/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"@vscode/test-cli/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
@@ -4078,6 +4092,8 @@
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.3", "", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="],
"@vscode/test-cli/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"@vscode/test-cli/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 MiB

+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-fj1ktaxzXPVZux/28NKWF9iMi//HBEO5rFivjpqugSI=",
"aarch64-linux": "sha256-c/BBYlA3DsO9nfc5pEtD4P1uQuQvs8pZrwDj2eiliIE=",
"aarch64-darwin": "sha256-pWJd/x5Yx7xP+kY0yrF1py3MiJZCs2gPp6Rwe/xCSzc=",
"x86_64-darwin": "sha256-WgYhTWgGP/z14vA+eLnDwflWiKx6iDX9DUM1Bqw8CI0="
"x86_64-linux": "sha256-V1SML9naH7HShbdovoQIUc46rH6YNgFF9MSTw95k0fU=",
"aarch64-linux": "sha256-wddgxfbK7v7O/zsdbx0e/TNYAw0KzzxyQbuUdwudf/Y=",
"aarch64-darwin": "sha256-y7bMXWMSiHTHfKKiJ+c/2WRJmc7mCw2o601Ny8W5Zzs=",
"x86_64-darwin": "sha256-hXdKlmVFWq2C0kJWgO1+g82B+/fvn9CG/JQyR31QtzY="
}
}
+1 -1
View File
@@ -102,6 +102,6 @@
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
},
"version": "1.0.23",
"version": "1.0.24",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.0.23",
"version": "1.0.24",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"scripts": {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "1.0.23"
version = "1.0.24"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilo"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "1.0.0",
"version": "1.0.24",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -49,8 +49,8 @@ Before enabling Code Reviews:
Once configured, the Review Agent runs automatically on PR/MR events. For platform-specific setup, see:
- [GitHub Code Reviews](./github.md)
- [GitLab Code Reviews](./gitlab.md)
- [GitHub Code Reviews](./github)
- [GitLab Code Reviews](./gitlab)
## Local Code Reviews
@@ -220,7 +220,7 @@ For most permissions, you can use an object to apply different actions based on
```json
{
"$schema": "https://opencode.ai/config.json",
"$schema": "https://app.kilo.ai/config.json",
"permission": {
"bash": {
"*": "ask",
@@ -305,7 +305,7 @@ Project-level configuration takes precedence over global settings.
```json
{
"$schema": "https://opencode.ai/config.json",
"$schema": "https://app.kilo.ai/config.json",
"model": "anthropic/claude-sonnet-4-20250514",
"provider": {
"anthropic": {
+1 -1
View File
@@ -30,7 +30,7 @@ const kilo = createOpenAI({
})
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "Why is the sky blue?",
})
```
@@ -13,7 +13,7 @@ Models are identified using the format `provider/model-name`. Pass this as the `
```typescript
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "Hello!",
})
```
@@ -53,7 +53,7 @@ const kilo = createOpenAI({
async function main() {
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "Invent a new holiday and describe its traditions.",
})
@@ -29,7 +29,7 @@ const kilo = createOpenAI({
})
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "Write a haiku about programming.",
})
@@ -51,7 +51,7 @@ const kilo = createOpenAI({
})
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "What is the weather in San Francisco?",
tools: {
getWeather: tool({
@@ -86,7 +86,7 @@ export async function POST(request: Request) {
const { messages } = await request.json()
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
messages,
})
@@ -37,7 +37,7 @@ const kilo = createOpenAI({
})
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "Write a short story about a robot.",
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
+5
View File
@@ -29,6 +29,7 @@ const openRouterModelSchema = z.object({
architecture: openRouterArchitectureSchema.optional(),
top_provider: z.object({ max_completion_tokens: z.number().nullish() }).optional(),
supported_parameters: z.array(z.string()).optional(),
preferredIndex: z.number().optional(),
})
const openRouterModelsResponseSchema = z.object({
@@ -167,6 +168,10 @@ function transformToModelDevFormat(model: OpenRouterModel): any {
output: mapModalities(outputModalities),
},
}),
...(model.preferredIndex !== undefined && {
recommended: true,
recommendedIndex: model.preferredIndex,
}),
options: {
...(model.description && { description: model.description }),
},
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
+2
View File
@@ -142,6 +142,8 @@ export namespace Telemetry {
outputTokens?: number
cacheReadTokens?: number
cacheWriteTokens?: number
cost?: number
completionTime?: number
duration?: number
}) {
track(TelemetryEvent.LLM_COMPLETION, properties)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-ui",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"exports": {
+2 -1
View File
@@ -1,2 +1,3 @@
bin/
out/
out/
.vscode-test/
+1
View File
@@ -85,6 +85,7 @@ New webview features must use **`@kilocode/kilo-ui`** components instead of raw
- New CSS for components not yet in kilo-ui goes into `chat.css` grouped by comment-delimited sections (`/* Component Name */`). Once a kilo-ui equivalent exists, remove the section.
- **Check the desktop app first**: [`packages/app/src/`](../../packages/app/src/) is the reference implementation for how kilo-ui components are composed together. Always check how the app uses a component before implementing it in the webview — don't just look at the component API in isolation.
- **`data-component` and `data-slot` attributes carry CSS styling** — kilo-ui uses `[data-component]` and `[data-slot]` attribute selectors, not class names. When the app uses e.g. `data-component="permission-prompt"` and `data-slot="permission-actions"`, these get kilo-ui styling for free.
- **Prefer kilo-ui styles**: Always reuse existing kilo-ui CSS variables, tokens, and component styles instead of writing custom CSS. If a style doesn't exist in kilo-ui yet, add it there and reuse it rather than inlining or duplicating styles in the webview.
- **Icons**: kilo-ui has 75+ custom SVG icons in [`packages/ui/src/components/icon.tsx`](../../packages/ui/src/components/icon.tsx). To list all available icon names: `node -e "const c=require('fs').readFileSync('../../packages/ui/src/components/icon.tsx','utf8');[...c.matchAll(/^\\s{2}[\"']?([\\w-]+)[\"']?:\\s*\x60/gm)].map(m=>m[1]).sort().forEach(n=>console.log(n))"`. Icon names use both hyphenated (`arrow-left`) and bare-word (`brain`, `console`, `providers`) keys.
## Debugging
@@ -1,20 +1,29 @@
# Context Menus & Tooltips
# Context Menus & Tooltips (Webview)
Right-click/context actions and tooltip affordances throughout the chat UI.
Right-click/context actions and tooltip affordances within the chat webview UI.
> **Note:** For VS Code-native context menus (editor right-click, terminal right-click, code action lightbulb), see [Editor Context Menus & Code Actions](../non-agent-features/editor-context-menus-and-code-actions.md).
## Scope
This document covers **webview-internal** context menus and tooltips — i.e., right-click menus and hover tooltips rendered inside the Kilo Code chat panel.
## Current State
- kilo-ui provides `Tooltip` and `Popover` components, already in use throughout the webview (TaskHeader, ModelSelector, etc.)
- No webview-internal right-click context menu exists yet
## Remaining Work
- Add hover tooltips with explanatory text for all interactive buttons in the chat UI
- Implement right-click context menus on chat messages (copy, retry, edit, delete)
- Consider context menus on code blocks (copy code, insert at cursor, apply diff)
## Location
- [`webview-ui/src/components/common/ContextMenu.tsx`](../../webview-ui/src/components/common/ContextMenu.tsx:1)
- Various `StandardTooltip` usage
- kilo-ui `Tooltip` component usage throughout webview
## Interactions
## Implementation Notes
- Hover tooltips with explanatory text for all interactive buttons
- Context actions via right-click or dedicated menu buttons
## Suggested migration
**Reimplement?** No (UI-only).
- These are presentation-layer affordances; backend migration does not affect them.
- Kilo CLI has similar tooltip infrastructure in its UI package ([`packages/ui/src/components/tooltip.tsx`](https://github.com/Kilo-Org/kilo/blob/main/packages/ui/src/components/tooltip.tsx:1)), but Kilo can keep its current tooltip + context menu components.
These are presentation-layer affordances; the CLI backend is not involved. kilo-ui already provides the tooltip infrastructure needed.
@@ -1,24 +0,0 @@
# Code Actions
- **What it is**: Integrates into VS Code Code Actions UI (lightbulb/context menu) to trigger AI actions like explain/fix/improve, or add selection to context.
## Notable characteristics
- Can run inside current task or spawn a new task.
- Prompt templates configurable.
## Docs references
- [`apps/kilocode-docs/pages/code-with-ai/features/code-actions.md`](../../apps/kilocode-docs/pages/code-with-ai/features/code-actions.md)
## Suggested migration
- **Kilo CLI availability**: Not present.
- **Migration recommendation**:
- Keep code actions in the VS Code extension host (VS Code APIs, diagnostics, and editor-specific UX).
- Reimplement any backing logic that currently depends on the core agent loop, but keep action registration and application IDE-side.
- **Reimplementation required?**: Yes.
## Primary implementation anchors (partial)
- [`src/services/ghost/GhostCodeActionProvider.ts`](../../src/services/ghost/GhostCodeActionProvider.ts)
@@ -0,0 +1,461 @@
# Editor Context Menus & Code Actions
Full spec of the VS Code-native menu, command, code action, keyboard shortcut, and prompt template system from the old Kilo Code extension. All of these need to be rebuilt in the new extension.
> **Note:** For webview-internal context menus (right-click inside the chat panel), see [Context Menus & Tooltips (Webview)](../chat-ui-features/context-menus-tooltips.md).
> For SCM commit message generation, see [Git Commit Message Generation](git-commit-message-generation.md).
> This document supersedes the old stub at [`code-actions.md`](code-actions.md).
---
## Overview
The old extension registered a comprehensive set of VS Code contributions:
- A **"Kilo Code" submenu** in the editor right-click context menu
- A **"Kilo Code" submenu** in the terminal right-click context menu
- A **CodeActionProvider** for lightbulb quick fixes
- **Keyboard shortcuts** for common actions
- **Prompt templates** (user-customizable) that turn captured context into agent task instructions
None of these exist yet in the rebuild.
---
## Editor Context Menus
Right-clicking in the editor shows a "Kilo Code" submenu with these commands:
| Command ID | Label | Captured Context | Behavior |
| -------------- | -------------- | ------------------------------------------------- | ------------------------------------------------------------------------ |
| `explainCode` | Explain Code | File path, selected text, line range | Starts an agent task with the EXPLAIN prompt |
| `fixCode` | Fix Code | File path, selected text, line range, diagnostics | Starts an agent task with the FIX prompt |
| `improveCode` | Improve Code | File path, selected text, line range | Starts an agent task with the IMPROVE prompt |
| `addToContext` | Add to Context | File path, selected text, line range | Injects formatted code block into chat input (does **not** start a task) |
All commands use the VS Code editor API to capture the active editor's file path (`document.uri`), current selection (`editor.selection`), and the selected text. `fixCode` additionally captures diagnostics from `vscode.languages.getDiagnostics()` for the selection range.
---
## Terminal Context Menus
Right-clicking in the terminal shows a "Kilo Code" submenu:
| Command ID | Label | Captured Context | Behavior |
| ------------------------ | --------------- | ----------------------------------- | ----------------------------------------------------- |
| `terminalAddToContext` | Add to Context | Terminal selection or recent buffer | Injects terminal output into chat input |
| `terminalFixCommand` | Fix Command | Last executed command + output | Starts an agent task with the TERMINAL_FIX prompt |
| `terminalExplainCommand` | Explain Command | Last executed command + output | Starts an agent task with the TERMINAL_EXPLAIN prompt |
Terminal context capture uses `vscode.window.activeTerminal` and the terminal selection API.
> See also [Terminal / Shell Integration](terminal-shell-integration.md) for the underlying terminal command execution and PTY management that these context menu actions depend on.
---
## Code Action Provider (Lightbulb Quick Fixes)
A `CodeActionProvider` is registered for all languages. When the user clicks the lightbulb or presses the quick fix shortcut:
| Condition | Actions shown |
| ------------------------------ | --------------------------------------------------- |
| Always | **Add to Kilo Code** → triggers `addToContext` |
| Diagnostics in selection range | **Fix with Kilo Code** → triggers `fixCode` |
| No diagnostics | **Explain with Kilo Code** → triggers `explainCode` |
| No diagnostics | **Improve with Kilo Code** → triggers `improveCode` |
Controlled by the `enableCodeActions` extension setting (default: `true`).
---
## Keyboard Shortcuts
| Shortcut (Mac / Win+Linux) | Command | Description |
| ------------------------------- | ------------------------- | ----------------------------- |
| `Cmd+Shift+A` / `Ctrl+Shift+A` | Focus chat input | Opens/focuses the chat panel |
| `Cmd+K Cmd+A` / `Ctrl+K Ctrl+A` | Add selection to context | Runs `addToContext` |
| `Cmd+Shift+G` / `Ctrl+Shift+G` | Generate terminal command | Starts TERMINAL_GENERATE task |
| `Cmd+Alt+A` / `Ctrl+Alt+A` | Toggle auto-approve | Toggles auto-approval setting |
---
## Prompt Templates
The old extension defined prompt templates in `support-prompt.ts` that format captured context into agent task instructions. Each template is user-customizable via extension settings.
| Template | Used by | Purpose |
| ------------------------- | ------------------------- | ------------------------------------------------------- |
| `EXPLAIN` | `explainCode` | Ask the agent to explain the selected code |
| `FIX` | `fixCode` | Ask the agent to fix code, including diagnostic details |
| `IMPROVE` | `improveCode` | Ask the agent to improve/refactor selected code |
| `ADD_TO_CONTEXT` | `addToContext` | Format a code block for injection into chat input |
| `TERMINAL_ADD_TO_CONTEXT` | `terminalAddToContext` | Format terminal output for injection into chat input |
| `TERMINAL_FIX` | `terminalFixCommand` | Ask the agent to fix a failed terminal command |
| `TERMINAL_EXPLAIN` | `terminalExplainCommand` | Ask the agent to explain a terminal command/output |
| `TERMINAL_GENERATE` | Generate terminal command | Ask the agent to generate a terminal command |
| `COMMIT_MESSAGE` | SCM integration | Generate a commit message (tracked separately) |
---
## Data Flow
There are two distinct patterns:
### 1. Action → Agent Task
Commands like `explainCode`, `fixCode`, `improveCode`, `terminalFixCommand`, and `terminalExplainCommand` follow this flow:
1. User triggers command (context menu, code action, or keybinding)
2. Extension captures context from VS Code APIs (editor selection, diagnostics, terminal buffer)
3. Extension fills in the prompt template with captured context
4. Extension sends the formatted prompt as a new message to the CLI session (creating a new task)
### 2. Action → Context Injection
Commands like `addToContext` and `terminalAddToContext` follow a different flow:
1. User triggers command
2. Extension captures context from VS Code APIs
3. Extension formats the context as a code block using the template
4. Extension posts a message to the webview to **set the chat input text** (not submit it)
5. User can review and edit before sending
---
## Implementation Notes for Rebuild
### `package.json` Contributions
Register in `contributes`:
- `submenus`: Define "Kilo Code" submenus for editor and terminal contexts
- `menus`: Register commands under `editor/context` and `terminal/context` menu groups
- `commands`: Register all command IDs with titles and icons
- `keybindings`: Register keyboard shortcuts with `key`, `mac`, and `when` clauses
> **Note:** [`package.json`](../../package.json) already has `contributes.commands` (8 commands) and `contributes.menus` (`view/title` + `editor/title` menus) registered. New commands, submenus, and keybindings should be **added alongside** the existing entries, not replace them.
### CodeActionProvider
- Register a `CodeActionProvider` for `*` (all languages)
- Check `vscode.languages.getDiagnostics()` to decide which actions to show
- Gate behind the `enableCodeActions` setting
- Return `CodeAction` instances with `command` set to the appropriate command ID
### Editor Context Capture
- `vscode.window.activeTextEditor` for file path, selection, document
- `editor.document.getText(selection)` for selected text
- `selection.start.line` / `selection.end.line` for line range
- `vscode.languages.getDiagnostics(document.uri)` filtered to selection range for `fixCode`
### Terminal Context Capture
- `vscode.window.activeTerminal` for the active terminal
- Terminal selection API for selected text in terminal
- Shell integration API for last command and its output (where available)
### Prompt Templates
Need an equivalent template system in the extension. Options:
- Hardcode templates with settings overrides (like the old extension)
- Delegate prompt construction to the CLI (if it supports parameterized task creation)
### "Add to Context" Pattern
The webview needs to handle an incoming message that **sets the chat input text** without submitting it. This requires:
- A new message type (e.g., `SetChatInput`) in the extension→webview protocol
- The `PromptInput` component to accept externally-set text
---
## Prompt Templates (Full Text)
Below are the exact prompt templates from the old extension's `src/shared/support-prompt.ts`. Each template uses `${variable}` interpolation. These are the defaults; users can customize any template via the `customSupportPrompts` extension setting.
> **Note:** The `diagnosticText` variable used in the FIX template is auto-generated from VS Code diagnostics using the format `- [source] message (code)`.
### EXPLAIN
**Variables:** `filePath`, `startLine`, `endLine`, `userInput`, `selectedText`
```
Explain the following code from file path ${filePath}:${startLine}-${endLine}
${userInput}
\`\`\`
${selectedText}
\`\`\`
Please provide a clear and concise explanation of what this code does, including:
1. The purpose and functionality
2. Key components and their interactions
3. Important patterns or techniques used
```
### FIX
**Variables:** `filePath`, `startLine`, `endLine`, `diagnosticText`, `userInput`, `selectedText`
```
Fix any issues in the following code from file path ${filePath}:${startLine}-${endLine}
${diagnosticText}
${userInput}
\`\`\`
${selectedText}
\`\`\`
Please:
1. Address all detected problems listed above (if any)
2. Identify any other potential bugs or issues
3. Provide corrected code
4. Explain what was fixed and why
```
### IMPROVE
**Variables:** `filePath`, `startLine`, `endLine`, `userInput`, `selectedText`
```
Improve the following code from file path ${filePath}:${startLine}-${endLine}
${userInput}
\`\`\`
${selectedText}
\`\`\`
Please suggest improvements for:
1. Code readability and maintainability
2. Performance optimization
3. Best practices and patterns
4. Error handling and edge cases
Provide the improved code along with explanations for each enhancement.
```
### ADD_TO_CONTEXT
**Variables:** `filePath`, `startLine`, `endLine`, `selectedText`
```
${filePath}:${startLine}-${endLine}
\`\`\`
${selectedText}
\`\`\`
```
### TERMINAL_ADD_TO_CONTEXT
**Variables:** `userInput`, `terminalContent`
```
${userInput}
Terminal output:
\`\`\`
${terminalContent}
\`\`\`
```
### TERMINAL_FIX
**Variables:** `userInput`, `terminalContent`
```
${userInput}
Fix this terminal command:
\`\`\`
${terminalContent}
\`\`\`
Please:
1. Identify any issues in the command
2. Provide the corrected command
3. Explain what was fixed and why
```
### TERMINAL_EXPLAIN
**Variables:** `userInput`, `terminalContent`
```
${userInput}
Explain this terminal command:
\`\`\`
${terminalContent}
\`\`\`
Please provide:
1. What the command does
2. Explanation of each part/flag
3. Expected output and behavior
```
### TERMINAL*GENERATE *(Kilo-specific addition)\_
**Variables:** `userInput`, `operatingSystem`, `currentDirectory`, `shell`
```
Generate a terminal command based on this description: "${userInput}"
Context:
- Operating System: ${operatingSystem}
- Current Directory: ${currentDirectory}
- Shell: ${shell}
Requirements:
1. Generate ONLY the command, no explanations or formatting
2. Ensure the command is safe and appropriate
3. Use common command-line tools and best practices
4. Consider the current working directory context
5. Return only the raw command that can be executed directly
```
### ENHANCE
**Variables:** `userInput`
```
Generate an enhanced version of this prompt (reply with only the enhanced prompt - no conversation, explanations, lead-in, bullet points, placeholders, or surrounding quotes):
${userInput}
```
### CONDENSE
**Variables:** none (system prompt for conversation summary)
```
Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions.
This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing with the conversation and supporting any continuing tasks.
Your summary should be structured as follows:
Context: The context to continue the conversation with. If applicable based on the current task, this should include:
1. Previous Conversation: High level details about what was discussed throughout the entire conversation with the user. This should be written to allow someone to be able to follow the general overarching conversation flow.
2. Current Work: Describe in detail what was being worked on prior to this request to summarize the conversation. Pay special attention to the more recent messages in the conversation.
3. Key Technical Concepts: List all important technical concepts, technologies, coding conventions, and frameworks discussed, which might be relevant for continuing with this work.
4. Relevant Files and Code: If applicable, enumerate specific files and code sections examined, modified, or created for the task continuation. Pay special attention to the most recent messages and changes.
5. Problem Solving: Document problems solved thus far and any ongoing troubleshooting efforts.
6. Pending Tasks and Next Steps: Outline all pending tasks that you have explicitly been asked to work on, as well as list the next steps you will take for all outstanding work, if applicable. Include code snippets where they add clarity. For any next steps, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no information loss in context between tasks.
Example summary structure:
1. Previous Conversation:
[Detailed description]
2. Current Work:
[Detailed description]
3. Key Technical Concepts:
- [Concept 1]
- [Concept 2]
- [...]
4. Relevant Files and Code:
- [File Name 1]
- [Summary of why this file is important]
- [Summary of the changes made to this file, if any]
- [Important Code Snippet]
- [File Name 2]
- [Important Code Snippet]
- [...]
5. Problem Solving:
[Detailed description]
6. Pending Tasks and Next Steps:
- [Task 1 details & next steps]
- [Task 2 details & next steps]
- [...]
Output only the summary of the conversation so far, without any additional commentary or explanation.
```
### COMMIT*MESSAGE *(Kilo-specific addition, tracked separately in [git-commit-message-generation.md](git-commit-message-generation.md))\_
**Variables:** `customInstructions`, `gitContext`
````
# Conventional Commit Message Generator
## System Instructions
You are an expert Git commit message generator that creates conventional commit messages based on staged changes. Analyze the provided git diff output and generate appropriate conventional commit messages following the specification.
${customInstructions}
## CRITICAL: Commit Message Output Rules
- DO NOT include any internal status indicators or bracketed metadata (e.g. "[Status: Active]", "[Context: Missing]")
- DO NOT include any task-specific formatting or artifacts from other rules
- ONLY Generate a clean conventional commit message as specified below
${gitContext}
## Conventional Commits Format
Generate commit messages following this exact structure:
```
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
```
### Core Types (Required)
- **feat**: New feature or functionality (MINOR version bump)
- **fix**: Bug fix or error correction (PATCH version bump)
### Additional Types (Extended)
- **docs**: Documentation changes only
- **style**: Code style changes (whitespace, formatting, semicolons, etc.)
- **refactor**: Code refactoring without feature changes or bug fixes
- **perf**: Performance improvements
- **test**: Adding or fixing tests
- **build**: Build system or external dependency changes
- **ci**: CI/CD configuration changes
- **chore**: Maintenance tasks, tooling changes
- **revert**: Reverting previous commits
### Scope Guidelines
- Use parentheses: `feat(api):`, `fix(ui):`
- Common scopes: `api`, `ui`, `auth`, `db`, `config`, `deps`, `docs`
- For monorepos: package or module names
- Keep scope concise and lowercase
### Description Rules
- Use imperative mood ("add" not "added" or "adds")
- Start with lowercase letter
- No period at the end
- Maximum 50 characters
- Be concise but descriptive
### Body Guidelines (Optional)
- Start one blank line after description
- Explain the "what" and "why", not the "how"
- Wrap at 72 characters per line
- Use for complex changes requiring explanation
### Footer Guidelines (Optional)
- Start one blank line after body
- **Breaking Changes**: `BREAKING CHANGE: description`
## Analysis Instructions
When analyzing staged changes:
1. Determine Primary Type based on the nature of changes
2. Identify Scope from modified directories or modules
3. Craft Description focusing on the most significant change
4. Determine if there are Breaking Changes
5. For complex changes, include a detailed body explaining what and why
6. Add appropriate footers for issue references or breaking changes
For significant changes, include a detailed body explaining the changes.
Return ONLY the commit message in the conventional format, nothing else.
````
### NEW_TASK
**Variables:** `userInput`
```
${userInput}
```
---
### Already Done / Tracked Elsewhere
- **View title bar buttons**: ✅ Done ([#181](https://github.com/Kilo-Org/kilo/issues/181))
- **SCM commit message generation**: Tracked in [git-commit-message-generation.md](git-commit-message-generation.md)
@@ -72,41 +72,41 @@ The rebuild has a working foundation:
## Non-Agent Feature Parity
| Feature | Status | Details | Backend | Priority |
| ------------------------------------------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------- |
| Agent Manager | ✅ Done | `AgentManagerProvider` opens editor panel, creates git worktrees for parallel sessions, full `createWorktreeSession` lifecycle (worktree → session → first message). `WorktreeManager` handles `git worktree add`, metadata, and discovery. `WorktreeSelector` in PromptInput toggles local/worktree mode. [#178](https://github.com/Kilo-Org/kilo/issues/178) | Extension orchestrates multiple CLI sessions | P1 |
| [Authentication & Enterprise](non-agent-features/authentication-organization-enterprise-enforcement.md) | 🔨 Partial | Device auth flow works, organization switching implemented. Missing: org feature flags, MDM policy enforcement. [#160](https://github.com/Kilo-Org/kilo/issues/160) | CLI handles its auth; extension handles org/MDM | P1 |
| [Auto-Purge](non-agent-features/auto-purge.md) | ❌ Not started | No scheduled cleanup of old session/task storage. | Extension-side (storage ownership TBD) | P3 |
| Autocomplete / Ghost | ✅ Done | `AutocompleteInlineCompletionProvider` implements `vscode.InlineCompletionItemProvider` with FIM completions via Kilo Gateway. Includes debouncing, LRU cache, tree-sitter context, bracket matching, and stream-based completion. `AutocompleteServiceManager` registers/unregisters the provider. [#164](https://github.com/Kilo-Org/kilo/issues/164) | Extension-side (VS Code InlineCompletionProvider) | P1 |
| [Browser Automation & URL Ingestion](non-agent-features/browser-automation-url-ingestion.md) | 🔨 Partial | Playwright MCP integration implemented: settings toggle, BrowserAutomationService, CLI MCP hub registration, BrowserTab settings UI. Missing: URL-to-markdown ingestion, screenshot viewing in chat. | CLI MCP hub (POST /mcp); extension manages lifecycle | P3 |
| [Checkpoints](non-agent-features/checkpoints.md) | ❌ Not started | No shadow git repo, per-task snapshots, restore UI, or diff viewing. Settings tab is a stub. | CLI (partial: session undo/redo); extension for git snapshots | P1 |
| [Cloud Task Support](non-agent-features/cloud-task-support.md) | ❌ Not started | No cloud sync for tasks across devices. [#168](https://github.com/Kilo-Org/kilo/issues/168) | Kilo cloud API + CLI; extension provides UI | P2 |
| [Code Actions](non-agent-features/code-actions.md) | ❌ Not started | No VS Code lightbulb/context menu integrations (explain, fix, improve). | Extension-side (VS Code CodeActionProvider) | P2 |
| [Code Reviews](non-agent-features/code-reviews.md) | ❌ Not started | No local review mode or automated AI review of uncommitted/branch changes. | CLI (partial); extension for VS Code review UX | P2 |
| [Codebase Indexing & Semantic Search](non-agent-features/codebase-indexing-semantic-search.md) | ❌ Not started | No vector indexing, semantic search, or embeddings infrastructure. | CLI has grep/glob endpoints; semantic indexing is extension or cloud | P2 |
| [Contribution Tracking](non-agent-features/contribution-tracking.md) | ❌ Not started | No AI attribution tracking, line fingerprinting, or reporting. | Extension-side | P3 |
| [Custom Commands](non-agent-features/custom-command-system.md) | ❌ Not started | No slash commands, project-level command discovery, or YAML frontmatter support. | CLI has custom commands; extension provides UI entry points | P2 |
| [Deploy & Secure Surfaces](non-agent-features/deploy-and-secure-surfaces.md) | ❌ Not started | No deploy workflows, managed indexing UI, or security review surfaces. | Extension-side | P3 |
| [Fast Edits](non-agent-features/fast-edits.md) | ❌ Not started | No fast edit mode for quick inline code changes. | CLI fast-edit runtime; extension provides UI | P2 |
| [Git Commit Message Generation](non-agent-features/git-commit-message-generation.md) | ❌ Not started | No AI commit message generation or VS Code Source Control integration. [#165](https://github.com/Kilo-Org/kilo/issues/165) | Extension-side (VS Code Git API) | P2 |
| [Integrations](non-agent-features/integrations.md) | ❌ Not started | No external system integrations (GitHub, etc.) beyond basic auth. | CLI plugin system (partial); extension for IDE hooks | P3 |
| Kilo Gateway | ✅ Done | Kilo Gateway (`KILO_GATEWAY_ID = "kilo"`) is the default provider, listed first in ModelSelector via `PROVIDER_ORDER`. Login/logout/org switching fully implemented in `KiloProvider.ts` and `ProfileView.tsx`. Default model selection in ProvidersTab. [#176](https://github.com/Kilo-Org/kilo/issues/176) | CLI handles gateway connection; extension provides config UI | P0 |
| Localization | ✅ Done | Full i18n system with 16 locales in [`i18n/`](../webview-ui/src/i18n/), three-layer dict merging, locale auto-detection, LanguageTab settings UI with locale selector. | Extension + webview; CLI locale mapping needed | P3 |
| [Marketplace](non-agent-features/marketplace.md) | 🔨 Partial | Placeholder view exists but is non-functional. No catalog, install, or update capabilities. [#169](https://github.com/Kilo-Org/kilo/issues/169) | Extension-side | P2 |
| [MCP & MCP Hub](non-agent-features/mcp-and-mcp-hub.md) | 🔨 Partial | MCP types and HTTP client methods added (getMcpStatus, addMcpServer, connectMcpServer, disconnectMcpServer). Used by BrowserAutomationService. Missing: general MCP configuration UI, server management, tool allowlisting, connection status display. | CLI owns MCP lifecycle; extension provides config UI | P1 |
| Mode Switcher | ✅ Done | ModeSwitcher.tsx implements popover-based agent/mode selector with descriptions, persisted via session.selectAgent(). Integrated into PromptInput. [#162](https://github.com/Kilo-Org/kilo/issues/162) | CLI manages modes; extension provides switcher UI | P2 |
| Model Switcher | ✅ Done | ModelSelector.tsx implements popover-based selector with search/filter, keyboard navigation, provider grouping, free model tags. Integrated into PromptInput. [#163](https://github.com/Kilo-Org/kilo/issues/163) | CLI provides model list; extension provides switcher UI | P1 |
| Provider Configuration | ✅ Done | `ProvidersTab` implements default/small model selection (backed by `ConfigProvider` + `PATCH /global/config`), disabled-providers list, and enabled-providers allowlist. Provider context wired into ModelSelector. [#175](https://github.com/Kilo-Org/kilo/issues/175) | CLI manages providers; extension provides config UI | P1 |
| [Repository Initialization](non-agent-features/repository-initialization.md) | ❌ Not started | No /init command support for setting up agentic engineering. [#174](https://github.com/Kilo-Org/kilo/issues/174) | CLI /init endpoint; extension provides UI trigger | P3 |
| [Rules & Workflows](non-agent-features/rules-and-workflows.md) | ❌ Not started | No rules or workflow management UI. [#173](https://github.com/Kilo-Org/kilo/issues/173) | CLI owns rules runtime; extension provides management UI | P3 |
| [Search & Repo Scanning](non-agent-features/search-and-repo-scanning-infrastructure.md) | ❌ Not started | No search infrastructure beyond CLI grep/glob. | CLI has grep/glob; extension may add UI | P2 |
| [Settings Sync](non-agent-features/settings-sync-integration.md) | ❌ Not started | No VS Code Settings Sync allowlist registration. Settings tabs are all stubs. | Extension-side (VS Code API) | P3 |
| [Settings UI](non-agent-features/settings-ui.md) | 🔨 Partial | 14-tab settings implemented: Providers (model selection + allowlists), AgentBehaviour (MCP read-only, rules, skills), AutoApprove (per-tool dropdowns), Browser, Autocomplete, Display, Notifications, Context, Terminal, Prompts, Experimental, Language, AboutKiloCode. Missing: Workflows subtab (placeholder). [#170](https://github.com/Kilo-Org/kilo/issues/170) | CLI exposes config; extension provides settings forms | P1 |
| [Skills System](non-agent-features/skills-system.md) | ❌ Not started | No skill discovery, management, or hot-reload in extension. | CLI has skills runtime; extension provides packaging/UI | P2 |
| [Speech-to-Text](non-agent-features/speech-to-text.md) | ❌ Not started | No voice input or streaming STT. | Webview (mic capture); CLI-compatible STT optional | P3 |
| Task History | ✅ Done | `SessionList` uses kilo-ui `List` with search (filterKeys=["title"]), date grouping (Today/Yesterday/This Week/This Month/Older), relative timestamps, inline rename, and delete with confirmation. [#167](https://github.com/Kilo-Org/kilo/issues/167) | CLI session storage; extension provides history UI | P1 |
| Telemetry | ✅ Done | `TelemetryProxy` singleton POSTs events to `/telemetry/capture` respecting `vscode.env.isTelemetryEnabled`. Webview `captureTelemetryEvent()` posts to extension which forwards to `TelemetryProxy`. Both sides wired end-to-end. | Extension-side (PostHog + kilo-telemetry) | P1 |
| [Terminal / Shell Integration](non-agent-features/terminal-shell-integration.md) | ❌ Not started | No VS Code terminal integration for command execution display, exit code tracking, or working directory changes. | CLI executes commands; extension provides terminal UX | P1 |
| Feature | Status | Details | Backend | Priority |
| ------------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------- |
| Agent Manager | ✅ Done | `AgentManagerProvider` opens editor panel, creates git worktrees for parallel sessions, full `createWorktreeSession` lifecycle (worktree → session → first message). `WorktreeManager` handles `git worktree add`, metadata, and discovery. `WorktreeSelector` in PromptInput toggles local/worktree mode. [#178](https://github.com/Kilo-Org/kilo/issues/178) | Extension orchestrates multiple CLI sessions | P1 |
| [Authentication & Enterprise](non-agent-features/authentication-organization-enterprise-enforcement.md) | 🔨 Partial | Device auth flow works, organization switching implemented. Missing: org feature flags, MDM policy enforcement. [#160](https://github.com/Kilo-Org/kilo/issues/160) | CLI handles its auth; extension handles org/MDM | P1 |
| [Auto-Purge](non-agent-features/auto-purge.md) | ❌ Not started | No scheduled cleanup of old session/task storage. | Extension-side (storage ownership TBD) | P3 |
| Autocomplete / Ghost | ✅ Done | `AutocompleteInlineCompletionProvider` implements `vscode.InlineCompletionItemProvider` with FIM completions via Kilo Gateway. Includes debouncing, LRU cache, tree-sitter context, bracket matching, and stream-based completion. `AutocompleteServiceManager` registers/unregisters the provider. [#164](https://github.com/Kilo-Org/kilo/issues/164) | Extension-side (VS Code InlineCompletionProvider) | P1 |
| [Browser Automation & URL Ingestion](non-agent-features/browser-automation-url-ingestion.md) | 🔨 Partial | Playwright MCP integration implemented: settings toggle, BrowserAutomationService, CLI MCP hub registration, BrowserTab settings UI. Missing: URL-to-markdown ingestion, screenshot viewing in chat. | CLI MCP hub (POST /mcp); extension manages lifecycle | P3 |
| [Checkpoints](non-agent-features/checkpoints.md) | ❌ Not started | No shadow git repo, per-task snapshots, restore UI, or diff viewing. Settings tab is a stub. | CLI (partial: session undo/redo); extension for git snapshots | P1 |
| [Cloud Task Support](non-agent-features/cloud-task-support.md) | ❌ Not started | No cloud sync for tasks across devices. [#168](https://github.com/Kilo-Org/kilo/issues/168) | Kilo cloud API + CLI; extension provides UI | P2 |
| [Code Actions & Editor Menus](non-agent-features/editor-context-menus-and-code-actions.md) | 🔨 Partial | Editor/terminal right-click submenus, CodeActionProvider lightbulb, keyboard shortcuts, and prompt templates implemented. Terminal content capture is a stub (needs shell integration API). Missing: custom prompt overrides via settings. | Extension-side (VS Code CodeActionProvider + menus + keybindings) | P1 |
| [Code Reviews](non-agent-features/code-reviews.md) | ❌ Not started | No local review mode or automated AI review of uncommitted/branch changes. | CLI (partial); extension for VS Code review UX | P2 |
| [Codebase Indexing & Semantic Search](non-agent-features/codebase-indexing-semantic-search.md) | ❌ Not started | No vector indexing, semantic search, or embeddings infrastructure. | CLI has grep/glob endpoints; semantic indexing is extension or cloud | P2 |
| [Contribution Tracking](non-agent-features/contribution-tracking.md) | ❌ Not started | No AI attribution tracking, line fingerprinting, or reporting. | Extension-side | P3 |
| [Custom Commands](non-agent-features/custom-command-system.md) | ❌ Not started | No slash commands, project-level command discovery, or YAML frontmatter support. | CLI has custom commands; extension provides UI entry points | P2 |
| [Deploy & Secure Surfaces](non-agent-features/deploy-and-secure-surfaces.md) | ❌ Not started | No deploy workflows, managed indexing UI, or security review surfaces. | Extension-side | P3 |
| [Fast Edits](non-agent-features/fast-edits.md) | ❌ Not started | No fast edit mode for quick inline code changes. | CLI fast-edit runtime; extension provides UI | P2 |
| [Git Commit Message Generation](non-agent-features/git-commit-message-generation.md) | ❌ Not started | No AI commit message generation or VS Code Source Control integration. [#165](https://github.com/Kilo-Org/kilo/issues/165) | Extension-side (VS Code Git API) | P2 |
| [Integrations](non-agent-features/integrations.md) | ❌ Not started | No external system integrations (GitHub, etc.) beyond basic auth. | CLI plugin system (partial); extension for IDE hooks | P3 |
| Kilo Gateway | ✅ Done | Kilo Gateway (`KILO_GATEWAY_ID = "kilo"`) is the default provider, listed first in ModelSelector via `PROVIDER_ORDER`. Login/logout/org switching fully implemented in `KiloProvider.ts` and `ProfileView.tsx`. Default model selection in ProvidersTab. [#176](https://github.com/Kilo-Org/kilo/issues/176) | CLI handles gateway connection; extension provides config UI | P0 |
| Localization | ✅ Done | Full i18n system with 16 locales in [`i18n/`](../webview-ui/src/i18n/), three-layer dict merging, locale auto-detection, LanguageTab settings UI with locale selector. | Extension + webview; CLI locale mapping needed | P3 |
| [Marketplace](non-agent-features/marketplace.md) | 🔨 Partial | Placeholder view exists but is non-functional. No catalog, install, or update capabilities. [#169](https://github.com/Kilo-Org/kilo/issues/169) | Extension-side | P2 |
| [MCP & MCP Hub](non-agent-features/mcp-and-mcp-hub.md) | 🔨 Partial | MCP types and HTTP client methods added (getMcpStatus, addMcpServer, connectMcpServer, disconnectMcpServer). Used by BrowserAutomationService. Missing: general MCP configuration UI, server management, tool allowlisting, connection status display. | CLI owns MCP lifecycle; extension provides config UI | P1 |
| [Mode Switcher](non-agent-features/mode-switcher.md) | ✅ Done | ModeSwitcher.tsx implements popover-based agent/mode selector with descriptions, persisted via session.selectAgent(). Integrated into PromptInput. [#162](https://github.com/Kilo-Org/kilo/issues/162) | CLI manages modes; extension provides switcher UI | P2 |
| [Model Switcher](non-agent-features/model-switcher.md) | ✅ Done | ModelSelector.tsx implements popover-based selector with search/filter, keyboard navigation, provider grouping, free model tags. Integrated into PromptInput. [#163](https://github.com/Kilo-Org/kilo/issues/163) | CLI provides model list; extension provides switcher UI | P1 |
| [Provider Configuration](non-agent-features/provider-configuration.md) | 🔨 Partial | Provider context fetches and exposes provider data, connected providers, defaults, model lists — wired into ModelSelector. ProvidersTab UI still a stub. [#175](https://github.com/Kilo-Org/kilo/issues/175) | CLI manages providers; extension provides config UI | P1 |
| [Repository Initialization](non-agent-features/repository-initialization.md) | ❌ Not started | No /init command support for setting up agentic engineering. [#174](https://github.com/Kilo-Org/kilo/issues/174) | CLI /init endpoint; extension provides UI trigger | P3 |
| [Rules & Workflows](non-agent-features/rules-and-workflows.md) | ❌ Not started | No rules or workflow management UI. [#173](https://github.com/Kilo-Org/kilo/issues/173) | CLI owns rules runtime; extension provides management UI | P3 |
| [Search & Repo Scanning](non-agent-features/search-and-repo-scanning-infrastructure.md) | ❌ Not started | No search infrastructure beyond CLI grep/glob. | CLI has grep/glob; extension may add UI | P2 |
| [Settings Sync](non-agent-features/settings-sync-integration.md) | ❌ Not started | No VS Code Settings Sync allowlist registration. Settings tabs are all stubs. | Extension-side (VS Code API) | P3 |
| [Settings UI](non-agent-features/settings-ui.md) | 🔨 Partial | 15-tab settings shell exists. BrowserTab has real settings controls (enable/disable, system Chrome, headless toggles). LanguageTab has working locale selector. Remaining 13 tabs are stubs. [#170](https://github.com/Kilo-Org/kilo/issues/170) | CLI exposes config; extension provides settings forms | P1 |
| [Skills System](non-agent-features/skills-system.md) | ❌ Not started | No skill discovery, management, or hot-reload in extension. | CLI has skills runtime; extension provides packaging/UI | P2 |
| [Speech-to-Text](non-agent-features/speech-to-text.md) | ❌ Not started | No voice input or streaming STT. | Webview (mic capture); CLI-compatible STT optional | P3 |
| [Task History](non-agent-features/task-history.md) | 🔨 Partial | Session list exists but lacks search, metadata, and full persistence. [#167](https://github.com/Kilo-Org/kilo/issues/167) | CLI session storage; extension provides history UI | P1 |
| [Telemetry](non-agent-features/telemetry.md) | ❌ Not started | Dual-layer telemetry (PostHog Node server-side + PostHog JS client-side) for extension usage, errors, LLM completions, and AI interactions. Includes privacy controls, typed events, and structured error tracking. See detailed plan. | Extension-side (PostHog + kilo-telemetry) | P1 |
| [Terminal / Shell Integration](non-agent-features/terminal-shell-integration.md) | ❌ Not started | No VS Code terminal integration for command execution display, exit code tracking, or working directory changes. | CLI executes commands; extension provides terminal UX | P1 |
---
+218 -22
View File
@@ -2,7 +2,7 @@
"name": "kilo-code",
"displayName": "Kilo Code",
"description": "Kilo Code AI Agent & Autocomplete",
"version": "7.0.23",
"version": "7.0.24",
"publisher": "kilocode",
"repository": {
"type": "git",
@@ -100,32 +100,97 @@
"command": "kilo-code.new.agentManager.nextTab",
"title": "Agent Manager: Next Tab",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.showTerminal",
"title": "Agent Manager: Focus Terminal",
"category": "Kilo Code",
"icon": "$(terminal)"
},
{
"command": "kilo-code.new.agentManager.focusPanel",
"title": "Agent Manager: Focus Panel",
"category": "Kilo Code",
"icon": "$(circuit-board)"
},
{
"command": "kilo-code.new.agentManager.newTab",
"title": "Agent Manager: New Tab",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.closeTab",
"title": "Agent Manager: Close Tab",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.newWorktree",
"title": "Agent Manager: New Worktree",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.closeWorktree",
"title": "Agent Manager: Close Worktree",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.generateCommitMessage",
"title": "Generate Commit Message",
"category": "Kilo Code (NEW)",
"icon": {
"light": "assets/icons/kilo-light.svg",
"dark": "assets/icons/kilo-dark.svg"
}
},
{
"command": "kilo-code.new.explainCode",
"title": "Explain Code",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.fixCode",
"title": "Fix Code",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.improveCode",
"title": "Improve Code",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.addToContext",
"title": "Add to Context",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.terminalAddToContext",
"title": "Add Terminal Content to Context",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.terminalFixCommand",
"title": "Fix This Command",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.terminalExplainCommand",
"title": "Explain This Command",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.focusChatInput",
"title": "Focus Chat Input",
"category": "Kilo Code"
}
],
"keybindings": [
"submenus": [
{
"command": "kilo-code.new.agentManager.previousSession",
"key": "ctrl+up",
"mac": "cmd+up",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
"id": "kilo-code.new.editorContextMenu",
"label": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.nextSession",
"key": "ctrl+down",
"mac": "cmd+down",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.previousTab",
"key": "ctrl+left",
"mac": "cmd+left",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.nextTab",
"key": "ctrl+right",
"mac": "cmd+right",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
"id": "kilo-code.new.terminalContextMenu",
"label": "Kilo Code"
}
],
"menus": {
@@ -161,14 +226,143 @@
"when": "view == kilo-code.new.sidebarView"
}
],
"scm/title": [
{
"command": "kilo-code.new.generateCommitMessage",
"group": "navigation",
"when": "scmProvider == git"
}
],
"editor/title": [
{
"command": "kilo-code.new.openInTab",
"group": "navigation",
"when": "true"
}
],
"editor/context": [
{
"submenu": "kilo-code.new.editorContextMenu",
"group": "1_kilocode"
}
],
"kilo-code.new.editorContextMenu": [
{
"command": "kilo-code.new.explainCode",
"group": "1_actions@1"
},
{
"command": "kilo-code.new.fixCode",
"group": "1_actions@2"
},
{
"command": "kilo-code.new.improveCode",
"group": "1_actions@3"
},
{
"command": "kilo-code.new.addToContext",
"group": "1_actions@4"
}
],
"terminal/context": [
{
"submenu": "kilo-code.new.terminalContextMenu",
"group": "2_kilocode"
}
],
"kilo-code.new.terminalContextMenu": [
{
"command": "kilo-code.new.terminalAddToContext",
"group": "1_actions@1"
},
{
"command": "kilo-code.new.terminalFixCommand",
"group": "1_actions@2"
},
{
"command": "kilo-code.new.terminalExplainCommand",
"group": "1_actions@3"
}
]
},
"keybindings": [
{
"command": "kilo-code.new.focusChatInput",
"key": "ctrl+shift+a",
"mac": "cmd+shift+a"
},
{
"command": "kilo-code.new.agentManagerOpen",
"key": "ctrl+shift+m",
"mac": "cmd+shift+m"
},
{
"command": "kilo-code.new.addToContext",
"key": "ctrl+k ctrl+a",
"mac": "cmd+k cmd+a",
"when": "editorTextFocus && editorHasSelection"
},
{
"command": "kilo-code.new.agentManager.previousSession",
"key": "ctrl+up",
"mac": "cmd+up",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.nextSession",
"key": "ctrl+down",
"mac": "cmd+down",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.previousTab",
"key": "ctrl+left",
"mac": "cmd+left",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.nextTab",
"key": "ctrl+right",
"mac": "cmd+right",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.showTerminal",
"key": "ctrl+/",
"mac": "cmd+/",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.focusPanel",
"key": "ctrl+.",
"mac": "cmd+.",
"when": "terminalFocus && kilo-code.agentTerminalFocus"
},
{
"command": "kilo-code.new.agentManager.newTab",
"key": "ctrl+t",
"mac": "cmd+t",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.closeTab",
"key": "ctrl+w",
"mac": "cmd+w",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.newWorktree",
"key": "ctrl+n",
"mac": "cmd+n",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
},
{
"command": "kilo-code.new.agentManager.closeWorktree",
"key": "ctrl+shift+w",
"mac": "cmd+shift+w",
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
}
],
"configuration": {
"title": "Kilo Code",
"properties": {
@@ -331,6 +525,7 @@
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"qrcode": "^1.5.4",
"ts-morph": "27.0.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0"
},
@@ -340,6 +535,7 @@
"@kilocode/kilo-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "^7.0.0",
"dotenv": "^16.4.7",
"eventsource": "^2.0.2",
+23 -3
View File
@@ -378,7 +378,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
case "requestFileSearch": {
const client = this.httpClient
if (client) {
const dir = this.getWorkspaceDirectory()
const dir = this.getWorkspaceDirectory(this.currentSession?.id)
void client
.findFiles(message.query, dir)
.then((paths) => {
@@ -596,6 +596,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
.catch((err) => console.error("[Kilo New] KiloProvider: Failed to fetch session for tracking:", err))
// Fetch current session status so the webview has the correct busy/idle
// state after switching tabs (SSE events may have been missed).
this.httpClient
.getSessionStatuses(workspaceDir)
.then((statuses) => {
for (const [sid, info] of Object.entries(statuses)) {
if (!this.trackedSessionIds.has(sid)) continue
this.postMessage({
type: "sessionStatus",
sessionID: sid,
status: info.type,
...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
})
}
})
.catch((err) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", err))
// Convert to webview format, including cost/tokens for assistant messages
const messages = messagesData.map((m) => ({
id: m.info.id,
@@ -1411,13 +1428,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
})
break
case "session.status":
case "session.status": {
const info = event.properties.status
this.postMessage({
type: "sessionStatus",
sessionID: event.properties.sessionID,
status: event.properties.status.type,
status: info.type,
...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
})
break
}
case "permission.asked":
this.postMessage({
@@ -4,6 +4,10 @@ import { KiloProvider } from "../KiloProvider"
import { buildWebviewHtml } from "../utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { WorktreeStateManager } from "./WorktreeStateManager"
import { SetupScriptService } from "./SetupScriptService"
import { SetupScriptRunner } from "./SetupScriptRunner"
import { SessionTerminalManager } from "./SessionTerminalManager"
import { formatKeybinding } from "./format-keybinding"
/**
* AgentManagerProvider opens the Agent Manager panel.
@@ -21,12 +25,18 @@ export class AgentManagerProvider implements vscode.Disposable {
private outputChannel: vscode.OutputChannel
private worktrees: WorktreeManager | undefined
private state: WorktreeStateManager | undefined
private setupScript: SetupScriptService | undefined
private terminalManager: SessionTerminalManager
private stateReady: Promise<void> | undefined
constructor(
private readonly extensionUri: vscode.Uri,
private readonly connectionService: KiloConnectionService,
) {
this.outputChannel = vscode.window.createOutputChannel("Kilo Agent Manager")
this.terminalManager = new SessionTerminalManager((msg) =>
this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
)
}
private log(...args: unknown[]) {
@@ -65,8 +75,9 @@ export class AgentManagerProvider implements vscode.Disposable {
onBeforeMessage: (msg) => this.onMessage(msg),
})
void this.initializeState()
this.stateReady = this.initializeState()
void this.sendRepoInfo()
this.sendKeybindings()
this.panel.onDidDispose(() => {
this.log("Panel disposed")
@@ -124,10 +135,36 @@ export class AgentManagerProvider implements vscode.Disposable {
return this.onAddSessionToWorktree(msg.worktreeId)
if (type === "agentManager.closeSession" && typeof msg.sessionId === "string")
return this.onCloseSession(msg.sessionId)
if (type === "agentManager.configureSetupScript") {
void this.configureSetupScript()
return null
}
if (type === "agentManager.showTerminal" && typeof msg.sessionId === "string") {
this.terminalManager.showTerminal(msg.sessionId, this.state)
return null
}
if (type === "agentManager.requestRepoInfo") {
void this.sendRepoInfo()
return null
}
if (type === "agentManager.requestState") {
void this.stateReady
?.then(() => this.pushState())
.catch((err) => {
this.log("initializeState failed, pushing partial state:", err)
this.pushState()
})
return null
}
if (type === "agentManager.setTabOrder" && typeof msg.key === "string" && Array.isArray(msg.order)) {
this.state?.setTabOrder(msg.key as string, msg.order as string[])
return null
}
// When switching sessions, show existing terminal if one is open
if (type === "loadMessages" && typeof msg.sessionID === "string") {
this.terminalManager.showExisting(msg.sessionID)
}
// After clearSession, re-register worktree sessions so SSE events keep flowing
if (type === "clearSession") {
@@ -154,7 +191,11 @@ export class AgentManagerProvider implements vscode.Disposable {
const manager = this.getWorktreeManager()
const state = this.getStateManager()
if (!manager || !state) {
this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", message: "No workspace folder open" })
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "error",
message: "Open a folder that contains a git repository to use worktrees",
})
return null
}
@@ -164,11 +205,11 @@ export class AgentManagerProvider implements vscode.Disposable {
try {
result = await manager.createWorktree({ prompt: "kilo" })
} catch (error) {
const err = error instanceof Error ? error.message : String(error)
const msg = error instanceof Error ? error.message : String(error)
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "error",
message: `Failed to create worktree: ${err}`,
message: msg,
})
return null
}
@@ -240,6 +281,9 @@ export class AgentManagerProvider implements vscode.Disposable {
const created = await this.createWorktreeOnDisk()
if (!created) return null
// Run setup script for new worktree (blocks until complete, shows in overlay)
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
const session = await this.createSessionInWorktree(created.result.path, created.result.branch)
if (!session) {
const state = this.getStateManager()
@@ -289,6 +333,9 @@ export class AgentManagerProvider implements vscode.Disposable {
const created = await this.createWorktreeOnDisk()
if (!created) return null
// Run setup script for new worktree (blocks until complete, shows in overlay)
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
const state = this.getStateManager()!
if (!state.getSession(sessionId)) {
state.addSession(sessionId, created.worktree.id)
@@ -308,11 +355,7 @@ export class AgentManagerProvider implements vscode.Disposable {
try {
client = this.connectionService.getHttpClient()
} catch {
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "error",
message: "Not connected to CLI backend",
})
this.postToWebview({ type: "error", message: "Not connected to CLI backend" })
return null
}
@@ -338,11 +381,9 @@ export class AgentManagerProvider implements vscode.Disposable {
this.registerWorktreeSession(session.id, worktree.path)
this.pushState()
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "ready",
message: "Session created",
type: "agentManager.sessionAdded",
sessionId: session.id,
branch: worktree.branch,
worktreeId,
})
if (this.provider) {
@@ -364,6 +405,64 @@ export class AgentManagerProvider implements vscode.Disposable {
return null
}
// ---------------------------------------------------------------------------
// Keybindings
// ---------------------------------------------------------------------------
private sendKeybindings(): void {
const ext = vscode.extensions.getExtension("kilocode.kilo-code")
const keybindings: Array<{ command: string; key?: string; mac?: string }> =
ext?.packageJSON?.contributes?.keybindings ?? []
const mac = process.platform === "darwin"
const prefix = "kilo-code.new.agentManager."
const bindings: Record<string, string> = {}
for (const kb of keybindings) {
if (!kb.command.startsWith(prefix)) continue
const action = kb.command.slice(prefix.length)
const raw = mac ? (kb.mac ?? kb.key) : kb.key
if (raw) bindings[action] = formatKeybinding(raw, mac)
}
this.postToWebview({ type: "agentManager.keybindings", bindings })
}
// ---------------------------------------------------------------------------
// Setup script
// ---------------------------------------------------------------------------
/** Open the worktree setup script in the editor for user configuration. */
private async configureSetupScript(): Promise<void> {
const service = this.getSetupScriptService()
if (!service) return
try {
await service.openInEditor()
} catch (error) {
this.log(`Failed to open setup script: ${error}`)
}
}
/** Run the worktree setup script if configured. Blocks until complete. Shows progress in overlay. */
private async runSetupScriptForWorktree(worktreePath: string, branch?: string): Promise<void> {
const root = this.getWorkspaceRoot()
if (!root) return
try {
const service = this.getSetupScriptService()
if (!service || !service.hasScript()) return
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Running setup script...",
branch,
})
const runner = new SetupScriptRunner(this.outputChannel, service)
await runner.runIfConfigured({ worktreePath, repoPath: root })
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
this.outputChannel.appendLine(`[AgentManager] Setup script error: ${msg}`)
}
}
// ---------------------------------------------------------------------------
// Repo info
// ---------------------------------------------------------------------------
@@ -396,6 +495,7 @@ export class AgentManagerProvider implements vscode.Disposable {
type: "agentManager.state",
worktrees: state.getWorktrees(),
sessions: state.getSessions(),
tabOrder: state.getTabOrder(),
})
}
@@ -431,6 +531,17 @@ export class AgentManagerProvider implements vscode.Disposable {
return this.state
}
private getSetupScriptService(): SetupScriptService | undefined {
if (this.setupScript) return this.setupScript
const root = this.getWorkspaceRoot()
if (!root) {
this.log("getSetupScriptService: no workspace folder available")
return undefined
}
this.setupScript = new SetupScriptService(root)
return this.setupScript
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -449,11 +560,29 @@ export class AgentManagerProvider implements vscode.Disposable {
})
}
/**
* Show terminal for the currently active session (triggered by keyboard shortcut).
* Posts an action to the webview which will respond with the session ID.
*/
public showTerminalForCurrentSession(): void {
this.postToWebview({ type: "action", action: "showTerminal" })
}
/**
* Reveal the Agent Manager panel and focus the prompt input.
* Used for the keyboard shortcut to switch back from terminal.
*/
public focusPanel(): void {
if (!this.panel) return
this.panel.reveal(vscode.ViewColumn.One, false)
}
public postMessage(message: unknown): void {
this.panel?.webview.postMessage(message)
}
public dispose(): void {
this.terminalManager.dispose()
this.provider?.dispose()
this.panel?.dispose()
this.outputChannel.dispose()
@@ -0,0 +1,135 @@
import * as vscode from "vscode"
import type { WorktreeStateManager } from "./WorktreeStateManager"
/**
* Manages VS Code terminals for agent manager sessions.
* Each session can have an associated terminal that opens in the session's worktree directory,
* or the main workspace folder for local sessions.
*/
export class SessionTerminalManager {
private terminals = new Map<string, { terminal: vscode.Terminal; cwd: string }>()
private disposables: vscode.Disposable[] = []
constructor(private log: (msg: string) => void) {
this.disposables.push(
vscode.window.onDidCloseTerminal((terminal) => {
for (const [sessionId, entry] of this.terminals) {
if (entry.terminal !== terminal) continue
this.terminals.delete(sessionId)
this.log(`Removed terminal mapping for session ${sessionId} (terminal closed)`)
break
}
this.updateContextKey()
}),
vscode.window.onDidChangeActiveTerminal((terminal) => {
const managed = terminal ? this.isManaged(terminal) : false
vscode.commands.executeCommand("setContext", "kilo-code.agentTerminalFocus", managed)
}),
)
}
/**
* Show (or create) a terminal for the given session.
* Resolves CWD from the worktree state, falling back to workspace root.
*/
showTerminal(sessionId: string, state: WorktreeStateManager | undefined): void {
// If terminal already exists, just focus it
if (this.showExisting(sessionId, false)) return
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
const worktreePath = state?.directoryFor(sessionId)
const cwd = worktreePath ?? workspacePath
if (!cwd) {
this.log(`showTerminal: no cwd resolved for session ${sessionId}`)
vscode.window.showWarningMessage("Open a folder that contains a git repository to use worktrees")
return
}
const session = state?.getSession(sessionId)
const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined
const name = worktree ? `Agent: ${worktree.branch}` : "Agent: local"
this.showOrCreate(sessionId, cwd, name)
}
/**
* Show the terminal for a session if it already exists (used when switching sessions).
* Returns true if the terminal was shown, false if no terminal exists for the session.
* Pass preserveFocus=true to keep focus on the current editor (default for session switching).
*/
showExisting(sessionId: string, preserveFocus = true): boolean {
const entry = this.terminals.get(sessionId)
if (!entry) return false
if (entry.terminal.exitStatus !== undefined) {
this.terminals.delete(sessionId)
this.log(`showExisting: terminal exited for session ${sessionId}, clearing`)
return false
}
entry.terminal.show(preserveFocus)
this.log(`showExisting: revealed terminal for session ${sessionId}`)
return true
}
/**
* Check if a session has an active terminal.
*/
hasTerminal(sessionId: string): boolean {
const entry = this.terminals.get(sessionId)
return entry !== undefined && entry.terminal.exitStatus === undefined
}
dispose(): void {
vscode.commands.executeCommand("setContext", "kilo-code.agentTerminalFocus", false)
for (const entry of this.terminals.values()) entry.terminal.dispose()
this.terminals.clear()
for (const d of this.disposables) d.dispose()
}
private isManaged(terminal: vscode.Terminal): boolean {
for (const entry of this.terminals.values()) {
if (entry.terminal === terminal) return true
}
return false
}
private updateContextKey(): void {
const active = vscode.window.activeTerminal
const managed = active ? this.isManaged(active) : false
vscode.commands.executeCommand("setContext", "kilo-code.agentTerminalFocus", managed)
}
private showOrCreate(sessionId: string, cwd: string, name: string): void {
let entry = this.terminals.get(sessionId)
// Clean up exited terminals
if (entry && entry.terminal.exitStatus !== undefined) {
this.terminals.delete(sessionId)
entry = undefined
}
// Recreate if CWD changed
if (entry && entry.cwd !== cwd) {
entry.terminal.dispose()
this.terminals.delete(sessionId)
entry = undefined
this.log(`showTerminal: cwd changed for session ${sessionId}, recreating`)
}
if (!entry) {
const terminal = vscode.window.createTerminal({
cwd,
name,
iconPath: new vscode.ThemeIcon("terminal"),
})
entry = { terminal, cwd }
this.terminals.set(sessionId, entry)
this.log(`showTerminal: created terminal for session ${sessionId} (cwd=${cwd})`)
}
entry.terminal.show(false)
this.updateContextKey()
}
}
@@ -0,0 +1,152 @@
/**
* SetupScriptRunner - Executes worktree setup scripts
*
* Runs setup scripts in VS Code integrated terminal before agent starts.
* Uses VS Code shell integration to track execution and exit code.
* Falls back to sendText + onDidCloseTerminal if shell integration is unavailable.
* Cross-platform: Unix uses sh, Windows uses cmd.exe.
*/
import * as vscode from "vscode"
import { SetupScriptService } from "./SetupScriptService"
import { buildSetupCommand } from "./setup-script-command"
export interface SetupScriptEnvironment {
/** Absolute path to the worktree directory */
worktreePath: string
/** Absolute path to the main repository */
repoPath: string
}
export class SetupScriptRunner {
constructor(
private readonly output: vscode.OutputChannel,
private readonly service: SetupScriptService,
) {}
/**
* Execute setup script in a worktree if script exists.
* Waits for the script to finish before resolving.
*
* @returns true if script was executed, false if skipped (no script configured)
*/
async runIfConfigured(env: SetupScriptEnvironment): Promise<boolean> {
if (!this.service.hasScript()) {
this.log("No setup script configured, skipping")
return false
}
const script = this.service.getScriptPath()
this.log(`Running setup script: ${script}`)
try {
await this.executeInTerminal(script, env)
this.log("Setup script completed")
return true
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
this.log(`Setup script execution failed: ${msg}`)
return true // Script was attempted
}
}
/** Execute the setup script in a VS Code terminal and wait for it to finish. */
private async executeInTerminal(script: string, env: SetupScriptEnvironment): Promise<void> {
const terminal = vscode.window.createTerminal({
name: "Worktree Setup",
cwd: env.worktreePath,
env: {
WORKTREE_PATH: env.worktreePath,
REPO_PATH: env.repoPath,
},
iconPath: new vscode.ThemeIcon("gear"),
})
terminal.show(true)
// Try shell integration first — gives us proper exit code tracking
const integration = await this.waitForShellIntegration(terminal, 5000)
if (integration) {
this.log("Using shell integration for setup script execution")
await this.runViaShellIntegration(terminal, integration, script, env)
} else {
this.log("Shell integration unavailable, falling back to sendText")
await this.runViaSendText(terminal, script, env)
}
}
/** Wait for shell integration to become available on a terminal, with timeout. */
private waitForShellIntegration(
terminal: vscode.Terminal,
timeout: number,
): Promise<vscode.TerminalShellIntegration | undefined> {
if (terminal.shellIntegration) return Promise.resolve(terminal.shellIntegration)
return new Promise((resolve) => {
const timer = setTimeout(() => {
listener.dispose()
resolve(undefined)
}, timeout)
const listener = vscode.window.onDidChangeTerminalShellIntegration((e) => {
if (e.terminal !== terminal) return
clearTimeout(timer)
listener.dispose()
resolve(e.shellIntegration)
})
})
}
/** Run script via shell integration — tracks execution and exit code properly. */
private runViaShellIntegration(
terminal: vscode.Terminal,
integration: vscode.TerminalShellIntegration,
script: string,
env: SetupScriptEnvironment,
): Promise<void> {
return new Promise((resolve) => {
const command = buildSetupCommand(script, env)
const execution = integration.executeCommand(command)
const cleanup = () => {
execListener.dispose()
closeListener.dispose()
}
// Primary: shell integration reports execution finished with exit code
const execListener = vscode.window.onDidEndTerminalShellExecution((e) => {
if (e.execution !== execution) return
cleanup()
this.log(`Setup script exited with code ${e.exitCode ?? "unknown"}`)
resolve()
})
// Fallback: terminal was closed externally (user, VS Code restart, etc.)
const closeListener = vscode.window.onDidCloseTerminal((closed) => {
if (closed !== terminal) return
cleanup()
this.log("Setup script terminal closed before execution event fired")
resolve()
})
})
}
/** Fallback: run via sendText and wait for terminal to close. */
private runViaSendText(terminal: vscode.Terminal, script: string, env: SetupScriptEnvironment): Promise<void> {
return new Promise((resolve) => {
const listener = vscode.window.onDidCloseTerminal((closed) => {
if (closed !== terminal) return
listener.dispose()
resolve()
})
const command = buildSetupCommand(script, env) + (process.platform === "win32" ? "& exit" : "; exit")
terminal.sendText(command)
this.log("Setup script started in terminal, waiting for completion...")
})
}
private log(message: string): void {
this.output.appendLine(`[SetupScriptRunner] ${message}`)
}
}
@@ -0,0 +1,68 @@
/**
* SetupScriptService - Manages worktree setup scripts
*
* Handles reading, creating, and checking for setup scripts stored in .kilocode/setup-script.
* Setup scripts run before an agent starts in a worktree (new sessions only).
*/
import * as vscode from "vscode"
import * as fs from "node:fs"
import * as path from "node:path"
import { SETUP_SCRIPT_TEMPLATE } from "./setup-script-template"
const SETUP_SCRIPT_FILENAME = "setup-script"
const KILOCODE_DIR = ".kilocode"
export class SetupScriptService {
private readonly root: string
private readonly script: string
constructor(root: string) {
this.root = root
this.script = path.join(root, KILOCODE_DIR, SETUP_SCRIPT_FILENAME)
}
/** Get the path to the setup script */
getScriptPath(): string {
return this.script
}
/** Check if a setup script exists */
hasScript(): boolean {
return fs.existsSync(this.script)
}
/** Read the setup script content. Returns null if not found or read fails. */
async getScript(): Promise<string | null> {
if (!this.hasScript()) return null
try {
return await fs.promises.readFile(this.script, "utf-8")
} catch (error) {
this.log(`Failed to read setup script: ${error}`)
return null
}
}
/** Create a default setup script with helpful comments */
async createDefaultScript(): Promise<void> {
const dir = path.join(this.root, KILOCODE_DIR)
if (!fs.existsSync(dir)) {
await fs.promises.mkdir(dir, { recursive: true })
}
await fs.promises.writeFile(this.script, SETUP_SCRIPT_TEMPLATE, "utf-8")
}
/** Open the setup script in VS Code editor. Creates the default script if it doesn't exist. */
async openInEditor(): Promise<void> {
if (!this.hasScript()) {
await this.createDefaultScript()
}
const document = await vscode.workspace.openTextDocument(this.script)
await vscode.window.showTextDocument(document)
}
private log(message: string): void {
// Log to console since we don't have an OutputChannel here
console.log(`[SetupScriptService] ${message}`)
}
}
@@ -46,7 +46,10 @@ export class WorktreeManager {
async createWorktree(params: { prompt?: string; existingBranch?: string }): Promise<CreateWorktreeResult> {
const repo = await this.git.checkIsRepo()
if (!repo) throw new Error("Workspace is not a git repository")
if (!repo)
throw new Error(
"This folder is not a git repository. Initialize a repository or open a git project to use worktrees.",
)
await this.ensureDir()
await this.ensureGitExclude()
@@ -182,6 +185,7 @@ export class WorktreeManager {
const excludePath = path.join(gitDir, "info", "exclude")
await this.addExcludeEntry(excludePath, ".kilocode/worktrees/", "Kilo Code agent worktrees")
await this.addExcludeEntry(excludePath, ".kilocode/agent-manager.json", "Kilo Agent Manager state")
await this.addExcludeEntry(excludePath, ".kilocode/setup-script", "Kilo Code worktree setup script")
}
private async ensureWorktreeExclude(worktreePath: string): Promise<void> {
@@ -29,6 +29,7 @@ export interface ManagedSession {
interface StateFile {
worktrees: Record<string, Omit<Worktree, "id">>
sessions: Record<string, Omit<ManagedSession, "id">>
tabOrder?: Record<string, string[]>
}
const STATE_FILE = "agent-manager.json"
@@ -44,6 +45,7 @@ export class WorktreeStateManager {
private readonly file: string
private worktrees = new Map<string, Worktree>()
private sessions = new Map<string, ManagedSession>()
private tabOrder: Record<string, string[]> = {}
private readonly log: (msg: string) => void
private saving: Promise<void> | undefined
private pendingSave = false
@@ -125,6 +127,9 @@ export class WorktreeStateManager {
}
}
// Clean up tab order for this worktree
delete this.tabOrder[id]
this.log(`Removed worktree ${id}, orphaned ${orphaned.length} sessions`)
void this.save()
return orphaned
@@ -149,6 +154,34 @@ export class WorktreeStateManager {
removeSession(id: string): void {
this.sessions.delete(id)
// Remove this session from any tab order arrays
for (const [key, order] of Object.entries(this.tabOrder)) {
const idx = order.indexOf(id)
if (idx !== -1) {
order.splice(idx, 1)
if (order.length === 0) delete this.tabOrder[key]
}
}
void this.save()
}
// ---------------------------------------------------------------------------
// Tab order
// ---------------------------------------------------------------------------
getTabOrder(): Record<string, string[]> {
return this.tabOrder
}
setTabOrder(key: string, order: string[]): void {
this.tabOrder[key] = order
void this.save()
}
removeTabOrder(key: string): void {
delete this.tabOrder[key]
void this.save()
}
@@ -162,6 +195,7 @@ export class WorktreeStateManager {
const data = JSON.parse(content) as StateFile
this.worktrees.clear()
this.sessions.clear()
this.tabOrder = {}
for (const [id, wt] of Object.entries(data.worktrees ?? {})) {
this.worktrees.set(id, { id, ...wt })
@@ -169,6 +203,9 @@ export class WorktreeStateManager {
for (const [id, s] of Object.entries(data.sessions ?? {})) {
this.sessions.set(id, { id, ...s })
}
if (data.tabOrder) {
this.tabOrder = data.tabOrder
}
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
@@ -229,6 +266,9 @@ export class WorktreeStateManager {
const { id: _, ...rest } = s
data.sessions[id] = rest
}
if (Object.keys(this.tabOrder).length > 0) {
data.tabOrder = this.tabOrder
}
const dir = path.dirname(this.file)
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true })
@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest"
import { buildSetupCommand } from "../setup-script-command"
const env = {
worktreePath: "/repos/project/.kilocode/worktrees/wt-1",
repoPath: "/repos/project",
}
const script = "/repos/project/.kilocode/setup-script"
describe("buildSetupCommand", () => {
it("builds unix command with inline env vars and sh", () => {
const result = buildSetupCommand(script, env, "darwin")
expect(result).toBe(
`WORKTREE_PATH="/repos/project/.kilocode/worktrees/wt-1" REPO_PATH="/repos/project" sh "/repos/project/.kilocode/setup-script"`,
)
})
it("builds linux command same as darwin", () => {
const result = buildSetupCommand(script, env, "linux")
expect(result).toContain("sh ")
expect(result).not.toContain("set ")
expect(result).not.toContain("call ")
})
it("builds windows command with set and call", () => {
const result = buildSetupCommand(script, env, "win32")
expect(result).toBe(
`set "WORKTREE_PATH=/repos/project/.kilocode/worktrees/wt-1" && set "REPO_PATH=/repos/project" && call "/repos/project/.kilocode/setup-script"`,
)
})
it("includes both env vars in unix command", () => {
const result = buildSetupCommand(script, env, "darwin")
expect(result).toContain(`WORKTREE_PATH="${env.worktreePath}"`)
expect(result).toContain(`REPO_PATH="${env.repoPath}"`)
})
it("includes both env vars in windows command", () => {
const result = buildSetupCommand(script, env, "win32")
expect(result).toContain(`set "WORKTREE_PATH=${env.worktreePath}"`)
expect(result).toContain(`set "REPO_PATH=${env.repoPath}"`)
})
it("handles paths with spaces", () => {
const spaced = {
worktreePath: "/Users/dev/my project/.kilocode/worktrees/wt-1",
repoPath: "/Users/dev/my project",
}
const spacedScript = "/Users/dev/my project/.kilocode/setup-script"
const unix = buildSetupCommand(spacedScript, spaced, "darwin")
expect(unix).toContain(`sh "/Users/dev/my project/.kilocode/setup-script"`)
const win = buildSetupCommand(spacedScript, spaced, "win32")
expect(win).toContain(`call "/Users/dev/my project/.kilocode/setup-script"`)
})
it("escapes double quotes in unix paths", () => {
const dangerous = {
worktreePath: '/repos/proj"ect',
repoPath: "/repos/safe",
}
const result = buildSetupCommand(script, dangerous, "darwin")
expect(result).toContain(`WORKTREE_PATH="/repos/proj\\"ect"`)
})
it("escapes dollar signs in unix paths", () => {
const dangerous = {
worktreePath: "/repos/$HOME/project",
repoPath: "/repos/safe",
}
const result = buildSetupCommand(script, dangerous, "darwin")
expect(result).toContain(`WORKTREE_PATH="/repos/\\$HOME/project"`)
})
it("escapes backticks in unix paths", () => {
const dangerous = {
worktreePath: "/repos/`whoami`/project",
repoPath: "/repos/safe",
}
const result = buildSetupCommand(script, dangerous, "darwin")
expect(result).toContain('WORKTREE_PATH="/repos/\\`whoami\\`/project"')
})
})
@@ -0,0 +1,34 @@
const KEY_SYMBOLS: Record<string, { mac: string; other: string }> = {
ctrl: { mac: "⌃", other: "Ctrl" },
cmd: { mac: "⌘", other: "Ctrl" },
shift: { mac: "⇧", other: "Shift" },
alt: { mac: "⌥", other: "Alt" },
}
const SPECIAL_KEYS: Record<string, string> = {
left: "←",
right: "→",
up: "↑",
down: "↓",
backspace: "⌫",
delete: "Del",
enter: "↵",
escape: "Esc",
}
/**
* Format a VS Code keybinding string (e.g. "cmd+shift+w") into
* a display string using platform-appropriate symbols.
* Mac: "⌘⇧W" Windows/Linux: "Ctrl+Shift+W"
*/
export function formatKeybinding(raw: string, mac: boolean): string {
const symbols = raw
.split("+")
.map((p) => p.trim().toLowerCase())
.map((part) => {
const mod = KEY_SYMBOLS[part]
if (mod) return mac ? mod.mac : mod.other
return SPECIAL_KEYS[part] ?? part.toUpperCase()
})
return mac ? symbols.join("") : symbols.join("+")
}
@@ -0,0 +1,21 @@
/** Escape characters that are special inside double-quoted shell strings. */
function escapeShell(value: string): string {
return value.replace(/["$`\\]/g, "\\$&")
}
/** Build the platform-appropriate command string for running a setup script. */
export function buildSetupCommand(
script: string,
env: { worktreePath: string; repoPath: string },
platform: NodeJS.Platform = process.platform,
): string {
if (platform === "win32") {
// Windows cmd.exe: double quotes in set values don't need escaping the same way,
// but we escape for the call argument
return `set "WORKTREE_PATH=${env.worktreePath}" && set "REPO_PATH=${env.repoPath}" && call "${script}"`
}
const wt = escapeShell(env.worktreePath)
const repo = escapeShell(env.repoPath)
const path = escapeShell(script)
return `WORKTREE_PATH="${wt}" REPO_PATH="${repo}" sh "${path}"`
}
@@ -0,0 +1,41 @@
/** Default template for worktree setup scripts. */
export const SETUP_SCRIPT_TEMPLATE = `#!/bin/bash
# Kilo Code Worktree Setup Script
# This script runs before the agent starts in a worktree (new sessions only).
#
# Available environment variables:
# WORKTREE_PATH - Absolute path to the worktree directory
# REPO_PATH - Absolute path to the main repository
#
# Example tasks:
# - Copy .env files from main repo
# - Install dependencies
# - Run database migrations
# - Set up local configuration
set -e # Exit on error
echo "Setting up worktree: $WORKTREE_PATH"
# Uncomment and modify as needed:
# Copy environment files
# if [ -f "$REPO_PATH/.env" ]; then
# cp "$REPO_PATH/.env" "$WORKTREE_PATH/.env"
# echo "Copied .env"
# fi
# Install dependencies (Node.js)
# if [ -f "$WORKTREE_PATH/package.json" ]; then
# cd "$WORKTREE_PATH"
# npm install
# fi
# Install dependencies (Python)
# if [ -f "$WORKTREE_PATH/requirements.txt" ]; then
# cd "$WORKTREE_PATH"
# pip install -r requirements.txt
# fi
echo "Setup complete!"
`
+36
View File
@@ -6,6 +6,8 @@ import { KiloConnectionService } from "./services/cli-backend"
import { registerAutocompleteProvider } from "./services/autocomplete"
import { BrowserAutomationService } from "./services/browser-automation"
import { TelemetryProxy } from "./services/telemetry"
import { registerCommitMessageService } from "./services/commit-message"
import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions"
export function activate(context: vscode.ExtensionContext) {
console.log("Kilo Code extension is now active")
@@ -80,11 +82,45 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand("kilo-code.new.agentManager.nextTab", () => {
agentManagerProvider.postMessage({ type: "action", action: "tabNext" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.showTerminal", () => {
agentManagerProvider.showTerminalForCurrentSession()
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.focusPanel", () => {
agentManagerProvider.focusPanel()
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.newTab", () => {
agentManagerProvider.postMessage({ type: "action", action: "newTab" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.closeTab", () => {
agentManagerProvider.postMessage({ type: "action", action: "closeTab" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.newWorktree", () => {
agentManagerProvider.postMessage({ type: "action", action: "newWorktree" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.closeWorktree", () => {
agentManagerProvider.postMessage({ type: "action", action: "closeWorktree" })
}),
)
// Register autocomplete provider
registerAutocompleteProvider(context, connectionService)
// Register commit message generation
registerCommitMessageService(context, connectionService)
// Register code actions (editor context menus, terminal context menus, keyboard shortcuts)
registerCodeActions(context, provider)
registerTerminalActions(context, provider)
// Register CodeActionProvider (lightbulb quick fixes)
context.subscriptions.push(
vscode.languages.registerCodeActionsProvider(
{ scheme: "file" },
new KiloCodeActionProvider(),
KiloCodeActionProvider.metadata,
),
)
// Dispose services when extension deactivates (kills the server)
context.subscriptions.push({
dispose: () => {
@@ -92,12 +92,17 @@ export class BrowserAutomationService implements vscode.Disposable {
}
try {
const status = await httpClient.addMcpServer(BrowserAutomationService.MCP_SERVER_NAME, {
type: "local",
command,
enabled: true,
timeout: 60000,
})
const directory = this.getWorkspaceDirectory()
const status = await httpClient.addMcpServer(
BrowserAutomationService.MCP_SERVER_NAME,
{
type: "local",
command,
enabled: true,
timeout: 60000,
},
directory,
)
const serverStatus = status[BrowserAutomationService.MCP_SERVER_NAME]
if (serverStatus?.status === "connected") {
@@ -128,7 +133,8 @@ export class BrowserAutomationService implements vscode.Disposable {
const httpClient = this.getHttpClient()
if (httpClient) {
try {
await httpClient.disconnectMcpServer(BrowserAutomationService.MCP_SERVER_NAME)
const directory = this.getWorkspaceDirectory()
await httpClient.disconnectMcpServer(BrowserAutomationService.MCP_SERVER_NAME, directory)
} catch (error) {
console.error("[Kilo New] BrowserAutomationService: Failed to disconnect MCP server:", error)
}
@@ -147,7 +153,8 @@ export class BrowserAutomationService implements vscode.Disposable {
}
try {
const allStatus = await httpClient.getMcpStatus()
const directory = this.getWorkspaceDirectory()
const allStatus = await httpClient.getMcpStatus(directory)
return allStatus[BrowserAutomationService.MCP_SERVER_NAME] ?? null
} catch {
return null
@@ -162,6 +169,14 @@ export class BrowserAutomationService implements vscode.Disposable {
}
}
private getWorkspaceDirectory(): string {
const folders = vscode.workspace.workspaceFolders
if (folders && folders.length > 0) {
return folders[0].uri.fsPath
}
return process.cwd()
}
private setState(state: BrowserAutomationState): void {
if (this.state === state) {
return
@@ -1,6 +1,7 @@
import type {
ServerConfig,
SessionInfo,
SessionStatusInfo,
MessageInfo,
MessagePart,
AgentInfo,
@@ -24,8 +25,8 @@ export class HttpClient {
constructor(config: ServerConfig) {
this.baseUrl = config.baseUrl
// Auth header format: Basic base64("opencode:password")
// NOTE: The CLI server expects a non-empty username ("opencode"). Using an empty username
// Auth header format: Basic base64("kilo:password")
// NOTE: The CLI server expects a non-empty username ("kilo"). Using an empty username
// (":password") results in 401 for both REST and SSE endpoints.
this.authHeader = `Basic ${Buffer.from(`${this.authUsername}:${config.password}`).toString("base64")}`
@@ -140,6 +141,14 @@ export class HttpClient {
return this.request<SessionInfo[]>("GET", "/session", undefined, { directory })
}
/**
* Get the status of all sessions.
* Returns a map of sessionID SessionStatusInfo.
*/
async getSessionStatuses(directory: string): Promise<Record<string, SessionStatusInfo>> {
return this.request<Record<string, SessionStatusInfo>>("GET", "/session/status", undefined, { directory })
}
/**
* Delete a session permanently.
*/
@@ -482,6 +491,22 @@ export class HttpClient {
return this.request<string[]>("GET", `/find/file?${params.toString()}`, undefined, { directory })
}
// ============================================
// Commit Message Methods
// ============================================
/**
* Generate a commit message for the current diff in the given directory.
*/
async generateCommitMessage(path: string, selectedFiles?: string[], previousMessage?: string): Promise<string> {
const result = await this.request<{ message: string }>("POST", "/commit-message", {
path,
selectedFiles,
previousMessage,
})
return result.message
}
// ============================================
// MCP Methods
// ============================================
@@ -489,28 +514,28 @@ export class HttpClient {
/**
* Get the status of all MCP servers.
*/
async getMcpStatus(): Promise<Record<string, McpStatus>> {
return this.request<Record<string, McpStatus>>("GET", "/mcp")
async getMcpStatus(directory: string): Promise<Record<string, McpStatus>> {
return this.request<Record<string, McpStatus>>("GET", "/mcp", undefined, { directory })
}
/**
* Add or update an MCP server configuration.
*/
async addMcpServer(name: string, config: McpConfig): Promise<Record<string, McpStatus>> {
return this.request<Record<string, McpStatus>>("POST", "/mcp", { name, config })
async addMcpServer(name: string, config: McpConfig, directory: string): Promise<Record<string, McpStatus>> {
return this.request<Record<string, McpStatus>>("POST", "/mcp", { name, config }, { directory })
}
/**
* Connect an MCP server by name.
*/
async connectMcpServer(name: string): Promise<boolean> {
return this.request<boolean>("POST", `/mcp/${encodeURIComponent(name)}/connect`)
async connectMcpServer(name: string, directory: string): Promise<boolean> {
return this.request<boolean>("POST", `/mcp/${encodeURIComponent(name)}/connect`, undefined, { directory })
}
/**
* Disconnect an MCP server by name.
*/
async disconnectMcpServer(name: string): Promise<boolean> {
return this.request<boolean>("POST", `/mcp/${encodeURIComponent(name)}/disconnect`)
async disconnectMcpServer(name: string, directory: string): Promise<boolean> {
return this.request<boolean>("POST", `/mcp/${encodeURIComponent(name)}/disconnect`, undefined, { directory })
}
}
@@ -0,0 +1,42 @@
import * as vscode from "vscode"
export class KiloCodeActionProvider implements vscode.CodeActionProvider {
static readonly metadata: vscode.CodeActionProviderMetadata = {
providedCodeActionKinds: [vscode.CodeActionKind.QuickFix, vscode.CodeActionKind.RefactorRewrite],
}
provideCodeActions(
document: vscode.TextDocument,
range: vscode.Range | vscode.Selection,
context: vscode.CodeActionContext,
): vscode.CodeAction[] {
if (range.isEmpty) return []
const actions: vscode.CodeAction[] = []
const add = new vscode.CodeAction("Add to Kilo Code", vscode.CodeActionKind.RefactorRewrite)
add.command = { command: "kilo-code.new.addToContext", title: "Add to Kilo Code" }
actions.push(add)
const hasDiagnostics = context.diagnostics.length > 0
if (hasDiagnostics) {
const fix = new vscode.CodeAction("Fix with Kilo Code", vscode.CodeActionKind.QuickFix)
fix.command = { command: "kilo-code.new.fixCode", title: "Fix with Kilo Code" }
fix.isPreferred = true
actions.push(fix)
}
if (!hasDiagnostics) {
const explain = new vscode.CodeAction("Explain with Kilo Code", vscode.CodeActionKind.RefactorRewrite)
explain.command = { command: "kilo-code.new.explainCode", title: "Explain with Kilo Code" }
actions.push(explain)
const improve = new vscode.CodeAction("Improve with Kilo Code", vscode.CodeActionKind.RefactorRewrite)
improve.command = { command: "kilo-code.new.improveCode", title: "Improve with Kilo Code" }
actions.push(improve)
}
return actions
}
}
@@ -0,0 +1,24 @@
import * as vscode from "vscode"
export interface EditorContext {
filePath: string
selectedText: string
startLine: number
endLine: number
diagnostics: vscode.Diagnostic[]
}
export function getEditorContext(): EditorContext | undefined {
const editor = vscode.window.activeTextEditor
if (!editor) return undefined
const selection = editor.selection
if (selection.isEmpty) return undefined
const doc = editor.document
return {
filePath: vscode.workspace.asRelativePath(doc.uri),
selectedText: doc.getText(selection),
startLine: selection.start.line + 1,
endLine: selection.end.line + 1,
diagnostics: vscode.languages.getDiagnostics(doc.uri).filter((d) => d.range.intersection(selection) !== undefined),
}
}
@@ -0,0 +1,3 @@
export { registerCodeActions } from "./register-code-actions"
export { registerTerminalActions } from "./register-terminal-actions"
export { KiloCodeActionProvider } from "./code-action-provider"
@@ -0,0 +1,65 @@
import * as vscode from "vscode"
import type { KiloProvider } from "../../KiloProvider"
import { getEditorContext } from "./editor-utils"
import { createPrompt } from "./support-prompt"
export function registerCodeActions(context: vscode.ExtensionContext, provider: KiloProvider): void {
context.subscriptions.push(
vscode.commands.registerCommand("kilo-code.new.explainCode", () => {
const ctx = getEditorContext()
if (!ctx) return
const prompt = createPrompt("EXPLAIN", {
filePath: ctx.filePath,
startLine: String(ctx.startLine),
endLine: String(ctx.endLine),
selectedText: ctx.selectedText,
userInput: "",
})
provider.postMessage({ type: "triggerTask", text: prompt })
}),
vscode.commands.registerCommand("kilo-code.new.fixCode", () => {
const ctx = getEditorContext()
if (!ctx) return
const prompt = createPrompt("FIX", {
filePath: ctx.filePath,
startLine: String(ctx.startLine),
endLine: String(ctx.endLine),
selectedText: ctx.selectedText,
diagnostics: ctx.diagnostics,
userInput: "",
})
provider.postMessage({ type: "triggerTask", text: prompt })
}),
vscode.commands.registerCommand("kilo-code.new.improveCode", () => {
const ctx = getEditorContext()
if (!ctx) return
const prompt = createPrompt("IMPROVE", {
filePath: ctx.filePath,
startLine: String(ctx.startLine),
endLine: String(ctx.endLine),
selectedText: ctx.selectedText,
userInput: "",
})
provider.postMessage({ type: "triggerTask", text: prompt })
}),
vscode.commands.registerCommand("kilo-code.new.addToContext", () => {
const ctx = getEditorContext()
if (!ctx) return
const prompt = createPrompt("ADD_TO_CONTEXT", {
filePath: ctx.filePath,
startLine: String(ctx.startLine),
endLine: String(ctx.endLine),
selectedText: ctx.selectedText,
})
provider.postMessage({ type: "setChatBoxMessage", text: prompt })
provider.postMessage({ type: "action", action: "focusInput" })
}),
vscode.commands.registerCommand("kilo-code.new.focusChatInput", () => {
provider.postMessage({ type: "action", action: "focusInput" })
}),
)
}
@@ -0,0 +1,57 @@
import * as vscode from "vscode"
import type { KiloProvider } from "../../KiloProvider"
import { createPrompt } from "./support-prompt"
function getTerminalSelection(): string {
const terminal = vscode.window.activeTerminal
if (!terminal) return ""
// VS Code terminal API doesn't expose buffer contents directly.
// Terminal selection is available via clipboard in some cases.
// For now, this is a placeholder — full implementation requires
// VS Code shell integration API (terminal.shellIntegration).
return ""
}
export function registerTerminalActions(context: vscode.ExtensionContext, provider: KiloProvider): void {
context.subscriptions.push(
vscode.commands.registerCommand("kilo-code.new.terminalAddToContext", () => {
const content = getTerminalSelection()
if (!content) {
vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.")
return
}
const prompt = createPrompt("TERMINAL_ADD_TO_CONTEXT", {
terminalContent: content,
userInput: "",
})
provider.postMessage({ type: "setChatBoxMessage", text: prompt })
provider.postMessage({ type: "action", action: "focusInput" })
}),
vscode.commands.registerCommand("kilo-code.new.terminalFixCommand", () => {
const content = getTerminalSelection()
if (!content) {
vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.")
return
}
const prompt = createPrompt("TERMINAL_FIX", {
terminalContent: content,
userInput: "",
})
provider.postMessage({ type: "triggerTask", text: prompt })
}),
vscode.commands.registerCommand("kilo-code.new.terminalExplainCommand", () => {
const content = getTerminalSelection()
if (!content) {
vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.")
return
}
const prompt = createPrompt("TERMINAL_EXPLAIN", {
terminalContent: content,
userInput: "",
})
provider.postMessage({ type: "triggerTask", text: prompt })
}),
)
}
@@ -0,0 +1,107 @@
type Params = Record<string, string | any[]>
function diagnosticText(diagnostics?: any[]) {
if (!diagnostics?.length) return ""
return `\nCurrent problems detected:\n${diagnostics
.map((d) => `- [${d.source || "Error"}] ${d.message}${d.code ? ` (${d.code})` : ""}`)
.join("\n")}`
}
function fill(template: string, params: Params): string {
return template.replace(/\${(.*?)}/g, (_, key) => {
if (key === "diagnosticText") return diagnosticText(params["diagnostics"] as any[])
if (key in params) return String(params[key] ?? "")
return ""
})
}
type PromptType =
| "EXPLAIN"
| "FIX"
| "IMPROVE"
| "ADD_TO_CONTEXT"
| "TERMINAL_ADD_TO_CONTEXT"
| "TERMINAL_FIX"
| "TERMINAL_EXPLAIN"
const templates: Record<PromptType, string> = {
EXPLAIN: `Explain the following code from file path \${filePath}:\${startLine}-\${endLine}
\${userInput}
\`\`\`
\${selectedText}
\`\`\`
Please provide a clear and concise explanation of what this code does, including:
1. The purpose and functionality
2. Key components and their interactions
3. Important patterns or techniques used`,
FIX: `Fix any issues in the following code from file path \${filePath}:\${startLine}-\${endLine}
\${diagnosticText}
\${userInput}
\`\`\`
\${selectedText}
\`\`\`
Please:
1. Address all detected problems listed above (if any)
2. Identify any other potential bugs or issues
3. Provide corrected code
4. Explain what was fixed and why`,
IMPROVE: `Improve the following code from file path \${filePath}:\${startLine}-\${endLine}
\${userInput}
\`\`\`
\${selectedText}
\`\`\`
Please suggest improvements for:
1. Code readability and maintainability
2. Performance optimization
3. Best practices and patterns
4. Error handling and edge cases
Provide the improved code along with explanations for each enhancement.`,
ADD_TO_CONTEXT: `\${filePath}:\${startLine}-\${endLine}
\`\`\`
\${selectedText}
\`\`\``,
TERMINAL_ADD_TO_CONTEXT: `\${userInput}
Terminal output:
\`\`\`
\${terminalContent}
\`\`\``,
TERMINAL_FIX: `\${userInput}
Fix this terminal command:
\`\`\`
\${terminalContent}
\`\`\`
Please:
1. Identify any issues in the command
2. Provide the corrected command
3. Explain what was fixed and why`,
TERMINAL_EXPLAIN: `\${userInput}
Explain this terminal command:
\`\`\`
\${terminalContent}
\`\`\`
Please provide:
1. What the command does
2. Explanation of each part/flag
3. Expected output and behavior`,
}
export function createPrompt(type: PromptType, params: Params): string {
return fill(templates[type], params)
}
export type { PromptType }
@@ -0,0 +1,207 @@
import { describe, it, expect, vi, beforeEach } from "vitest"
// Mock vscode following the pattern from AutocompleteServiceManager.spec.ts
vi.mock("vscode", () => {
const disposable = { dispose: vi.fn() }
return {
commands: {
registerCommand: vi.fn((_command: string, _callback: (...args: any[]) => any) => disposable),
},
window: {
showErrorMessage: vi.fn(),
withProgress: vi.fn(),
},
workspace: {
workspaceFolders: [
{
uri: { fsPath: "/test/workspace" },
},
],
},
extensions: {
getExtension: vi.fn(),
},
ProgressLocation: {
SourceControl: 1,
},
Uri: {
parse: (s: string) => ({ fsPath: s }),
},
}
})
import * as vscode from "vscode"
import { registerCommitMessageService } from "../index"
import type { KiloConnectionService } from "../../cli-backend/connection-service"
describe("commit-message service", () => {
let mockContext: vscode.ExtensionContext
let mockConnectionService: KiloConnectionService
let mockHttpClient: { generateCommitMessage: ReturnType<typeof vi.fn> }
beforeEach(() => {
vi.clearAllMocks()
mockContext = {
subscriptions: [],
} as any
mockHttpClient = {
generateCommitMessage: vi.fn().mockResolvedValue("feat: add new feature"),
}
mockConnectionService = {
getHttpClient: vi.fn().mockReturnValue(mockHttpClient),
} as any
})
describe("registerCommitMessageService", () => {
it("returns an array of disposables", () => {
const disposables = registerCommitMessageService(mockContext, mockConnectionService)
expect(Array.isArray(disposables)).toBe(true)
expect(disposables.length).toBeGreaterThan(0)
})
it("registers the kilo-code.new.generateCommitMessage command", () => {
registerCommitMessageService(mockContext, mockConnectionService)
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
"kilo-code.new.generateCommitMessage",
expect.any(Function),
)
})
it("pushes the command disposable to context.subscriptions", () => {
registerCommitMessageService(mockContext, mockConnectionService)
expect(mockContext.subscriptions.length).toBe(1)
})
})
describe("command execution", () => {
let commandCallback: (...args: any[]) => Promise<void>
beforeEach(() => {
registerCommitMessageService(mockContext, mockConnectionService)
// Extract the registered command callback
const registerCall = vi.mocked(vscode.commands.registerCommand).mock.calls[0]!
commandCallback = registerCall[1] as (...args: any[]) => Promise<void>
})
it("shows error when git extension is not found", async () => {
vi.mocked(vscode.extensions.getExtension).mockReturnValue(undefined)
await commandCallback()
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Git extension not found")
})
it("shows error when no git repository is found", async () => {
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
isActive: true,
activate: vi.fn().mockResolvedValue(undefined),
exports: {
getAPI: () => ({ repositories: [] }),
},
} as any)
await commandCallback()
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No Git repository found")
})
it("shows error when backend is not connected", async () => {
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
isActive: true,
activate: vi.fn().mockResolvedValue(undefined),
exports: {
getAPI: () => ({
repositories: [{ inputBox: { value: "" }, rootUri: { fsPath: "/repo" } }],
}),
},
} as any)
vi.mocked(mockConnectionService.getHttpClient as any).mockImplementation(() => {
throw new Error("Not connected")
})
await commandCallback()
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
"Kilo backend is not connected. Please wait for the connection to establish.",
)
})
it("calls generateCommitMessage on the HTTP client with repository root path", async () => {
const mockInputBox = { value: "" }
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
isActive: true,
activate: vi.fn().mockResolvedValue(undefined),
exports: {
getAPI: () => ({
repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }],
}),
},
} as any)
// Make withProgress execute its callback
vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => {
await task({} as any, {} as any)
})
await commandCallback()
expect(mockHttpClient.generateCommitMessage).toHaveBeenCalledWith("/repo", undefined, undefined)
})
it("sets the generated message on the repository inputBox", async () => {
const mockInputBox = { value: "" }
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
isActive: true,
activate: vi.fn().mockResolvedValue(undefined),
exports: {
getAPI: () => ({
repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }],
}),
},
} as any)
vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => {
await task({} as any, {} as any)
})
await commandCallback()
expect(mockInputBox.value).toBe("feat: add new feature")
})
it("shows progress in SourceControl location", async () => {
const mockInputBox = { value: "" }
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
isActive: true,
activate: vi.fn().mockResolvedValue(undefined),
exports: {
getAPI: () => ({
repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }],
}),
},
} as any)
vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => {
await task({} as any, {} as any)
})
await commandCallback()
expect(vscode.window.withProgress).toHaveBeenCalledWith(
expect.objectContaining({
location: vscode.ProgressLocation.SourceControl,
title: "Generating commit message...",
}),
expect.any(Function),
)
})
})
})
@@ -0,0 +1,79 @@
import * as vscode from "vscode"
import type { KiloConnectionService } from "../cli-backend/connection-service"
import type { HttpClient } from "../cli-backend/http-client"
let lastGeneratedMessage: string | undefined
let lastWorkspacePath: string | undefined
interface GitRepository {
inputBox: { value: string }
rootUri: vscode.Uri
}
interface GitAPI {
repositories: GitRepository[]
}
interface GitExtensionExports {
getAPI(version: number): GitAPI
}
export function registerCommitMessageService(
context: vscode.ExtensionContext,
connectionService: KiloConnectionService,
): vscode.Disposable[] {
const command = vscode.commands.registerCommand("kilo-code.new.generateCommitMessage", async () => {
const extension = vscode.extensions.getExtension<GitExtensionExports>("vscode.git")
if (!extension) {
vscode.window.showErrorMessage("Git extension not found")
return
}
if (!extension.isActive) {
await extension.activate()
}
const git = extension.exports?.getAPI(1)
const repository = git?.repositories[0]
if (!repository) {
vscode.window.showErrorMessage("No Git repository found")
return
}
let client: HttpClient | undefined
try {
client = connectionService.getHttpClient()
} catch {
vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.")
return
}
if (!client) {
vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.")
return
}
const path = repository.rootUri.fsPath
const previousMessage = lastWorkspacePath === path ? lastGeneratedMessage : undefined
await vscode.window
.withProgress(
{ location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." },
async () => {
const message = await client.generateCommitMessage(path, undefined, previousMessage)
repository.inputBox.value = message
lastGeneratedMessage = message
lastWorkspacePath = path
console.log("[Kilo New] Commit message generated successfully")
},
)
.then(undefined, (error: unknown) => {
const msg = error instanceof Error ? error.message : String(error)
console.error("[Kilo New] Failed to generate commit message:", msg)
vscode.window.showErrorMessage(`Failed to generate commit message: ${msg}`)
})
})
context.subscriptions.push(command)
return [command]
}
@@ -3,16 +3,27 @@
*
* The agent manager runs in the same webview context as other UI.
* All its CSS classes must be prefixed with "am-" to avoid conflicts.
* These tests also verify consistency between CSS definitions and TSX usage.
* These tests also verify consistency between CSS definitions and TSX usage,
* and that the provider sends correct message types for each action.
*/
import { describe, it, expect } from "bun:test"
import fs from "node:fs"
import path from "node:path"
import { Project, SyntaxKind } from "ts-morph"
const ROOT = path.resolve(import.meta.dir, "../..")
const CSS_FILE = path.join(ROOT, "webview-ui/agent-manager/agent-manager.css")
const TSX_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx")
const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
]
const TSX_FILE = TSX_FILES[0]
const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts")
function readAllTsx(): string {
return TSX_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n")
}
describe("Agent Manager CSS Prefix", () => {
it("all class selectors should use am- prefix", () => {
@@ -51,7 +62,7 @@ describe("Agent Manager CSS Prefix", () => {
describe("Agent Manager CSS/TSX Consistency", () => {
it("all classes used in TSX should be defined in CSS", () => {
const css = fs.readFileSync(CSS_FILE, "utf-8")
const tsx = fs.readFileSync(TSX_FILE, "utf-8")
const tsx = readAllTsx()
// Extract am- classes defined in CSS
const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)]
@@ -68,7 +79,7 @@ describe("Agent Manager CSS/TSX Consistency", () => {
it("all am- classes defined in CSS should be used in TSX", () => {
const css = fs.readFileSync(CSS_FILE, "utf-8")
const tsx = fs.readFileSync(TSX_FILE, "utf-8")
const tsx = readAllTsx()
// Extract am- classes defined in CSS
const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)]
@@ -79,3 +90,29 @@ describe("Agent Manager CSS/TSX Consistency", () => {
expect(unused, `Classes defined in CSS but not used in TSX: ${unused.join(", ")}`).toEqual([])
})
})
describe("Agent Manager Provider Messages", () => {
function getMethodBody(name: string): string {
const project = new Project({ compilerOptions: { allowJs: true } })
const source = project.addSourceFileAtPath(PROVIDER_FILE)
const cls = source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration)
const method = cls?.getMethod(name)
expect(method, `method ${name} not found in AgentManagerProvider`).toBeTruthy()
return method!.getText()
}
/**
* Regression: onAddSessionToWorktree must NOT send agentManager.worktreeSetup
* because that triggers a full-screen overlay with a spinner. Adding a session
* to an existing worktree should use agentManager.sessionAdded instead.
*/
it("onAddSessionToWorktree should not send worktreeSetup messages", () => {
const body = getMethodBody("onAddSessionToWorktree")
expect(body).not.toContain("agentManager.worktreeSetup")
})
it("onAddSessionToWorktree should send sessionAdded message", () => {
const body = getMethodBody("onAddSessionToWorktree")
expect(body).toContain("agentManager.sessionAdded")
})
})
@@ -0,0 +1,72 @@
import { describe, it, expect } from "bun:test"
import { formatKeybinding } from "../../src/agent-manager/format-keybinding"
describe("formatKeybinding", () => {
describe("mac", () => {
it("formats cmd as ⌘", () => {
expect(formatKeybinding("cmd+w", true)).toBe("⌘W")
})
it("formats cmd+shift as ⌘⇧", () => {
expect(formatKeybinding("cmd+shift+w", true)).toBe("⌘⇧W")
})
it("formats ctrl as ⌃", () => {
expect(formatKeybinding("ctrl+c", true)).toBe("⌃C")
})
it("formats alt as ⌥", () => {
expect(formatKeybinding("alt+f", true)).toBe("⌥F")
})
it("formats arrow keys as symbols", () => {
expect(formatKeybinding("cmd+left", true)).toBe("⌘←")
expect(formatKeybinding("cmd+right", true)).toBe("⌘→")
expect(formatKeybinding("cmd+up", true)).toBe("⌘↑")
expect(formatKeybinding("cmd+down", true)).toBe("⌘↓")
})
it("formats special keys", () => {
expect(formatKeybinding("cmd+backspace", true)).toBe("⌘⌫")
expect(formatKeybinding("cmd+enter", true)).toBe("⌘↵")
expect(formatKeybinding("escape", true)).toBe("Esc")
})
it("joins without separator on mac", () => {
expect(formatKeybinding("cmd+shift+alt+t", true)).toBe("⌘⇧⌥T")
})
it("formats plain key", () => {
expect(formatKeybinding("cmd+/", true)).toBe("⌘/")
})
})
describe("windows/linux", () => {
it("formats cmd as Ctrl", () => {
expect(formatKeybinding("cmd+w", false)).toBe("Ctrl+W")
})
it("formats ctrl as Ctrl", () => {
expect(formatKeybinding("ctrl+w", false)).toBe("Ctrl+W")
})
it("formats ctrl+shift", () => {
expect(formatKeybinding("ctrl+shift+w", false)).toBe("Ctrl+Shift+W")
})
it("formats alt as Alt", () => {
expect(formatKeybinding("alt+f", false)).toBe("Alt+F")
})
it("formats arrow keys as symbols", () => {
expect(formatKeybinding("ctrl+left", false)).toBe("Ctrl+←")
expect(formatKeybinding("ctrl+right", false)).toBe("Ctrl+→")
expect(formatKeybinding("ctrl+up", false)).toBe("Ctrl+↑")
expect(formatKeybinding("ctrl+down", false)).toBe("Ctrl+↓")
})
it("joins with + separator on non-mac", () => {
expect(formatKeybinding("ctrl+shift+alt+t", false)).toBe("Ctrl+Shift+Alt+T")
})
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test"
import { resolveNavigation, validateLocalSession } from "../../webview-ui/agent-manager/navigate"
import { resolveNavigation, validateLocalSession, adjacentHint, LOCAL } from "../../webview-ui/agent-manager/navigate"
const ids = ["a", "b", "c", "d"]
@@ -24,7 +24,7 @@ describe("resolveNavigation", () => {
describe("from first session", () => {
it("up → local", () => {
expect(resolveNavigation("up", "a", ids)).toEqual({ action: "local" })
expect(resolveNavigation("up", "a", ids)).toEqual({ action: LOCAL })
})
it("down → selects second session", () => {
@@ -68,7 +68,7 @@ describe("resolveNavigation", () => {
})
it("up from only session → local", () => {
expect(resolveNavigation("up", "x", ["x"])).toEqual({ action: "local" })
expect(resolveNavigation("up", "x", ["x"])).toEqual({ action: LOCAL })
})
it("down from only session → none", () => {
@@ -95,20 +95,20 @@ describe("resolveNavigation", () => {
expect(trail).toEqual(["s1", "s2", "s3"])
// Navigate back up through all sessions to local
const upTrail: (string | "local")[] = []
const upTrail: (string | typeof LOCAL)[] = []
for (let i = 0; i < 4; i++) {
const result = resolveNavigation("up", current, sessions)
if (result.action === "select") {
current = result.id
upTrail.push(current)
} else if (result.action === "local") {
} else if (result.action === LOCAL) {
current = undefined
upTrail.push("local")
upTrail.push(LOCAL)
} else {
break
}
}
expect(upTrail).toEqual(["s2", "s1", "local"])
expect(upTrail).toEqual(["s2", "s1", LOCAL])
})
})
})
@@ -134,3 +134,49 @@ describe("validateLocalSession", () => {
expect(validateLocalSession(undefined, [])).toBeUndefined()
})
})
describe("adjacentHint", () => {
const flat = [LOCAL, "wt1", "wt2", "wt3", "s1"]
it("returns prev hint when item is directly above active", () => {
expect(adjacentHint("wt1", "wt2", flat, "⌘↑", "⌘↓")).toBe("⌘↑")
})
it("returns next hint when item is directly below active", () => {
expect(adjacentHint("wt3", "wt2", flat, "⌘↑", "⌘↓")).toBe("⌘↓")
})
it("returns empty string for the active item itself", () => {
expect(adjacentHint("wt2", "wt2", flat, "⌘↑", "⌘↓")).toBe("")
})
it("returns empty string for non-adjacent items", () => {
expect(adjacentHint("wt1", "wt3", flat, "⌘↑", "⌘↓")).toBe("")
expect(adjacentHint("s1", "wt1", flat, "⌘↑", "⌘↓")).toBe("")
})
it("returns empty string when active is undefined", () => {
expect(adjacentHint("wt1", undefined, flat, "⌘↑", "⌘↓")).toBe("")
})
it("returns empty string when active is not in list", () => {
expect(adjacentHint("wt1", "unknown", flat, "⌘↑", "⌘↓")).toBe("")
})
it("returns empty string when item is not in list", () => {
expect(adjacentHint("unknown", "wt2", flat, "⌘↑", "⌘↓")).toBe("")
})
it("works at boundaries — first item with LOCAL active", () => {
expect(adjacentHint("wt1", LOCAL, flat, "⌘↑", "⌘↓")).toBe("⌘↓")
})
it("works at boundaries — LOCAL with first item active", () => {
expect(adjacentHint(LOCAL, "wt1", flat, "⌘↑", "⌘↓")).toBe("⌘↑")
})
it("works with single-item list", () => {
expect(adjacentHint("a", "b", ["a", "b"], "prev", "next")).toBe("prev")
expect(adjacentHint("b", "a", ["a", "b"], "prev", "next")).toBe("next")
})
})
@@ -0,0 +1,36 @@
import { describe, it, expect } from "bun:test"
import { nextSelectionAfterDelete, LOCAL } from "../../webview-ui/agent-manager/navigate"
describe("nextSelectionAfterDelete", () => {
it("selects the worktree below when deleting from the middle", () => {
expect(nextSelectionAfterDelete("b", ["a", "b", "c"])).toBe("c")
})
it("selects the worktree above when deleting the last item", () => {
expect(nextSelectionAfterDelete("c", ["a", "b", "c"])).toBe("b")
})
it("selects the worktree below when deleting the first item", () => {
expect(nextSelectionAfterDelete("a", ["a", "b", "c"])).toBe("b")
})
it("falls back to LOCAL when deleting the only worktree", () => {
expect(nextSelectionAfterDelete("a", ["a"])).toBe(LOCAL)
})
it("falls back to LOCAL when ID is not found", () => {
expect(nextSelectionAfterDelete("x", ["a", "b"])).toBe(LOCAL)
})
it("falls back to LOCAL when list is empty", () => {
expect(nextSelectionAfterDelete("a", [])).toBe(LOCAL)
})
it("handles two-item list deleting first", () => {
expect(nextSelectionAfterDelete("a", ["a", "b"])).toBe("b")
})
it("handles two-item list deleting second", () => {
expect(nextSelectionAfterDelete("b", ["a", "b"])).toBe("a")
})
})
@@ -0,0 +1,53 @@
import { describe, expect, it } from "bun:test"
import { removeSessionPermissions, upsertPermission } from "../../webview-ui/src/context/permission-queue"
import type { PermissionRequest } from "../../webview-ui/src/types/messages"
const permission = (input: Partial<PermissionRequest> = {}): PermissionRequest => ({
id: input.id ?? "perm-1",
sessionID: input.sessionID ?? "session-1",
toolName: input.toolName ?? "read",
patterns: input.patterns ?? ["/tmp/*"],
args: input.args ?? {},
message: input.message,
tool: input.tool,
})
describe("permission queue", () => {
it("appends a new permission id", () => {
const result = upsertPermission([], permission({ id: "perm-1" }))
expect(result).toHaveLength(1)
expect(result[0].id).toBe("perm-1")
})
it("updates an existing permission id instead of duplicating", () => {
const existing = permission({ id: "perm-1", toolName: "read", patterns: ["a"] })
const incoming = permission({ id: "perm-1", toolName: "write", patterns: ["b"] })
const result = upsertPermission([existing], incoming)
expect(result).toHaveLength(1)
expect(result[0]).toEqual(incoming)
})
it("keeps other permission entries when updating one id", () => {
const first = permission({ id: "perm-1", sessionID: "session-1" })
const second = permission({ id: "perm-2", sessionID: "session-2" })
const incoming = permission({ id: "perm-1", toolName: "edit" })
const result = upsertPermission([first, second], incoming)
expect(result).toHaveLength(2)
expect(result.find((item) => item.id === "perm-1")).toEqual(incoming)
expect(result.find((item) => item.id === "perm-2")).toEqual(second)
})
it("removes only permissions from the deleted session", () => {
const first = permission({ id: "perm-1", sessionID: "session-1" })
const second = permission({ id: "perm-2", sessionID: "session-2" })
const third = permission({ id: "perm-3", sessionID: "session-1" })
const result = removeSessionPermissions([first, second, third], "session-1")
expect(result).toEqual([second])
})
})
@@ -0,0 +1,200 @@
import { describe, it, expect } from "bun:test"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "../../webview-ui/agent-manager/tab-order"
describe("reorderTabs", () => {
const tabs = ["a", "b", "c", "d"]
it("moves an item forward", () => {
expect(reorderTabs(tabs, "a", "c")).toEqual(["b", "c", "a", "d"])
})
it("moves an item backward", () => {
expect(reorderTabs(tabs, "c", "a")).toEqual(["c", "a", "b", "d"])
})
it("swaps adjacent items forward", () => {
expect(reorderTabs(tabs, "a", "b")).toEqual(["b", "a", "c", "d"])
})
it("swaps adjacent items backward", () => {
expect(reorderTabs(tabs, "b", "a")).toEqual(["b", "a", "c", "d"])
})
it("moves first to last", () => {
expect(reorderTabs(tabs, "a", "d")).toEqual(["b", "c", "d", "a"])
})
it("moves last to first", () => {
expect(reorderTabs(tabs, "d", "a")).toEqual(["d", "a", "b", "c"])
})
it("returns undefined when from equals to", () => {
expect(reorderTabs(tabs, "a", "a")).toBeUndefined()
})
it("returns undefined when from is not found", () => {
expect(reorderTabs(tabs, "x", "a")).toBeUndefined()
})
it("returns undefined when to is not found", () => {
expect(reorderTabs(tabs, "a", "x")).toBeUndefined()
})
it("returns undefined when both are missing", () => {
expect(reorderTabs(tabs, "x", "y")).toBeUndefined()
})
it("handles a two-item list", () => {
expect(reorderTabs(["a", "b"], "a", "b")).toEqual(["b", "a"])
expect(reorderTabs(["a", "b"], "b", "a")).toEqual(["b", "a"])
})
it("handles a single-item list (from === to)", () => {
expect(reorderTabs(["a"], "a", "a")).toBeUndefined()
})
it("handles empty list", () => {
expect(reorderTabs([], "a", "b")).toBeUndefined()
})
it("does not mutate the original array", () => {
const original = ["a", "b", "c"]
reorderTabs(original, "a", "c")
expect(original).toEqual(["a", "b", "c"])
})
it("preserves unrelated items", () => {
const result = reorderTabs(["a", "b", "c", "d", "e"], "b", "d")!
expect(result).toEqual(["a", "c", "d", "b", "e"])
expect(result.sort()).toEqual(["a", "b", "c", "d", "e"])
})
it("round-trip: moving forward then back restores original order", () => {
const moved = reorderTabs(tabs, "a", "c")!
const restored = reorderTabs(moved, "a", "b")!
expect(restored).toEqual(["a", "b", "c", "d"])
})
})
describe("applyTabOrder", () => {
const items = [
{ id: "a", name: "Alice" },
{ id: "b", name: "Bob" },
{ id: "c", name: "Carol" },
]
it("reorders items according to custom order", () => {
const result = applyTabOrder(items, ["c", "a", "b"])
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"])
})
it("appends items not in the order", () => {
const result = applyTabOrder(items, ["b"])
expect(result.map((i) => i.id)).toEqual(["b", "a", "c"])
})
it("skips order IDs that are not in items", () => {
const result = applyTabOrder(items, ["x", "c", "y", "a"])
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"])
})
it("returns original array when order is undefined", () => {
const result = applyTabOrder(items, undefined)
expect(result).toBe(items)
})
it("returns original array when order is empty", () => {
const result = applyTabOrder(items, [])
expect(result).toBe(items)
})
it("handles empty items", () => {
expect(applyTabOrder([], ["a", "b"])).toEqual([])
})
it("preserves item properties", () => {
const result = applyTabOrder(items, ["b", "a", "c"])
expect(result[0]).toEqual({ id: "b", name: "Bob" })
})
})
describe("firstOrderedTitle", () => {
const items = [{ id: "a", title: "Alpha" }, { id: "b", title: "Beta" }, { id: "c", title: "" }, { id: "d" }]
it("returns first titled item from custom order", () => {
expect(firstOrderedTitle(items, ["b", "a"], "fallback")).toBe("Beta")
})
it("skips items without titles in order", () => {
expect(firstOrderedTitle(items, ["d", "c", "b"], "fallback")).toBe("Beta")
})
it("falls back to first titled item when order has no matches", () => {
expect(firstOrderedTitle(items, ["x", "y"], "fallback")).toBe("Alpha")
})
it("falls back to first titled item when order is undefined", () => {
expect(firstOrderedTitle(items, undefined, "fallback")).toBe("Alpha")
})
it("returns fallback when no items have titles", () => {
expect(firstOrderedTitle([{ id: "a" }, { id: "b", title: "" }], ["a", "b"], "fallback")).toBe("fallback")
})
it("returns fallback for empty items", () => {
expect(firstOrderedTitle([], ["a"], "fallback")).toBe("fallback")
})
})
// Helper: simulate reconciliation the same way handleDragOver does
function reconcile(current: string[], stored: string[]): string[] {
return applyTabOrder(
current.map((id) => ({ id })),
stored,
).map((item) => item.id)
}
describe("applyTabOrder as reconciliation (string IDs)", () => {
it("returns stored order unchanged when it matches current IDs", () => {
expect(reconcile(["a", "b", "c"], ["a", "b", "c"])).toEqual(["a", "b", "c"])
})
it("appends new IDs not in stored order", () => {
expect(reconcile(["a", "b", "c"], ["a", "b"])).toEqual(["a", "b", "c"])
})
it("removes stale IDs no longer in current", () => {
expect(reconcile(["a", "c"], ["a", "b", "c"])).toEqual(["a", "c"])
})
it("preserves custom ordering while adding new tabs", () => {
expect(reconcile(["a", "b", "c"], ["b", "a"])).toEqual(["b", "a", "c"])
})
it("returns current IDs when stored order is undefined", () => {
expect(applyTabOrder([{ id: "a" }, { id: "b" }], undefined).map((i) => i.id)).toEqual(["a", "b"])
})
describe("regression: reorder a newly added tab immediately", () => {
it("new tab should be reorderable after reconcile via applyTabOrder", () => {
// Stored order from a previous drag: [s2, s1]
// A third session s3 was just added to the worktree
const stored = ["s2", "s1"]
const current = ["s2", "s1", "s3"]
const reconciled = reconcile(current, stored)
expect(reconciled).toEqual(["s2", "s1", "s3"])
// Now the user drags s3 to position of s2 — this must succeed
const reordered = reorderTabs(reconciled, "s3", "s2")
expect(reordered).toEqual(["s3", "s2", "s1"])
expect(reordered).not.toBeUndefined()
})
it("without reconcile, reorderTabs fails on the new tab", () => {
const stored = ["s2", "s1"]
const reordered = reorderTabs(stored, "s3", "s2")
expect(reordered).toBeUndefined()
})
})
})
@@ -174,6 +174,81 @@ describe("WorktreeStateManager", () => {
})
})
describe("tab order", () => {
it("sets and gets tab order for a key", () => {
manager.setTabOrder("wt-1", ["s1", "s2", "s3"])
expect(manager.getTabOrder()["wt-1"]).toEqual(["s1", "s2", "s3"])
})
it("overwrites existing tab order", () => {
manager.setTabOrder("wt-1", ["s1", "s2"])
manager.setTabOrder("wt-1", ["s2", "s1"])
expect(manager.getTabOrder()["wt-1"]).toEqual(["s2", "s1"])
})
it("removes tab order for a key", () => {
manager.setTabOrder("wt-1", ["s1"])
manager.removeTabOrder("wt-1")
expect(manager.getTabOrder()["wt-1"]).toBeUndefined()
})
it("removeTabOrder is a no-op for missing key", () => {
manager.removeTabOrder("nonexistent")
expect(Object.keys(manager.getTabOrder())).toHaveLength(0)
})
it("cleans up tab order when worktree is removed", () => {
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
manager.addSession("s1", wt.id)
manager.setTabOrder(wt.id, ["s1"])
manager.removeWorktree(wt.id)
expect(manager.getTabOrder()[wt.id]).toBeUndefined()
})
it("removes session from tab order arrays when session is removed", () => {
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
manager.addSession("s1", wt.id)
manager.addSession("s2", wt.id)
manager.setTabOrder(wt.id, ["s1", "s2"])
manager.removeSession("s1")
expect(manager.getTabOrder()[wt.id]).toEqual(["s2"])
})
it("removes tab order entry when last session in order is removed", () => {
manager.addSession("s1", null)
manager.setTabOrder("local", ["s1"])
manager.removeSession("s1")
expect(manager.getTabOrder()["local"]).toBeUndefined()
})
it("persists and loads tab order", async () => {
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
manager.setTabOrder(wt.id, ["s2", "s1"])
manager.setTabOrder("local", ["s3", "s4"])
await manager.flush()
await manager.save()
const loaded = new WorktreeStateManager(root, () => {})
await loaded.load()
expect(loaded.getTabOrder()[wt.id]).toEqual(["s2", "s1"])
expect(loaded.getTabOrder()["local"]).toEqual(["s3", "s4"])
})
it("does not persist empty tab order", async () => {
manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
await manager.flush()
await manager.save()
const content = fs.readFileSync(path.join(root, ".kilocode", "agent-manager.json"), "utf-8")
const data = JSON.parse(content)
expect(data.tabOrder).toBeUndefined()
})
})
describe("validate", () => {
it("removes worktrees whose directories do not exist", async () => {
const existing = path.join(root, "wt-exists")
@@ -1,28 +1,45 @@
// Agent Manager root component
import { Component, For, Show, createSignal, createMemo, createEffect, onMount, onCleanup } from "solid-js"
import {
Component,
For,
Show,
createSignal,
createMemo,
createEffect,
onMount,
onCleanup,
type Accessor,
} from "solid-js"
import type {
ExtensionMessage,
AgentManagerRepoInfoMessage,
AgentManagerWorktreeSetupMessage,
AgentManagerStateMessage,
AgentManagerKeybindingsMessage,
WorktreeState,
ManagedSessionState,
SessionInfo,
} from "../src/types/messages"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
import { DialogProvider } from "@kilocode/kilo-ui/context/dialog"
import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog"
import { Dialog } from "@kilocode/kilo-ui/dialog"
import { MarkedProvider } from "@kilocode/kilo-ui/context/marked"
import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code"
import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff"
import { Code } from "@kilocode/kilo-ui/code"
import { Diff } from "@kilocode/kilo-ui/diff"
import { Toast } from "@kilocode/kilo-ui/toast"
import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { HoverCard } from "@kilocode/kilo-ui/hover-card"
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
import { VSCodeProvider, useVSCode } from "../src/context/vscode"
import { ServerProvider } from "../src/context/server"
import { ProviderProvider } from "../src/context/provider"
@@ -32,7 +49,9 @@ import { WorktreeModeProvider } from "../src/context/worktree-mode"
import { ChatView } from "../src/components/chat"
import { LanguageBridge, DataBridge } from "../src/App"
import { formatRelativeDate } from "../src/utils/date"
import { validateLocalSession } from "./navigate"
import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, LOCAL } from "./navigate"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
import { ConstrainDragYAxis, SortableTab } from "./sortable-tab"
import "./agent-manager.css"
interface SetupState {
@@ -42,30 +61,129 @@ interface SetupState {
error?: boolean
}
/** Sidebar selection: "local" for workspace, worktree ID for a worktree, or null for an unassigned session. */
type SidebarSelection = "local" | string | null
/** Sidebar selection: LOCAL for workspace, worktree ID for a worktree, or null for an unassigned session. */
type SidebarSelection = typeof LOCAL | string | null
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
// Fallback keybindings before extension sends resolved ones
const defaultBindings: Record<string, string> = {
previousSession: isMac ? "⌘↑" : "Ctrl+↑",
nextSession: isMac ? "⌘↓" : "Ctrl+↓",
previousTab: isMac ? "⌘←" : "Ctrl+←",
nextTab: isMac ? "⌘→" : "Ctrl+→",
showTerminal: isMac ? "⌘/" : "Ctrl+/",
newTab: isMac ? "⌘T" : "Ctrl+T",
closeTab: isMac ? "⌘W" : "Ctrl+W",
newWorktree: isMac ? "⌘N" : "Ctrl+N",
closeWorktree: isMac ? "⌘⇧W" : "Ctrl+Shift+W",
}
/** Manages horizontal scroll for the tab list: hides the scrollbar, converts
* vertical wheel events to horizontal scroll, tracks overflow to show/hide
* fade indicators, and auto-scrolls the active tab into view. */
function useTabScroll(activeTabs: Accessor<SessionInfo[]>, activeId: Accessor<string | undefined>) {
const [ref, setRef] = createSignal<HTMLDivElement | undefined>()
const [showLeft, setShowLeft] = createSignal(false)
const [showRight, setShowRight] = createSignal(false)
const update = () => {
const el = ref()
if (!el) return
setShowLeft(el.scrollLeft > 2)
setShowRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 2)
}
// Wheel → horizontal scroll conversion
const onWheel = (e: WheelEvent) => {
const el = ref()
if (!el) return
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
e.preventDefault()
el.scrollLeft += e.deltaY > 0 ? 60 : -60
}
// Recalculate on scroll, resize, or tab changes
createEffect(() => {
const el = ref()
if (!el) return
el.addEventListener("scroll", update, { passive: true })
el.addEventListener("wheel", onWheel, { passive: false })
const ro = new ResizeObserver(update)
ro.observe(el)
const mo = new MutationObserver(update)
mo.observe(el, { childList: true, subtree: true })
onCleanup(() => {
el.removeEventListener("scroll", update)
el.removeEventListener("wheel", onWheel)
ro.disconnect()
mo.disconnect()
})
})
// Auto-scroll active tab into view
createEffect(() => {
const id = activeId()
const el = ref()
// depend on tabs length to trigger on tab add/remove
activeTabs()
if (!id || !el) return
requestAnimationFrame(() => {
const tab = el.querySelector(`[data-tab-id="${id}"]`) as HTMLElement | null
if (!tab) return
const left = tab.offsetLeft
const right = left + tab.offsetWidth
if (left < el.scrollLeft) {
el.scrollTo({ left: left - 8, behavior: "smooth" })
} else if (right > el.scrollLeft + el.clientWidth) {
el.scrollTo({ left: right - el.clientWidth + 8, behavior: "smooth" })
}
})
})
return { setRef, showLeft, showRight }
}
const AgentManagerContent: Component = () => {
const session = useSession()
const vscode = useVSCode()
const dialog = useDialog()
const [kb, setKb] = createSignal<Record<string, string>>(defaultBindings)
const [setup, setSetup] = createSignal<SetupState>({ active: false, message: "" })
const [worktrees, setWorktrees] = createSignal<WorktreeState[]>([])
const [managedSessions, setManagedSessions] = createSignal<ManagedSessionState[]>([])
const [selection, setSelection] = createSignal<SidebarSelection>("local")
const [selection, setSelection] = createSignal<SidebarSelection>(LOCAL)
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
const [deletingWorktrees, setDeletingWorktrees] = createSignal<Set<string>>(new Set())
const [worktreesLoaded, setWorktreesLoaded] = createSignal(false)
const [sessionsLoaded, setSessionsLoaded] = createSignal(false)
const DEFAULT_SIDEBAR_WIDTH = 260
const MIN_SIDEBAR_WIDTH = 200
const MAX_SIDEBAR_WIDTH_RATIO = 0.4
// Recover persisted local session IDs from webview state
const persisted = vscode.getState<{ localSessionIDs?: string[] }>()
const persisted = vscode.getState<{ localSessionIDs?: string[]; sidebarWidth?: number }>()
const [localSessionIDs, setLocalSessionIDs] = createSignal<string[]>(persisted?.localSessionIDs ?? [])
const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH)
// Pending local tab counter for generating unique IDs
let pendingCounter = 0
const PENDING_PREFIX = "pending:"
const [activePendingId, setActivePendingId] = createSignal<string | undefined>()
// Per-context tab memory: maps sidebar selection key -> last active session/pending ID
const [tabMemory, setTabMemory] = createSignal<Record<string, string>>({})
const isPending = (id: string) => id.startsWith(PENDING_PREFIX)
// Drag-and-drop state for tab reordering
const [draggingTab, setDraggingTab] = createSignal<string | undefined>()
// Tab ordering: context key → ordered session ID array (recovered from extension state)
const [worktreeTabOrder, setWorktreeTabOrder] = createSignal<Record<string, string[]>>({})
const addPendingTab = () => {
const id = `${PENDING_PREFIX}${++pendingCounter}`
setLocalSessionIDs((prev) => [...prev, id])
@@ -74,11 +192,25 @@ const AgentManagerContent: Component = () => {
return id
}
// Persist local session IDs to webview state for recovery (exclude pending tabs)
// Persist local session IDs and sidebar width to webview state for recovery (exclude pending tabs)
createEffect(() => {
vscode.setState({ localSessionIDs: localSessionIDs().filter((id) => !isPending(id)) })
vscode.setState({
localSessionIDs: localSessionIDs().filter((id) => !isPending(id)),
sidebarWidth: sidebarWidth(),
})
})
// Save the currently active tab for the current sidebar context before switching away
const saveTabMemory = () => {
const sel = selection()
if (sel === null) return
const key = sel === LOCAL ? LOCAL : sel
const active = session.currentSessionID() ?? activePendingId()
if (active) {
setTabMemory((prev) => (prev[key] === active ? prev : { ...prev, [key]: active }))
}
}
// Invalidate local session IDs if they no longer exist (preserve pending tabs)
createEffect(() => {
const all = session.sessions()
@@ -126,22 +258,23 @@ const AgentManagerContent: Component = () => {
return result
})
// Sessions for the currently selected worktree (tab bar), sorted by creation date
// Sessions for the currently selected worktree (tab bar), respecting custom order if set
const activeWorktreeSessions = createMemo((): SessionInfo[] => {
const sel = selection()
if (!sel || sel === "local") return []
if (!sel || sel === LOCAL) return []
const managed = managedSessions().filter((ms) => ms.worktreeId === sel)
const ids = new Set(managed.map((ms) => ms.id))
return session
const sessions = session
.sessions()
.filter((s) => ids.has(s.id))
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
return applyTabOrder(sessions, worktreeTabOrder()[sel])
})
// Active tab sessions: local sessions when on "local", worktree sessions otherwise
const activeTabs = createMemo((): SessionInfo[] => {
const sel = selection()
if (sel === "local") return localSessions()
if (sel === LOCAL) return localSessions()
if (sel) return activeWorktreeSessions()
return []
})
@@ -149,7 +282,7 @@ const AgentManagerContent: Component = () => {
// Whether the selected context has zero sessions
const contextEmpty = createMemo(() => {
const sel = selection()
if (sel === "local") return localSessionIDs().length === 0
if (sel === LOCAL) return localSessionIDs().length === 0
if (sel) return activeWorktreeSessions().length === 0
return false
})
@@ -157,12 +290,16 @@ const AgentManagerContent: Component = () => {
// Read-only mode: viewing an unassigned session (not in a worktree or local)
const readOnly = createMemo(() => selection() === null && !!session.currentSessionID())
// Display name for worktree
// Tab scroll: hidden scrollbar with fade overflow indicators
const visibleTabId = createMemo(() => session.currentSessionID() ?? activePendingId())
const tabScroll = useTabScroll(activeTabs, visibleTabId)
// Display name for worktree — uses first tab in custom order when available
const worktreeLabel = (wt: WorktreeState): string => {
const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id)
const ids = new Set(managed.map((ms) => ms.id))
const first = session.sessions().find((s) => ids.has(s.id))
return first?.title || wt.branch
const sessions = session.sessions().filter((s) => ids.has(s.id))
return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch)
}
const scrollIntoView = (el: HTMLElement) => {
@@ -171,8 +308,8 @@ const AgentManagerContent: Component = () => {
// Navigate sidebar items with arrow keys
const navigate = (direction: "up" | "down") => {
const flat: { type: "local" | "wt" | "session"; id: string }[] = [
{ type: "local", id: "local" },
const flat: { type: typeof LOCAL | "wt" | "session"; id: string }[] = [
{ type: LOCAL, id: LOCAL },
...worktrees().map((wt) => ({ type: "wt" as const, id: wt.id })),
...unassignedSessions().map((s) => ({ type: "session" as const, id: s.id })),
]
@@ -184,11 +321,12 @@ const AgentManagerContent: Component = () => {
if (next < 0 || next >= flat.length) return
const item = flat[next]!
if (item.type === "local") {
if (item.type === LOCAL) {
selectLocal()
} else if (item.type === "wt") {
selectWorktree(item.id)
} else {
saveTabMemory()
setSelection(null)
session.selectSession(item.id)
}
@@ -217,15 +355,18 @@ const AgentManagerContent: Component = () => {
}
const selectLocal = () => {
setSelection("local")
saveTabMemory()
setSelection(LOCAL)
vscode.postMessage({ type: "agentManager.requestRepoInfo" })
const locals = localSessions()
const first = locals[0]
if (first && !isPending(first.id)) {
const remembered = tabMemory()[LOCAL]
const target = remembered ? locals.find((s) => s.id === remembered) : undefined
const fallback = target ?? locals[0]
if (fallback && !isPending(fallback.id)) {
setActivePendingId(undefined)
session.selectSession(first.id)
} else if (first && isPending(first.id)) {
setActivePendingId(first.id)
session.selectSession(fallback.id)
} else if (fallback && isPending(fallback.id)) {
setActivePendingId(fallback.id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
@@ -234,12 +375,16 @@ const AgentManagerContent: Component = () => {
}
const selectWorktree = (worktreeId: string) => {
saveTabMemory()
setSelection(worktreeId)
const managed = managedSessions().filter((ms) => ms.worktreeId === worktreeId)
const ids = new Set(managed.map((ms) => ms.id))
const first = session.sessions().find((s) => ids.has(s.id))
if (first) {
session.selectSession(first.id)
const sessions = session.sessions().filter((s) => ids.has(s.id))
const remembered = tabMemory()[worktreeId]
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
const fallback = target ?? sessions[0]
if (fallback) {
session.selectSession(fallback.id)
} else {
session.setCurrentSessionID(undefined)
}
@@ -253,17 +398,32 @@ const AgentManagerContent: Component = () => {
else if (msg.action === "sessionNext") navigate("down")
else if (msg.action === "tabPrevious") navigateTab("left")
else if (msg.action === "tabNext") navigateTab("right")
else if (msg.action === "showTerminal") {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
} else if (msg.action === "newTab") handleNewTabForCurrentSelection()
else if (msg.action === "closeTab") closeActiveTab()
else if (msg.action === "newWorktree") handleNewWorktreeOrPromote()
else if (msg.action === "closeWorktree") closeSelectedWorktree()
}
window.addEventListener("message", handler)
// Prevent Cmd+Up/Down/Left/Right from triggering native scroll
const preventScroll = (e: KeyboardEvent) => {
// Prevent Cmd+Arrow/T/W/N from triggering native browser actions
const preventDefaults = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
e.preventDefault()
}
// Prevent browser defaults for our shortcuts (new tab, close tab, new window)
if (["t", "w", "n"].includes(e.key.toLowerCase()) && !e.shiftKey) {
e.preventDefault()
}
// Prevent defaults for shift variants (close worktree)
if (e.key.toLowerCase() === "w" && e.shiftKey) {
e.preventDefault()
}
}
window.addEventListener("keydown", preventScroll)
window.addEventListener("keydown", preventDefaults)
// When the panel regains focus (e.g. returning from terminal), focus the prompt
const onWindowFocus = () => window.dispatchEvent(new Event("focusPrompt"))
@@ -272,7 +432,7 @@ const AgentManagerContent: Component = () => {
// When a session is created while on local, replace the current pending tab with the real session.
// Guard against duplicate sessionCreated events (HTTP response + SSE can both fire).
const unsubCreate = vscode.onMessage((msg) => {
if (msg.type === "sessionCreated" && selection() === "local") {
if (msg.type === "sessionCreated" && selection() === LOCAL) {
const created = msg as { type: string; session: { id: string } }
if (localSessionIDs().includes(created.session.id)) return
const pending = activePendingId()
@@ -285,6 +445,11 @@ const AgentManagerContent: Component = () => {
}
})
// Mark sessions loaded as soon as the session context receives data (even if empty)
const unsubSessions = vscode.onMessage((msg) => {
if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true)
})
const unsub = vscode.onMessage((msg) => {
if (msg.type === "agentManager.repoInfo") {
const info = msg as AgentManagerRepoInfoMessage
@@ -299,29 +464,60 @@ const AgentManagerContent: Component = () => {
globalThis.setTimeout(() => setSetup({ active: false, message: "" }), error ? 3000 : 500)
if (!error && ev.sessionId) {
session.selectSession(ev.sessionId)
// Auto-switch sidebar to the worktree containing this session
const ms = managedSessions().find((s) => s.id === ev.sessionId)
if (ms?.worktreeId) setSelection(ms.worktreeId)
}
} else {
setSetup({ active: true, message: ev.message, branch: ev.branch })
}
}
if (msg.type === "agentManager.sessionAdded") {
const ev = msg as { type: string; sessionId: string; worktreeId: string }
session.selectSession(ev.sessionId)
}
if (msg.type === "agentManager.keybindings") {
const ev = msg as AgentManagerKeybindingsMessage
setKb(ev.bindings)
}
if (msg.type === "agentManager.state") {
const state = msg as AgentManagerStateMessage
setWorktrees(state.worktrees)
setManagedSessions(state.sessions)
if (!worktreesLoaded()) setWorktreesLoaded(true)
if (state.tabOrder) setWorktreeTabOrder(state.tabOrder)
const current = session.currentSessionID()
if (current) {
const ms = state.sessions.find((s) => s.id === current)
if (ms?.worktreeId) setSelection(ms.worktreeId)
}
// 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)
}
// Clear deleting state for worktrees that have been removed
const ids = new Set(state.worktrees.map((wt) => wt.id))
setDeletingWorktrees((prev) => {
const next = new Set([...prev].filter((id) => ids.has(id)))
return next.size === prev.size ? prev : next
})
}
})
onCleanup(() => {
window.removeEventListener("message", handler)
window.removeEventListener("keydown", preventScroll)
window.removeEventListener("keydown", preventDefaults)
window.removeEventListener("focus", onWindowFocus)
unsubCreate()
unsubSessions()
unsub()
})
})
@@ -329,20 +525,71 @@ const AgentManagerContent: Component = () => {
// Always select local on mount to initialize branch info and session state
onMount(() => {
selectLocal()
// Request worktree/session state from extension — handles race where
// initializeState() pushState fires before the webview is mounted
vscode.postMessage({ type: "agentManager.requestState" })
// Open a pending "New Session" tab if there are no persisted local sessions
if (localSessionIDs().length === 0) {
addPendingTab()
}
})
const handleConfigureSetupScript = () => {
vscode.postMessage({ type: "agentManager.configureSetupScript" })
}
const handleCreateWorktree = () => {
vscode.postMessage({ type: "agentManager.createWorktree" })
}
const confirmDeleteWorktree = (worktreeId: string) => {
const wt = worktrees().find((w) => w.id === worktreeId)
if (!wt) return
const doDelete = () => {
setDeletingWorktrees((prev) => new Set([...prev, wt.id]))
vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId: wt.id })
if (selection() === wt.id) {
const next = nextSelectionAfterDelete(
wt.id,
worktrees().map((w) => w.id),
)
if (next === LOCAL) selectLocal()
else selectWorktree(next)
}
dialog.close()
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
doDelete()
}
}
dialog.show(() => (
<Dialog title="Delete Worktree" fit>
<div class="am-confirm" onKeyDown={onKeyDown}>
<div class="am-confirm-message">
<Icon name="trash" size="small" />
<span>
Delete worktree <code class="am-confirm-branch">{wt.branch}</code>? This removes the worktree from disk
and dissociates all sessions.
</span>
</div>
<div class="am-confirm-actions">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" class="am-confirm-delete" onClick={doDelete} autofocus>
Delete
</Button>
</div>
</div>
</Dialog>
))
}
const handleDeleteWorktree = (worktreeId: string, e: MouseEvent) => {
e.stopPropagation()
vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId })
if (selection() === worktreeId) selectLocal()
confirmDeleteWorktree(worktreeId)
}
const handlePromote = (sessionId: string, e: MouseEvent) => {
@@ -352,7 +599,7 @@ const AgentManagerContent: Component = () => {
const handleAddSession = () => {
const sel = selection()
if (sel === "local") {
if (sel === LOCAL) {
addPendingTab()
} else if (sel) {
vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel })
@@ -394,12 +641,115 @@ const AgentManagerContent: Component = () => {
}
}
// Drag-and-drop handlers for tab reordering
const tabIds = createMemo(() => activeTabs().map((s) => s.id))
const handleDragStart = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id === "string") setDraggingTab(id)
}
const handleDragOver = (event: DragEvent) => {
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
const sel = selection()
if (sel === LOCAL) {
setLocalSessionIDs((prev) => reorderTabs(prev, from, to) ?? prev)
} else if (sel) {
setWorktreeTabOrder((prev) => {
const ids = applyTabOrder(
tabIds().map((id) => ({ id })),
prev[sel],
).map((item) => item.id)
const reordered = reorderTabs(ids, from, to)
if (!reordered) return prev
return { ...prev, [sel]: reordered }
})
}
}
const handleDragEnd = () => {
setDraggingTab(undefined)
// Persist the new tab order to the extension
const sel = selection()
if (sel === LOCAL) {
const order = localSessionIDs().filter((id) => !isPending(id))
if (order.length > 0) vscode.postMessage({ type: "agentManager.setTabOrder", key: LOCAL, order })
} else if (sel) {
const order = worktreeTabOrder()[sel]
if (order) vscode.postMessage({ type: "agentManager.setTabOrder", key: sel, order })
}
}
const draggedTab = createMemo(() => {
const id = draggingTab()
if (!id) return undefined
return activeTabs().find((s) => s.id === id)
})
// Close the currently active tab via keyboard shortcut.
// If no tabs remain, fall through to close the selected worktree.
const closeActiveTab = () => {
const tabs = activeTabs()
if (tabs.length === 0) {
closeSelectedWorktree()
return
}
const current = session.currentSessionID()
const pending = activePendingId()
const target = current
? tabs.find((s) => s.id === current)
: pending
? tabs.find((s) => s.id === pending)
: undefined
if (!target) return
const synthetic = new MouseEvent("click")
handleCloseTab(target.id, synthetic)
}
// Cmd+T: add a new tab strictly to the current selection (no side effects)
const handleNewTabForCurrentSelection = () => {
const sel = selection()
if (sel === LOCAL) {
addPendingTab()
} else if (sel) {
// Pass the captured worktree ID directly to avoid race conditions
vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel })
}
}
// Cmd+N: if an unassigned session is selected, promote it; otherwise create a new worktree
const handleNewWorktreeOrPromote = () => {
const sel = selection()
const sid = session.currentSessionID()
if (sel === null && sid && !worktreeSessionIds().has(sid)) {
vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
return
}
handleCreateWorktree()
}
// Close the currently selected worktree with a confirmation dialog
const closeSelectedWorktree = () => {
const sel = selection()
if (!sel || sel === LOCAL) return
confirmDeleteWorktree(sel)
}
return (
<div class="am-layout">
<div class="am-sidebar">
<div class="am-sidebar" style={{ width: `${sidebarWidth()}px` }}>
<ResizeHandle
direction="horizontal"
size={sidebarWidth()}
min={MIN_SIDEBAR_WIDTH}
max={9999}
onResize={(width) => setSidebarWidth(Math.min(width, window.innerWidth * MAX_SIDEBAR_WIDTH_RATIO))}
/>
{/* Local workspace item */}
<button
class={`am-local-item ${selection() === "local" ? "am-local-item-active" : ""}`}
class={`am-local-item ${selection() === LOCAL ? "am-local-item-active" : ""}`}
data-sidebar-id="local"
onClick={() => selectLocal()}
>
@@ -420,36 +770,141 @@ const AgentManagerContent: Component = () => {
<div class="am-section">
<div class="am-section-header">
<span class="am-section-label">WORKTREES</span>
<IconButton icon="plus" size="small" variant="ghost" label="New Worktree" onClick={handleCreateWorktree} />
<div class="am-section-actions">
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="settings-gear"
size="small"
variant="ghost"
label="Worktree settings"
/>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={handleConfigureSetupScript}>
<DropdownMenu.ItemLabel>Worktree Setup Script</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<TooltipKeybind title="New worktree" keybind={kb().newWorktree ?? ""} placement="bottom">
<IconButton
icon="plus"
size="small"
variant="ghost"
label="New worktree"
onClick={handleCreateWorktree}
/>
</TooltipKeybind>
</div>
</div>
<div class="am-worktree-list">
<For each={worktrees()}>
{(wt) => (
<div
class={`am-worktree-item ${selection() === wt.id ? "am-worktree-item-active" : ""}`}
data-sidebar-id={wt.id}
onClick={() => selectWorktree(wt.id)}
>
<Icon name="branch" size="small" />
<span class="am-worktree-branch" title={wt.branch}>
{worktreeLabel(wt)}
</span>
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Close worktree"
class="am-worktree-close"
onClick={(e: MouseEvent) => handleDeleteWorktree(wt.id, e)}
/>
<Show
when={worktreesLoaded() && sessionsLoaded()}
fallback={
<div class="am-skeleton-list">
<div class="am-skeleton-wt">
<div class="am-skeleton-wt-icon" />
<div class="am-skeleton-wt-text" style={{ width: "60%" }} />
</div>
</div>
)}
</For>
<Show when={worktrees().length === 0}>
<button class="am-worktree-create" onClick={handleCreateWorktree}>
<Icon name="plus" size="small" />
<span>New Worktree</span>
</button>
}
>
{(() => {
const [hoveredWt, setHoveredWt] = createSignal<string | null>(null)
const [overClose, setOverClose] = createSignal(false)
return (
<For each={worktrees()}>
{(wt, wtIdx) => {
const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id))
const navHint = () => {
const flat = [
LOCAL as string,
...worktrees().map((w) => w.id),
...unassignedSessions().map((s) => s.id),
]
const active = selection() ?? session.currentSessionID() ?? ""
return adjacentHint(wt.id, active, flat, kb().previousSession ?? "", kb().nextSession ?? "")
}
return (
<HoverCard
openDelay={100}
closeDelay={100}
placement="right-start"
gutter={8}
open={hoveredWt() === wt.id && !overClose()}
onOpenChange={(open) => setHoveredWt(open ? wt.id : null)}
trigger={
<div
class={`am-worktree-item ${selection() === wt.id ? "am-worktree-item-active" : ""}`}
data-sidebar-id={wt.id}
onClick={() => selectWorktree(wt.id)}
>
<Icon name="branch" size="small" />
<span class="am-worktree-branch">{worktreeLabel(wt)}</span>
<Show
when={!deletingWorktrees().has(wt.id)}
fallback={<Spinner class="am-worktree-spinner" />}
>
<div
class="am-worktree-close"
onMouseEnter={() => setOverClose(true)}
onMouseLeave={() => setOverClose(false)}
>
<TooltipKeybind
title="Delete worktree"
keybind={kb().closeWorktree ?? ""}
placement="top"
>
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Delete worktree"
onClick={(e: MouseEvent) => handleDeleteWorktree(wt.id, e)}
/>
</TooltipKeybind>
</div>
</Show>
</div>
}
>
<div class="am-hover-card">
<div class="am-hover-card-header">
<div>
<div class="am-hover-card-label">BRANCH</div>
<div class="am-hover-card-branch">{wt.branch}</div>
<div class="am-hover-card-meta">{formatRelativeDate(wt.createdAt)}</div>
</div>
<Show when={navHint()}>
<span class="am-hover-card-keybind">{navHint()}</span>
</Show>
</div>
<Show when={wt.parentBranch}>
<div class="am-hover-card-divider" />
<div class="am-hover-card-row">
<span class="am-hover-card-row-label">Base</span>
<span class="am-hover-card-row-value">{wt.parentBranch}</span>
</div>
</Show>
<div class="am-hover-card-divider" />
<div class="am-hover-card-row">
<span class="am-hover-card-row-label">Sessions</span>
<span class="am-hover-card-row-value">{sessions().length}</span>
</div>
</div>
</HoverCard>
)
}}
</For>
)
})()}
<Show when={worktrees().length === 0}>
<button class="am-worktree-create" onClick={handleCreateWorktree}>
<Icon name="plus" size="small" />
<span>New Worktree</span>
</button>
</Show>
</Show>
</div>
</div>
@@ -460,29 +915,53 @@ const AgentManagerContent: Component = () => {
<span class="am-section-label">SESSIONS</span>
</div>
<div class="am-list">
<For each={unassignedSessions()}>
{(s) => (
<button
class={`am-item ${s.id === session.currentSessionID() && selection() === null ? "am-item-active" : ""}`}
data-sidebar-id={s.id}
onClick={() => {
setSelection(null)
session.selectSession(s.id)
}}
>
<span class="am-item-title">{s.title || "Untitled"}</span>
<span class="am-item-time">{formatRelativeDate(s.updatedAt)}</span>
<IconButton
icon="branch"
size="small"
variant="ghost"
label="Open in worktree"
class="am-item-promote"
onClick={(e: MouseEvent) => handlePromote(s.id, e)}
/>
</button>
)}
</For>
<Show
when={sessionsLoaded()}
fallback={
<div class="am-skeleton-list">
<div class="am-skeleton-session">
<div class="am-skeleton-session-title" style={{ width: "70%" }} />
<div class="am-skeleton-session-time" />
</div>
<div class="am-skeleton-session">
<div class="am-skeleton-session-title" style={{ width: "55%" }} />
<div class="am-skeleton-session-time" />
</div>
<div class="am-skeleton-session">
<div class="am-skeleton-session-title" style={{ width: "65%" }} />
<div class="am-skeleton-session-time" />
</div>
</div>
}
>
<For each={unassignedSessions()}>
{(s) => (
<button
class={`am-item ${s.id === session.currentSessionID() && selection() === null ? "am-item-active" : ""}`}
data-sidebar-id={s.id}
onClick={() => {
saveTabMemory()
setSelection(null)
session.selectSession(s.id)
}}
>
<span class="am-item-title">{s.title || "Untitled"}</span>
<span class="am-item-time">{formatRelativeDate(s.updatedAt)}</span>
<div class="am-item-promote">
<TooltipKeybind title="Open in worktree" keybind={kb().newWorktree ?? ""} placement="right">
<IconButton
icon="branch"
size="small"
variant="ghost"
label="Open in worktree"
onClick={(e: MouseEvent) => handlePromote(s.id, e)}
/>
</TooltipKeybind>
</div>
</button>
)}
</For>
</Show>
</div>
</div>
</div>
@@ -490,53 +969,92 @@ const AgentManagerContent: Component = () => {
<div class="am-detail">
{/* Tab bar — visible when a section is selected and has tabs or a pending new session */}
<Show when={selection() !== null && !contextEmpty()}>
<div class="am-tab-bar">
<div class="am-tab-list">
<For each={activeTabs()}>
{(s) => {
const pending = isPending(s.id)
const active = () =>
pending
? s.id === activePendingId() && !session.currentSessionID()
: s.id === session.currentSessionID()
return (
<Tooltip value={s.title || "Untitled"} placement="bottom">
<div
class={`am-tab ${active() ? "am-tab-active" : ""}`}
onClick={() => {
if (pending) {
setActivePendingId(s.id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
session.selectSession(s.id)
}
}}
onMouseDown={(e: MouseEvent) => handleTabMouseDown(s.id, e)}
>
<span class="am-tab-label">{s.title || "Untitled"}</span>
<button
class="am-tab-close"
onClick={(e: MouseEvent) => handleCloseTab(s.id, e)}
aria-label="Close tab"
>
×
</button>
</div>
</Tooltip>
)
}}
</For>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="am-tab-bar">
<div class="am-tab-scroll-area">
<div class={`am-tab-fade am-tab-fade-left ${tabScroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list" ref={tabScroll.setRef}>
<SortableProvider ids={tabIds()}>
<For each={activeTabs()}>
{(s) => {
const pending = isPending(s.id)
const active = () =>
pending
? s.id === activePendingId() && !session.currentSessionID()
: s.id === session.currentSessionID()
const tabDirection = () => {
if (active()) return ""
const ids = activeTabs().map((t) => t.id)
const activeId = session.currentSessionID() ?? activePendingId() ?? ""
return adjacentHint(s.id, activeId, ids, kb().previousTab ?? "", kb().nextTab ?? "")
}
return (
<SortableTab
tab={s}
active={active()}
keybind={tabDirection()}
closeKeybind={kb().closeTab ?? ""}
onSelect={() => {
if (pending) {
setActivePendingId(s.id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
session.selectSession(s.id)
}
}}
onMiddleClick={(e: MouseEvent) => handleTabMouseDown(s.id, e)}
onClose={(e: MouseEvent) => handleCloseTab(s.id, e)}
/>
)
}}
</For>
</SortableProvider>
</div>
<div class={`am-tab-fade am-tab-fade-right ${tabScroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
<TooltipKeybind title="New session" keybind={kb().newTab ?? ""} placement="bottom">
<IconButton
icon="plus"
size="small"
variant="ghost"
label="New session"
class="am-tab-add"
onClick={handleAddSession}
/>
</TooltipKeybind>
<div class="am-tab-terminal">
<TooltipKeybind title="Terminal" keybind={kb().showTerminal ?? ""} placement="bottom">
<IconButton
icon="console"
size="small"
variant="ghost"
label="Open Terminal"
onClick={() => {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
}}
/>
</TooltipKeybind>
</div>
</div>
<IconButton
icon="plus"
size="small"
variant="ghost"
label="New session"
class="am-tab-add"
onClick={handleAddSession}
/>
</div>
<DragOverlay>
<Show when={draggedTab()}>
{(tab) => (
<div class="am-tab am-tab-overlay">
<span class="am-tab-label">{tab().title || "Untitled"}</span>
</div>
)}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
{/* Empty worktree state */}
@@ -548,6 +1066,7 @@ const AgentManagerContent: Component = () => {
<div class="am-empty-state-text">No sessions open</div>
<Button variant="primary" size="small" onClick={handleAddSession}>
New session
<span class="am-shortcut-hint">{kb().newTab ?? ""}</span>
</Button>
</div>
</Show>
@@ -556,7 +1075,7 @@ const AgentManagerContent: Component = () => {
<div class="am-setup-overlay">
<div class="am-setup-card">
<Icon name="branch" size="large" />
<div class="am-setup-title">Setting up workspace</div>
<div class="am-setup-title">{setup().error ? "Workspace setup failed" : "Setting up workspace"}</div>
<Show when={setup().branch}>
<div class="am-setup-branch">{setup().branch}</div>
</Show>
@@ -7,17 +7,21 @@
}
.am-sidebar {
width: 260px;
position: relative;
min-width: 200px;
border-right: 1px solid var(--border-weak-base, var(--vscode-panel-border, #333));
border-right: 1px solid var(--border-weak-base);
display: flex;
flex-direction: column;
flex-shrink: 0;
background: var(--surface-base, var(--vscode-sideBar-background, #181818));
background: var(--surface-base);
padding: 8px;
gap: 4px;
}
.am-sidebar > [data-component="resize-handle"]::after {
background: var(--surface-interactive-base);
}
/* Fixed local workspace item */
.am-local-item {
@@ -25,32 +29,34 @@
align-items: center;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
background: none;
border: none;
width: 100%;
text-align: left;
color: var(--text-base, var(--vscode-foreground));
font-size: 13px;
font-family: inherit;
color: var(--text-base);
font-size: var(--font-size-base);
flex-shrink: 0;
transition: background-color 0.15s ease;
}
.am-local-item:hover {
background: var(--surface-inset-base-hover, rgba(255, 255, 255, 0.05));
background: var(--surface-inset-base-hover);
}
.am-local-item:active {
opacity: 0.8;
}
.am-local-item-active {
background: var(--surface-base-active, rgba(255, 255, 255, 0.08));
background: var(--surface-base-active);
}
.am-local-icon {
width: 18px;
height: 18px;
flex-shrink: 0;
color: var(--text-weaker, var(--vscode-descriptionForeground, #666));
color: var(--text-weaker);
}
.am-local-text {
@@ -61,14 +67,14 @@
}
.am-local-label {
font-size: 13px;
font-size: var(--font-size-base);
font-weight: 500;
line-height: 1.4;
}
.am-local-branch {
font-size: 11px;
color: var(--text-weaker, var(--vscode-descriptionForeground, #777));
font-size: var(--font-size-small);
color: var(--text-weaker);
line-height: 1.2;
overflow: hidden;
text-overflow: ellipsis;
@@ -96,11 +102,17 @@
}
.am-section-label {
font-size: 11px;
font-size: var(--font-size-small);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
color: var(--text-weak);
}
.am-section-actions {
display: flex;
align-items: center;
gap: 2px;
}
/* Worktree list */
@@ -110,29 +122,41 @@
flex-direction: column;
gap: 2px;
overflow-y: auto;
overflow-x: hidden;
max-height: 50vh;
}
/* Worktree item — larger card style */
.am-worktree-list [data-slot="hover-card-trigger"] {
min-width: 0;
}
.am-worktree-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
padding: 10px 10px;
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 13px;
color: var(--text-base, var(--vscode-foreground));
font-size: var(--font-size-base);
color: var(--text-base);
min-width: 0;
width: 100%;
}
.am-worktree-item:hover {
background: var(--surface-inset-base-hover, var(--vscode-list-hoverBackground, #2a2d2e));
background: var(--surface-inset-base-hover);
}
.am-worktree-item:active {
opacity: 0.8;
}
.am-worktree-item-active {
background: var(--surface-interactive-base, var(--vscode-list-activeSelectionBackground, #04395e));
color: var(--text-on-interactive-base, var(--vscode-list-activeSelectionForeground, #fff));
background: var(--surface-interactive-base);
color: var(--text-on-interactive-base);
}
.am-worktree-branch {
@@ -145,6 +169,24 @@
}
.am-worktree-close {
position: absolute;
right: 4px;
flex-shrink: 0;
opacity: 0;
}
.am-worktree-item:hover .am-worktree-branch {
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent calc(100% - 16px));
-webkit-mask-image: linear-gradient(to right, black calc(100% - 48px), transparent calc(100% - 16px));
}
.am-worktree-item:hover .am-worktree-close {
opacity: 1;
}
.am-worktree-spinner {
width: 16px;
height: 16px;
flex-shrink: 0;
}
@@ -162,30 +204,33 @@
/* Session items */
.am-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
border-radius: 6px;
border-radius: var(--radius-sm);
cursor: pointer;
background: none;
border: none;
width: 100%;
text-align: left;
color: var(--text-base, var(--vscode-foreground));
font-size: 13px;
font-family: inherit;
transition: background-color 0.15s ease;
color: var(--text-base);
font-size: var(--font-size-base);
}
.am-item:hover {
background: var(--surface-inset-base-hover, rgba(255, 255, 255, 0.05));
background: var(--surface-inset-base-hover);
}
.am-item:active {
opacity: 0.8;
}
.am-item-active {
background: var(--surface-base-active, rgba(255, 255, 255, 0.08));
color: var(--text-base, var(--vscode-foreground));
background: var(--surface-base-active);
color: var(--text-base);
}
.am-item-title {
@@ -198,22 +243,27 @@
}
.am-item-time {
font-size: 11px;
font-size: var(--font-size-small);
white-space: nowrap;
color: var(--text-weaker, var(--vscode-descriptionForeground, #888));
color: var(--text-weaker);
flex-shrink: 0;
}
.am-item-active .am-item-time {
color: var(--text-weaker, var(--vscode-descriptionForeground, #888));
color: var(--text-weaker);
}
/* Promote button on session rows */
.am-item-promote {
position: absolute;
right: 4px;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.1s ease;
}
.am-item:hover .am-item-time {
opacity: 0;
}
.am-item:hover .am-item-promote {
@@ -229,18 +279,21 @@
gap: 6px;
width: 100%;
padding: 8px;
border: 1px dashed var(--border-weak-base, var(--vscode-panel-border, #444));
border-radius: 6px;
border: 1px dashed var(--border-weak-base);
border-radius: var(--radius-sm);
background: none;
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
color: var(--text-weak);
font-size: 12px;
font-family: inherit;
cursor: pointer;
}
.am-worktree-create:hover {
background: var(--surface-inset-base-hover, var(--vscode-list-hoverBackground, #2a2d2e));
color: var(--text-base, var(--vscode-foreground));
background: var(--surface-inset-base-hover);
color: var(--text-base);
}
.am-worktree-create:active {
opacity: 0.8;
}
/* Detail pane */
@@ -259,8 +312,8 @@
.am-tab-bar {
display: flex;
align-items: center;
border-bottom: 1px solid var(--border-weak-base, var(--vscode-panel-border, #333));
background: var(--surface-base, var(--vscode-editorGroupHeader-tabsBackground, #1e1e1e));
border-bottom: 1px solid var(--border-weak-base);
background: var(--surface-base);
height: 36px;
padding: 0 4px;
flex-shrink: 0;
@@ -272,6 +325,51 @@
min-width: 0;
overflow-x: auto;
height: 100%;
/* Hide scrollbar while keeping scroll functionality */
scrollbar-width: none;
-ms-overflow-style: none;
}
.am-tab-list::-webkit-scrollbar {
display: none;
}
/* Wrapper that positions overflow fade indicators */
.am-tab-scroll-area {
position: relative;
display: flex;
min-width: 0;
flex-shrink: 1;
align-items: stretch;
height: 100%;
overflow: hidden;
}
/* Fade indicators for overflow */
.am-tab-fade {
position: absolute;
top: 0;
bottom: 0;
width: 32px;
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 0.15s ease;
}
.am-tab-fade-visible {
opacity: 1;
}
.am-tab-fade-left {
left: 0;
background: linear-gradient(to right, var(--surface-base) 0%, transparent 100%);
}
.am-tab-fade-right {
right: 0;
background: linear-gradient(to left, var(--surface-base) 0%, transparent 100%);
}
.am-tab {
@@ -280,9 +378,8 @@
gap: 6px;
padding: 0 12px;
background: none;
color: var(--text-weak, var(--vscode-tab-inactiveForeground, #888));
color: var(--text-weak);
font-size: 12px;
font-family: inherit;
cursor: pointer;
white-space: nowrap;
border: none;
@@ -292,12 +389,12 @@
}
.am-tab:hover {
color: var(--text-base, var(--vscode-tab-activeForeground, #fff));
color: var(--text-base);
}
.am-tab-active {
color: var(--text-base, var(--vscode-tab-activeForeground, #fff));
border-bottom-color: var(--surface-interactive-base, var(--vscode-focusBorder, #007acc));
color: var(--text-base);
border-bottom-color: var(--surface-interactive-base);
}
.am-tab-label {
@@ -308,21 +405,11 @@
}
.am-tab-close {
display: flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border: none;
background: none;
color: inherit;
font-size: 14px;
line-height: 1;
border-radius: 3px;
cursor: pointer;
opacity: 0;
flex-shrink: 0;
transition: opacity 0.1s ease;
}
.am-tab .am-tab-close {
opacity: 0;
}
.am-tab:hover .am-tab-close {
@@ -335,7 +422,29 @@
.am-tab-close:hover {
opacity: 1;
background: var(--surface-inset-base-hover, rgba(255, 255, 255, 0.1));
}
/* Drag-and-drop sortable tab wrapper */
.am-tab-sortable {
display: flex;
height: 100%;
touch-action: none;
}
.am-tab-dragging {
opacity: 0.25;
}
/* Drag overlay tab (follows the cursor) */
.am-tab-overlay {
background: var(--surface-base);
border: 1px solid var(--border-weak-base);
border-radius: var(--radius-sm);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
pointer-events: none;
color: var(--text-base);
}
.am-tab-add {
@@ -343,6 +452,13 @@
align-self: center;
}
.am-tab-terminal {
flex-shrink: 0;
align-self: center;
margin-left: auto;
padding-right: 4px;
}
/* Empty worktree state */
.am-empty-state {
@@ -360,15 +476,15 @@
display: flex;
align-items: center;
justify-content: center;
border-radius: 16px;
background: var(--surface-inset-base, var(--vscode-input-background, #252526));
color: var(--text-weaker, var(--vscode-descriptionForeground, #555));
border-radius: var(--radius-xl);
background: var(--surface-inset-base);
color: var(--text-weaker);
}
.am-empty-state-text {
font-size: 15px;
font-weight: 500;
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
color: var(--text-weak);
}
/* Read-only session wrapper + banner */
@@ -388,13 +504,13 @@
gap: 12px;
padding: 14px 16px;
flex-shrink: 0;
border-top: 1px solid var(--border-weak-base, var(--vscode-panel-border, #333));
background: var(--surface-inset-base, var(--vscode-input-background, #252526));
border-top: 1px solid var(--border-weak-base);
background: var(--surface-inset-base);
}
.am-readonly-text {
font-size: 13px;
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
font-size: var(--font-size-base);
color: var(--text-weak);
}
/* Setup overlay */
@@ -406,7 +522,7 @@
display: flex;
align-items: center;
justify-content: center;
background: var(--surface-base, var(--vscode-editor-background, #1e1e1e));
background: var(--surface-base);
animation: am-fade-in 0.15s ease;
}
@@ -424,36 +540,38 @@
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 300px;
max-width: 400px;
text-align: center;
}
.am-setup-card > [data-component="icon"] {
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
color: var(--text-weak);
opacity: 0.5;
}
.am-setup-title {
font-size: 16px;
font-size: var(--font-size-large);
font-weight: 600;
color: var(--text-base, var(--vscode-foreground));
color: var(--text-base);
}
.am-setup-branch {
font-size: 12px;
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
font-family: var(--vscode-editor-font-family, monospace);
color: var(--text-weak);
font-family: var(--font-family-mono, monospace);
}
.am-setup-status {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
border-radius: 6px;
background: var(--surface-inset-base, var(--vscode-input-background, #252526));
color: var(--text-base, var(--vscode-foreground));
font-size: 13px;
border-radius: var(--radius-sm);
background: var(--surface-inset-base);
color: var(--text-base);
font-size: var(--font-size-base);
text-align: center;
}
.am-setup-spinner {
@@ -461,3 +579,226 @@
height: 16px;
flex-shrink: 0;
}
/* Confirmation dialog */
.am-confirm {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 24px 16px 24px;
}
.am-confirm-message {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: var(--font-size-base);
color: var(--text-base);
line-height: 1.5;
}
.am-confirm-message > [data-component="icon"] {
flex-shrink: 0;
color: var(--icon-critical-base);
margin-top: 2px;
}
.am-confirm-branch {
font-family: var(--font-family-mono, monospace);
font-size: var(--font-size-small);
padding: 1px 6px;
border-radius: var(--radius-sm);
background: var(--surface-inset-base);
white-space: nowrap;
}
.am-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.am-confirm-delete {
background: var(--surface-critical-strong) !important;
color: var(--text-on-brand-base) !important;
}
.am-confirm-delete:hover {
opacity: 0.9;
}
/* Keyboard shortcut hint inside buttons */
.am-shortcut-hint {
font-size: var(--font-size-small);
opacity: 0.6;
margin-left: 6px;
}
/* HoverCard popover for worktree items */
.am-hover-card {
padding: 10px 12px;
min-width: 160px;
max-width: 240px;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.am-hover-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.am-hover-card-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-weaker);
line-height: 1.4;
}
.am-hover-card-branch {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
line-height: 1.4;
word-break: break-all;
}
.am-hover-card-meta {
font-size: 12px;
color: var(--text-weaker);
line-height: 1.4;
}
.am-hover-card-keybind {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
padding: 0 6px;
border-radius: 3px;
background: var(--surface-inset-base);
border: 1px solid var(--border-weak-base);
font-family: var(--font-family-sans);
font-size: 11px;
font-weight: 500;
line-height: 1;
color: var(--text-weak);
white-space: nowrap;
}
.am-hover-card-divider {
height: 1px;
background: var(--border-weak-base);
margin: 6px 0;
}
.am-hover-card-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.am-hover-card-row-label {
font-size: 12px;
color: var(--text-weaker);
}
.am-hover-card-row-value {
font-size: 12px;
font-weight: 500;
color: var(--text-base);
}
/* Skeleton loading states */
@keyframes am-skeleton-pulse {
0%,
100% {
opacity: 0.12;
}
50% {
opacity: 0.28;
}
}
.am-skeleton-list {
display: flex;
flex-direction: column;
gap: 2px;
animation: am-fade-in 0.2s ease;
}
/* Worktree skeleton — matches .am-worktree-item layout */
.am-skeleton-wt {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 10px;
}
.am-skeleton-wt-icon {
width: 16px;
height: 16px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
flex-shrink: 0;
}
.am-skeleton-wt-text {
height: 13px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
}
.am-skeleton-wt:nth-child(2) .am-skeleton-wt-icon,
.am-skeleton-wt:nth-child(2) .am-skeleton-wt-text {
animation-delay: 0.15s;
}
/* Session skeleton — matches .am-item layout */
.am-skeleton-session {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 10px;
}
.am-skeleton-session-title {
height: 13px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
}
.am-skeleton-session-time {
height: 10px;
width: 52px;
border-radius: 3px;
background: var(--text-base);
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
flex-shrink: 0;
}
.am-skeleton-session:nth-child(2) .am-skeleton-session-title,
.am-skeleton-session:nth-child(2) .am-skeleton-session-time {
animation-delay: 0.15s;
}
.am-skeleton-session:nth-child(3) .am-skeleton-session-title,
.am-skeleton-session:nth-child(3) .am-skeleton-session-time {
animation-delay: 0.3s;
}
@@ -7,7 +7,10 @@
* Returns the action to take: select a session by ID, go to local, or do nothing.
*/
export type NavResult = { action: "select"; id: string } | { action: "local" } | { action: "none" }
/** Sentinel value for the local workspace selection. */
export const LOCAL = "local" as const
export type NavResult = { action: "select"; id: string } | { action: typeof LOCAL } | { action: "none" }
export function resolveNavigation(direction: "up" | "down", current: string | undefined, ids: string[]): NavResult {
// Determine current position: -1 = local, 0..N-1 = session index
@@ -25,7 +28,7 @@ export function resolveNavigation(direction: "up" | "down", current: string | un
const next = direction === "up" ? idx - 1 : idx + 1
// Moving up past the first session → go to local
if (next === -1) return { action: "local" }
if (next === -1) return { action: LOCAL }
// At the bottom boundary
if (next >= ids.length) return { action: "none" }
@@ -42,3 +45,44 @@ export function validateLocalSession(persisted: string | undefined, ids: string[
if (ids.indexOf(persisted) === -1) return undefined
return persisted
}
/**
* Return the keybinding hint for an item adjacent to the active item.
* Only returns a hint when the item is exactly one step away in the flat list.
* Returns empty string for non-adjacent items or the active item itself.
*
* @param itemId - The item being hovered
* @param activeId - The currently selected/active item (or undefined for LOCAL)
* @param flatIds - The full ordered sidebar list (LOCAL first, then worktrees, then sessions)
* @param prev - Display string for "go up" (e.g. "⌘↑" or keybinding)
* @param next - Display string for "go down" (e.g. "⌘↓" or keybinding)
*/
export function adjacentHint(
itemId: string,
activeId: string | undefined,
flatIds: string[],
prev: string,
next: string,
): string {
if (!activeId || itemId === activeId) return ""
const activeIdx = flatIds.indexOf(activeId)
const itemIdx = flatIds.indexOf(itemId)
if (activeIdx === -1 || itemIdx === -1) return ""
const diff = itemIdx - activeIdx
if (diff === -1) return prev
if (diff === 1) return next
return ""
}
/**
* After removing a worktree, pick the nearest remaining sidebar neighbor.
* Order: the worktree just below the one above LOCAL.
*/
export function nextSelectionAfterDelete(deletedId: string, worktreeIds: string[]): typeof LOCAL | string {
const idx = worktreeIds.indexOf(deletedId)
if (idx === -1) return LOCAL
const remaining = worktreeIds.filter((id) => id !== deletedId)
if (remaining.length === 0) return LOCAL
// Prefer the item that was below (same index in the shortened list), else the one above
return remaining[Math.min(idx, remaining.length - 1)]!
}
@@ -0,0 +1,78 @@
/**
* Drag-and-drop sortable tab components for the agent manager tab bar.
*/
import { Component, onCleanup } from "solid-js"
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd"
import type { Transformer } from "@thisbeyond/solid-dnd"
import { createRoot } from "solid-js"
import type { SessionInfo } from "../src/types/messages"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
/** Lock drag movement to the X axis (horizontal-only tab dragging). */
export const ConstrainDragYAxis: Component = () => {
const context = useDragDropContext()
if (!context) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = { id: "constrain-y-axis", order: 100, callback: (t) => ({ ...t, y: 0 }) }
const dispose = createRoot((dispose) => {
onDragStart(({ draggable }) => {
if (draggable) addTransformer("draggables", draggable.id as string, transformer)
})
onDragEnd(({ draggable }) => {
if (draggable) removeTransformer("draggables", draggable.id as string, transformer.id)
})
return dispose
})
onCleanup(dispose)
return null
}
/** Individual sortable tab wrapper using the `use:sortable` directive. */
export const SortableTab: Component<{
tab: SessionInfo
active: boolean
keybind?: string
closeKeybind?: string
onSelect: () => void
onMiddleClick: (e: MouseEvent) => void
onClose: (e: MouseEvent) => void
}> = (props) => {
const sortable = createSortable(props.tab.id)
// Prevent tree-shaking of the directive reference used by `use:sortable`
void sortable
return (
// @ts-ignore - use:sortable is a SolidJS directive compiled by esbuild-plugin-solid
<div
use:sortable
class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`}
data-tab-id={props.tab.id}
>
<TooltipKeybind
title={props.tab.title || "Untitled"}
keybind={props.keybind ?? ""}
placement="bottom"
inactive={props.active}
>
<div
class={`am-tab ${props.active ? "am-tab-active" : ""}`}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
>
<span class="am-tab-label">{props.tab.title || "Untitled"}</span>
<TooltipKeybind title="Close" keybind={props.closeKeybind ?? ""} placement="bottom">
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Close tab"
class="am-tab-close"
onClick={props.onClose}
/>
</TooltipKeybind>
</div>
</TooltipKeybind>
</div>
)
}
@@ -0,0 +1,62 @@
/**
* Pure tab-ordering logic for the agent manager.
*/
/**
* Reorder an array by moving the item at `from` to the position of `to`.
* Returns a new array, or undefined if either ID is not found or they are equal.
*/
export function reorderTabs(tabs: readonly string[], from: string, to: string): string[] | undefined {
if (from === to) return undefined
const fi = tabs.indexOf(from)
const ti = tabs.indexOf(to)
if (fi === -1 || ti === -1) return undefined
const result = [...tabs]
result.splice(fi, 1)
result.splice(ti, 0, from)
return result
}
/**
* Apply a custom ordering to a list of items.
*
* Items are returned in `order` sequence (skipping IDs not in `items`),
* followed by any items not present in `order`.
* Returns the original array unchanged if `order` is undefined or empty.
*/
export function applyTabOrder<T extends { id: string }>(items: T[], order: string[] | undefined): T[] {
if (!order || order.length === 0) return items
const lookup = new Map(items.map((item) => [item.id, item]))
const ordered: T[] = []
for (const id of order) {
const item = lookup.get(id)
if (item) {
ordered.push(item)
lookup.delete(id)
}
}
for (const item of lookup.values()) ordered.push(item)
return ordered
}
/**
* Find the title of the first item according to a custom order.
*
* Falls back to the first titled item in `items` if the order
* doesn't produce a match, then to `fallback`.
*/
export function firstOrderedTitle(
items: { id: string; title?: string }[],
order: string[] | undefined,
fallback: string,
): string {
if (order) {
const lookup = new Map(items.map((item) => [item.id, item]))
for (const id of order) {
const item = lookup.get(id)
if (item?.title) return item.title
}
}
const first = items.find((item) => item.title)
return first?.title || fallback
}
+11 -5
View File
@@ -51,15 +51,21 @@ export const DataBridge: Component<{ children: any }> = (props) => {
const data = createMemo(() => {
const id = session.currentSessionID()
const msgs = session.allMessages()
const parts = session.allParts()
const perms = id ? session.permissions().filter((p) => p.sessionID === id) : []
return {
session: session.sessions().map((s) => ({ ...s, id: s.id, role: "user" as const })) as unknown as any[],
session_status: {} as Record<string, any>,
session_diff: {} as Record<string, any[]>,
message: msgs as unknown as Record<string, SDKMessage[]>,
part: parts as unknown as Record<string, SDKPart[]>,
permission: id ? { [id]: session.permissions() as unknown as any[] } : {},
message: id ? { [id]: session.messages() as unknown as SDKMessage[] } : {},
part: id
? Object.fromEntries(
session
.messages()
.map((msg) => [msg.id, session.getParts(msg.id) as unknown as SDKPart[]])
.filter(([, parts]) => (parts as SDKPart[]).length > 0),
)
: {},
permission: id ? { [id]: perms as unknown as any[] } : {},
}
})
@@ -28,7 +28,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const sessionPermissions = () => session.permissions().filter((p) => p.sessionID === id())
const questionRequest = () => sessionQuestions()[0]
const permissionRequest = () => sessionPermissions()[0]
const permissionRequest = () => sessionPermissions().find((p) => !p.tool)
const blocked = () => sessionPermissions().length > 0 || sessionQuestions().length > 0
const [responding, setResponding] = createSignal(false)
@@ -70,10 +70,30 @@ export const PromptInput: Component = () => {
const canSend = () => (text().trim().length > 0 || imageAttach.images().length > 0) && !isBusy() && !isDisabled()
const unsubscribe = vscode.onMessage((message) => {
if (message.type !== "chatCompletionResult") return
const result = message as { type: "chatCompletionResult"; text: string; requestId: string }
if (result.requestId === `chat-ac-${requestCounter}` && result.text) {
setGhostText(result.text)
if (message.type === "chatCompletionResult") {
const result = message as { type: "chatCompletionResult"; text: string; requestId: string }
if (result.requestId === `chat-ac-${requestCounter}` && result.text) {
setGhostText(result.text)
}
}
if (message.type === "setChatBoxMessage") {
setText(message.text)
setGhostText("")
if (textareaRef) {
textareaRef.value = message.text
adjustHeight()
}
}
if (message.type === "triggerTask") {
if (isBusy() || isDisabled()) return
const sel = session.selected()
session.sendMessage(message.text, sel?.providerID, sel?.modelID)
}
if (message.type === "action" && message.action === "focusInput") {
textareaRef?.focus()
}
})
@@ -0,0 +1,13 @@
import type { PermissionRequest } from "../types/messages"
export function upsertPermission(list: PermissionRequest[], permission: PermissionRequest) {
const idx = list.findIndex((item) => item.id === permission.id)
if (idx === -1) return [...list, permission]
const next = list.slice()
next[idx] = permission
return next
}
export function removeSessionPermissions(list: PermissionRequest[], sessionID: string) {
return list.filter((item) => item.sessionID !== sessionID)
}
@@ -37,6 +37,7 @@ import type {
ExtensionMessage,
FileAttachment,
} from "../types/messages"
import { removeSessionPermissions, upsertPermission } from "./permission-queue"
// Derive human-readable status from the last streaming part
function computeStatus(
@@ -163,13 +164,22 @@ export const SessionProvider: ParentComponent = (props) => {
// Current session ID
const [currentSessionID, setCurrentSessionID] = createSignal<string | undefined>()
// Session status — store full info object, derive simple string for compat
const [statusInfo, setStatusInfo] = createSignal<SessionStatusInfo>({ type: "idle" })
const status = () => statusInfo().type as SessionStatus
const [loading, setLoading] = createSignal(false)
// Per-session status map — keyed by sessionID
const [statusMap, setStatusMap] = createStore<Record<string, SessionStatusInfo>>({})
const [busySinceMap, setBusySinceMap] = createStore<Record<string, number>>({})
// Track when the agent started working
const [busySince, setBusySince] = createSignal<number | undefined>()
// Derived accessors for the current session (backwards compatible)
const statusInfo = () => {
const id = currentSessionID()
return id ? (statusMap[id] ?? { type: "idle" }) : { type: "idle" }
}
const status = () => statusInfo().type as SessionStatus
const busySince = () => {
const id = currentSessionID()
return id ? busySinceMap[id] : undefined
}
const [loading, setLoading] = createSignal(false)
// Pending permissions
const [permissions, setPermissions] = createSignal<PermissionRequest[]>([])
@@ -479,24 +489,27 @@ export const SessionProvider: ParentComponent = (props) => {
message?: string,
next?: number,
) {
if (sessionID !== currentSessionID()) return
const prev = statusInfo()
const prev = statusMap[sessionID] ?? { type: "idle" }
const info: SessionStatusInfo =
newStatus === "retry"
? { type: "retry", attempt: attempt ?? 0, message: message ?? "", next: next ?? 0 }
: { type: newStatus }
setStatusInfo(info)
setStatusMap(sessionID, info)
// Track busy start time
if (prev.type === "idle" && newStatus !== "idle") {
setBusySince(Date.now())
setBusySinceMap(sessionID, Date.now())
}
if (newStatus === "idle") {
setBusySince(undefined)
setBusySinceMap(
produce((map) => {
delete map[sessionID]
}),
)
}
}
function handlePermissionRequest(permission: PermissionRequest) {
setPermissions((prev) => [...prev, permission])
setPermissions((prev) => upsertPermission(prev, permission))
}
function handleQuestionRequest(question: QuestionRequest) {
@@ -591,10 +604,19 @@ export const SessionProvider: ParentComponent = (props) => {
return next
})
}
setPermissions((prev) => removeSessionPermissions(prev, sessionID))
setStatusMap(
produce((map) => {
delete map[sessionID]
}),
)
setBusySinceMap(
produce((map) => {
delete map[sessionID]
}),
)
if (currentSessionID() === sessionID) {
setCurrentSessionID(undefined)
setStatusInfo({ type: "idle" })
setBusySince(undefined)
setLoading(false)
}
})
@@ -734,8 +756,6 @@ export const SessionProvider: ParentComponent = (props) => {
function clearCurrentSession() {
setCurrentSessionID(undefined)
setStatusInfo({ type: "idle" })
setBusySince(undefined)
setLoading(false)
setPermissions([])
setQuestions([])
@@ -760,8 +780,6 @@ export const SessionProvider: ParentComponent = (props) => {
return
}
setCurrentSessionID(id)
setStatusInfo({ type: "idle" })
setBusySince(undefined)
setLoading(true)
vscode.postMessage({ type: "loadMessages", sessionID: id })
}
@@ -401,6 +401,16 @@ export interface ActionMessage {
action: string
}
export interface SetChatBoxMessage {
type: "setChatBoxMessage"
text: string
}
export interface TriggerTaskMessage {
type: "triggerTask"
text: string
}
export interface ProfileDataMessage {
type: "profileData"
data: ProfileData | null
@@ -561,11 +571,25 @@ export interface ManagedSessionState {
createdAt: string
}
// Agent Manager session added to an existing worktree (no setup overlay needed)
export interface AgentManagerSessionAddedMessage {
type: "agentManager.sessionAdded"
sessionId: string
worktreeId: string
}
// Full state push from extension to webview
export interface AgentManagerStateMessage {
type: "agentManager.state"
worktrees: WorktreeState[]
sessions: ManagedSessionState[]
tabOrder?: Record<string, string[]>
}
// Resolved keybindings for agent manager actions
export interface AgentManagerKeybindingsMessage {
type: "agentManager.keybindings"
bindings: Record<string, string>
}
export type ExtensionMessage =
@@ -605,7 +629,11 @@ export type ExtensionMessage =
| AgentManagerSessionMetaMessage
| AgentManagerRepoInfoMessage
| AgentManagerWorktreeSetupMessage
| AgentManagerSessionAddedMessage
| AgentManagerStateMessage
| AgentManagerKeybindingsMessage
| SetChatBoxMessage
| TriggerTaskMessage
// ============================================
// Messages FROM webview TO extension
@@ -843,6 +871,28 @@ export interface RequestRepoInfoMessage {
type: "agentManager.requestRepoInfo"
}
export interface RequestStateMessage {
type: "agentManager.requestState"
}
// Configure worktree setup script
export interface ConfigureSetupScriptRequest {
type: "agentManager.configureSetupScript"
}
// Show terminal for a session
export interface ShowTerminalRequest {
type: "agentManager.showTerminal"
sessionId: string
}
// Persist tab order for a context (worktree ID or "local")
export interface SetTabOrderRequest {
type: "agentManager.setTabOrder"
key: string
order: string[]
}
export type WebviewMessage =
| SendMessageRequest
| AbortRequest
@@ -888,6 +938,10 @@ export type WebviewMessage =
| CloseSessionRequest
| TelemetryRequest
| RequestRepoInfoMessage
| RequestStateMessage
| ConfigureSetupScriptRequest
| ShowTerminalRequest
| SetTabOrderRequest
// ============================================
// VS Code API type
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.23",
"version": "1.0.24",
"name": "@kilocode/cli",
"type": "module",
"license": "MIT",
@@ -8,20 +8,6 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
import { useKeybind } from "../context/keybind"
import * as fuzzysort from "fuzzysort"
// kilocode_change start - Recommended models for Kilo Gateway (order determines display priority)
const KILO_RECOMMENDED_MODELS = [
"kilo/auto",
"anthropic/claude-sonnet-4.5",
"anthropic/claude-opus-4.5",
"anthropic/claude-haiku-4.5",
"openai/gpt-5.2",
"openai/gpt-5.2-codex",
"google/gemini-3-pro-preview",
"google/gemini-3-flash-preview",
"mistralai/devstral-2512",
]
// kilocode_change end
export function useConnected() {
const sync = useSync()
return createMemo(() =>
@@ -100,7 +86,7 @@ export function DialogModel(props: { providerID?: string }) {
: undefined,
// kilocode_change start
category: connected()
? provider.id === "kilo" && KILO_RECOMMENDED_MODELS.includes(model)
? provider.id === "kilo" && info.recommended
? "Recommended"
: provider.name
: undefined,
@@ -123,11 +109,11 @@ export function DialogModel(props: { providerID?: string }) {
sortBy(
// kilocode_change start - Sort recommended models first for Kilo Gateway
(x) => {
if (x.value.providerID === "kilo") {
const idx = KILO_RECOMMENDED_MODELS.indexOf(x.value.modelID)
return idx >= 0 ? idx : KILO_RECOMMENDED_MODELS.length + 1
}
return 0
if (x.value.providerID !== "kilo") return 0
const provider = sync.data.provider.find((p) => p.id === "kilo")
const model = provider?.models[x.value.modelID]
if (model?.recommendedIndex !== undefined) return model.recommendedIndex
return Object.keys(provider?.models ?? {}).length
},
// kilocode_change end
(x) => x.footer !== "Free",
@@ -0,0 +1,162 @@
import { describe, expect, test, mock, beforeEach } from "bun:test"
import type { GitContext } from "../types"
// Mock dependencies before importing the module under test
let mockGitContext: GitContext = {
branch: "main",
recentCommits: ["abc1234 initial commit"],
files: [{ status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" }],
}
mock.module("../git-context", () => ({
getGitContext: async () => mockGitContext,
}))
let mockStreamText = "feat(src): add hello world logging"
mock.module("@/provider/provider", () => ({
Provider: {
defaultModel: async () => ({ providerID: "test", modelID: "test-model" }),
getSmallModel: async () => ({
providerID: "test",
id: "test-small-model",
}),
getModel: async () => ({ providerID: "test", id: "test-model" }),
},
}))
mock.module("@/session/llm", () => ({
LLM: {
stream: async () => ({
text: Promise.resolve(mockStreamText),
}),
},
}))
mock.module("@/agent/agent", () => ({
Agent: {},
}))
mock.module("@/util/log", () => ({
Log: {
create: () => ({
info: () => {},
error: () => {},
warn: () => {},
debug: () => {},
}),
},
}))
import { generateCommitMessage } from "../generate"
describe("commit-message.generate", () => {
beforeEach(() => {
mockGitContext = {
branch: "main",
recentCommits: ["abc1234 initial commit"],
files: [{ status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" }],
}
mockStreamText = "feat(src): add hello world logging"
})
describe("prompt construction", () => {
test("passes path to getGitContext", async () => {
const result = await generateCommitMessage({ path: "/my/repo" })
// If getGitContext is called, it returns our mock context and generates a message
expect(result.message).toBeTruthy()
})
test("generates message from git context with multiple files", async () => {
mockGitContext = {
branch: "feature/api",
recentCommits: ["abc feat: add api", "def fix: typo"],
files: [
{ status: "added" as const, path: "src/api.ts", diff: "+export function api() {}" },
{ status: "modified" as const, path: "src/index.ts", diff: "+import { api } from './api'" },
],
}
mockStreamText = "feat(api): add api module"
const result = await generateCommitMessage({ path: "/repo" })
expect(result.message).toBe("feat(api): add api module")
})
})
describe("response cleaning", () => {
test("strips code block markers from response", async () => {
mockStreamText = "```\nfeat: add feature\n```"
const result = await generateCommitMessage({ path: "/repo" })
expect(result.message).toBe("feat: add feature")
})
test("strips code block markers with language tag", async () => {
mockStreamText = "```text\nfix(auth): resolve token refresh\n```"
const result = await generateCommitMessage({ path: "/repo" })
expect(result.message).toBe("fix(auth): resolve token refresh")
})
test("strips surrounding double quotes", async () => {
mockStreamText = '"feat: add new feature"'
const result = await generateCommitMessage({ path: "/repo" })
expect(result.message).toBe("feat: add new feature")
})
test("strips surrounding single quotes", async () => {
mockStreamText = "'fix: resolve bug'"
const result = await generateCommitMessage({ path: "/repo" })
expect(result.message).toBe("fix: resolve bug")
})
test("strips whitespace around the message", async () => {
mockStreamText = " \n chore: update deps \n "
const result = await generateCommitMessage({ path: "/repo" })
expect(result.message).toBe("chore: update deps")
})
test("strips code blocks AND quotes together", async () => {
mockStreamText = '```\n"refactor: simplify logic"\n```'
const result = await generateCommitMessage({ path: "/repo" })
expect(result.message).toBe("refactor: simplify logic")
})
test("returns clean message when no markers present", async () => {
mockStreamText = "docs: update readme"
const result = await generateCommitMessage({ path: "/repo" })
expect(result.message).toBe("docs: update readme")
})
})
describe("error on no changes", () => {
test("throws when no git changes are found", async () => {
mockGitContext = {
branch: "main",
recentCommits: [],
files: [],
}
await expect(generateCommitMessage({ path: "/repo" })).rejects.toThrow(
"No changes found to generate a commit message for",
)
})
})
describe("selectedFiles pass-through", () => {
test("passes selectedFiles to getGitContext", async () => {
// This verifies the function doesn't crash when selectedFiles is provided
const result = await generateCommitMessage({
path: "/repo",
selectedFiles: ["src/a.ts"],
})
expect(result.message).toBeTruthy()
})
})
})
@@ -0,0 +1,484 @@
import { describe, expect, test, beforeEach, mock } from "bun:test"
// Mock Bun.spawnSync via mock.module so it integrates properly with bun:test
// and doesn't conflict with other test files that mock "../git-context".
const spawnSyncResults: Record<string, string> = {}
function setGitOutput(args: string, output: string) {
spawnSyncResults[args] = output
}
function clearGitOutputs() {
for (const key of Object.keys(spawnSyncResults)) {
delete spawnSyncResults[key]
}
}
// Override the git-context module with a version that uses our mock spawnSync.
// This avoids conflicts with generate.test.ts which also mocks this module.
mock.module("../git-context", () => {
function git(args: string[], cwd: string): string {
const key = args.join(" ")
return spawnSyncResults[key] ?? ""
}
const LOCK_FILES = new Set([
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
"pnpm-lock.yaml",
"shrinkwrap.yaml",
"bun.lockb",
"bun.lock",
".pnp.js",
".pnp.cjs",
"jspm.lock",
"Pipfile.lock",
"poetry.lock",
"pdm.lock",
".pdm-lock.toml",
"uv.lock",
"conda-lock.yml",
"pylock.toml",
"Gemfile.lock",
"composer.lock",
"gradle.lockfile",
"lockfile.json",
"dependency-lock.json",
"dependency-reduced-pom.xml",
"coursier.lock",
"build.sbt.lock",
"packages.lock.json",
"paket.lock",
"project.assets.json",
"Cargo.lock",
"go.sum",
"Gopkg.lock",
"glide.lock",
"build.zig.zon.lock",
"dune.lock",
"opam.lock",
"Package.resolved",
"Podfile.lock",
"Cartfile.resolved",
"pubspec.lock",
"mix.lock",
"rebar.lock",
"stack.yaml.lock",
"cabal.project.freeze",
"exact-dependencies.json",
"shard.lock",
"Manifest.toml",
"JuliaManifest.toml",
"renv.lock",
"packrat.lock",
"nimble.lock",
"dub.selections.json",
"rocks.lock",
"carton.lock",
"cpanfile.snapshot",
"conan.lock",
"vcpkg-lock.json",
".terraform.lock.hcl",
"Berksfile.lock",
"Puppetfile.lock",
"MODULE.bazel.lock",
"flake.lock",
"deno.lock",
"devcontainer.lock.json",
])
const MAX_DIFF_LENGTH = 4000
function isLockFile(filepath: string): boolean {
const name = filepath.split("/").pop() ?? filepath
return LOCK_FILES.has(name)
}
function parseNameStatus(output: string): Array<{ status: string; path: string }> {
if (!output) return []
return output.split("\n").map((line) => {
const [status, ...rest] = line.split("\t")
const path = status!.startsWith("R") ? (rest[1] ?? rest[0]) : rest.join("\t")
return { status: status!, path }
})
}
function parsePorcelain(output: string): Array<{ status: string; path: string }> {
if (!output) return []
return output
.split("\n")
.filter((line) => line.length > 0)
.map((line) => {
const xy = line.slice(0, 2)
const filepath = line.slice(3)
return { status: xy.trim(), path: filepath }
})
}
type FileStatus = "added" | "modified" | "deleted" | "renamed"
function mapStatus(code: string): FileStatus {
if (code.startsWith("R")) return "renamed"
if (code === "A" || code === "??" || code === "?") return "added"
if (code === "D") return "deleted"
if (code === "M") return "modified"
return "modified"
}
function isUntracked(code: string): boolean {
return code === "??" || code === "?"
}
async function getGitContext(repoPath: string, selectedFiles?: string[]) {
const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
const log = git(["log", "--oneline", "-5"], repoPath)
const recentCommits = log ? log.split("\n") : []
const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
const useStaged = staged.length > 0
const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
const selected = selectedFiles ? new Set(selectedFiles) : undefined
const files: Array<{ status: FileStatus; path: string; diff: string }> = []
for (const entry of raw) {
if (isLockFile(entry.path)) continue
if (selected && !selected.has(entry.path)) continue
const status = mapStatus(entry.status)
const untracked = isUntracked(entry.status)
let diff: string
if (untracked) {
diff = `New untracked file: ${entry.path}`
} else if (status === "deleted") {
diff = useStaged
? git(["diff", "--cached", "--", entry.path], repoPath)
: git(["diff", "--", entry.path], repoPath)
} else {
const raw = useStaged
? git(["diff", "--cached", "--", entry.path], repoPath)
: git(["diff", "--", entry.path], repoPath)
if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
diff = `Binary file ${entry.path} has been modified`
} else {
diff = raw
}
}
if (diff.length > MAX_DIFF_LENGTH) {
diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
}
files.push({ status, path: entry.path, diff })
}
return { branch, recentCommits, files }
}
return { getGitContext }
})
import { getGitContext } from "../git-context"
describe("commit-message.git-context", () => {
beforeEach(() => {
clearGitOutputs()
// Defaults
setGitOutput("branch --show-current", "main")
setGitOutput("log --oneline -5", "abc1234 initial commit")
setGitOutput("diff --name-status --cached", "")
setGitOutput("status --porcelain", "")
})
// NOTE: git() trims stdout, which eats the leading space of the first
// porcelain line. We use staged (--name-status) tests for path-sensitive
// assertions and only use porcelain for behavior tests where this is acceptable.
describe("lock file filtering", () => {
test("filters out package-lock.json from staged changes", async () => {
setGitOutput("diff --name-status --cached", "M\tsrc/index.ts\nM\tpackage-lock.json")
setGitOutput("diff --cached -- src/index.ts", "+console.log('hello')")
setGitOutput("diff --cached -- package-lock.json", "+lots of lock content")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.path).toBe("src/index.ts")
})
test("filters out yarn.lock from staged changes", async () => {
setGitOutput("diff --name-status --cached", "M\tsrc/app.ts\nM\tyarn.lock")
setGitOutput("diff --cached -- src/app.ts", "+import x")
setGitOutput("diff --cached -- yarn.lock", "+lock data")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.path).toBe("src/app.ts")
})
test("filters out pnpm-lock.yaml from staged changes", async () => {
setGitOutput("diff --name-status --cached", "M\treadme.md\nM\tpnpm-lock.yaml")
setGitOutput("diff --cached -- pnpm-lock.yaml", "+lock")
setGitOutput("diff --cached -- readme.md", "+docs")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.path).toBe("readme.md")
})
test("filters lock files in subdirectories", async () => {
setGitOutput("diff --name-status --cached", "M\tpackages/api/package-lock.json\nM\tpackages/api/src/index.ts")
setGitOutput("diff --cached -- packages/api/package-lock.json", "+lock stuff")
setGitOutput("diff --cached -- packages/api/src/index.ts", "+code")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.path).toBe("packages/api/src/index.ts")
})
test("filters out bun.lockb, go.sum, Cargo.lock, poetry.lock", async () => {
setGitOutput(
"diff --name-status --cached",
"M\tbun.lockb\nM\tgo.sum\nM\tCargo.lock\nM\tpoetry.lock\nM\tsrc/main.rs",
)
setGitOutput("diff --cached -- bun.lockb", "binary")
setGitOutput("diff --cached -- go.sum", "+hash")
setGitOutput("diff --cached -- Cargo.lock", "+lock")
setGitOutput("diff --cached -- poetry.lock", "+lock")
setGitOutput("diff --cached -- src/main.rs", "+fn main() {}")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.path).toBe("src/main.rs")
})
})
describe("status parsing", () => {
test("parses staged added files", async () => {
setGitOutput("diff --name-status --cached", "A\tsrc/new-file.ts")
setGitOutput("diff --cached -- src/new-file.ts", "+new content")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.status).toBe("added")
expect(ctx.files[0]!.path).toBe("src/new-file.ts")
})
test("parses staged modified files", async () => {
setGitOutput("diff --name-status --cached", "M\tsrc/existing.ts")
setGitOutput("diff --cached -- src/existing.ts", "+changed line")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.status).toBe("modified")
})
test("parses staged deleted files", async () => {
setGitOutput("diff --name-status --cached", "D\tsrc/removed.ts")
setGitOutput("diff --cached -- src/removed.ts", "-deleted content")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.status).toBe("deleted")
})
test("parses staged renamed files", async () => {
setGitOutput("diff --name-status --cached", "R100\told-name.ts\tnew-name.ts")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.status).toBe("renamed")
})
test("parses untracked files from porcelain", async () => {
setGitOutput("status --porcelain", "?? src/brand-new.ts")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.status).toBe("added")
expect(ctx.files[0]!.diff).toBe("New untracked file: src/brand-new.ts")
})
test("parses porcelain modified files", async () => {
// Use staged to avoid porcelain trim edge case
setGitOutput("diff --name-status --cached", "M\tsrc/changed.ts")
setGitOutput("diff --cached -- src/changed.ts", "+line")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.status).toBe("modified")
})
test("prefers staged changes over unstaged", async () => {
setGitOutput("diff --name-status --cached", "M\tsrc/staged.ts")
setGitOutput("diff --cached -- src/staged.ts", "+staged change")
// unstaged also exists but should be ignored when staged is present
setGitOutput("status --porcelain", " M src/unstaged.ts")
setGitOutput("diff -- src/unstaged.ts", "+unstaged change")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.path).toBe("src/staged.ts")
})
test("mapStatus returns 'modified' for unknown codes", async () => {
setGitOutput("diff --name-status --cached", "X\tsrc/weird.ts")
setGitOutput("diff --cached -- src/weird.ts", "+stuff")
const ctx = await getGitContext("/repo")
expect(ctx.files[0]!.status).toBe("modified")
})
})
describe("diff truncation", () => {
test("truncates diffs exceeding 4000 characters", async () => {
const longDiff = "x".repeat(5000)
setGitOutput("diff --name-status --cached", "M\tsrc/big.ts")
setGitOutput("diff --cached -- src/big.ts", longDiff)
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.diff.length).toBeLessThan(5000)
expect(ctx.files[0]!.diff).toContain("... [truncated]")
// 4000 chars + "\n... [truncated]"
expect(ctx.files[0]!.diff.length).toBe(4000 + "\n... [truncated]".length)
})
test("does not truncate diffs at exactly 4000 characters", async () => {
const exactDiff = "y".repeat(4000)
setGitOutput("diff --name-status --cached", "M\tsrc/exact.ts")
setGitOutput("diff --cached -- src/exact.ts", exactDiff)
const ctx = await getGitContext("/repo")
expect(ctx.files[0]!.diff).toBe(exactDiff)
expect(ctx.files[0]!.diff).not.toContain("... [truncated]")
})
test("does not truncate diffs under 4000 characters", async () => {
const shortDiff = "z".repeat(100)
setGitOutput("diff --name-status --cached", "M\tsrc/small.ts")
setGitOutput("diff --cached -- src/small.ts", shortDiff)
const ctx = await getGitContext("/repo")
expect(ctx.files[0]!.diff).toBe(shortDiff)
})
})
describe("binary file detection", () => {
test("detects 'Binary files' in diff output", async () => {
setGitOutput("diff --name-status --cached", "M\tassets/logo.png")
setGitOutput("diff --cached -- assets/logo.png", "Binary files a/assets/logo.png and b/assets/logo.png differ")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.diff).toBe("Binary file assets/logo.png has been modified")
})
test("detects 'GIT binary patch' in diff output", async () => {
setGitOutput("diff --name-status --cached", "M\tassets/icon.ico")
setGitOutput("diff --cached -- assets/icon.ico", "GIT binary patch\nliteral 1234\ndata...")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(1)
expect(ctx.files[0]!.diff).toBe("Binary file assets/icon.ico has been modified")
})
test("does not flag normal diffs as binary", async () => {
setGitOutput("diff --name-status --cached", "M\tsrc/code.ts")
setGitOutput("diff --cached -- src/code.ts", "+const x = 1")
const ctx = await getGitContext("/repo")
expect(ctx.files[0]!.diff).toBe("+const x = 1")
})
})
describe("selected files filtering", () => {
test("only includes files in selectedFiles set", async () => {
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts\nM\tsrc/c.ts")
setGitOutput("diff --cached -- src/a.ts", "+a")
setGitOutput("diff --cached -- src/b.ts", "+b")
setGitOutput("diff --cached -- src/c.ts", "+c")
const ctx = await getGitContext("/repo", ["src/a.ts", "src/c.ts"])
expect(ctx.files).toHaveLength(2)
const paths = ctx.files.map((f) => f.path)
expect(paths).toContain("src/a.ts")
expect(paths).toContain("src/c.ts")
expect(paths).not.toContain("src/b.ts")
})
test("includes all files when selectedFiles is undefined", async () => {
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts")
setGitOutput("diff --cached -- src/a.ts", "+a")
setGitOutput("diff --cached -- src/b.ts", "+b")
const ctx = await getGitContext("/repo")
expect(ctx.files).toHaveLength(2)
})
test("returns empty files when selectedFiles has no matches", async () => {
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts")
setGitOutput("diff --cached -- src/a.ts", "+a")
const ctx = await getGitContext("/repo", ["src/nonexistent.ts"])
expect(ctx.files).toHaveLength(0)
})
})
describe("branch and recent commits", () => {
test("returns current branch name", async () => {
setGitOutput("branch --show-current", "feature/my-branch")
const ctx = await getGitContext("/repo")
expect(ctx.branch).toBe("feature/my-branch")
})
test("falls back to HEAD when branch is empty", async () => {
setGitOutput("branch --show-current", "")
const ctx = await getGitContext("/repo")
expect(ctx.branch).toBe("HEAD")
})
test("returns recent commits as array", async () => {
setGitOutput("log --oneline -5", "abc1234 first\ndef5678 second\nghi9012 third")
const ctx = await getGitContext("/repo")
expect(ctx.recentCommits).toEqual(["abc1234 first", "def5678 second", "ghi9012 third"])
})
test("returns empty array when no commits", async () => {
setGitOutput("log --oneline -5", "")
const ctx = await getGitContext("/repo")
expect(ctx.recentCommits).toEqual([])
})
})
})
@@ -0,0 +1,178 @@
import { Provider } from "@/provider/provider"
import { LLM } from "@/session/llm"
import { Agent } from "@/agent/agent"
import { Log } from "@/util/log"
import type { CommitMessageRequest, CommitMessageResponse, GitContext } from "./types"
import { getGitContext } from "./git-context"
const log = Log.create({ service: "commit-message" })
const SYSTEM_PROMPT = `You are an expert Git commit message generator that creates conventional commit messages based on staged changes. Analyze the provided git diff output and generate an appropriate conventional commit message following the specification.
## Conventional Commits Format
Generate commit messages following this exact structure:
\`\`\`
<type>[optional scope]: <description>
[optional body]
[optional footer(s)]
\`\`\`
### Core Types (Required)
- **feat**: New feature or functionality (MINOR version bump)
- **fix**: Bug fix or error correction (PATCH version bump)
### Additional Types (Extended)
- **docs**: Documentation changes only
- **style**: Code style changes (whitespace, formatting, semicolons, etc.)
- **refactor**: Code refactoring without feature changes or bug fixes
- **perf**: Performance improvements
- **test**: Adding or fixing tests
- **build**: Build system or external dependency changes
- **ci**: CI/CD configuration changes
- **chore**: Maintenance tasks, tooling changes
- **revert**: Reverting previous commits
### Scope Guidelines
- Use parentheses: \`feat(api):\`, \`fix(ui):\`
- Common scopes: \`api\`, \`ui\`, \`auth\`, \`db\`, \`config\`, \`deps\`, \`docs\`
- For monorepos: package or module names
- Keep scope concise and lowercase
### Description Rules
- Use imperative mood ("add" not "added" or "adds")
- Start with lowercase letter
- No period at the end
- Maximum 72 characters
- Be concise but descriptive
### Body Guidelines (Optional)
- Start one blank line after description
- Explain the "what" and "why", not the "how"
- Wrap at 72 characters per line
- Use for complex changes requiring explanation
### Footer Guidelines (Optional)
- Start one blank line after body
- **Breaking Changes**: \`BREAKING CHANGE: description\`
## Analysis Instructions
When analyzing staged changes:
1. Determine Primary Type based on the nature of changes
2. Identify Scope from modified directories or modules
3. Craft Description focusing on the most significant change
4. Determine if there are Breaking Changes
5. For complex changes, include a detailed body explaining what and why
6. Add appropriate footers for issue references or breaking changes
For significant changes, include a detailed body explaining the changes.
Return ONLY the commit message in the conventional format, nothing else.`
function buildUserMessage(ctx: GitContext): string {
const fileList = ctx.files.map((f) => `${f.status} ${f.path}`).join("\n")
const diffs = ctx.files
.filter((f) => f.diff)
.map((f) => `--- ${f.path} ---\n${f.diff}`)
.join("\n\n")
return `Generate a commit message for the following changes:
Branch: ${ctx.branch}
Recent commits:
${ctx.recentCommits.join("\n")}
Changed files:
${fileList}
Diffs:
${diffs}`
}
function clean(text: string): string {
let result = text.trim()
// Strip code block markers
if (result.startsWith("```")) {
const first = result.indexOf("\n")
if (first !== -1) {
result = result.slice(first + 1)
}
}
if (result.endsWith("```")) {
result = result.slice(0, -3)
}
result = result.trim()
// Strip surrounding quotes
if ((result.startsWith('"') && result.endsWith('"')) || (result.startsWith("'") && result.endsWith("'"))) {
result = result.slice(1, -1)
}
return result.trim()
}
export async function generateCommitMessage(request: CommitMessageRequest): Promise<CommitMessageResponse> {
const ctx = await getGitContext(request.path, request.selectedFiles)
if (ctx.files.length === 0) {
throw new Error("No changes found to generate a commit message for")
}
log.info("generating", {
branch: ctx.branch,
files: ctx.files.length,
})
const defaultModel = await Provider.defaultModel()
const model =
(await Provider.getSmallModel(defaultModel.providerID)) ??
(await Provider.getModel(defaultModel.providerID, defaultModel.modelID))
const agent: Agent.Info = {
name: "commit-message",
mode: "primary",
hidden: true,
options: {},
permission: [],
prompt: SYSTEM_PROMPT,
temperature: 0.3,
}
let userMessage = buildUserMessage(ctx)
if (request.previousMessage) {
userMessage = `IMPORTANT: Generate a COMPLETELY DIFFERENT commit message from the previous one. The previous message was: "${request.previousMessage}". Use a different type, scope, or description approach.\n\n${userMessage}`
}
const stream = await LLM.stream({
agent,
user: {
id: "commit-message",
sessionID: "commit-message",
role: "user",
model: {
providerID: model.providerID,
modelID: model.id,
},
time: {
created: Date.now(),
completed: Date.now(),
},
} as any,
tools: {},
model,
small: true,
messages: [
{
role: "user" as const,
content: userMessage,
},
],
abort: new AbortController().signal,
sessionID: "commit-message",
system: [],
retries: 3,
})
const result = await stream.text
log.info("generated", { message: result })
return { message: clean(result) }
}
@@ -0,0 +1,230 @@
import type { GitContext, FileChange } from "./types"
const LOCK_FILES = new Set([
// --- JavaScript / Node.js ---
"package-lock.json",
"npm-shrinkwrap.json",
"yarn.lock",
"pnpm-lock.yaml",
"shrinkwrap.yaml",
"bun.lockb",
"bun.lock",
".pnp.js",
".pnp.cjs",
"jspm.lock",
// --- Python ---
"Pipfile.lock",
"poetry.lock",
"pdm.lock",
".pdm-lock.toml",
"uv.lock",
"conda-lock.yml",
"pylock.toml",
// --- Ruby ---
"Gemfile.lock",
// --- PHP ---
"composer.lock",
// --- Java / JVM ---
"gradle.lockfile",
"lockfile.json",
"dependency-lock.json",
"dependency-reduced-pom.xml",
"coursier.lock",
// --- Scala ---
"build.sbt.lock",
// --- .NET ---
"packages.lock.json",
"paket.lock",
"project.assets.json",
// --- Rust ---
"Cargo.lock",
// --- Go ---
"go.sum",
"Gopkg.lock",
"glide.lock",
// --- Zig ---
"build.zig.zon.lock",
// --- OCaml ---
"dune.lock",
"opam.lock",
// --- Swift / iOS ---
"Package.resolved",
"Podfile.lock",
"Cartfile.resolved",
// --- Dart / Flutter ---
"pubspec.lock",
// --- Elixir / Erlang ---
"mix.lock",
"rebar.lock",
// --- Haskell ---
"stack.yaml.lock",
"cabal.project.freeze",
// --- Elm ---
"exact-dependencies.json",
// --- Crystal ---
"shard.lock",
// --- Julia ---
"Manifest.toml",
"JuliaManifest.toml",
// --- R ---
"renv.lock",
"packrat.lock",
// --- Nim ---
"nimble.lock",
// --- D ---
"dub.selections.json",
// --- Lua ---
"rocks.lock",
// --- Perl ---
"carton.lock",
"cpanfile.snapshot",
// --- C/C++ ---
"conan.lock",
"vcpkg-lock.json",
// --- Infrastructure as Code ---
".terraform.lock.hcl",
"Berksfile.lock",
"Puppetfile.lock",
"MODULE.bazel.lock",
// --- Nix ---
"flake.lock",
// --- Deno ---
"deno.lock",
// --- DevContainers ---
"devcontainer.lock.json",
])
const MAX_DIFF_LENGTH = 4000
function isLockFile(filepath: string): boolean {
const name = filepath.split("/").pop() ?? filepath
return LOCK_FILES.has(name)
}
function git(args: string[], cwd: string): string {
const result = Bun.spawnSync(["git", ...args], {
cwd,
stdout: "pipe",
stderr: "pipe",
})
return result.stdout.toString().trimEnd()
}
function parseNameStatus(output: string): Array<{ status: string; path: string }> {
if (!output) return []
return output.split("\n").map((line) => {
const [status, ...rest] = line.split("\t")
let path: string
if (status!.startsWith("R")) {
// Rename: rest = ["old.ts", "new.ts"], use the new path
path = rest[1] ?? rest[0]
} else {
path = rest.join("\t")
}
return { status: status!, path }
})
}
function parsePorcelain(output: string): Array<{ status: string; path: string }> {
if (!output) return []
return output
.split("\n")
.filter((line) => line.length > 0)
.map((line) => {
const xy = line.slice(0, 2)
const filepath = line.slice(3)
return { status: xy.trim(), path: filepath }
})
}
function mapStatus(code: string): FileChange["status"] {
if (code.startsWith("R")) return "renamed"
if (code === "A" || code === "??" || code === "?") return "added"
if (code === "D") return "deleted"
if (code === "M") return "modified"
return "modified"
}
function isUntracked(code: string): boolean {
return code === "??" || code === "?"
}
export async function getGitContext(repoPath: string, selectedFiles?: string[]): Promise<GitContext> {
const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
const log = git(["log", "--oneline", "-5"], repoPath)
const recentCommits = log ? log.split("\n") : []
// Check staged files first
const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
const useStaged = staged.length > 0
// Fall back to all changes if nothing staged
const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
const selected = selectedFiles ? new Set(selectedFiles) : undefined
const files: FileChange[] = []
for (const entry of raw) {
if (isLockFile(entry.path)) continue
if (selected && !selected.has(entry.path)) continue
const status = mapStatus(entry.status)
const untracked = isUntracked(entry.status)
let diff: string
if (untracked) {
diff = `New untracked file: ${entry.path}`
} else if (status === "deleted") {
diff = useStaged
? git(["diff", "--cached", "--", entry.path], repoPath)
: git(["diff", "--", entry.path], repoPath)
} else {
const raw = useStaged
? git(["diff", "--cached", "--", entry.path], repoPath)
: git(["diff", "--", entry.path], repoPath)
// Detect binary files
if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
diff = `Binary file ${entry.path} has been modified`
} else {
diff = raw
}
}
// Truncate large diffs
if (diff.length > MAX_DIFF_LENGTH) {
diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
}
files.push({ status, path: entry.path, diff })
}
return { branch, recentCommits, files }
}
@@ -0,0 +1,2 @@
export { generateCommitMessage } from "./generate"
export type { CommitMessageRequest, CommitMessageResponse, GitContext, FileChange } from "./types"
@@ -0,0 +1,29 @@
export interface CommitMessageRequest {
/** Workspace/repo path */
path: string
/** Optional subset of files to include */
selectedFiles?: string[]
/** Previously generated message — when set, the LLM is asked to produce a different one */
previousMessage?: string
}
export interface CommitMessageResponse {
/** The generated commit message */
message: string
}
export interface GitContext {
/** Current branch name */
branch: string
/** Last 5 commit summaries */
recentCommits: string[]
/** File changes with status and diff content */
files: FileChange[]
}
export interface FileChange {
status: "added" | "modified" | "deleted" | "renamed"
path: string
/** Diff content, or placeholder for binary/untracked files */
diff: string
}
@@ -0,0 +1,274 @@
import { Agent } from "@/agent/agent"
import { Bus } from "@/bus"
import { TuiEvent } from "@/cli/cmd/tui/event"
import { Identifier } from "@/id/id"
import { Provider } from "@/provider/provider"
import { Question } from "@/question"
import { Session } from "@/session"
import { LLM } from "@/session/llm"
import { MessageV2 } from "@/session/message-v2"
import { Todo } from "@/session/todo"
import { Log } from "@/util/log"
function toText(item: MessageV2.WithParts): string {
return item.parts
.filter((part): part is MessageV2.TextPart => part.type === "text")
.map((part) => part.text)
.join("\n")
.trim()
}
const HANDOVER_PROMPT = `You are summarizing a planning session to hand off to an implementation session.
The plan itself will be provided separately do NOT repeat it. Instead, focus on information discovered during planning that would help the implementing agent but is NOT already in the plan text.
Produce a concise summary using this template:
---
## Discoveries
[Key findings from code exploration architecture patterns, gotchas, edge cases, relevant existing code that the plan references but doesn't fully explain]
## Relevant Files
[Structured list of files/directories that were read or discussed, with brief notes on what's relevant in each]
## Implementation Notes
[Any important context: conventions to follow, potential pitfalls, dependencies between steps, things the implementing agent should watch out for]
---
If there is nothing useful to add beyond what the plan already says, respond with an empty string.
Keep the summary concise focus on high-entropy information that would save the implementing agent time.`
export function formatTodos(todos: Todo.Info[]): string {
if (!todos.length) return ""
const icons: Record<string, string> = {
completed: "[x]",
in_progress: "[~]",
cancelled: "[-]",
}
return todos.map((t) => `- ${icons[t.status] ?? "[ ]"} ${t.content}`).join("\n")
}
export async function generateHandover(input: {
messages: MessageV2.WithParts[]
model: MessageV2.User["model"]
abort?: AbortSignal
}): Promise<string> {
const log = Log.create({ service: "plan.followup" })
try {
const agent = await Agent.get("compaction")
const model = agent?.model
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
: await Provider.getModel(input.model.providerID, input.model.modelID)
const sessionID = Identifier.ascending("session")
const userMsg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID,
role: "user",
time: { created: Date.now() },
agent: "plan",
model: input.model,
}
const stream = await LLM.stream({
agent: agent ?? {
name: "compaction",
mode: "subagent",
permission: [],
options: {},
},
user: userMsg,
tools: {},
model,
small: true,
messages: [
...MessageV2.toModelMessages(input.messages, model),
{
role: "user" as const,
content: HANDOVER_PROMPT,
},
],
abort: input.abort ? AbortSignal.any([input.abort, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000),
sessionID,
system: [],
retries: 1,
})
const result = await stream.text
return result.trim()
} catch (error) {
log.error("handover generation failed", { error })
return ""
}
}
export namespace PlanFollowup {
const log = Log.create({ service: "plan.followup" })
export const ANSWER_NEW_SESSION = "Start new session"
export const ANSWER_CONTINUE = "Continue here"
async function inject(input: {
sessionID: string
agent: string
model: MessageV2.User["model"]
text: string
synthetic?: boolean
}) {
const msg: MessageV2.User = {
id: Identifier.ascending("message"),
sessionID: input.sessionID,
role: "user",
time: {
created: Date.now(),
},
agent: input.agent,
model: input.model,
}
await Session.updateMessage(msg)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: msg.id,
sessionID: input.sessionID,
type: "text",
text: input.text,
synthetic: input.synthetic ?? true,
} satisfies MessageV2.TextPart)
}
function prompt(input: { sessionID: string; abort: AbortSignal }) {
const promise = Question.ask({
sessionID: input.sessionID,
questions: [
{
question: "Ready to implement?",
header: "Implement",
custom: true,
options: [
{
label: ANSWER_NEW_SESSION,
description: "Implement in a fresh session with a clean context",
},
{
label: ANSWER_CONTINUE,
description: "Implement the plan in this session",
},
],
},
],
})
const listener = () =>
Question.list().then((qs) => {
const match = qs.find((q) => q.sessionID === input.sessionID)
if (match) Question.reject(match.id)
})
input.abort.addEventListener("abort", listener, { once: true })
return promise
.catch((error) => {
if (error instanceof Question.RejectedError) return undefined
throw error
})
.finally(() => {
input.abort.removeEventListener("abort", listener)
})
}
async function startNew(input: {
sessionID: string
plan: string
messages: MessageV2.WithParts[]
model: MessageV2.User["model"]
abort?: AbortSignal
}) {
const [handover, todos] = await Promise.all([
generateHandover({ messages: input.messages, model: input.model, abort: input.abort }),
Todo.get(input.sessionID),
])
const sections = [`Implement the following plan:\n\n${input.plan}`]
if (handover) {
sections.push(`## Handover from Planning Session\n\n${handover}`)
}
const todoList = formatTodos(todos)
if (todoList) {
sections.push(`## Todo List\n\n${todoList}`)
}
const next = await Session.create({})
await inject({
sessionID: next.id,
agent: "code",
model: input.model,
text: sections.join("\n\n"),
synthetic: false,
})
if (todos.length) {
await Todo.update({ sessionID: next.id, todos })
}
await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id })
void import("@/session/prompt")
.then((item) => item.SessionPrompt.loop({ sessionID: next.id }))
.catch((error) => {
log.error("failed to start follow-up session", { sessionID: next.id, error })
})
}
export async function ask(input: {
sessionID: string
messages: MessageV2.WithParts[]
abort: AbortSignal
}): Promise<"continue" | "break"> {
if (input.abort.aborted) return "break"
const latest = input.messages.slice().reverse()
const assistant = latest.find((msg) => msg.info.role === "assistant")
if (!assistant) return "break"
const plan = toText(assistant)
if (!plan) return "break"
const user = latest.find((msg) => msg.info.role === "user")?.info
if (!user || user.role !== "user" || !user.model) return "break"
const answers = await prompt({ sessionID: input.sessionID, abort: input.abort })
if (!answers) return "break"
const answer = answers[0]?.[0]?.trim()
if (!answer) return "break"
if (answer === ANSWER_NEW_SESSION) {
await startNew({
sessionID: input.sessionID,
plan,
messages: input.messages,
model: user.model,
abort: input.abort,
})
return "break"
}
if (answer === ANSWER_CONTINUE) {
await inject({
sessionID: input.sessionID,
agent: "code",
model: user.model,
text: "Implement the plan above.",
})
return "continue"
}
await inject({
sessionID: input.sessionID,
agent: "plan",
model: user.model,
text: answer,
})
return "continue"
}
}
+2
View File
@@ -78,6 +78,8 @@ export namespace ModelsDev {
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
})
.optional(),
recommended: z.boolean().optional(), // kilocode_change
recommendedIndex: z.number().optional(), // kilocode_change
experimental: z.boolean().optional(),
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
options: z.record(z.string(), z.any()),
@@ -621,6 +621,8 @@ export namespace Provider {
headers: z.record(z.string(), z.string()),
release_date: z.string(),
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
recommended: z.boolean().optional(), // kilocode_change
recommendedIndex: z.number().optional(), // kilocode_change
})
.meta({
ref: "Model",
@@ -702,6 +704,8 @@ export namespace Provider {
},
release_date: model.release_date,
variants: {},
recommended: model.recommended, // kilocode_change
recommendedIndex: model.recommendedIndex, // kilocode_change
}
m.variants = mapValues(ProviderTransform.variants(m), (v) => v)
@@ -845,6 +849,8 @@ export namespace Provider {
family: model.family ?? existingModel?.family ?? "",
release_date: model.release_date ?? existingModel?.release_date ?? "",
variants: {},
recommended: model.recommended ?? existingModel?.recommended, // kilocode_change
recommendedIndex: model.recommendedIndex ?? existingModel?.recommendedIndex, // kilocode_change
}
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
parsedModel.variants = mapValues(
@@ -0,0 +1,44 @@
import { Hono } from "hono"
import { describeRoute, resolver, validator } from "hono-openapi"
import z from "zod"
import { generateCommitMessage } from "../../commit-message"
import { lazy } from "../../util/lazy"
import { errors } from "../error"
export const CommitMessageRoutes = lazy(() =>
new Hono().post(
"/",
describeRoute({
summary: "Generate commit message",
description: "Generate a commit message using AI based on the current git diff.",
operationId: "commitMessage.generate",
responses: {
200: {
description: "Generated commit message",
content: {
"application/json": {
schema: resolver(z.object({ message: z.string() })),
},
},
},
...errors(400),
},
}),
validator(
"json",
z.object({
path: z.string().meta({ description: "Workspace/repo path" }),
selectedFiles: z.array(z.string()).optional().meta({ description: "Optional subset of files to include" }),
previousMessage: z
.string()
.optional()
.meta({ description: "Previously generated message — triggers regeneration with a different result" }),
}),
),
async (c) => {
const body = c.req.valid("json")
const result = await generateCommitMessage(body)
return c.json({ message: result.message })
},
),
)
+2
View File
@@ -38,6 +38,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
import { websocket } from "hono/bun"
import { HTTPException } from "hono/http-exception"
import { errors } from "./error"
import { CommitMessageRoutes } from "./routes/commit-message"
import { QuestionRoutes } from "./routes/question"
import { PermissionRoutes } from "./routes/permission"
import { GlobalRoutes } from "./routes/global"
@@ -235,6 +236,7 @@ export namespace Server {
.route("/question", QuestionRoutes())
.route("/provider", ProviderRoutes())
.route("/telemetry", TelemetryRoutes()) // kilocode_change
.route("/commit-message", CommitMessageRoutes()) // kilocode_change
// kilocode_change start - Kilo Gateway routes
.route(
"/kilo",
+3 -2
View File
@@ -26,7 +26,7 @@ import { DEFAULT_HEADERS } from "@/kilocode/const" // kilocode_change
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
// kilocode_change start
import { getKiloProjectId } from "@/kilocode/project-id"
import { HEADER_PROJECTID, HEADER_MACHINEID } from "@kilocode/kilo-gateway"
import { HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway"
import { Identity } from "@kilocode/kilo-telemetry"
// kilocode_change end
@@ -239,9 +239,10 @@ export namespace LLM {
? DEFAULT_HEADERS // kilocode_change
: undefined),
...(isKilo && input.agent.name ? { "x-kilocode-mode": input.agent.name.toLowerCase() } : {}),
// kilocode_change start - add project ID and machine ID headers for kilo provider
// kilocode_change start - add project ID, machine ID, and task ID headers for kilo provider
...(isKilo && kiloProjectId ? { [HEADER_PROJECTID]: kiloProjectId } : {}),
...(isKilo && machineId ? { [HEADER_MACHINEID]: machineId } : {}),
...(isKilo ? { [HEADER_TASKID]: input.sessionID } : {}),
// kilocode_change end
...input.model.headers,
...headers,
@@ -15,6 +15,7 @@ import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
import { Question } from "@/question"
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -34,6 +35,7 @@ export namespace SessionProcessor {
let blocked = false
let attempt = 0
let needsCompaction = false
let stepStart = 0 // kilocode_change
const result = {
get message() {
@@ -223,6 +225,7 @@ export namespace SessionProcessor {
throw value.error
case "start-step":
stepStart = performance.now() // kilocode_change
snapshot = await Snapshot.track()
await Session.updatePart({
id: Identifier.ascending("part"),
@@ -239,6 +242,26 @@ export namespace SessionProcessor {
usage: value.usage,
metadata: value.providerMetadata,
})
// kilocode_change start
if (
usage.tokens.input > 0 ||
usage.tokens.output > 0 ||
usage.tokens.cache.write > 0 ||
usage.tokens.cache.read > 0
) {
Telemetry.trackLlmCompletion({
sessionId: input.sessionID,
provider: input.model.providerID,
model: input.model.id,
inputTokens: usage.tokens.input,
outputTokens: usage.tokens.output,
cacheReadTokens: usage.tokens.cache.read,
cacheWriteTokens: usage.tokens.cache.write,
cost: usage.cost,
completionTime: Math.round(performance.now() - stepStart),
})
}
// kilocode_change end
input.assistantMessage.finish = value.finishReason
input.assistantMessage.cost += usage.cost
input.assistantMessage.tokens = usage.tokens
+7
View File
@@ -45,6 +45,7 @@ import { LLM } from "./llm"
import { iife } from "@/util/iife"
import { Shell } from "@/shell/shell"
import { Truncate } from "@/tool/truncation"
import { PlanFollowup } from "@/kilocode/plan-followup" // kilocode_change
// @ts-ignore
globalThis.AI_SDK_LOG_WARNINGS = false
@@ -336,6 +337,12 @@ export namespace SessionPrompt {
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
lastUser.id < lastAssistant.id
) {
// kilocode_change start - ask follow-up after plan agent completes
if (lastUser.agent === "plan" && !abort.aborted && ["cli", "vscode"].includes(Flag.KILO_CLIENT)) {
const action = await PlanFollowup.ask({ sessionID, messages: msgs, abort })
if (action === "continue") continue
}
// kilocode_change end
log.info("exiting loop", { sessionID })
break
}
@@ -0,0 +1,547 @@
import { describe, expect, spyOn, test } from "bun:test"
import { Agent } from "../../src/agent/agent"
import { Bus } from "../../src/bus"
import { TuiEvent } from "../../src/cli/cmd/tui/event"
import { Identifier } from "../../src/id/id"
import { formatTodos, generateHandover, PlanFollowup } from "../../src/kilocode/plan-followup"
import { Instance } from "../../src/project/instance"
import { Provider } from "../../src/provider/provider"
import { Question } from "../../src/question"
import { Session } from "../../src/session"
import { LLM } from "../../src/session/llm"
import { MessageV2 } from "../../src/session/message-v2"
import { SessionPrompt } from "../../src/session/prompt"
import { Todo } from "../../src/session/todo"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
const model = {
providerID: "openai",
modelID: "gpt-4",
}
async function withInstance(fn: () => Promise<void>) {
await using tmp = await tmpdir({ git: true })
await Instance.provide({ directory: tmp.path, fn })
}
async function seed(input: {
text: string
tools?: Array<{ tool: string; input: Record<string, unknown>; output: string }>
}) {
const session = await Session.create({})
const user = await Session.updateMessage({
id: Identifier.ascending("message"),
role: "user",
sessionID: session.id,
time: {
created: Date.now(),
},
agent: "plan",
model,
})
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: user.id,
sessionID: session.id,
type: "text",
text: "Create a plan",
})
const assistant: MessageV2.Assistant = {
id: Identifier.ascending("message"),
role: "assistant",
sessionID: session.id,
time: {
created: Date.now(),
},
parentID: user.id,
modelID: model.modelID,
providerID: model.providerID,
mode: "plan",
agent: "plan",
path: {
cwd: Instance.directory,
root: Instance.worktree,
},
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
finish: "end_turn",
}
await Session.updateMessage(assistant)
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: assistant.id,
sessionID: session.id,
type: "text",
text: input.text,
})
for (const t of input.tools ?? []) {
await Session.updatePart({
id: Identifier.ascending("part"),
messageID: assistant.id,
sessionID: session.id,
type: "tool",
callID: Identifier.ascending("tool"),
tool: t.tool,
state: {
status: "completed",
input: t.input,
output: t.output,
title: t.tool,
metadata: {},
time: { start: Date.now(), end: Date.now() },
},
} satisfies MessageV2.ToolPart)
}
const messages = await Session.messages({ sessionID: session.id })
return {
sessionID: session.id,
messages,
}
}
async function latestUser(sessionID: string) {
const messages = await Session.messages({ sessionID })
return messages
.slice()
.reverse()
.find((item) => item.info.role === "user")
}
async function sessions() {
return Array.fromAsync(Session.list())
}
const fakeAgent: Agent.Info = {
name: "compaction",
mode: "subagent",
permission: [],
options: {},
}
const fakeModel = {
id: "gpt-4",
providerID: "openai",
limit: { context: 128000, input: 0 },
api: { id: "openai", npm: "@ai-sdk/openai" },
capabilities: {},
} as Provider.Model
function mockHandoverDeps(text: string, opts?: { agent?: Agent.Info | null }) {
const agentSpy = spyOn(Agent, "get").mockResolvedValue(
(opts?.agent === null ? undefined : (opts?.agent ?? fakeAgent)) as any,
)
const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: Promise.resolve(text),
} as any)
return {
agentSpy,
modelSpy,
llmSpy,
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
},
}
}
describe("plan follow-up", () => {
test("ask - returns break when dismissed", () =>
withInstance(async () => {
const seeded = await seed({ text: "1. Step one\n2. Step two" })
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const list = await Question.list()
expect(list).toHaveLength(1)
await Question.reject(list[0].id)
await expect(pending).resolves.toBe("break")
}))
test("ask - returns continue and creates code message on Continue here", () =>
withInstance(async () => {
const seeded = await seed({ text: "1. Build\n2. Test" })
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
const list = await Question.list()
await Question.reply({
requestID: list[0].id,
answers: [[PlanFollowup.ANSWER_CONTINUE]],
})
await expect(pending).resolves.toBe("continue")
const user = await latestUser(seeded.sessionID)
expect(user?.info.role).toBe("user")
if (!user || user.info.role !== "user") return
expect(user.info.agent).toBe("code")
const part = user.parts.find((item) => item.type === "text")
expect(part?.type).toBe("text")
if (!part || part.type !== "text") return
expect(part.text).toBe("Implement the plan above.")
expect(part.synthetic).toBe(true)
}))
test("ask - returns continue and creates plan message for custom text", () =>
withInstance(async () => {
const seeded = await seed({ text: "1. Build\n2. Test" })
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
await Question.reply({
requestID: (await Question.list())[0].id,
answers: [["Add rollback support too"]],
})
await expect(pending).resolves.toBe("continue")
const user = await latestUser(seeded.sessionID)
expect(user?.info.role).toBe("user")
if (!user || user.info.role !== "user") return
expect(user.info.agent).toBe("plan")
const part = user.parts.find((item) => item.type === "text")
expect(part?.type).toBe("text")
if (!part || part.type !== "text") return
expect(part.text).toBe("Add rollback support too")
expect(part.synthetic).toBe(true)
}))
test("ask - creates a new session on Start new session with handover and todos", () =>
withInstance(async () => {
const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({
info: {
id: "msg_test",
role: "assistant",
sessionID: "ses_test",
time: {
created: Date.now(),
},
parentID: "msg_parent",
modelID: "test",
providerID: "test",
mode: "code",
agent: "code",
path: {
cwd: "/tmp",
root: "/tmp",
},
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: {
read: 0,
write: 0,
},
},
},
parts: [],
})
using _mocks = mockHandoverDeps(
"## Discoveries\n\nFound REST endpoints in src/api.ts\n\n## Relevant Files\n\n- src/api.ts: REST endpoints\n- src/db.ts: Database layer",
)
using _loop = {
[Symbol.dispose]() {
loop.mockRestore()
},
}
const seeded = await seed({
text: "1. Add API\n2. Add tests",
})
await Todo.update({
sessionID: seeded.sessionID,
todos: [
{ id: "1", content: "Add API endpoint", status: "completed", priority: "high" },
{ id: "2", content: "Write tests", status: "pending", priority: "medium" },
],
})
const before = await sessions()
const created = [] as string[]
const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => {
created.push(event.properties.sessionID)
})
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
await Question.reply({
requestID: (await Question.list())[0].id,
answers: [[PlanFollowup.ANSWER_NEW_SESSION]],
})
await expect(pending).resolves.toBe("break")
unsub()
const after = await sessions()
const prev = new Set(before.map((item) => item.id))
const added = after.filter((item) => !prev.has(item.id))
expect(added).toHaveLength(1)
expect(created).toHaveLength(1)
expect(loop).toHaveBeenCalledTimes(1)
expect(_mocks.llmSpy).toHaveBeenCalledTimes(1)
const newSessionID = created[0]
expect(added[0].id).toBe(newSessionID)
const messages = await Session.messages({ sessionID: newSessionID })
const user = messages.find((item) => item.info.role === "user")
expect(user?.info.role).toBe("user")
if (!user || user.info.role !== "user") throw new Error("expected seeded user message")
expect(user.info.agent).toBe("code")
const part = user.parts.find((item) => item.type === "text")
expect(part?.type).toBe("text")
if (!part || part.type !== "text") throw new Error("expected text part")
expect(part.text).toContain("Implement the following plan:")
expect(part.text).toContain("1. Add API\n2. Add tests")
expect(part.text).toContain("## Handover from Planning Session")
expect(part.text).toContain("Found REST endpoints in src/api.ts")
expect(part.text).toContain("## Todo List")
expect(part.text).toContain("[x] Add API endpoint")
expect(part.text).toContain("[ ] Write tests")
expect(part.synthetic).toBe(false)
const newTodos = await Todo.get(newSessionID)
expect(newTodos).toHaveLength(2)
expect(newTodos).toContainEqual({ id: "1", content: "Add API endpoint", status: "completed", priority: "high" })
expect(newTodos).toContainEqual({ id: "2", content: "Write tests", status: "pending", priority: "medium" })
SessionPrompt.cancel(newSessionID)
}))
test("ask - new session omits handover section when LLM returns empty", () =>
withInstance(async () => {
const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({
info: {
id: "msg_test",
role: "assistant",
sessionID: "ses_test",
time: { created: Date.now() },
parentID: "msg_parent",
modelID: "test",
providerID: "test",
mode: "code",
agent: "code",
path: { cwd: "/tmp", root: "/tmp" },
cost: 0,
tokens: {
total: 0,
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
parts: [],
})
using _mocks = mockHandoverDeps("")
using _loop = {
[Symbol.dispose]() {
loop.mockRestore()
},
}
const seeded = await seed({ text: "1. Add API\n2. Add tests" })
const created = [] as string[]
const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => {
created.push(event.properties.sessionID)
})
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
await Question.reply({
requestID: (await Question.list())[0].id,
answers: [[PlanFollowup.ANSWER_NEW_SESSION]],
})
await expect(pending).resolves.toBe("break")
unsub()
const messages = await Session.messages({ sessionID: created[0] })
const user = messages.find((item) => item.info.role === "user")
if (!user || user.info.role !== "user") throw new Error("expected user message")
const part = user.parts.find((item) => item.type === "text")
if (!part || part.type !== "text") throw new Error("expected text part")
expect(part.text).toContain("Implement the following plan:")
expect(part.text).not.toContain("## Handover from Planning Session")
expect(part.text).not.toContain("## Todo List")
SessionPrompt.cancel(created[0])
}))
test("ask - returns break when assistant text is empty", () =>
withInstance(async () => {
const seeded = await seed({ text: " " })
const result = await PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
expect(result).toBe("break")
expect(await Question.list()).toHaveLength(0)
}))
test("ask - returns break when already aborted", () =>
withInstance(async () => {
const abort = new AbortController()
abort.abort()
const result = await PlanFollowup.ask({
sessionID: "ses_test",
messages: [],
abort: abort.signal,
})
expect(result).toBe("break")
}))
test("ask - returns break when aborted while question is pending", () =>
withInstance(async () => {
const abort = new AbortController()
const seeded = await seed({ text: "1. Step one\n2. Step two" })
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: abort.signal,
})
const list = await Question.list()
expect(list).toHaveLength(1)
abort.abort()
await expect(pending).resolves.toBe("break")
expect(await Question.list()).toHaveLength(0)
}))
test("ask - returns break for blank custom answer", () =>
withInstance(async () => {
const seeded = await seed({ text: "1. Build\n2. Test" })
const pending = PlanFollowup.ask({
sessionID: seeded.sessionID,
messages: seeded.messages,
abort: AbortSignal.any([]),
})
await Question.reply({
requestID: (await Question.list())[0].id,
answers: [[" "]],
})
await expect(pending).resolves.toBe("break")
expect((await Session.messages({ sessionID: seeded.sessionID })).length).toBe(2)
}))
test("formatTodos - returns empty string for no todos", () => {
expect(formatTodos([])).toBe("")
})
test("formatTodos - formats todos with status icons", () => {
const todos: Todo.Info[] = [
{ id: "1", content: "Set up project", status: "completed", priority: "high" },
{ id: "2", content: "Write code", status: "in_progress", priority: "high" },
{ id: "3", content: "Add tests", status: "pending", priority: "medium" },
{ id: "4", content: "Dropped task", status: "cancelled", priority: "low" },
]
const result = formatTodos(todos)
expect(result).toBe("- [x] Set up project\n- [~] Write code\n- [ ] Add tests\n- [-] Dropped task")
})
test("generateHandover - returns empty string on LLM.stream failure", () =>
withInstance(async () => {
const agentSpy = spyOn(Agent, "get").mockResolvedValue(fakeAgent)
const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
const llmSpy = spyOn(LLM, "stream").mockRejectedValue(new Error("provider unavailable"))
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
},
}
const seeded = await seed({ text: "1. Build\n2. Test" })
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("")
}))
test("generateHandover - returns empty string on stream.text rejection", () =>
withInstance(async () => {
const agentSpy = spyOn(Agent, "get").mockResolvedValue(fakeAgent)
const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
const textPromise = new Promise<string>((_, reject) => {
setTimeout(() => reject(new Error("stream aborted")), 0)
})
textPromise.catch(() => {})
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
text: textPromise,
} as any)
using _ = {
[Symbol.dispose]() {
agentSpy.mockRestore()
modelSpy.mockRestore()
llmSpy.mockRestore()
},
}
const seeded = await seed({ text: "1. Build\n2. Test" })
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("")
}))
test("generateHandover - uses fallback agent when compaction agent is not configured", () =>
withInstance(async () => {
using mocks = mockHandoverDeps("## Discoveries\n\nFallback works", { agent: null })
const seeded = await seed({ text: "1. Build\n2. Test" })
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("## Discoveries\n\nFallback works")
expect(mocks.agentSpy).toHaveBeenCalledWith("compaction")
expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
}))
test("generateHandover - returns LLM output on success", () =>
withInstance(async () => {
using mocks = mockHandoverDeps("## Discoveries\n\nKey finding here")
const seeded = await seed({ text: "1. Build\n2. Test" })
const result = await generateHandover({ messages: seeded.messages, model })
expect(result).toBe("## Discoveries\n\nKey finding here")
expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
}))
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/plugin",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -8,7 +8,7 @@
"exports": {
".": "./src/index.ts"
},
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {},
"peerDependencies": {}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/sdk",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"repository": {
+52
View File
@@ -14,6 +14,8 @@ import type {
AuthSetErrors,
AuthSetResponses,
CommandListResponses,
CommitMessageGenerateErrors,
CommitMessageGenerateResponses,
Config as Config3,
ConfigGetResponses,
ConfigProvidersResponses,
@@ -2217,6 +2219,51 @@ export class Telemetry extends HeyApiClient {
}
}
export class CommitMessage extends HeyApiClient {
/**
* Generate commit message
*
* Generate a commit message using AI based on the current git diff.
*/
public generate<ThrowOnError extends boolean = false>(
parameters?: {
directory?: string
path?: string
selectedFiles?: Array<string>
previousMessage?: string
},
options?: Options<never, ThrowOnError>,
) {
const params = buildClientParams(
[parameters],
[
{
args: [
{ in: "query", key: "directory" },
{ in: "body", key: "path" },
{ in: "body", key: "selectedFiles" },
{ in: "body", key: "previousMessage" },
],
},
],
)
return (options?.client ?? this.client).post<
CommitMessageGenerateResponses,
CommitMessageGenerateErrors,
ThrowOnError
>({
url: "/commit-message",
...options,
...params,
headers: {
"Content-Type": "application/json",
...options?.headers,
...params.headers,
},
})
}
}
export class Organization extends HeyApiClient {
/**
* Update Kilo Gateway organization
@@ -3439,6 +3486,11 @@ export class OpencodeClient extends HeyApiClient {
return (this._telemetry ??= new Telemetry({ client: this.client }))
}
private _commitMessage?: CommitMessage
get commitMessage(): CommitMessage {
return (this._commitMessage ??= new CommitMessage({ client: this.client }))
}
private _kilo?: Kilo
get kilo(): Kilo {
return (this._kilo ??= new Kilo({ client: this.client }))
+48
View File
@@ -1564,6 +1564,8 @@ export type ProviderConfig = {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
recommended?: boolean
recommendedIndex?: number
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
options?: {
@@ -2017,6 +2019,8 @@ export type Model = {
[key: string]: unknown
}
}
recommended?: boolean
recommendedIndex?: number
}
export type Provider = {
@@ -4087,6 +4091,8 @@ export type ProviderListResponses = {
input: Array<"text" | "audio" | "image" | "video" | "pdf">
output: Array<"text" | "audio" | "image" | "video" | "pdf">
}
recommended?: boolean
recommendedIndex?: number
experimental?: boolean
status?: "alpha" | "beta" | "deprecated"
options: {
@@ -4252,6 +4258,48 @@ export type TelemetryCaptureResponses = {
export type TelemetryCaptureResponse = TelemetryCaptureResponses[keyof TelemetryCaptureResponses]
export type CommitMessageGenerateData = {
body?: {
/**
* Workspace/repo path
*/
path: string
/**
* Optional subset of files to include
*/
selectedFiles?: Array<string>
/**
* Previously generated message triggers regeneration with a different result
*/
previousMessage?: string
}
path?: never
query?: {
directory?: string
}
url: "/commit-message"
}
export type CommitMessageGenerateErrors = {
/**
* Bad request
*/
400: BadRequestError
}
export type CommitMessageGenerateError = CommitMessageGenerateErrors[keyof CommitMessageGenerateErrors]
export type CommitMessageGenerateResponses = {
/**
* Generated commit message
*/
200: {
message: string
}
}
export type CommitMessageGenerateResponse = CommitMessageGenerateResponses[keyof CommitMessageGenerateResponses]
export type KiloProfileData = {
body?: never
path?: never
+95
View File
@@ -3768,6 +3768,12 @@
},
"required": ["input", "output"]
},
"recommended": {
"type": "boolean"
},
"recommendedIndex": {
"type": "number"
},
"experimental": {
"type": "boolean"
},
@@ -4116,6 +4122,83 @@
]
}
},
"/commit-message": {
"post": {
"operationId": "commitMessage.generate",
"parameters": [
{
"in": "query",
"name": "directory",
"schema": {
"type": "string"
}
}
],
"summary": "Generate commit message",
"description": "Generate a commit message using AI based on the current git diff.",
"responses": {
"200": {
"description": "Generated commit message",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"message": {
"type": "string"
}
},
"required": ["message"]
}
}
}
},
"400": {
"description": "Bad request",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BadRequestError"
}
}
}
}
},
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"path": {
"description": "Workspace/repo path",
"type": "string"
},
"selectedFiles": {
"description": "Optional subset of files to include",
"type": "array",
"items": {
"type": "string"
}
},
"previousMessage": {
"description": "Previously generated message — triggers regeneration with a different result",
"type": "string"
}
},
"required": ["path"]
}
}
}
},
"x-codeSamples": [
{
"lang": "js",
"source": "import { createOpencodeClient } from \"@kilocode/sdk\n\nconst client = createOpencodeClient()\nawait client.commitMessage.generate({\n ...\n})"
}
]
}
},
"/kilo/profile": {
"get": {
"operationId": "kilo.profile",
@@ -9812,6 +9895,12 @@
},
"required": ["input", "output"]
},
"recommended": {
"type": "boolean"
},
"recommendedIndex": {
"type": "number"
},
"experimental": {
"type": "boolean"
},
@@ -10795,6 +10884,12 @@
},
"additionalProperties": {}
}
},
"recommended": {
"type": "boolean"
},
"recommendedIndex": {
"type": "number"
}
},
"required": [
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.0.23",
"version": "1.0.24",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/upstream-merge",
"version": "1.0.23",
"version": "1.0.24",
"private": true,
"type": "module",
"description": "Scripts for automating upstream opencode merges into Kilo",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.23",
"version": "1.0.24",
"publisher": "sst-dev",
"repository": {
"type": "git",