Merge branch 'dev' into docs/2154-update-custom-instruction-files-docs
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
name: test-vscode
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
paths:
|
||||
- "packages/kilo-vscode/**"
|
||||
pull_request:
|
||||
paths:
|
||||
- "packages/kilo-vscode/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
unit:
|
||||
name: unit tests
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Run unit tests
|
||||
working-directory: packages/kilo-vscode
|
||||
run: bun run test:unit
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
[](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.
|
||||
You’re 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)
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
Generated
+3
-3
@@ -2,11 +2,11 @@
|
||||
"nodes": {
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1770073757,
|
||||
"narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=",
|
||||
"lastModified": 1771207753,
|
||||
"narHash": "sha256-b9uG8yN50DRQ6A7JdZBfzq718ryYrlmGgqkRm9OOwCE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "47472570b1e607482890801aeaf29bfb749884f6",
|
||||
"rev": "d1c15b7d5806069da59e819999d70e1cec0760bf",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
|
||||
@@ -162,6 +162,7 @@
|
||||
unzip
|
||||
gnutar
|
||||
gzip
|
||||
ripgrep
|
||||
kilo-dev
|
||||
kilo-install-bin
|
||||
kilo-bin
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-BEhWiPWkXCUblpkXVAIDmWndNbuulgfaDRO80dvssPY=",
|
||||
"aarch64-linux": "sha256-8ttezrn/CuIJDoAS1SZvq0laoQR6kBcrvv615uCxCJg=",
|
||||
"aarch64-darwin": "sha256-krlzO1o/VVOC15hneG9W1dgp3Od96QFqZ0wKnjt89c4=",
|
||||
"x86_64-darwin": "sha256-WjIaEu3jIQTqdWLQ29nacIraxsxZp5YMvzYC0j+pBMY="
|
||||
"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
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@ import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { closeDialog, openSettings, clickListItem } from "../actions"
|
||||
|
||||
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||
// kilocode_change start
|
||||
test.skip("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||
// kilocode_change end
|
||||
await gotoSession()
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
|
||||
@@ -2,7 +2,9 @@ import { test, expect } from "../fixtures"
|
||||
import { promptSelector } from "../selectors"
|
||||
import { closeDialog, openSettings } from "../actions"
|
||||
|
||||
test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||
// kilocode_change start
|
||||
test.skip("hiding a model removes it from the model picker", async ({ page, gotoSession }) => {
|
||||
// kilocode_change end
|
||||
await gotoSession()
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
@@ -60,7 +62,9 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi
|
||||
await expect(pickerAgain).toHaveCount(0)
|
||||
})
|
||||
|
||||
test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
|
||||
// kilocode_change start
|
||||
test.skip("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => {
|
||||
// kilocode_change end
|
||||
await gotoSession()
|
||||
|
||||
await page.locator(promptSelector).click()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -58,6 +58,22 @@ Once created, you can control your instance from the dashboard.
|
||||
| **Settings** | Model configuration and instance parameters |
|
||||
| **Actions** | Quick actions and connected platform management |
|
||||
|
||||
### Changelog
|
||||
|
||||
Your instance page includes a changelog with recent KiloClaw platform updates.
|
||||
|
||||
Each changelog entry is labeled by update type:
|
||||
|
||||
- **Feature** — New capability or enhancement
|
||||
- **Bug** — Fix for incorrect or broken behavior
|
||||
|
||||
Some entries also include a redeploy label:
|
||||
|
||||
- **Redeploy required** — You must redeploy your instance to fully take advantage of the change
|
||||
- **Redeploy suggested** — Redeploy is optional and only needed if you want to use the new behavior
|
||||
|
||||
For example, if you manually configured a channel such as Telegram, and would prefer to have KiloClaw manage the channel for you, you would need to redeploy.
|
||||
|
||||
## Accessing Your Agent
|
||||
|
||||
To connect to your agent's web interface:
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -85,6 +85,15 @@ You can customize each Cloud Agent session by also defining env vars and startup
|
||||
- Bootstrapping tooling
|
||||
- Running setup scripts
|
||||
|
||||
### Setup Commands vs `.kilocode/setup-script`
|
||||
|
||||
- Cloud Agent executes **Setup Commands** configured in the Cloud UI/profile.
|
||||
- Cloud Agent does **not** automatically discover or run `.kilocode/setup-script`.
|
||||
- If you want to use `.kilocode/setup-script` in Cloud Agent, call it explicitly from Setup Commands, for example: `bash .kilocode/setup-script`.
|
||||
- If both are present, execution order is:
|
||||
1. Setup Commands (in the order you define them)
|
||||
2. Anything those commands invoke (such as `.kilocode/setup-script`)
|
||||
|
||||
## Skills
|
||||
|
||||
Cloud Agents support project-level [skills](/docs/code-with-ai/platforms/cli#skills) stored in your repository. When your repo is cloned, any skills in `.kilocode/skills/` are automatically available.
|
||||
|
||||
@@ -74,7 +74,7 @@ Modes are managed from the Modes area in Kilo Code. Depending on your UI layout,
|
||||
|
||||
1. Open the Modes area from the mode selector in the chat panel (or via the <Codicon name="notebook" /> icon if shown)
|
||||
2. Click the Import Mode button (upload icon)
|
||||
3. Select the mode's YAML file
|
||||
3. Select the mode's YAML file (`.yaml`)
|
||||
4. Choose the import level:
|
||||
- **Project:** Available only in current workspace (saved to `.kilocodemodes` file)
|
||||
- **Global:** Available in all projects (saved to global settings)
|
||||
@@ -121,11 +121,16 @@ The interface provides fields for Name, Slug, Description, Save Location, Role D
|
||||
|
||||
You can directly edit the configuration files to create or modify custom modes. This method offers the most control over all properties. Kilo Code now supports both YAML (preferred) and JSON formats.
|
||||
|
||||
- **Global Modes:** Edit the `custom_modes.yaml` (preferred) or `custom_modes.json` file. Open the Modes area, click <Codicon name="gear" /> next to Global Modes, then choose "Edit Global Modes"
|
||||
- **Project Modes:** Edit the `.kilocodemodes` file (which can be YAML or JSON) in your project root. Open the Modes area, click <Codicon name="gear" /> next to Project Modes, then choose "Edit Project Modes"
|
||||
- **Global Modes:** Edit `custom_modes.yaml` (primary). `custom_modes.json` is a legacy fallback and may still exist in older setups.
|
||||
- **Project Modes:** Edit `.kilocodemodes` in your project root (YAML preferred; JSON still supported for compatibility).
|
||||
- **Open from UI:** Open the Modes area, click <Codicon name="gear" /> next to Global or Project Modes, then choose **Edit Global Modes** or **Edit Project Modes**.
|
||||
|
||||
These files define an array/list of custom modes.
|
||||
|
||||
{% callout type="info" title="Why JSON Files May Still Exist" %}
|
||||
If you see both YAML and JSON mode files, this is usually from legacy configuration. Kilo Code reads YAML first and does not keep both files synchronized line-by-line. In practice, edit YAML unless you have a specific reason to stay on JSON.
|
||||
{% /callout %}
|
||||
|
||||
## YAML Configuration Format (Preferred)
|
||||
|
||||
YAML is now the preferred format for defining custom modes due to better readability, comment support, and cleaner multi-line strings.
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -2,4 +2,32 @@
|
||||
.shiki {
|
||||
margin: 0rem;
|
||||
}
|
||||
|
||||
pre {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--border-weak-base) transparent;
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-corner {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-button {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--border-weak-base);
|
||||
border-radius: 4px;
|
||||
|
||||
&:hover {
|
||||
background: var(--border-strong-base);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,4 +5,8 @@
|
||||
[data-slot="text-part-body"] {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
[data-slot="text-part-copy-wrapper"] {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
display: block;
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
@@ -63,7 +64,8 @@ html[data-theme="kilo-vscode"] {
|
||||
font-weight: var(--vscode-font-weight);
|
||||
|
||||
::-webkit-scrollbar-button {
|
||||
display: block;
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
bin/
|
||||
out/
|
||||
.vscode-test/
|
||||
@@ -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)
|
||||
+461
@@ -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)
|
||||
@@ -73,7 +73,7 @@ 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 |
|
||||
@@ -81,7 +81,7 @@ The rebuild has a working foundation:
|
||||
| [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 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 |
|
||||
@@ -94,18 +94,18 @@ The rebuild has a working foundation:
|
||||
| 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 |
|
||||
| [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 | 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 |
|
||||
| [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 | ✅ 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 |
|
||||
| [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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -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": {
|
||||
@@ -315,7 +509,8 @@
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"lint": "eslint src",
|
||||
"test": "vscode-test"
|
||||
"test": "vscode-test",
|
||||
"test:unit": "bun test tests/unit/"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/diff": "^6.0.0",
|
||||
@@ -331,6 +526,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 +536,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",
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import * as vscode from "vscode"
|
||||
import { z } from "zod"
|
||||
import { type HttpClient, type SessionInfo, type SSEEvent, type KiloConnectionService } from "./services/cli-backend"
|
||||
import {
|
||||
type HttpClient,
|
||||
type SessionInfo,
|
||||
type SSEEvent,
|
||||
type KiloConnectionService,
|
||||
type KilocodeNotification,
|
||||
} from "./services/cli-backend"
|
||||
import { handleChatCompletionRequest } from "./services/autocomplete/chat-autocomplete/handleChatCompletionRequest"
|
||||
import { handleChatCompletionAccepted } from "./services/autocomplete/chat-autocomplete/handleChatCompletionAccepted"
|
||||
import { buildWebviewHtml } from "./utils"
|
||||
import { TelemetryProxy, type TelemetryPropertiesProvider } from "./services/telemetry"
|
||||
import {
|
||||
sessionToWebview,
|
||||
normalizeProviders,
|
||||
filterVisibleAgents,
|
||||
buildSettingPath,
|
||||
mapSSEEventToWebviewMessage,
|
||||
} from "./kilo-provider-utils"
|
||||
|
||||
export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider {
|
||||
public static readonly viewType = "kilo-code.new.sidebarView"
|
||||
@@ -22,12 +35,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
private cachedAgentsMessage: unknown = null
|
||||
/** Cached configLoaded payload so requestConfig can be served before httpClient is ready */
|
||||
private cachedConfigMessage: unknown = null
|
||||
/** Cached notificationsLoaded payload */
|
||||
private cachedNotificationsMessage: unknown = null
|
||||
|
||||
private trackedSessionIds: Set<string> = new Set()
|
||||
/** Per-session directory overrides (e.g., worktree paths registered by AgentManagerProvider). */
|
||||
private sessionDirectories = new Map<string, string>()
|
||||
/** Abort controller for the current loadMessages request; aborted when a new session is selected. */
|
||||
private loadMessagesAbort: AbortController | null = null
|
||||
private unsubscribeEvent: (() => void) | null = null
|
||||
private unsubscribeState: (() => void) | null = null
|
||||
private unsubscribeNotificationDismiss: (() => void) | null = null
|
||||
private webviewMessageDisposable: vscode.Disposable | null = null
|
||||
|
||||
/** Optional interceptor called before the standard message handler.
|
||||
@@ -37,6 +55,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
constructor(
|
||||
private readonly extensionUri: vscode.Uri,
|
||||
private readonly connectionService: KiloConnectionService,
|
||||
private readonly extensionContext?: vscode.ExtensionContext,
|
||||
) {
|
||||
TelemetryProxy.getInstance().setProvider(this)
|
||||
}
|
||||
@@ -267,6 +286,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
message.providerID,
|
||||
message.modelID,
|
||||
message.agent,
|
||||
message.variant,
|
||||
files,
|
||||
)
|
||||
break
|
||||
@@ -285,7 +305,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.trackedSessionIds.clear()
|
||||
break
|
||||
case "loadMessages":
|
||||
await this.handleLoadMessages(message.sessionID)
|
||||
// Don't await: allow parallel loads so rapid session switching
|
||||
// isn't blocked by slow responses for earlier sessions.
|
||||
void this.handleLoadMessages(message.sessionID)
|
||||
break
|
||||
case "syncSession":
|
||||
await this.handleSyncSession(message.sessionID)
|
||||
@@ -316,6 +338,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
vscode.env.openExternal(vscode.Uri.parse(message.url))
|
||||
}
|
||||
break
|
||||
case "openFile":
|
||||
if (message.filePath) {
|
||||
this.handleOpenFile(message.filePath, message.line, message.column)
|
||||
}
|
||||
break
|
||||
case "requestProviders":
|
||||
await this.fetchAndSendProviders()
|
||||
break
|
||||
@@ -402,12 +429,29 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
case "requestNotificationSettings":
|
||||
this.sendNotificationSettings()
|
||||
break
|
||||
case "requestNotifications":
|
||||
await this.fetchAndSendNotifications()
|
||||
break
|
||||
case "dismissNotification":
|
||||
await this.handleDismissNotification(message.notificationId)
|
||||
break
|
||||
case "resetAllSettings":
|
||||
await this.handleResetAllSettings()
|
||||
break
|
||||
case "telemetry":
|
||||
TelemetryProxy.capture(message.event, message.properties)
|
||||
break
|
||||
case "persistVariant": {
|
||||
const stored = this.extensionContext?.globalState.get<Record<string, string>>("variantSelections") ?? {}
|
||||
stored[message.key] = message.value
|
||||
await this.extensionContext?.globalState.update("variantSelections", stored)
|
||||
break
|
||||
}
|
||||
case "requestVariants": {
|
||||
const variants = this.extensionContext?.globalState.get<Record<string, string>>("variantSelections") ?? {}
|
||||
this.postMessage({ type: "variantsLoaded", variants })
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -422,6 +466,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
// Clean up any existing subscriptions (e.g., sidebar re-shown)
|
||||
this.unsubscribeEvent?.()
|
||||
this.unsubscribeState?.()
|
||||
this.unsubscribeNotificationDismiss?.()
|
||||
|
||||
try {
|
||||
const workspaceDir = this.getWorkspaceDirectory()
|
||||
@@ -469,6 +514,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
})
|
||||
|
||||
// Subscribe to notification dismiss broadcast from other KiloProvider instances
|
||||
this.unsubscribeNotificationDismiss = this.connectionService.onNotificationDismissed(() => {
|
||||
this.fetchAndSendNotifications()
|
||||
})
|
||||
|
||||
// Get current state and push to webview
|
||||
const serverInfo = this.connectionService.getServerInfo()
|
||||
this.connectionState = this.connectionService.getConnectionState()
|
||||
@@ -491,6 +541,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
await this.fetchAndSendProviders()
|
||||
await this.fetchAndSendAgents()
|
||||
await this.fetchAndSendConfig()
|
||||
await this.fetchAndSendNotifications()
|
||||
this.sendNotificationSettings()
|
||||
|
||||
console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully")
|
||||
@@ -505,16 +556,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert SessionInfo to webview format.
|
||||
*/
|
||||
private sessionToWebview(session: SessionInfo) {
|
||||
return {
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
createdAt: new Date(session.time.created).toISOString(),
|
||||
updatedAt: new Date(session.time.updated).toISOString(),
|
||||
}
|
||||
return sessionToWebview(session)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -560,25 +603,53 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
this.postMessage({
|
||||
type: "error",
|
||||
message: "Not connected to CLI backend",
|
||||
sessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Abort any previous in-flight loadMessages request so the backend
|
||||
// isn't overwhelmed when the user switches sessions rapidly.
|
||||
this.loadMessagesAbort?.abort()
|
||||
const abort = new AbortController()
|
||||
this.loadMessagesAbort = abort
|
||||
|
||||
try {
|
||||
const workspaceDir = this.getWorkspaceDirectory(sessionID)
|
||||
const messagesData = await this.httpClient.getMessages(sessionID, workspaceDir)
|
||||
const messagesData = await this.httpClient.getMessages(sessionID, workspaceDir, abort.signal)
|
||||
|
||||
// If this request was aborted while awaiting, skip posting stale results
|
||||
if (abort.signal.aborted) return
|
||||
|
||||
// Update currentSession so fallback logic in handleSendMessage/handleAbort
|
||||
// references the correct session after switching to a historical session.
|
||||
// Non-blocking: don't let a failure here prevent messages from loading.
|
||||
// 404s are expected for cross-worktree sessions — use silent to suppress HTTP error logs.
|
||||
this.httpClient
|
||||
.getSession(sessionID, workspaceDir)
|
||||
.getSession(sessionID, workspaceDir, true)
|
||||
.then((session) => {
|
||||
if (!this.currentSession || this.currentSession.id === sessionID) {
|
||||
this.currentSession = session
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error("[Kilo New] KiloProvider: Failed to fetch session for tracking:", err))
|
||||
.catch((err) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", 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) => ({
|
||||
@@ -601,10 +672,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
messages,
|
||||
})
|
||||
} catch (error) {
|
||||
// Silently ignore aborted requests — the user switched to a different session
|
||||
if (abort.signal.aborted) return
|
||||
console.error("[Kilo New] KiloProvider: Failed to load messages:", error)
|
||||
this.postMessage({
|
||||
type: "error",
|
||||
message: error instanceof Error ? error.message : "Failed to load messages",
|
||||
sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -769,11 +843,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
const workspaceDir = this.getWorkspaceDirectory()
|
||||
const response = await this.httpClient.listProviders(workspaceDir)
|
||||
|
||||
// Re-key providers from numeric indices to provider.id
|
||||
const normalized: typeof response.all = {}
|
||||
for (const provider of Object.values(response.all)) {
|
||||
normalized[provider.id] = provider
|
||||
}
|
||||
const normalized = normalizeProviders(response.all)
|
||||
|
||||
const config = vscode.workspace.getConfiguration("kilo-code.new.model")
|
||||
const providerID = config.get<string>("providerID", "kilo")
|
||||
@@ -808,11 +878,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
const workspaceDir = this.getWorkspaceDirectory()
|
||||
const agents = await this.httpClient.listAgents(workspaceDir)
|
||||
|
||||
// Filter to only visible primary/all modes (not subagents, not hidden)
|
||||
const visible = agents.filter((a) => a.mode !== "subagent" && !a.hidden)
|
||||
|
||||
// Find default agent: first one in list (CLI sorts default first)
|
||||
const defaultAgent = visible.length > 0 ? visible[0].name : "code"
|
||||
const { visible, defaultAgent } = filterVisibleAgents(agents)
|
||||
|
||||
const message = {
|
||||
type: "agentsLoaded",
|
||||
@@ -858,6 +924,47 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Kilo news/notifications and send to webview.
|
||||
* Uses the cached message pattern so the webview gets data immediately on refresh.
|
||||
*/
|
||||
private async fetchAndSendNotifications(): Promise<void> {
|
||||
if (!this.httpClient) {
|
||||
if (this.cachedNotificationsMessage) {
|
||||
this.postMessage(this.cachedNotificationsMessage)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const notifications = await this.httpClient.getNotifications()
|
||||
const existing = this.extensionContext?.globalState.get<string[]>("kilo.dismissedNotificationIds", []) ?? []
|
||||
const active = new Set(notifications.map((n) => n.id))
|
||||
const dismissedIds = existing.filter((id) => active.has(id))
|
||||
if (dismissedIds.length !== existing.length) {
|
||||
await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", dismissedIds)
|
||||
}
|
||||
const message = { type: "notificationsLoaded", notifications, dismissedIds }
|
||||
this.cachedNotificationsMessage = message
|
||||
this.postMessage(message)
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to fetch notifications:", error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist a dismissed notification ID in globalState and push updated lists to webview.
|
||||
*/
|
||||
private async handleDismissNotification(notificationId: string): Promise<void> {
|
||||
if (!this.extensionContext) return
|
||||
const existing = this.extensionContext.globalState.get<string[]>("kilo.dismissedNotificationIds", [])
|
||||
if (!existing.includes(notificationId)) {
|
||||
await this.extensionContext.globalState.update("kilo.dismissedNotificationIds", [...existing, notificationId])
|
||||
}
|
||||
await this.fetchAndSendNotifications()
|
||||
this.connectionService.notifyNotificationDismissed(notificationId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Read notification/sound settings from VS Code config and push to webview.
|
||||
*/
|
||||
@@ -915,6 +1022,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
providerID?: string,
|
||||
modelID?: string,
|
||||
agent?: string,
|
||||
variant?: string,
|
||||
files?: Array<{ mime: string; url: string }>,
|
||||
): Promise<void> {
|
||||
if (!this.httpClient) {
|
||||
@@ -970,6 +1078,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
providerID,
|
||||
modelID,
|
||||
agent,
|
||||
variant,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error("[Kilo New] KiloProvider: Failed to send message:", error)
|
||||
@@ -1201,6 +1310,28 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle openFile request from the webview — open a file in the VS Code editor.
|
||||
*/
|
||||
private handleOpenFile(filePath: string, line?: number, column?: number): void {
|
||||
const absolute = /^(?:\/|[a-zA-Z]:[\\/])/.test(filePath)
|
||||
const uri = absolute
|
||||
? vscode.Uri.file(filePath)
|
||||
: vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory()), filePath)
|
||||
vscode.workspace.openTextDocument(uri).then(
|
||||
(doc) => {
|
||||
const options: vscode.TextDocumentShowOptions = { preview: true }
|
||||
if (line !== undefined && line > 0) {
|
||||
const col = column !== undefined && column > 0 ? column - 1 : 0
|
||||
const pos = new vscode.Position(line - 1, col)
|
||||
options.selection = new vscode.Range(pos, pos)
|
||||
}
|
||||
vscode.window.showTextDocument(doc, options)
|
||||
},
|
||||
(err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle logout request from the webview.
|
||||
*/
|
||||
@@ -1239,9 +1370,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
* The key uses dot notation relative to `kilo-code.new` (e.g. "browserAutomation.enabled").
|
||||
*/
|
||||
private async handleUpdateSetting(key: string, value: unknown): Promise<void> {
|
||||
const parts = key.split(".")
|
||||
const section = parts.slice(0, -1).join(".")
|
||||
const leaf = parts[parts.length - 1]
|
||||
const { section, leaf } = buildSettingPath(key)
|
||||
const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`)
|
||||
await config.update(leaf, value, vscode.ConfigurationTarget.Global)
|
||||
}
|
||||
@@ -1325,121 +1454,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
}
|
||||
|
||||
// Forward relevant events to webview
|
||||
switch (event.type) {
|
||||
case "message.part.updated": {
|
||||
// The part contains the full part data including messageID, delta is optional text delta
|
||||
const part = event.properties.part as { messageID?: string; sessionID?: string }
|
||||
const messageID = part.messageID || ""
|
||||
|
||||
const resolvedSessionID = sessionID
|
||||
if (!resolvedSessionID) {
|
||||
return
|
||||
}
|
||||
this.postMessage({
|
||||
type: "partUpdated",
|
||||
sessionID: resolvedSessionID,
|
||||
messageID,
|
||||
part: event.properties.part,
|
||||
delta: event.properties.delta ? { type: "text-delta", textDelta: event.properties.delta } : undefined,
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case "message.updated":
|
||||
// Message info updated — forward cost/tokens for assistant messages
|
||||
this.postMessage({
|
||||
type: "messageCreated",
|
||||
message: {
|
||||
id: event.properties.info.id,
|
||||
sessionID: event.properties.info.sessionID,
|
||||
role: event.properties.info.role,
|
||||
createdAt: new Date(event.properties.info.time.created).toISOString(),
|
||||
cost: event.properties.info.cost,
|
||||
tokens: event.properties.info.tokens,
|
||||
},
|
||||
})
|
||||
break
|
||||
|
||||
case "session.status":
|
||||
this.postMessage({
|
||||
type: "sessionStatus",
|
||||
sessionID: event.properties.sessionID,
|
||||
status: event.properties.status.type,
|
||||
})
|
||||
break
|
||||
|
||||
case "permission.asked":
|
||||
this.postMessage({
|
||||
type: "permissionRequest",
|
||||
permission: {
|
||||
id: event.properties.id,
|
||||
sessionID: event.properties.sessionID,
|
||||
toolName: event.properties.permission,
|
||||
patterns: event.properties.patterns ?? [],
|
||||
args: event.properties.metadata,
|
||||
message: `Permission required: ${event.properties.permission}`,
|
||||
tool: event.properties.tool,
|
||||
},
|
||||
})
|
||||
break
|
||||
|
||||
case "todo.updated":
|
||||
this.postMessage({
|
||||
type: "todoUpdated",
|
||||
sessionID: event.properties.sessionID,
|
||||
items: event.properties.items,
|
||||
})
|
||||
break
|
||||
|
||||
case "question.asked":
|
||||
this.postMessage({
|
||||
type: "questionRequest",
|
||||
question: {
|
||||
id: event.properties.id,
|
||||
sessionID: event.properties.sessionID,
|
||||
questions: event.properties.questions,
|
||||
tool: event.properties.tool,
|
||||
},
|
||||
})
|
||||
break
|
||||
|
||||
case "question.replied":
|
||||
this.postMessage({
|
||||
type: "questionResolved",
|
||||
requestID: event.properties.requestID,
|
||||
})
|
||||
break
|
||||
|
||||
case "question.rejected":
|
||||
this.postMessage({
|
||||
type: "questionResolved",
|
||||
requestID: event.properties.requestID,
|
||||
})
|
||||
break
|
||||
|
||||
case "session.created":
|
||||
// Store session if we don't have one yet
|
||||
if (!this.currentSession) {
|
||||
// Side effects that must happen before the webview message is sent
|
||||
if (event.type === "session.created" && !this.currentSession) {
|
||||
this.currentSession = event.properties.info
|
||||
this.trackedSessionIds.add(event.properties.info.id)
|
||||
}
|
||||
// Notify webview
|
||||
this.postMessage({
|
||||
type: "sessionCreated",
|
||||
session: this.sessionToWebview(event.properties.info),
|
||||
})
|
||||
break
|
||||
|
||||
case "session.updated":
|
||||
// Keep local state in sync (e.g. title generation)
|
||||
if (this.currentSession?.id === event.properties.info.id) {
|
||||
if (event.type === "session.updated" && this.currentSession?.id === event.properties.info.id) {
|
||||
this.currentSession = event.properties.info
|
||||
}
|
||||
this.postMessage({
|
||||
type: "sessionUpdated",
|
||||
session: this.sessionToWebview(event.properties.info),
|
||||
})
|
||||
break
|
||||
|
||||
const msg = mapSSEEventToWebviewMessage(event, sessionID)
|
||||
if (msg) {
|
||||
this.postMessage(msg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1514,6 +1540,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
||||
dispose(): void {
|
||||
this.unsubscribeEvent?.()
|
||||
this.unsubscribeState?.()
|
||||
this.unsubscribeNotificationDismiss?.()
|
||||
this.webviewMessageDisposable?.dispose()
|
||||
this.trackedSessionIds.clear()
|
||||
this.sessionDirectories.clear()
|
||||
|
||||
@@ -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")
|
||||
@@ -83,7 +94,10 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
private async initializeState(): Promise<void> {
|
||||
const manager = this.getWorktreeManager()
|
||||
const state = this.getStateManager()
|
||||
if (!manager || !state) return
|
||||
if (!manager || !state) {
|
||||
this.pushEmptyState()
|
||||
return
|
||||
}
|
||||
|
||||
await state.load()
|
||||
|
||||
@@ -124,10 +138,54 @@ 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.createMultiVersion") {
|
||||
void this.onCreateMultiVersion(msg)
|
||||
return null
|
||||
}
|
||||
if (type === "agentManager.requestState") {
|
||||
void this.stateReady
|
||||
?.then(() => {
|
||||
this.pushState()
|
||||
// Refresh sessions after pushState so the webview's sessionsLoaded
|
||||
// handler is guaranteed to be registered (requestState fires from
|
||||
// onMount). Without this, the initial refreshSessions() in
|
||||
// initializeState() can race ahead of webview mount, causing
|
||||
// sessionsLoaded to never flip to true.
|
||||
if (this.state && this.state.getSessions().length > 0) {
|
||||
this.provider?.refreshSessions()
|
||||
}
|
||||
})
|
||||
.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
|
||||
}
|
||||
if (type === "agentManager.setSessionsCollapsed" && typeof msg.collapsed === "boolean") {
|
||||
this.state?.setSessionsCollapsed(msg.collapsed as boolean)
|
||||
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") {
|
||||
@@ -147,14 +205,18 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create a git worktree on disk and register it in state. Returns null on failure. */
|
||||
private async createWorktreeOnDisk(): Promise<{
|
||||
private async createWorktreeOnDisk(groupId?: string): Promise<{
|
||||
worktree: ReturnType<WorktreeStateManager["addWorktree"]>
|
||||
result: CreateWorktreeResult
|
||||
} | null> {
|
||||
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,21 +226,41 @@ 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
|
||||
}
|
||||
|
||||
const worktree = state.addWorktree({ branch: result.branch, path: result.path, parentBranch: result.parentBranch })
|
||||
const worktree = state.addWorktree({
|
||||
branch: result.branch,
|
||||
path: result.path,
|
||||
parentBranch: result.parentBranch,
|
||||
groupId,
|
||||
})
|
||||
|
||||
// Push state immediately so the sidebar shows the new worktree with a loading indicator
|
||||
this.pushState()
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "creating",
|
||||
message: "Setting up workspace...",
|
||||
branch: result.branch,
|
||||
worktreeId: worktree.id,
|
||||
})
|
||||
|
||||
return { worktree, result }
|
||||
}
|
||||
|
||||
/** Create a CLI session in a worktree directory. Returns null on failure. */
|
||||
private async createSessionInWorktree(worktreePath: string, branch: string): Promise<SessionInfo | null> {
|
||||
private async createSessionInWorktree(
|
||||
worktreePath: string,
|
||||
branch: string,
|
||||
worktreeId?: string,
|
||||
): Promise<SessionInfo | null> {
|
||||
let client: HttpClient
|
||||
try {
|
||||
client = this.connectionService.getHttpClient()
|
||||
@@ -187,6 +269,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "error",
|
||||
message: "Not connected to CLI backend",
|
||||
worktreeId,
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -196,6 +279,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
status: "starting",
|
||||
message: "Starting session...",
|
||||
branch,
|
||||
worktreeId,
|
||||
})
|
||||
|
||||
try {
|
||||
@@ -206,13 +290,14 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "error",
|
||||
message: `Failed to create session: ${err}`,
|
||||
worktreeId,
|
||||
})
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Send worktreeSetup.ready + sessionMeta + pushState after worktree creation. */
|
||||
private notifyWorktreeReady(sessionId: string, result: CreateWorktreeResult): void {
|
||||
private notifyWorktreeReady(sessionId: string, result: CreateWorktreeResult, worktreeId?: string): void {
|
||||
this.pushState()
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
@@ -220,6 +305,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
message: "Worktree ready",
|
||||
sessionId,
|
||||
branch: result.branch,
|
||||
worktreeId,
|
||||
})
|
||||
this.postToWebview({
|
||||
type: "agentManager.sessionMeta",
|
||||
@@ -240,19 +326,23 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
const created = await this.createWorktreeOnDisk()
|
||||
if (!created) return null
|
||||
|
||||
const session = await this.createSessionInWorktree(created.result.path, created.result.branch)
|
||||
// Run setup script for new worktree (blocks until complete, shows in overlay)
|
||||
await this.runSetupScriptForWorktree(created.result.path, created.result.branch, created.worktree.id)
|
||||
|
||||
const session = await this.createSessionInWorktree(created.result.path, created.result.branch, created.worktree.id)
|
||||
if (!session) {
|
||||
const state = this.getStateManager()
|
||||
const manager = this.getWorktreeManager()
|
||||
state?.removeWorktree(created.worktree.id)
|
||||
await manager?.removeWorktree(created.result.path)
|
||||
this.pushState()
|
||||
return null
|
||||
}
|
||||
|
||||
const state = this.getStateManager()!
|
||||
state.addSession(session.id, created.worktree.id)
|
||||
this.registerWorktreeSession(session.id, created.result.path)
|
||||
this.notifyWorktreeReady(session.id, created.result)
|
||||
this.notifyWorktreeReady(session.id, created.result, created.worktree.id)
|
||||
this.log(`Created worktree ${created.worktree.id} with session ${session.id}`)
|
||||
return null
|
||||
}
|
||||
@@ -289,6 +379,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, created.worktree.id)
|
||||
|
||||
const state = this.getStateManager()!
|
||||
if (!state.getSession(sessionId)) {
|
||||
state.addSession(sessionId, created.worktree.id)
|
||||
@@ -297,7 +390,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
}
|
||||
|
||||
this.registerWorktreeSession(sessionId, created.result.path)
|
||||
this.notifyWorktreeReady(sessionId, created.result)
|
||||
this.notifyWorktreeReady(sessionId, created.result, created.worktree.id)
|
||||
this.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`)
|
||||
return null
|
||||
}
|
||||
@@ -308,11 +401,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 +427,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 +451,210 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
return null
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-version worktree creation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Create N worktree sessions for the same prompt (multi-version mode). */
|
||||
private async onCreateMultiVersion(msg: Record<string, unknown>): Promise<null> {
|
||||
const text = msg.text as string
|
||||
if (!text) return null
|
||||
|
||||
const versions = Math.min(Math.max(Number(msg.versions) || 1, 1), 4)
|
||||
const providerID = msg.providerID as string | undefined
|
||||
const modelID = msg.modelID as string | undefined
|
||||
const agent = msg.agent as string | undefined
|
||||
const files = msg.files as Array<{ mime: string; url: string }> | undefined
|
||||
|
||||
// Generate a shared group ID for multi-version worktrees
|
||||
const groupId = versions > 1 ? `grp-${Date.now()}` : undefined
|
||||
|
||||
this.log(
|
||||
`Creating ${versions} multi-version worktrees for: ${text.slice(0, 60)}${groupId ? ` (group=${groupId})` : ""}`,
|
||||
)
|
||||
|
||||
// Notify webview that multi-version creation has started
|
||||
this.postToWebview({
|
||||
type: "agentManager.multiVersionProgress",
|
||||
status: "creating",
|
||||
total: versions,
|
||||
completed: 0,
|
||||
groupId,
|
||||
})
|
||||
|
||||
// Phase 1: Create all worktrees + sessions first
|
||||
const created: Array<{
|
||||
worktreeId: string
|
||||
sessionId: string
|
||||
path: string
|
||||
branch: string
|
||||
parentBranch: string
|
||||
}> = []
|
||||
|
||||
for (let i = 0; i < versions; i++) {
|
||||
this.log(`Creating worktree ${i + 1}/${versions}`)
|
||||
|
||||
const wt = await this.createWorktreeOnDisk(groupId)
|
||||
if (!wt) {
|
||||
this.log(`Failed to create worktree for version ${i + 1}`)
|
||||
continue
|
||||
}
|
||||
|
||||
await this.runSetupScriptForWorktree(wt.result.path, wt.result.branch)
|
||||
|
||||
const session = await this.createSessionInWorktree(wt.result.path, wt.result.branch)
|
||||
if (!session) {
|
||||
const state = this.getStateManager()
|
||||
const manager = this.getWorktreeManager()
|
||||
state?.removeWorktree(wt.worktree.id)
|
||||
await manager?.removeWorktree(wt.result.path)
|
||||
this.log(`Failed to create session for version ${i + 1}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const state = this.getStateManager()!
|
||||
state.addSession(session.id, wt.worktree.id)
|
||||
this.registerWorktreeSession(session.id, wt.result.path)
|
||||
this.notifyWorktreeReady(session.id, wt.result)
|
||||
|
||||
created.push({
|
||||
worktreeId: wt.worktree.id,
|
||||
sessionId: session.id,
|
||||
path: wt.result.path,
|
||||
branch: wt.result.branch,
|
||||
parentBranch: wt.result.parentBranch,
|
||||
})
|
||||
|
||||
this.log(`Version ${i + 1} worktree ready: session=${session.id}`)
|
||||
|
||||
// Update progress
|
||||
this.postToWebview({
|
||||
type: "agentManager.multiVersionProgress",
|
||||
status: "creating",
|
||||
total: versions,
|
||||
completed: created.length,
|
||||
groupId,
|
||||
})
|
||||
}
|
||||
|
||||
// Phase 2: Send the initial prompt to all sessions via the KiloProvider's
|
||||
// message handling (same path as typing in the chat). This ensures SSE
|
||||
// subscriptions and session tracking are properly set up before the message
|
||||
// is sent. We route each message through the webview→KiloProvider pipeline.
|
||||
for (let i = 0; i < created.length; i++) {
|
||||
const entry = created[i]!
|
||||
this.log(`Sending initial message to version ${i + 1} (session=${entry.sessionId})`)
|
||||
|
||||
// Tell the webview to send the message through the normal session flow
|
||||
this.postToWebview({
|
||||
type: "agentManager.sendInitialMessage",
|
||||
sessionId: entry.sessionId,
|
||||
worktreeId: entry.worktreeId,
|
||||
text,
|
||||
providerID,
|
||||
modelID,
|
||||
agent,
|
||||
files,
|
||||
})
|
||||
|
||||
// Small delay between sends to avoid overwhelming the backend
|
||||
if (i < created.length - 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300))
|
||||
}
|
||||
}
|
||||
|
||||
// Notify completion
|
||||
this.postToWebview({
|
||||
type: "agentManager.multiVersionProgress",
|
||||
status: "done",
|
||||
total: versions,
|
||||
completed: created.length,
|
||||
groupId,
|
||||
})
|
||||
|
||||
if (created.length === 0) {
|
||||
vscode.window.showErrorMessage(`Failed to create any of the ${versions} multi-version worktrees.`)
|
||||
}
|
||||
|
||||
this.log(`Multi-version creation complete: ${created.length}/${versions} versions`)
|
||||
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> = {}
|
||||
|
||||
// Global keybindings exposed to the shortcuts dialog
|
||||
const globals: Record<string, string> = {
|
||||
"kilo-code.new.agentManagerOpen": "agentManagerOpen",
|
||||
}
|
||||
|
||||
for (const kb of keybindings) {
|
||||
const raw = mac ? (kb.mac ?? kb.key) : kb.key
|
||||
if (!raw) continue
|
||||
|
||||
if (kb.command.startsWith(prefix)) {
|
||||
bindings[kb.command.slice(prefix.length)] = formatKeybinding(raw, mac)
|
||||
} else if (globals[kb.command]) {
|
||||
bindings[globals[kb.command]] = 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, worktreeId?: 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,
|
||||
worktreeId,
|
||||
})
|
||||
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}`)
|
||||
this.postToWebview({
|
||||
type: "agentManager.worktreeSetup",
|
||||
status: "error",
|
||||
message: `Setup script failed: ${msg}`,
|
||||
branch,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repo info
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -396,6 +687,19 @@ export class AgentManagerProvider implements vscode.Disposable {
|
||||
type: "agentManager.state",
|
||||
worktrees: state.getWorktrees(),
|
||||
sessions: state.getSessions(),
|
||||
tabOrder: state.getTabOrder(),
|
||||
sessionsCollapsed: state.getSessionsCollapsed(),
|
||||
isGitRepo: true,
|
||||
})
|
||||
}
|
||||
|
||||
/** Push empty state when the workspace is not a git repo or has no workspace folder. */
|
||||
private pushEmptyState(): void {
|
||||
this.postToWebview({
|
||||
type: "agentManager.state",
|
||||
worktrees: [],
|
||||
sessions: [],
|
||||
isGitRepo: false,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -431,6 +735,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 +764,33 @@ 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 isActive(): boolean {
|
||||
return this.panel?.active === true
|
||||
}
|
||||
|
||||
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> {
|
||||
|
||||
@@ -18,6 +18,8 @@ export interface Worktree {
|
||||
path: string
|
||||
parentBranch: string
|
||||
createdAt: string
|
||||
/** Shared identifier for worktrees created together via multi-version mode. */
|
||||
groupId?: string
|
||||
}
|
||||
|
||||
export interface ManagedSession {
|
||||
@@ -29,6 +31,8 @@ export interface ManagedSession {
|
||||
interface StateFile {
|
||||
worktrees: Record<string, Omit<Worktree, "id">>
|
||||
sessions: Record<string, Omit<ManagedSession, "id">>
|
||||
tabOrder?: Record<string, string[]>
|
||||
sessionsCollapsed?: boolean
|
||||
}
|
||||
|
||||
const STATE_FILE = "agent-manager.json"
|
||||
@@ -44,6 +48,8 @@ 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 collapsed = false
|
||||
private readonly log: (msg: string) => void
|
||||
private saving: Promise<void> | undefined
|
||||
private pendingSave = false
|
||||
@@ -103,11 +109,18 @@ export class WorktreeStateManager {
|
||||
// Mutations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
addWorktree(params: { branch: string; path: string; parentBranch: string }): Worktree {
|
||||
addWorktree(params: { branch: string; path: string; parentBranch: string; groupId?: string }): Worktree {
|
||||
const id = generateId("wt")
|
||||
const wt: Worktree = { id, ...params, createdAt: new Date().toISOString() }
|
||||
const wt: Worktree = {
|
||||
id,
|
||||
branch: params.branch,
|
||||
path: params.path,
|
||||
parentBranch: params.parentBranch,
|
||||
createdAt: new Date().toISOString(),
|
||||
}
|
||||
if (params.groupId) wt.groupId = params.groupId
|
||||
this.worktrees.set(id, wt)
|
||||
this.log(`Added worktree ${id}: ${params.branch}`)
|
||||
this.log(`Added worktree ${id}: ${params.branch}${params.groupId ? ` (group=${params.groupId})` : ""}`)
|
||||
void this.save()
|
||||
return wt
|
||||
}
|
||||
@@ -125,6 +138,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 +165,47 @@ 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()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sessions collapsed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getSessionsCollapsed(): boolean {
|
||||
return this.collapsed
|
||||
}
|
||||
|
||||
setSessionsCollapsed(value: boolean): void {
|
||||
this.collapsed = value
|
||||
void this.save()
|
||||
}
|
||||
|
||||
@@ -162,6 +219,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 +227,10 @@ 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.collapsed = data.sessionsCollapsed ?? false
|
||||
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
@@ -229,6 +291,12 @@ export class WorktreeStateManager {
|
||||
const { id: _, ...rest } = s
|
||||
data.sessions[id] = rest
|
||||
}
|
||||
if (Object.keys(this.tabOrder).length > 0) {
|
||||
data.tabOrder = this.tabOrder
|
||||
}
|
||||
if (this.collapsed) {
|
||||
data.sessionsCollapsed = true
|
||||
}
|
||||
|
||||
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!"
|
||||
`
|
||||
@@ -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")
|
||||
@@ -31,7 +33,7 @@ export function activate(context: vscode.ExtensionContext) {
|
||||
})
|
||||
|
||||
// Create the provider with shared service
|
||||
const provider = new KiloProvider(context.extensionUri, connectionService)
|
||||
const provider = new KiloProvider(context.extensionUri, connectionService, context)
|
||||
|
||||
// Register the webview view provider for the sidebar.
|
||||
// retainContextWhenHidden keeps the webview alive when switching to other sidebar panels.
|
||||
@@ -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, agentManagerProvider)
|
||||
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: () => {
|
||||
@@ -121,7 +157,7 @@ async function openKiloInNewTab(context: vscode.ExtensionContext, connectionServ
|
||||
dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "kilo-dark.svg"),
|
||||
}
|
||||
|
||||
const tabProvider = new KiloProvider(context.extensionUri, connectionService)
|
||||
const tabProvider = new KiloProvider(context.extensionUri, connectionService, context)
|
||||
tabProvider.resolveWebviewPanel(panel)
|
||||
|
||||
// Wait for the new panel to become active before locking the editor group.
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { SessionInfo, AgentInfo, Provider, SSEEvent } from "./services/cli-backend/types"
|
||||
|
||||
export function sessionToWebview(session: SessionInfo) {
|
||||
return {
|
||||
id: session.id,
|
||||
title: session.title,
|
||||
createdAt: new Date(session.time.created).toISOString(),
|
||||
updatedAt: new Date(session.time.updated).toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeProviders(all: Record<string, Provider>): Record<string, Provider> {
|
||||
const normalized: Record<string, Provider> = {}
|
||||
for (const provider of Object.values(all)) {
|
||||
normalized[provider.id] = provider
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function filterVisibleAgents(agents: AgentInfo[]): { visible: AgentInfo[]; defaultAgent: string } {
|
||||
const visible = agents.filter((a) => a.mode !== "subagent" && !a.hidden)
|
||||
const defaultAgent = visible.length > 0 ? visible[0]!.name : "code"
|
||||
return { visible, defaultAgent }
|
||||
}
|
||||
|
||||
export function buildSettingPath(key: string): { section: string; leaf: string } {
|
||||
const parts = key.split(".")
|
||||
const section = parts.slice(0, -1).join(".")
|
||||
const leaf = parts[parts.length - 1]!
|
||||
return { section, leaf }
|
||||
}
|
||||
|
||||
export type WebviewMessage =
|
||||
| {
|
||||
type: "partUpdated"
|
||||
sessionID: string
|
||||
messageID: string
|
||||
part: unknown
|
||||
delta?: { type: "text-delta"; textDelta: string }
|
||||
}
|
||||
| {
|
||||
type: "messageCreated"
|
||||
message: { id: string; sessionID: string; role: string; createdAt: string; cost?: number; tokens?: unknown }
|
||||
}
|
||||
| { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number }
|
||||
| {
|
||||
type: "permissionRequest"
|
||||
permission: {
|
||||
id: string
|
||||
sessionID: string
|
||||
toolName: string
|
||||
patterns: string[]
|
||||
args: Record<string, unknown>
|
||||
message: string
|
||||
tool?: { messageID: string; callID: string }
|
||||
}
|
||||
}
|
||||
| { type: "todoUpdated"; sessionID: string; items: unknown[] }
|
||||
| { type: "questionRequest"; question: { id: string; sessionID: string; questions: unknown[]; tool?: unknown } }
|
||||
| { type: "questionResolved"; requestID: string }
|
||||
| { type: "sessionCreated"; session: ReturnType<typeof sessionToWebview> }
|
||||
| { type: "sessionUpdated"; session: ReturnType<typeof sessionToWebview> }
|
||||
| null
|
||||
|
||||
export function mapSSEEventToWebviewMessage(event: SSEEvent, sessionID: string | undefined): WebviewMessage {
|
||||
switch (event.type) {
|
||||
case "message.part.updated": {
|
||||
const part = event.properties.part as { messageID?: string; sessionID?: string }
|
||||
if (!sessionID) return null
|
||||
return {
|
||||
type: "partUpdated",
|
||||
sessionID,
|
||||
messageID: part.messageID || "",
|
||||
part: event.properties.part,
|
||||
delta: event.properties.delta ? { type: "text-delta", textDelta: event.properties.delta } : undefined,
|
||||
}
|
||||
}
|
||||
case "message.updated":
|
||||
return {
|
||||
type: "messageCreated",
|
||||
message: {
|
||||
id: event.properties.info.id,
|
||||
sessionID: event.properties.info.sessionID,
|
||||
role: event.properties.info.role,
|
||||
createdAt: new Date(event.properties.info.time.created).toISOString(),
|
||||
cost: event.properties.info.cost,
|
||||
tokens: event.properties.info.tokens,
|
||||
},
|
||||
}
|
||||
case "session.status": {
|
||||
const info = event.properties.status
|
||||
return {
|
||||
type: "sessionStatus",
|
||||
sessionID: event.properties.sessionID,
|
||||
status: info.type,
|
||||
...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
|
||||
}
|
||||
}
|
||||
case "permission.asked":
|
||||
return {
|
||||
type: "permissionRequest",
|
||||
permission: {
|
||||
id: event.properties.id,
|
||||
sessionID: event.properties.sessionID,
|
||||
toolName: event.properties.permission,
|
||||
patterns: event.properties.patterns ?? [],
|
||||
args: event.properties.metadata,
|
||||
message: `Permission required: ${event.properties.permission}`,
|
||||
tool: event.properties.tool,
|
||||
},
|
||||
}
|
||||
case "todo.updated":
|
||||
return {
|
||||
type: "todoUpdated",
|
||||
sessionID: event.properties.sessionID,
|
||||
items: event.properties.items,
|
||||
}
|
||||
case "question.asked":
|
||||
return {
|
||||
type: "questionRequest",
|
||||
question: {
|
||||
id: event.properties.id,
|
||||
sessionID: event.properties.sessionID,
|
||||
questions: event.properties.questions,
|
||||
tool: event.properties.tool,
|
||||
},
|
||||
}
|
||||
case "question.replied":
|
||||
case "question.rejected":
|
||||
return {
|
||||
type: "questionResolved",
|
||||
requestID: event.properties.requestID,
|
||||
}
|
||||
case "session.created":
|
||||
return {
|
||||
type: "sessionCreated",
|
||||
session: sessionToWebview(event.properties.info),
|
||||
}
|
||||
case "session.updated":
|
||||
return {
|
||||
type: "sessionUpdated",
|
||||
session: sessionToWebview(event.properties.info),
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import * as vscode from "vscode"
|
||||
import { t } from "./shims/i18n"
|
||||
import type { AutocompleteStatusBarStateProps } from "./types"
|
||||
import { humanFormatSessionCost, formatTime } from "./statusbar-utils"
|
||||
|
||||
const SUPPORTED_PROVIDER_DISPLAY_NAME = "Kilo Gateway"
|
||||
|
||||
@@ -40,14 +41,7 @@ export class AutocompleteStatusBar {
|
||||
}
|
||||
|
||||
private humanFormatSessionCost(): string {
|
||||
const cost = this.props.totalSessionCost
|
||||
if (cost === 0) {
|
||||
return t("kilocode:autocomplete.statusBar.cost.zero")
|
||||
}
|
||||
if (cost > 0 && cost < 0.01) {
|
||||
return t("kilocode:autocomplete.statusBar.cost.lessThanCent")
|
||||
}
|
||||
return `$${cost.toFixed(2)}`
|
||||
return humanFormatSessionCost(this.props.totalSessionCost)
|
||||
}
|
||||
|
||||
public update(params: Partial<AutocompleteStatusBarStateProps>) {
|
||||
@@ -60,8 +54,7 @@ export class AutocompleteStatusBar {
|
||||
}
|
||||
|
||||
private formatTime(timestamp: number): string {
|
||||
const date = new Date(timestamp)
|
||||
return date.toLocaleTimeString()
|
||||
return formatTime(timestamp)
|
||||
}
|
||||
|
||||
private renderDefault() {
|
||||
|
||||
+6
-40
@@ -4,6 +4,7 @@ import { removePrefixOverlap } from "../continuedev/core/autocomplete/postproces
|
||||
import { AutocompleteTelemetry } from "../classic-auto-complete/AutocompleteTelemetry"
|
||||
import { postprocessAutocompleteSuggestion } from "../classic-auto-complete/uselessSuggestionFilter"
|
||||
import type { KiloConnectionService } from "../../cli-backend"
|
||||
import { finalizeChatSuggestion, buildChatPrefix } from "./chat-autocomplete-utils"
|
||||
|
||||
export class ChatTextAreaAutocomplete {
|
||||
private model: AutocompleteModel
|
||||
@@ -134,52 +135,17 @@ TASK: Complete the user's message naturally.
|
||||
}
|
||||
|
||||
private async buildPrefix(userText: string, visibleCodeContext?: VisibleCodeContext): Promise<string> {
|
||||
const contextParts: string[] = []
|
||||
|
||||
// Add visible code context (replaces cursor-based prefix/suffix)
|
||||
if (visibleCodeContext && visibleCodeContext.editors.length > 0) {
|
||||
contextParts.push("// Code visible in editor:")
|
||||
|
||||
for (const editor of visibleCodeContext.editors) {
|
||||
const fileName = editor.filePath.split("/").pop() || editor.filePath
|
||||
contextParts.push(`\n// File: ${fileName} (${editor.languageId})`)
|
||||
|
||||
for (const range of editor.visibleRanges) {
|
||||
contextParts.push(range.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contextParts.push("\n// User's message:")
|
||||
contextParts.push(userText)
|
||||
|
||||
return contextParts.join("\n")
|
||||
return buildChatPrefix(userText, visibleCodeContext?.editors)
|
||||
}
|
||||
|
||||
public cleanSuggestion(suggestion: string, userText: string): string {
|
||||
let cleaned = postprocessAutocompleteSuggestion({
|
||||
const cleaned = postprocessAutocompleteSuggestion({
|
||||
suggestion: removePrefixOverlap(suggestion, userText),
|
||||
prefix: userText,
|
||||
suffix: "", // Chat textarea has no suffix
|
||||
suffix: "",
|
||||
model: this.model.getModelName() ?? "unknown",
|
||||
})
|
||||
|
||||
if (cleaned === undefined) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Filter suggestions that look like code rather than natural language
|
||||
if (cleaned.match(/^(\/\/|\/\*|\*|#)/)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Chat-specific: truncate at first newline for single-line suggestions
|
||||
const firstNewline = cleaned.indexOf("\n")
|
||||
if (firstNewline !== -1) {
|
||||
cleaned = cleaned.substring(0, firstNewline)
|
||||
}
|
||||
cleaned = cleaned.trimEnd()
|
||||
|
||||
return cleaned
|
||||
if (cleaned === undefined) return ""
|
||||
return finalizeChatSuggestion(cleaned)
|
||||
}
|
||||
}
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Apply chat-specific post-processing to a suggestion:
|
||||
* - Filter suggestions that look like code comments
|
||||
* - Truncate at first newline (chat is single-line)
|
||||
* - Trim trailing whitespace
|
||||
* Returns empty string when the suggestion should be discarded.
|
||||
*/
|
||||
export function finalizeChatSuggestion(cleaned: string): string {
|
||||
if (!cleaned) return ""
|
||||
|
||||
if (cleaned.match(/^(\/\/|\/\*|\*|#)/)) {
|
||||
return ""
|
||||
}
|
||||
|
||||
const firstNewline = cleaned.indexOf("\n")
|
||||
const truncated = firstNewline !== -1 ? cleaned.substring(0, firstNewline) : cleaned
|
||||
return truncated.trimEnd()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the prefix string for a chat completion request from user text and visible code context.
|
||||
*/
|
||||
export function buildChatPrefix(
|
||||
userText: string,
|
||||
editors?: Array<{
|
||||
filePath: string
|
||||
languageId: string
|
||||
visibleRanges: Array<{ content: string }>
|
||||
}>,
|
||||
): string {
|
||||
const parts: string[] = []
|
||||
if (editors && editors.length > 0) {
|
||||
parts.push("// Code visible in editor:")
|
||||
for (const editor of editors) {
|
||||
const fileName = editor.filePath.split("/").pop() || editor.filePath
|
||||
parts.push(`\n// File: ${fileName} (${editor.languageId})`)
|
||||
for (const range of editor.visibleRanges) {
|
||||
parts.push(range.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
parts.push("\n// User's message:")
|
||||
parts.push(userText)
|
||||
return parts.join("\n")
|
||||
}
|
||||
+16
-156
@@ -13,6 +13,15 @@ import {
|
||||
AutocompleteContext,
|
||||
LastSuggestionInfo,
|
||||
} from "../types"
|
||||
import {
|
||||
findMatchingSuggestion as _findMatchingSuggestion,
|
||||
applyFirstLineOnly as _applyFirstLineOnly,
|
||||
countLines as _countLines,
|
||||
shouldShowOnlyFirstLine as _shouldShowOnlyFirstLine,
|
||||
getFirstLine as _getFirstLine,
|
||||
calcDebounceDelay,
|
||||
MatchingSuggestionWithFillIn as _MatchingSuggestionWithFillIn,
|
||||
} from "./inline-utils"
|
||||
import { HoleFiller } from "./HoleFiller"
|
||||
import { FimPromptBuilder } from "./FillInTheMiddle"
|
||||
import { AutocompleteModel } from "../AutocompleteModel"
|
||||
@@ -59,101 +68,21 @@ const LATENCY_SAMPLE_SIZE = 10
|
||||
|
||||
export type { CostTrackingCallback, AutocompletePrompt, MatchingSuggestionResult, LLMRetrievalResult }
|
||||
|
||||
/**
|
||||
* Result from findMatchingSuggestion including the original suggestion for telemetry tracking
|
||||
*/
|
||||
export interface MatchingSuggestionWithFillIn extends MatchingSuggestionResult {
|
||||
/** The original FillInAtCursorSuggestion for telemetry tracking */
|
||||
fillInAtCursor: FillInAtCursorSuggestion
|
||||
}
|
||||
export type MatchingSuggestionWithFillIn = _MatchingSuggestionWithFillIn
|
||||
|
||||
/**
|
||||
* Find a matching suggestion from the history based on current prefix and suffix.
|
||||
*
|
||||
* @param prefix - The text before the cursor position
|
||||
* @param suffix - The text after the cursor position
|
||||
* @param suggestionsHistory - Array of previous suggestions (most recent last)
|
||||
* @returns The matching suggestion with match type and the original FillInAtCursorSuggestion, or null if no match found
|
||||
*/
|
||||
export function findMatchingSuggestion(
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
suggestionsHistory: FillInAtCursorSuggestion[],
|
||||
): MatchingSuggestionWithFillIn | null {
|
||||
// Search from most recent to least recent
|
||||
for (let i = suggestionsHistory.length - 1; i >= 0; i--) {
|
||||
const fillInAtCursor = suggestionsHistory[i]
|
||||
|
||||
// First, try exact prefix/suffix match
|
||||
if (prefix === fillInAtCursor.prefix && suffix === fillInAtCursor.suffix) {
|
||||
return {
|
||||
text: fillInAtCursor.text,
|
||||
matchType: "exact",
|
||||
fillInAtCursor,
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact match, but suggestion is available, check for partial typing
|
||||
// The user may have started typing the suggested text
|
||||
if (fillInAtCursor.text !== "" && prefix.startsWith(fillInAtCursor.prefix) && suffix === fillInAtCursor.suffix) {
|
||||
// Extract what the user has typed between the original prefix and current position
|
||||
const typedContent = prefix.substring(fillInAtCursor.prefix.length)
|
||||
|
||||
// Check if the typed content matches the beginning of the suggestion
|
||||
if (fillInAtCursor.text.startsWith(typedContent)) {
|
||||
// Return the remaining part of the suggestion (with already-typed portion removed)
|
||||
return {
|
||||
text: fillInAtCursor.text.substring(typedContent.length),
|
||||
matchType: "partial_typing",
|
||||
fillInAtCursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for backward deletion: user deleted characters from the end of the prefix
|
||||
// The stored prefix should start with the current prefix (current is shorter)
|
||||
// Only use this logic if the original suggestion is non-empty
|
||||
if (fillInAtCursor.text !== "" && fillInAtCursor.prefix.startsWith(prefix) && suffix === fillInAtCursor.suffix) {
|
||||
// Extract the deleted portion of the prefix
|
||||
const deletedContent = fillInAtCursor.prefix.substring(prefix.length)
|
||||
|
||||
// Return the deleted portion plus the original suggestion text
|
||||
return {
|
||||
text: deletedContent + fillInAtCursor.text,
|
||||
matchType: "backward_deletion",
|
||||
fillInAtCursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
return _findMatchingSuggestion(prefix, suffix, suggestionsHistory)
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a matching suggestion result by applying first-line-only logic if needed.
|
||||
* Use this at call sites where you want to show only the first line of multi-line completions
|
||||
* when the cursor is in the middle of a line.
|
||||
*
|
||||
* @param result - The result from findMatchingSuggestion
|
||||
* @param prefix - The text before the cursor position
|
||||
* @returns A new result with potentially truncated text, or null if input was null
|
||||
*/
|
||||
export function applyFirstLineOnly(
|
||||
result: MatchingSuggestionWithFillIn | null,
|
||||
prefix: string,
|
||||
): MatchingSuggestionWithFillIn | null {
|
||||
if (result === null || result.text === "") {
|
||||
return result
|
||||
}
|
||||
if (shouldShowOnlyFirstLine(prefix, result.text)) {
|
||||
const firstLineText = getFirstLine(result.text)
|
||||
return {
|
||||
text: firstLineText,
|
||||
matchType: result.matchType,
|
||||
fillInAtCursor: result.fillInAtCursor,
|
||||
}
|
||||
}
|
||||
return result
|
||||
return _applyFirstLineOnly(result, prefix)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -162,76 +91,16 @@ export function applyFirstLineOnly(
|
||||
*/
|
||||
export const INLINE_COMPLETION_ACCEPTED_COMMAND = "kilocode.autocomplete.inline-completion.accepted"
|
||||
|
||||
/**
|
||||
* Counts the number of lines in a text string.
|
||||
*
|
||||
* Notes:
|
||||
* - Returns 0 for an empty string
|
||||
* - A single trailing newline (or CRLF) does not count as an additional line
|
||||
*
|
||||
* @param text - The text to count lines in
|
||||
* @returns The number of lines
|
||||
*/
|
||||
export function countLines(text: string): number {
|
||||
if (text === "") {
|
||||
return 0
|
||||
}
|
||||
|
||||
// Count line breaks and add 1 for the first line.
|
||||
// If the text ends with a line break, don't count the implicit trailing empty line.
|
||||
const lineBreakCount = (text.match(/\r?\n/g) || []).length
|
||||
const endsWithLineBreak = text.endsWith("\n")
|
||||
|
||||
return lineBreakCount + 1 - (endsWithLineBreak ? 1 : 0)
|
||||
return _countLines(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if only the first line of a completion should be shown.
|
||||
*
|
||||
* The logic is:
|
||||
* - If the suggestion starts with a newline → show the whole block
|
||||
* - If the prefix's last line has non-whitespace text → show only the first line
|
||||
* - If at start of line and suggestion is 3+ lines → show only the first line
|
||||
* - Otherwise → show the whole block
|
||||
*
|
||||
* @param prefix - The text before the cursor position
|
||||
* @param suggestion - The completion text being suggested
|
||||
* @returns true if only the first line should be shown
|
||||
*/
|
||||
export function shouldShowOnlyFirstLine(prefix: string, suggestion: string): boolean {
|
||||
// If the suggestion starts with a newline, show the whole block
|
||||
if (suggestion.startsWith("\n") || suggestion.startsWith("\r\n")) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the current line (before cursor) has non-whitespace text
|
||||
const lastNewlineIndex = prefix.lastIndexOf("\n")
|
||||
const currentLinePrefix = prefix.slice(lastNewlineIndex + 1)
|
||||
|
||||
// if the first line contains no word characters, show the whole block
|
||||
if (!currentLinePrefix.match(/\w/)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// If the current line prefix contains non-whitespace, only show the first line
|
||||
if (currentLinePrefix.trim().length > 0) {
|
||||
return true
|
||||
}
|
||||
|
||||
// At start of line (only whitespace before cursor on this line)
|
||||
// Show only first line if suggestion is 3 or more lines
|
||||
const lineCount = countLines(suggestion)
|
||||
return lineCount >= 3
|
||||
return _shouldShowOnlyFirstLine(prefix, suggestion)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts the first line from a completion text.
|
||||
*
|
||||
* @param text - The full completion text
|
||||
* @returns The first line of the completion (without the newline)
|
||||
*/
|
||||
export function getFirstLine(text: string): string {
|
||||
return text.split(/\r?\n/, 1)[0]
|
||||
return _getFirstLine(text)
|
||||
}
|
||||
|
||||
export function stringToInlineCompletions(text: string, position: vscode.Position): vscode.InlineCompletionItem[] {
|
||||
@@ -398,19 +267,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple
|
||||
* @param latencyMs - The latency of the most recent request in milliseconds
|
||||
*/
|
||||
public recordLatency(latencyMs: number): void {
|
||||
// Add the new latency to the history
|
||||
this.latencyHistory.push(latencyMs)
|
||||
|
||||
// Remove oldest if we exceed the sample size
|
||||
if (this.latencyHistory.length > LATENCY_SAMPLE_SIZE) {
|
||||
this.latencyHistory.shift()
|
||||
|
||||
// Once we have enough samples, update the debounce delay to the average
|
||||
const sum = this.latencyHistory.reduce((acc, val) => acc + val, 0)
|
||||
const averageLatency = Math.round(sum / this.latencyHistory.length)
|
||||
|
||||
// Clamp the debounce delay between MIN and MAX
|
||||
this.debounceDelayMs = Math.max(MIN_DEBOUNCE_DELAY_MS, Math.min(averageLatency, MAX_DEBOUNCE_DELAY_MS))
|
||||
this.debounceDelayMs = calcDebounceDelay(this.latencyHistory)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-13
@@ -1,14 +1,11 @@
|
||||
import { TelemetryProxy, TelemetryEventName } from "../../telemetry"
|
||||
import type { AutocompleteContext, CacheMatchType, FillInAtCursorSuggestion } from "../types"
|
||||
import { getSuggestionKey as _getSuggestionKey, insertWithLRUEviction } from "./telemetry-utils"
|
||||
|
||||
export type { AutocompleteContext, CacheMatchType, FillInAtCursorSuggestion }
|
||||
|
||||
/**
|
||||
* Generate a unique key for a suggestion based on its content and context.
|
||||
* This key is used to track whether the same suggestion is still being displayed.
|
||||
*/
|
||||
export function getSuggestionKey(suggestion: FillInAtCursorSuggestion): string {
|
||||
return `${suggestion.prefix}|${suggestion.suffix}|${suggestion.text}`
|
||||
return _getSuggestionKey(suggestion)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,14 +61,7 @@ export class AutocompleteTelemetry {
|
||||
private firedUniqueTelemetryKeys: Map<string, true> = new Map()
|
||||
|
||||
private markSuggestionKeyAsFired(suggestionKey: string): void {
|
||||
this.firedUniqueTelemetryKeys.set(suggestionKey, true)
|
||||
|
||||
if (this.firedUniqueTelemetryKeys.size > MAX_FIRED_UNIQUE_TELEMETRY_KEYS) {
|
||||
const oldestKey = this.firedUniqueTelemetryKeys.keys().next().value as string | undefined
|
||||
if (oldestKey) {
|
||||
this.firedUniqueTelemetryKeys.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
insertWithLRUEviction(this.firedUniqueTelemetryKeys, suggestionKey, MAX_FIRED_UNIQUE_TELEMETRY_KEYS)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
FillInAtCursorSuggestion,
|
||||
ChatCompletionResult,
|
||||
} from "../types"
|
||||
import { parseAutocompleteResponse as _parseAutocompleteResponse } from "./hole-filler-utils"
|
||||
import { getProcessedSnippets } from "./getProcessedSnippets"
|
||||
import { formatSnippets } from "../continuedev/core/autocomplete/templating/formatting"
|
||||
import { AutocompleteModel, ApiStreamChunk } from "../AutocompleteModel"
|
||||
@@ -20,24 +21,7 @@ export function parseAutocompleteResponse(
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
): FillInAtCursorSuggestion {
|
||||
let fimText: string = ""
|
||||
|
||||
// Match content strictly between <COMPLETION> and </COMPLETION> tags
|
||||
const completionMatch = fullResponse.match(/<COMPLETION>([\s\S]*?)<\/COMPLETION>/i)
|
||||
|
||||
if (completionMatch) {
|
||||
// Extract the captured group (content between tags)
|
||||
fimText = completionMatch[1] || ""
|
||||
}
|
||||
// Remove any accidentally captured tag remnants
|
||||
fimText = fimText.replace(/<\/?COMPLETION>/gi, "")
|
||||
|
||||
// Return FillInAtCursorSuggestion with the text (empty string if nothing found)
|
||||
return {
|
||||
text: fimText,
|
||||
prefix,
|
||||
suffix,
|
||||
}
|
||||
return _parseAutocompleteResponse(fullResponse, prefix, suffix)
|
||||
}
|
||||
|
||||
export class HoleFiller {
|
||||
@@ -200,8 +184,7 @@ Return the COMPLETION tags`
|
||||
const usageInfo = await model.generateResponse(systemPrompt, userPrompt, onChunk)
|
||||
|
||||
// Extract just the text from the response - prefix/suffix are handled by the caller
|
||||
const completionMatch = response.match(/<COMPLETION>([\s\S]*?)<\/COMPLETION>/i)
|
||||
const suggestionText = completionMatch ? (completionMatch[1] || "").replace(/<\/?COMPLETION>/gi, "") : ""
|
||||
const { text: suggestionText } = _parseAutocompleteResponse(response, "", "")
|
||||
|
||||
const fillInAtCursorSuggestion = processSuggestion(suggestionText)
|
||||
|
||||
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import type { FillInAtCursorSuggestion } from "../types"
|
||||
|
||||
/**
|
||||
* Parse a chat completion response and extract the text between <COMPLETION> tags.
|
||||
* Returns a FillInAtCursorSuggestion with the extracted text, or empty string if not found.
|
||||
*/
|
||||
export function parseAutocompleteResponse(
|
||||
fullResponse: string,
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
): FillInAtCursorSuggestion {
|
||||
let fimText = ""
|
||||
const completionMatch = fullResponse.match(/<COMPLETION>([\s\S]*?)<\/COMPLETION>/i)
|
||||
if (completionMatch) {
|
||||
fimText = completionMatch[1] || ""
|
||||
}
|
||||
fimText = fimText.replace(/<\/?COMPLETION>/gi, "")
|
||||
return { text: fimText, prefix, suffix }
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import type { FillInAtCursorSuggestion, MatchingSuggestionResult } from "../types"
|
||||
|
||||
export interface MatchingSuggestionWithFillIn extends MatchingSuggestionResult {
|
||||
fillInAtCursor: FillInAtCursorSuggestion
|
||||
}
|
||||
|
||||
const MIN_DEBOUNCE_DELAY_MS = 150
|
||||
const MAX_DEBOUNCE_DELAY_MS = 1000
|
||||
|
||||
/**
|
||||
* Find a matching suggestion from history based on current prefix and suffix.
|
||||
* Searches from most recent to least recent.
|
||||
*/
|
||||
export function findMatchingSuggestion(
|
||||
prefix: string,
|
||||
suffix: string,
|
||||
suggestionsHistory: FillInAtCursorSuggestion[],
|
||||
): MatchingSuggestionWithFillIn | null {
|
||||
for (let i = suggestionsHistory.length - 1; i >= 0; i--) {
|
||||
const fillInAtCursor = suggestionsHistory[i]!
|
||||
|
||||
if (prefix === fillInAtCursor.prefix && suffix === fillInAtCursor.suffix) {
|
||||
return { text: fillInAtCursor.text, matchType: "exact", fillInAtCursor }
|
||||
}
|
||||
|
||||
if (fillInAtCursor.text !== "" && prefix.startsWith(fillInAtCursor.prefix) && suffix === fillInAtCursor.suffix) {
|
||||
const typedContent = prefix.substring(fillInAtCursor.prefix.length)
|
||||
if (fillInAtCursor.text.startsWith(typedContent)) {
|
||||
return {
|
||||
text: fillInAtCursor.text.substring(typedContent.length),
|
||||
matchType: "partial_typing",
|
||||
fillInAtCursor,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fillInAtCursor.text !== "" && fillInAtCursor.prefix.startsWith(prefix) && suffix === fillInAtCursor.suffix) {
|
||||
const deletedContent = fillInAtCursor.prefix.substring(prefix.length)
|
||||
return { text: deletedContent + fillInAtCursor.text, matchType: "backward_deletion", fillInAtCursor }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of lines in a text string.
|
||||
* A single trailing newline does not count as an additional line.
|
||||
*/
|
||||
export function countLines(text: string): number {
|
||||
if (text === "") return 0
|
||||
const lineBreakCount = (text.match(/\r?\n/g) || []).length
|
||||
const endsWithLineBreak = text.endsWith("\n")
|
||||
return lineBreakCount + 1 - (endsWithLineBreak ? 1 : 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if only the first line of a completion should be shown.
|
||||
*/
|
||||
export function shouldShowOnlyFirstLine(prefix: string, suggestion: string): boolean {
|
||||
if (suggestion.startsWith("\n") || suggestion.startsWith("\r\n")) return false
|
||||
const lastNewlineIndex = prefix.lastIndexOf("\n")
|
||||
const currentLinePrefix = prefix.slice(lastNewlineIndex + 1)
|
||||
if (!currentLinePrefix.match(/\w/)) return false
|
||||
if (currentLinePrefix.trim().length > 0) return true
|
||||
return countLines(suggestion) >= 3
|
||||
}
|
||||
|
||||
/** Extracts the first line from a completion text. */
|
||||
export function getFirstLine(text: string): string {
|
||||
return text.split(/\r?\n/, 1)[0]!
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply first-line-only logic to a matching suggestion result.
|
||||
*/
|
||||
export function applyFirstLineOnly(
|
||||
result: MatchingSuggestionWithFillIn | null,
|
||||
prefix: string,
|
||||
): MatchingSuggestionWithFillIn | null {
|
||||
if (result === null || result.text === "") return result
|
||||
if (shouldShowOnlyFirstLine(prefix, result.text)) {
|
||||
return { text: getFirstLine(result.text), matchType: result.matchType, fillInAtCursor: result.fillInAtCursor }
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate adaptive debounce delay from a latency history.
|
||||
* Clamps result between MIN_DEBOUNCE_DELAY_MS and MAX_DEBOUNCE_DELAY_MS.
|
||||
*/
|
||||
export function calcDebounceDelay(latencyHistory: number[]): number {
|
||||
if (latencyHistory.length === 0) return MIN_DEBOUNCE_DELAY_MS
|
||||
const sum = latencyHistory.reduce((acc, v) => acc + v, 0)
|
||||
const avg = Math.round(sum / latencyHistory.length)
|
||||
return Math.max(MIN_DEBOUNCE_DELAY_MS, Math.min(avg, MAX_DEBOUNCE_DELAY_MS))
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import type { FillInAtCursorSuggestion } from "../types"
|
||||
|
||||
/**
|
||||
* Generate a unique key for a suggestion based on its content and context.
|
||||
* Used to deduplicate telemetry for the same suggestion shown multiple times.
|
||||
*/
|
||||
export function getSuggestionKey(suggestion: FillInAtCursorSuggestion): string {
|
||||
return `${suggestion.prefix}|${suggestion.suffix}|${suggestion.text}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert a key into a Map used as a bounded LRU set.
|
||||
* Evicts the oldest entry when the map exceeds `maxSize`.
|
||||
* Returns the (possibly evicted) updated map.
|
||||
*/
|
||||
export function insertWithLRUEviction(map: Map<string, true>, key: string, maxSize: number): void {
|
||||
map.set(key, true)
|
||||
if (map.size > maxSize) {
|
||||
const oldest = map.keys().next().value as string | undefined
|
||||
if (oldest !== undefined) {
|
||||
map.delete(oldest)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ function toRelativePath(absolutePath: string, workspacePath: string): string {
|
||||
}
|
||||
|
||||
import { VisibleCodeContext, VisibleEditorInfo, VisibleRange, DiffInfo } from "../types"
|
||||
import { extractDiffInfo as _extractDiffInfo } from "./visible-code-utils"
|
||||
|
||||
// Git-related URI schemes that should be captured for diff support
|
||||
const GIT_SCHEMES = ["git", "gitfs", "file", "vscode-remote"]
|
||||
@@ -120,38 +121,6 @@ export class VisibleCodeTracker {
|
||||
* Git URIs typically look like: git:/path/to/file.ts?ref=HEAD~1
|
||||
*/
|
||||
private extractDiffInfo(uri: vscode.Uri): DiffInfo | undefined {
|
||||
const scheme = uri.scheme
|
||||
|
||||
// Only extract diff info for git-related schemes
|
||||
if (scheme === "git" || scheme === "gitfs") {
|
||||
// Parse query parameters for git reference
|
||||
const query = uri.query
|
||||
let gitRef: string | undefined
|
||||
|
||||
if (query) {
|
||||
// Common patterns: ref=HEAD, ref=abc123
|
||||
const refMatch = query.match(/ref=([^&]+)/)
|
||||
if (refMatch) {
|
||||
gitRef = refMatch[1]
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
scheme,
|
||||
side: "old", // Git scheme documents are typically the "old" side
|
||||
gitRef,
|
||||
originalPath: uri.fsPath,
|
||||
}
|
||||
}
|
||||
|
||||
// File scheme in a diff view is the "new" side
|
||||
// We can't always tell if it's in a diff, so we mark it as new when there's a paired git doc
|
||||
if (scheme === "file") {
|
||||
// This will be marked as diffInfo only if we detect it's paired with a git document
|
||||
// For now, we don't set diffInfo for regular file scheme documents
|
||||
return undefined
|
||||
}
|
||||
|
||||
return undefined
|
||||
return _extractDiffInfo(uri.scheme, uri.query, uri.fsPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { DiffInfo } from "../types"
|
||||
|
||||
/**
|
||||
* Extract git diff metadata from a URI.
|
||||
* Returns DiffInfo for git/gitfs scheme URIs, undefined for regular file URIs.
|
||||
*/
|
||||
export function extractDiffInfo(scheme: string, query: string, fsPath: string): DiffInfo | undefined {
|
||||
if (scheme === "git" || scheme === "gitfs") {
|
||||
let gitRef: string | undefined
|
||||
if (query) {
|
||||
const refMatch = query.match(/ref=([^&]+)/)
|
||||
if (refMatch) {
|
||||
gitRef = refMatch[1]
|
||||
}
|
||||
}
|
||||
return { scheme, side: "old", gitRef, originalPath: fsPath }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { t } from "./shims/i18n"
|
||||
|
||||
/**
|
||||
* Format a session cost value to a human-readable string.
|
||||
* - $0 → translated zero string
|
||||
* - $0.001 → translated "less than a cent"
|
||||
* - $0.12 → "$0.12"
|
||||
*/
|
||||
export function humanFormatSessionCost(cost: number): string {
|
||||
if (cost === 0) {
|
||||
return t("kilocode:autocomplete.statusBar.cost.zero")
|
||||
}
|
||||
if (cost > 0 && cost < 0.01) {
|
||||
return t("kilocode:autocomplete.statusBar.cost.lessThanCent")
|
||||
}
|
||||
return `$${cost.toFixed(2)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp (ms) as a locale time string.
|
||||
*/
|
||||
export function formatTime(timestamp: number): string {
|
||||
return new Date(timestamp).toLocaleTimeString()
|
||||
}
|
||||
@@ -3,11 +3,13 @@ import { ServerManager } from "./server-manager"
|
||||
import { HttpClient } from "./http-client"
|
||||
import { SSEClient } from "./sse-client"
|
||||
import type { ServerConfig, SSEEvent } from "./types"
|
||||
import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils"
|
||||
|
||||
export type ConnectionState = "connecting" | "connected" | "disconnected" | "error"
|
||||
type SSEEventListener = (event: SSEEvent) => void
|
||||
type StateListener = (state: ConnectionState) => void
|
||||
type SSEEventFilter = (event: SSEEvent) => boolean
|
||||
type NotificationDismissListener = (notificationId: string) => void
|
||||
|
||||
/**
|
||||
* Shared connection service that owns the single ServerManager, HttpClient, and SSEClient.
|
||||
@@ -24,6 +26,7 @@ export class KiloConnectionService {
|
||||
|
||||
private readonly eventListeners: Set<SSEEventListener> = new Set()
|
||||
private readonly stateListeners: Set<StateListener> = new Set()
|
||||
private readonly notificationDismissListeners: Set<NotificationDismissListener> = new Set()
|
||||
|
||||
/**
|
||||
* Shared mapping used to resolve session scope for events that don't reliably include a sessionID.
|
||||
@@ -131,35 +134,29 @@ export class KiloConnectionService {
|
||||
* Returns undefined for global events.
|
||||
*/
|
||||
resolveEventSessionId(event: SSEEvent): string | undefined {
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
case "session.updated":
|
||||
return event.properties.info.id
|
||||
case "session.status":
|
||||
case "session.idle":
|
||||
case "todo.updated":
|
||||
return event.properties.sessionID
|
||||
case "message.updated":
|
||||
this.recordMessageSessionId(event.properties.info.id, event.properties.info.sessionID)
|
||||
return event.properties.info.sessionID
|
||||
case "message.part.updated": {
|
||||
const part = event.properties.part as { messageID?: string; sessionID?: string }
|
||||
if (part.sessionID) {
|
||||
return part.sessionID
|
||||
return resolveEventSessionIdPure(
|
||||
event,
|
||||
(messageId) => this.messageSessionIdsByMessageId.get(messageId),
|
||||
(messageId, sessionId) => this.recordMessageSessionId(messageId, sessionId),
|
||||
)
|
||||
}
|
||||
if (!part.messageID) {
|
||||
return undefined
|
||||
|
||||
/**
|
||||
* Subscribe to notification dismiss events broadcast from any KiloProvider. Returns unsubscribe function.
|
||||
*/
|
||||
onNotificationDismissed(listener: NotificationDismissListener): () => void {
|
||||
this.notificationDismissListeners.add(listener)
|
||||
return () => {
|
||||
this.notificationDismissListeners.delete(listener)
|
||||
}
|
||||
return this.messageSessionIdsByMessageId.get(part.messageID)
|
||||
}
|
||||
case "permission.asked":
|
||||
case "permission.replied":
|
||||
case "question.asked":
|
||||
case "question.replied":
|
||||
case "question.rejected":
|
||||
return event.properties.sessionID
|
||||
default:
|
||||
return undefined
|
||||
|
||||
/**
|
||||
* Broadcast a notification dismiss event to all subscribed KiloProvider instances.
|
||||
*/
|
||||
notifyNotificationDismissed(notificationId: string): void {
|
||||
for (const listener of this.notificationDismissListeners) {
|
||||
listener(notificationId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +178,7 @@ export class KiloConnectionService {
|
||||
this.serverManager.dispose()
|
||||
this.eventListeners.clear()
|
||||
this.stateListeners.clear()
|
||||
this.notificationDismissListeners.clear()
|
||||
this.messageSessionIdsByMessageId.clear()
|
||||
this.client = null
|
||||
this.sseClient = null
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { SSEEvent } from "./types"
|
||||
|
||||
/**
|
||||
* Pure session ID resolution for SSE events.
|
||||
* The lookupMessageSessionId callback is used for message.part.updated fallback lookup,
|
||||
* and onMessageUpdated is called when message.updated is encountered so the caller can
|
||||
* record the messageID -> sessionID mapping.
|
||||
*/
|
||||
export function resolveEventSessionId(
|
||||
event: SSEEvent,
|
||||
lookupMessageSessionId: (messageId: string) => string | undefined,
|
||||
onMessageUpdated?: (messageId: string, sessionId: string) => void,
|
||||
): string | undefined {
|
||||
switch (event.type) {
|
||||
case "session.created":
|
||||
case "session.updated":
|
||||
return event.properties.info.id
|
||||
case "session.status":
|
||||
case "session.idle":
|
||||
case "todo.updated":
|
||||
return event.properties.sessionID
|
||||
case "message.updated":
|
||||
onMessageUpdated?.(event.properties.info.id, event.properties.info.sessionID)
|
||||
return event.properties.info.sessionID
|
||||
case "message.part.updated": {
|
||||
const part = event.properties.part as { messageID?: string; sessionID?: string }
|
||||
if (part.sessionID) {
|
||||
return part.sessionID
|
||||
}
|
||||
if (!part.messageID) {
|
||||
return undefined
|
||||
}
|
||||
return lookupMessageSessionId(part.messageID)
|
||||
}
|
||||
case "permission.asked":
|
||||
case "permission.replied":
|
||||
case "question.asked":
|
||||
case "question.replied":
|
||||
case "question.rejected":
|
||||
return event.properties.sessionID
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ServerConfig,
|
||||
SessionInfo,
|
||||
SessionStatusInfo,
|
||||
MessageInfo,
|
||||
MessagePart,
|
||||
AgentInfo,
|
||||
@@ -10,7 +11,9 @@ import type {
|
||||
McpStatus,
|
||||
McpConfig,
|
||||
Config,
|
||||
KilocodeNotification,
|
||||
} from "./types"
|
||||
import { extractHttpErrorMessage, parseSSEDataLine } from "./http-utils"
|
||||
|
||||
/**
|
||||
* HTTP Client for communicating with the CLI backend server.
|
||||
@@ -23,8 +26,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")}`
|
||||
|
||||
@@ -42,7 +45,7 @@ export class HttpClient {
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
options?: { directory?: string; allowEmpty?: boolean },
|
||||
options?: { directory?: string; allowEmpty?: boolean; silent?: boolean; signal?: AbortSignal },
|
||||
): Promise<T> {
|
||||
const url = `${this.baseUrl}${path}`
|
||||
|
||||
@@ -59,6 +62,7 @@ export class HttpClient {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
signal: options?.signal,
|
||||
})
|
||||
|
||||
// Read the raw response first so we can produce useful errors when JSON is empty/truncated.
|
||||
@@ -66,22 +70,16 @@ export class HttpClient {
|
||||
|
||||
// Non-2xx: try to extract an error message from JSON, otherwise fall back to raw text.
|
||||
if (!response.ok) {
|
||||
let errorMessage = response.statusText
|
||||
if (rawText.trim().length > 0) {
|
||||
try {
|
||||
const errorJson = JSON.parse(rawText) as { error?: string; message?: string }
|
||||
errorMessage = errorJson.error || errorJson.message || errorMessage
|
||||
} catch {
|
||||
errorMessage = rawText
|
||||
}
|
||||
}
|
||||
const errorMessage = extractHttpErrorMessage(response.statusText, rawText)
|
||||
|
||||
if (!options?.silent) {
|
||||
console.error("[Kilo New] HTTP: ❌ Request failed", {
|
||||
method,
|
||||
path,
|
||||
status: response.status,
|
||||
errorMessage,
|
||||
})
|
||||
}
|
||||
|
||||
throw new Error(`HTTP ${response.status}: ${errorMessage}`)
|
||||
}
|
||||
@@ -127,9 +125,10 @@ export class HttpClient {
|
||||
|
||||
/**
|
||||
* Get information about an existing session.
|
||||
* Set silent to suppress error logging (e.g. for expected 404s on cross-worktree sessions).
|
||||
*/
|
||||
async getSession(sessionId: string, directory: string): Promise<SessionInfo> {
|
||||
return this.request<SessionInfo>("GET", `/session/${sessionId}`, undefined, { directory })
|
||||
async getSession(sessionId: string, directory: string, silent?: boolean): Promise<SessionInfo> {
|
||||
return this.request<SessionInfo>("GET", `/session/${sessionId}`, undefined, { directory, silent })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,6 +138,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.
|
||||
*/
|
||||
@@ -207,7 +214,7 @@ export class HttpClient {
|
||||
sessionId: string,
|
||||
parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string }>,
|
||||
directory: string,
|
||||
options?: { providerID?: string; modelID?: string; agent?: string },
|
||||
options?: { providerID?: string; modelID?: string; agent?: string; variant?: string },
|
||||
): Promise<void> {
|
||||
const body: Record<string, unknown> = { parts }
|
||||
if (options?.providerID && options?.modelID) {
|
||||
@@ -217,6 +224,9 @@ export class HttpClient {
|
||||
if (options?.agent) {
|
||||
body.agent = options.agent
|
||||
}
|
||||
if (options?.variant) {
|
||||
body.variant = options.variant
|
||||
}
|
||||
|
||||
await this.request<void>("POST", `/session/${sessionId}/message`, body, { directory, allowEmpty: true })
|
||||
}
|
||||
@@ -224,12 +234,16 @@ export class HttpClient {
|
||||
/**
|
||||
* Get all messages for a session.
|
||||
*/
|
||||
async getMessages(sessionId: string, directory: string): Promise<Array<{ info: MessageInfo; parts: MessagePart[] }>> {
|
||||
async getMessages(
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Array<{ info: MessageInfo; parts: MessagePart[] }>> {
|
||||
return this.request<Array<{ info: MessageInfo; parts: MessagePart[] }>>(
|
||||
"GET",
|
||||
`/session/${sessionId}/message`,
|
||||
undefined,
|
||||
{ directory },
|
||||
{ directory, signal },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -313,6 +327,19 @@ export class HttpClient {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch Kilo notifications for the current user from the kilo-gateway.
|
||||
* Returns an empty array if not logged in or if the request fails.
|
||||
*/
|
||||
async getNotifications(): Promise<KilocodeNotification[]> {
|
||||
try {
|
||||
return await this.request<KilocodeNotification[]>("GET", "/kilo/notifications")
|
||||
} catch (err) {
|
||||
console.warn("[Kilo] Failed to fetch notifications:", err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch the active organization.
|
||||
* Pass null to switch back to personal account.
|
||||
@@ -389,38 +416,12 @@ export class HttpClient {
|
||||
buffer = lines.pop() ?? "" // Keep incomplete line in buffer
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) {
|
||||
continue
|
||||
}
|
||||
|
||||
const data = line.slice(6).trim()
|
||||
if (data === "[DONE]") {
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
choices?: Array<{ delta?: { content?: string } }>
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number }
|
||||
cost?: number
|
||||
}
|
||||
|
||||
const content = parsed.choices?.[0]?.delta?.content
|
||||
if (content) {
|
||||
onChunk(content)
|
||||
}
|
||||
|
||||
if (parsed.usage) {
|
||||
inputTokens = parsed.usage.prompt_tokens ?? 0
|
||||
outputTokens = parsed.usage.completion_tokens ?? 0
|
||||
}
|
||||
|
||||
if (parsed.cost !== undefined) {
|
||||
cost = parsed.cost
|
||||
}
|
||||
} catch {
|
||||
// Skip malformed JSON lines
|
||||
}
|
||||
const chunk = parseSSEDataLine(line)
|
||||
if (!chunk) continue
|
||||
if (chunk.content) onChunk(chunk.content)
|
||||
if (chunk.inputTokens !== undefined) inputTokens = chunk.inputTokens
|
||||
if (chunk.outputTokens !== undefined) outputTokens = chunk.outputTokens
|
||||
if (chunk.cost !== undefined) cost = chunk.cost
|
||||
}
|
||||
}
|
||||
|
||||
@@ -469,6 +470,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
|
||||
// ============================================
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Extract a human-readable error message from an HTTP error response.
|
||||
* Tries to parse JSON and look for `error` or `message` fields; falls back to raw text.
|
||||
*/
|
||||
export function extractHttpErrorMessage(statusText: string, rawText: string): string {
|
||||
if (rawText.trim().length === 0) {
|
||||
return statusText
|
||||
}
|
||||
try {
|
||||
const errorJson = JSON.parse(rawText) as { error?: string; message?: string }
|
||||
return errorJson.error || errorJson.message || statusText
|
||||
} catch {
|
||||
return rawText
|
||||
}
|
||||
}
|
||||
|
||||
export type SSEChunkResult = {
|
||||
content?: string
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cost?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a single SSE data line (starting with "data: ") into its structured parts.
|
||||
* Returns null for non-data lines and the [DONE] sentinel.
|
||||
*/
|
||||
export function parseSSEDataLine(line: string): SSEChunkResult | null {
|
||||
if (!line.startsWith("data: ")) {
|
||||
return null
|
||||
}
|
||||
const data = line.slice(6).trim()
|
||||
if (data === "[DONE]") {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(data) as {
|
||||
choices?: Array<{ delta?: { content?: string } }>
|
||||
usage?: { prompt_tokens?: number; completion_tokens?: number }
|
||||
cost?: number
|
||||
}
|
||||
const result: SSEChunkResult = {}
|
||||
const content = parsed.choices?.[0]?.delta?.content
|
||||
if (content) {
|
||||
result.content = content
|
||||
}
|
||||
if (parsed.usage) {
|
||||
result.inputTokens = parsed.usage.prompt_tokens ?? 0
|
||||
result.outputTokens = parsed.usage.completion_tokens ?? 0
|
||||
}
|
||||
if (parsed.cost !== undefined) {
|
||||
result.cost = parsed.cost
|
||||
}
|
||||
return result
|
||||
} catch (err) {
|
||||
console.warn("[Kilo New] Failed to parse SSE data line", { err, line })
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,8 @@ export type {
|
||||
McpRemoteConfig,
|
||||
McpConfig,
|
||||
Config,
|
||||
KilocodeNotification,
|
||||
KilocodeNotificationAction,
|
||||
} from "./types"
|
||||
|
||||
export { ServerManager } from "./server-manager"
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as crypto from "crypto"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import * as vscode from "vscode"
|
||||
import { parseServerPort } from "./server-utils"
|
||||
|
||||
export interface ServerInstance {
|
||||
port: number
|
||||
@@ -85,11 +86,9 @@ export class ServerManager {
|
||||
const output = data.toString()
|
||||
console.log("[Kilo New] ServerManager: 📥 CLI Server stdout:", output)
|
||||
|
||||
// Parse: "kilo server listening on http://127.0.0.1:12345"
|
||||
const match = output.match(/listening on http:\/\/[\w.]+:(\d+)/)
|
||||
if (match && !resolved) {
|
||||
const port = parseServerPort(output)
|
||||
if (port !== null && !resolved) {
|
||||
resolved = true
|
||||
const port = parseInt(match[1], 10)
|
||||
console.log("[Kilo New] ServerManager: 🎯 Port detected:", port)
|
||||
resolve({ port, password, process: serverProcess })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Parse the port number from CLI server startup output.
|
||||
* Matches lines like: "kilo server listening on http://127.0.0.1:12345"
|
||||
* Returns the port number or null if not found.
|
||||
*/
|
||||
export function parseServerPort(output: string): number | null {
|
||||
const match = output.match(/listening on http:\/\/[\w.]+:(\d+)/)
|
||||
if (!match) return null
|
||||
return parseInt(match[1]!, 10)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import EventSource from "eventsource"
|
||||
import type { ServerConfig, SSEEvent } from "./types"
|
||||
import { unwrapSSEPayload } from "./sse-utils"
|
||||
|
||||
// Type definitions for handlers
|
||||
export type SSEEventHandler = (event: SSEEvent) => void
|
||||
@@ -67,9 +68,8 @@ export class SSEClient {
|
||||
console.log("[Kilo New] SSE: 📨 Received message event:", messageEvent.data)
|
||||
try {
|
||||
const raw = JSON.parse(messageEvent.data)
|
||||
// Global endpoint wraps events as { directory, payload: { type, properties } }
|
||||
const event = (raw.payload ?? raw) as SSEEvent
|
||||
if (!event.type) {
|
||||
const event = unwrapSSEPayload(raw)
|
||||
if (!event) {
|
||||
console.warn("[Kilo New] SSE: ⚠️ Received event without type:", raw)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { SSEEvent } from "./types"
|
||||
|
||||
/**
|
||||
* Unwrap an SSE message payload.
|
||||
* The global /global/event endpoint wraps events as { directory, payload: SSEEvent }.
|
||||
* Direct event endpoints return the SSEEvent directly.
|
||||
* Returns null if the parsed data has no `type` field (malformed or unknown event).
|
||||
*/
|
||||
export function unwrapSSEPayload(raw: unknown): SSEEvent | null {
|
||||
if (!raw || typeof raw !== "object") return null
|
||||
const event = ((raw as { payload?: SSEEvent }).payload ?? raw) as SSEEvent
|
||||
if (!event || typeof event !== "object" || !("type" in event)) {
|
||||
return null
|
||||
}
|
||||
return event
|
||||
}
|
||||
@@ -139,6 +139,8 @@ export interface ProviderModel {
|
||||
latest?: boolean
|
||||
// Actual shape returned by the server (Provider.Model)
|
||||
limit?: { context: number; input?: number; output: number }
|
||||
variants?: Record<string, Record<string, unknown>>
|
||||
capabilities?: { reasoning: boolean }
|
||||
}
|
||||
|
||||
// Provider definition
|
||||
@@ -174,6 +176,20 @@ export interface ProviderAuthAuthorization {
|
||||
instructions: string
|
||||
}
|
||||
|
||||
// Kilo notification from kilo-gateway
|
||||
export interface KilocodeNotificationAction {
|
||||
actionText: string
|
||||
actionURL: string
|
||||
}
|
||||
|
||||
export interface KilocodeNotification {
|
||||
id: string
|
||||
title: string
|
||||
message: string
|
||||
action?: KilocodeNotificationAction
|
||||
showIn?: string[]
|
||||
}
|
||||
|
||||
// Profile types from kilo-gateway
|
||||
export interface KilocodeOrganization {
|
||||
id: string
|
||||
|
||||
@@ -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,72 @@
|
||||
import * as vscode from "vscode"
|
||||
import type { KiloProvider } from "../../KiloProvider"
|
||||
import type { AgentManagerProvider } from "../../agent-manager/AgentManagerProvider"
|
||||
import { getEditorContext } from "./editor-utils"
|
||||
import { createPrompt } from "./support-prompt"
|
||||
|
||||
export function registerCodeActions(
|
||||
context: vscode.ExtensionContext,
|
||||
provider: KiloProvider,
|
||||
agentManager?: AgentManagerProvider,
|
||||
): void {
|
||||
const target = () => (agentManager?.isActive() ? agentManager : provider)
|
||||
|
||||
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,
|
||||
})
|
||||
target().postMessage({ type: "setChatBoxMessage", text: prompt })
|
||||
target().postMessage({ type: "action", action: "focusInput" })
|
||||
}),
|
||||
|
||||
vscode.commands.registerCommand("kilo-code.new.focusChatInput", () => {
|
||||
target().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]
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Build the merged properties object for a telemetry event.
|
||||
* Provider properties are included first so event-specific properties can override them.
|
||||
*/
|
||||
export function buildTelemetryPayload(
|
||||
event: string,
|
||||
properties: Record<string, unknown> | undefined,
|
||||
providerProperties: Record<string, unknown> | undefined,
|
||||
): { event: string; properties: Record<string, unknown> } {
|
||||
return {
|
||||
event,
|
||||
properties: { ...providerProperties, ...properties },
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the Authorization header value for the telemetry endpoint.
|
||||
*/
|
||||
export function buildTelemetryAuthHeader(password: string): string {
|
||||
return `Basic ${Buffer.from(`kilo:${password}`).toString("base64")}`
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as vscode from "vscode"
|
||||
import { TelemetryEventName, type TelemetryPropertiesProvider } from "./types"
|
||||
import { buildTelemetryPayload, buildTelemetryAuthHeader } from "./telemetry-proxy-utils"
|
||||
|
||||
/**
|
||||
* Singleton proxy that captures telemetry events and forwards them to the CLI
|
||||
@@ -45,13 +46,9 @@ export class TelemetryProxy {
|
||||
if (!this.isVSCodeTelemetryEnabled()) return
|
||||
if (!this.url || !this.password) return
|
||||
|
||||
const merged = {
|
||||
...this.provider?.getTelemetryProperties(),
|
||||
...properties,
|
||||
}
|
||||
|
||||
const payload = JSON.stringify({ event, properties: merged })
|
||||
const auth = `Basic ${Buffer.from(`kilo:${this.password}`).toString("base64")}`
|
||||
const built = buildTelemetryPayload(event, properties, this.provider?.getTelemetryProperties())
|
||||
const payload = JSON.stringify(built)
|
||||
const auth = buildTelemetryAuthHeader(this.password)
|
||||
|
||||
fetch(`${this.url}/telemetry/capture`, {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as crypto from "crypto"
|
||||
import * as vscode from "vscode"
|
||||
import { buildCspString } from "./webview-html-utils"
|
||||
|
||||
export function getNonce(): string {
|
||||
return crypto.randomBytes(16).toString("hex")
|
||||
@@ -17,18 +18,7 @@ export function buildWebviewHtml(
|
||||
},
|
||||
): string {
|
||||
const nonce = getNonce()
|
||||
const connectSrc = opts.port
|
||||
? `http://127.0.0.1:${opts.port} http://localhost:${opts.port} ws://127.0.0.1:${opts.port} ws://localhost:${opts.port}`
|
||||
: "http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*"
|
||||
|
||||
const csp = [
|
||||
"default-src 'none'",
|
||||
`style-src 'unsafe-inline' ${webview.cspSource}`,
|
||||
`script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`,
|
||||
`font-src ${webview.cspSource}`,
|
||||
`connect-src ${connectSrc}`,
|
||||
`img-src ${webview.cspSource} data: https:`,
|
||||
].join("; ")
|
||||
const csp = buildCspString(webview.cspSource, nonce, opts.port)
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="en" data-theme="kilo-vscode">
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Build the Content-Security-Policy connect-src directive value.
|
||||
* If a port is specified, restricts connections to that port.
|
||||
* Otherwise, allows any localhost/127.0.0.1 port.
|
||||
*/
|
||||
export function buildConnectSrc(port?: number): string {
|
||||
if (port) {
|
||||
return `http://127.0.0.1:${port} http://localhost:${port} ws://127.0.0.1:${port} ws://localhost:${port}`
|
||||
}
|
||||
return "http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*"
|
||||
}
|
||||
|
||||
/**
|
||||
* Join an array of CSP directives into a policy string.
|
||||
*/
|
||||
export function joinCspDirectives(directives: string[]): string {
|
||||
return directives.join("; ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full CSP policy string for a webview.
|
||||
*/
|
||||
export function buildCspString(cspSource: string, nonce: string, port?: number): string {
|
||||
const connectSrc = buildConnectSrc(port)
|
||||
const directives = [
|
||||
"default-src 'none'",
|
||||
`style-src 'unsafe-inline' ${cspSource}`,
|
||||
`script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`,
|
||||
`font-src ${cspSource}`,
|
||||
`connect-src ${connectSrc}`,
|
||||
`img-src ${cspSource} data: https:`,
|
||||
]
|
||||
return joinCspDirectives(directives)
|
||||
}
|
||||
@@ -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,170 @@ 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")
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider message routing — static-analysis regression tests
|
||||
//
|
||||
// These tests use ts-morph to inspect the source code of AgentManagerProvider
|
||||
// and verify structural invariants that prevent regressions without needing
|
||||
// a VS Code test host.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Agent Manager Provider — onMessage routing", () => {
|
||||
let source: import("ts-morph").SourceFile
|
||||
let cls: import("ts-morph").ClassDeclaration
|
||||
|
||||
function setup() {
|
||||
if (source) return
|
||||
const project = new Project({ compilerOptions: { allowJs: true } })
|
||||
source = project.addSourceFileAtPath(PROVIDER_FILE)
|
||||
cls = source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration)!
|
||||
}
|
||||
|
||||
function body(name: string): string {
|
||||
setup()
|
||||
const method = cls.getMethod(name)
|
||||
expect(method, `method ${name} not found`).toBeTruthy()
|
||||
return method!.getText()
|
||||
}
|
||||
|
||||
// -- onMessage dispatches all expected message types -----------------------
|
||||
|
||||
it("onMessage handles all documented agentManager.* message types", () => {
|
||||
const text = body("onMessage")
|
||||
const expected = [
|
||||
"agentManager.createWorktree",
|
||||
"agentManager.deleteWorktree",
|
||||
"agentManager.promoteSession",
|
||||
"agentManager.addSessionToWorktree",
|
||||
"agentManager.closeSession",
|
||||
"agentManager.configureSetupScript",
|
||||
"agentManager.showTerminal",
|
||||
"agentManager.requestRepoInfo",
|
||||
"agentManager.requestState",
|
||||
"agentManager.setTabOrder",
|
||||
]
|
||||
for (const msg of expected) {
|
||||
expect(text, `onMessage should handle "${msg}"`).toContain(msg)
|
||||
}
|
||||
})
|
||||
|
||||
it("onMessage handles loadMessages for terminal switching", () => {
|
||||
const text = body("onMessage")
|
||||
expect(text).toContain("loadMessages")
|
||||
expect(text).toContain("showExisting")
|
||||
})
|
||||
|
||||
it("onMessage handles clearSession for SSE re-registration", () => {
|
||||
const text = body("onMessage")
|
||||
expect(text).toContain("clearSession")
|
||||
expect(text).toContain("trackSession")
|
||||
})
|
||||
|
||||
// -- onDeleteWorktree invariants -------------------------------------------
|
||||
|
||||
/**
|
||||
* Regression: deletion must clean up both disk (manager) and state, then
|
||||
* push to webview. Missing any step leaves ghost worktrees or stale UI.
|
||||
*/
|
||||
it("onDeleteWorktree removes from disk, state, clears orphans, and pushes", () => {
|
||||
const text = body("onDeleteWorktree")
|
||||
expect(text).toContain("manager.removeWorktree")
|
||||
expect(text).toContain("state.removeWorktree")
|
||||
expect(text).toContain("clearSessionDirectory")
|
||||
expect(text).toContain("this.pushState()")
|
||||
})
|
||||
|
||||
// -- onCreateWorktree invariants -------------------------------------------
|
||||
|
||||
/**
|
||||
* Regression: the setup script MUST run before session creation.
|
||||
* If reversed, the agent starts in an unconfigured worktree (missing .env,
|
||||
* deps, etc.) which causes hard-to-debug failures.
|
||||
*/
|
||||
it("onCreateWorktree runs setup script before creating session", () => {
|
||||
const text = body("onCreateWorktree")
|
||||
const setupIdx = text.indexOf("runSetupScriptForWorktree")
|
||||
const sessionIdx = text.indexOf("createSessionInWorktree")
|
||||
expect(setupIdx, "setup script call must exist").toBeGreaterThan(-1)
|
||||
expect(sessionIdx, "session creation call must exist").toBeGreaterThan(-1)
|
||||
expect(setupIdx, "setup script must run before session creation").toBeLessThan(sessionIdx)
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression: if session creation fails after the worktree was already
|
||||
* created on disk, the worktree must be cleaned up to avoid orphaned dirs.
|
||||
*/
|
||||
it("onCreateWorktree cleans up worktree on session creation failure", () => {
|
||||
const text = body("onCreateWorktree")
|
||||
expect(text).toContain("removeWorktree")
|
||||
})
|
||||
|
||||
// -- onPromoteSession invariants -------------------------------------------
|
||||
|
||||
/**
|
||||
* Regression: same setup-before-move ordering as onCreateWorktree.
|
||||
*/
|
||||
it("onPromoteSession runs setup script before modifying session", () => {
|
||||
const text = body("onPromoteSession")
|
||||
const setupIdx = text.indexOf("runSetupScriptForWorktree")
|
||||
const moveIdx = text.indexOf("moveSession")
|
||||
expect(setupIdx).toBeGreaterThan(-1)
|
||||
expect(moveIdx).toBeGreaterThan(-1)
|
||||
expect(setupIdx, "setup must run before move").toBeLessThan(moveIdx)
|
||||
})
|
||||
|
||||
/**
|
||||
* Regression: promote must handle the case where the session doesn't
|
||||
* exist in state yet (e.g. a workspace session that was never tracked).
|
||||
* It must branch between addSession (new) and moveSession (existing).
|
||||
*/
|
||||
it("onPromoteSession handles both new and existing sessions", () => {
|
||||
const text = body("onPromoteSession")
|
||||
expect(text).toContain("getSession")
|
||||
expect(text).toContain("addSession")
|
||||
expect(text).toContain("moveSession")
|
||||
})
|
||||
|
||||
// -- notifyWorktreeReady invariants ----------------------------------------
|
||||
|
||||
/**
|
||||
* Regression: pushState must come before the ready/meta messages.
|
||||
* If reversed, the webview receives the "ready" signal but can't find
|
||||
* the worktree/session in state, causing a blank panel.
|
||||
*/
|
||||
it("notifyWorktreeReady pushes state before sending ready message", () => {
|
||||
const text = body("notifyWorktreeReady")
|
||||
const pushIdx = text.indexOf("this.pushState()")
|
||||
const readyIdx = text.indexOf("agentManager.worktreeSetup")
|
||||
expect(pushIdx, "pushState must come before worktreeSetup").toBeLessThan(readyIdx)
|
||||
// Must also send sessionMeta so the webview knows the branch/path
|
||||
expect(text).toContain("agentManager.sessionMeta")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
findMatchingSuggestion,
|
||||
applyFirstLineOnly,
|
||||
countLines,
|
||||
shouldShowOnlyFirstLine,
|
||||
getFirstLine,
|
||||
calcDebounceDelay,
|
||||
} from "../../src/services/autocomplete/classic-auto-complete/inline-utils"
|
||||
import type { FillInAtCursorSuggestion } from "../../src/services/autocomplete/types"
|
||||
|
||||
function makeSuggestion(prefix: string, text: string, suffix = ""): FillInAtCursorSuggestion {
|
||||
return { prefix, suffix, text }
|
||||
}
|
||||
|
||||
describe("countLines", () => {
|
||||
it("returns 0 for empty string", () => {
|
||||
expect(countLines("")).toBe(0)
|
||||
})
|
||||
|
||||
it("returns 1 for single line without newline", () => {
|
||||
expect(countLines("hello")).toBe(1)
|
||||
})
|
||||
|
||||
it("returns 1 for single line with trailing newline", () => {
|
||||
expect(countLines("hello\n")).toBe(1)
|
||||
})
|
||||
|
||||
it("returns 2 for two lines", () => {
|
||||
expect(countLines("line1\nline2")).toBe(2)
|
||||
})
|
||||
|
||||
it("returns 2 for two lines with trailing newline", () => {
|
||||
expect(countLines("line1\nline2\n")).toBe(2)
|
||||
})
|
||||
|
||||
it("handles CRLF line endings", () => {
|
||||
expect(countLines("a\r\nb\r\nc")).toBe(3)
|
||||
})
|
||||
|
||||
it("handles CRLF with trailing newline", () => {
|
||||
expect(countLines("a\r\nb\r\n")).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getFirstLine", () => {
|
||||
it("returns the first line of multi-line text", () => {
|
||||
expect(getFirstLine("line1\nline2\nline3")).toBe("line1")
|
||||
})
|
||||
|
||||
it("returns the full text when single line", () => {
|
||||
expect(getFirstLine("hello")).toBe("hello")
|
||||
})
|
||||
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(getFirstLine("")).toBe("")
|
||||
})
|
||||
|
||||
it("handles CRLF line endings", () => {
|
||||
expect(getFirstLine("line1\r\nline2")).toBe("line1")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldShowOnlyFirstLine", () => {
|
||||
it("returns false when suggestion starts with newline", () => {
|
||||
expect(shouldShowOnlyFirstLine("const x = ", "\n return x")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true when cursor is mid-line with code", () => {
|
||||
expect(shouldShowOnlyFirstLine("function foo() { return ", "bar\nbaz")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when prefix last line has no word chars (empty line)", () => {
|
||||
expect(shouldShowOnlyFirstLine("code\n", "line1\nline2\nline3")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for 2-line suggestion at start of line", () => {
|
||||
expect(shouldShowOnlyFirstLine(" ", "line1\nline2")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true for 3-line suggestion at start of line with word chars", () => {
|
||||
expect(shouldShowOnlyFirstLine(" code", "line1\nline2\nline3")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for empty prefix", () => {
|
||||
expect(shouldShowOnlyFirstLine("", "any text")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("findMatchingSuggestion", () => {
|
||||
it("returns null for empty history", () => {
|
||||
expect(findMatchingSuggestion("prefix", "suffix", [])).toBeNull()
|
||||
})
|
||||
|
||||
it("returns exact match", () => {
|
||||
const hist = [makeSuggestion("hello ", "world")]
|
||||
const result = findMatchingSuggestion("hello ", "", hist)
|
||||
expect(result?.matchType).toBe("exact")
|
||||
expect(result?.text).toBe("world")
|
||||
})
|
||||
|
||||
it("returns partial_typing match when user typed beginning of suggestion", () => {
|
||||
const hist = [makeSuggestion("he", "llo world")]
|
||||
const result = findMatchingSuggestion("hell", "", hist)
|
||||
expect(result?.matchType).toBe("partial_typing")
|
||||
expect(result?.text).toBe("o world")
|
||||
})
|
||||
|
||||
it("returns backward_deletion match when user deleted chars", () => {
|
||||
const hist = [makeSuggestion("hello world", "more", "suffix")]
|
||||
const result = findMatchingSuggestion("hello", "suffix", hist)
|
||||
expect(result?.matchType).toBe("backward_deletion")
|
||||
expect(result?.text).toBe(" worldmore")
|
||||
})
|
||||
|
||||
it("prefers most recent suggestion (searches from end)", () => {
|
||||
const hist = [makeSuggestion("prefix", "old suggestion"), makeSuggestion("prefix", "new suggestion")]
|
||||
const result = findMatchingSuggestion("prefix", "", hist)
|
||||
expect(result?.text).toBe("new suggestion")
|
||||
})
|
||||
|
||||
it("returns null when no match found", () => {
|
||||
const hist = [makeSuggestion("different", "no match")]
|
||||
expect(findMatchingSuggestion("unrelated", "suffix", hist)).toBeNull()
|
||||
})
|
||||
|
||||
it("does not match empty suggestion text for partial_typing", () => {
|
||||
const hist = [makeSuggestion("prefix", "")]
|
||||
const result = findMatchingSuggestion("prefix more", "", hist)
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("applyFirstLineOnly", () => {
|
||||
it("returns null when input is null", () => {
|
||||
expect(applyFirstLineOnly(null, "prefix")).toBeNull()
|
||||
})
|
||||
|
||||
it("returns empty result unchanged", () => {
|
||||
const hist = [makeSuggestion("p", "")]
|
||||
const result = findMatchingSuggestion("p", "", hist)!
|
||||
const applied = applyFirstLineOnly(result, "p")
|
||||
expect(applied?.text).toBe("")
|
||||
})
|
||||
|
||||
it("truncates to first line when mid-line suggestion", () => {
|
||||
const hist = [makeSuggestion("function foo() { return ", "x\n const y = 1\n}")]
|
||||
const result = findMatchingSuggestion("function foo() { return ", "", hist)!
|
||||
const applied = applyFirstLineOnly(result, "function foo() { return ")
|
||||
expect(applied?.text).toBe("x")
|
||||
})
|
||||
|
||||
it("preserves full multi-line suggestion when starting with newline", () => {
|
||||
const hist = [makeSuggestion("foo", "\n const x = 1\n const y = 2")]
|
||||
const result = findMatchingSuggestion("foo", "", hist)!
|
||||
const applied = applyFirstLineOnly(result, "foo")
|
||||
expect(applied?.text).toBe("\n const x = 1\n const y = 2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("calcDebounceDelay", () => {
|
||||
it("returns MIN when history is empty", () => {
|
||||
expect(calcDebounceDelay([])).toBe(150)
|
||||
})
|
||||
|
||||
it("returns average of latencies clamped to min", () => {
|
||||
expect(calcDebounceDelay([50, 50, 50])).toBe(150)
|
||||
})
|
||||
|
||||
it("returns average of latencies in normal range", () => {
|
||||
expect(calcDebounceDelay([400, 400, 400])).toBe(400)
|
||||
})
|
||||
|
||||
it("clamps to MAX for very high latencies", () => {
|
||||
expect(calcDebounceDelay([2000, 2000, 2000])).toBe(1000)
|
||||
})
|
||||
|
||||
it("rounds to nearest integer", () => {
|
||||
const result = calcDebounceDelay([300, 301])
|
||||
expect(Number.isInteger(result)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { humanFormatSessionCost, formatTime } from "../../src/services/autocomplete/statusbar-utils"
|
||||
|
||||
describe("humanFormatSessionCost", () => {
|
||||
it("returns '$0.00' for 0 cost", () => {
|
||||
expect(humanFormatSessionCost(0)).toBe("$0.00")
|
||||
})
|
||||
|
||||
it("returns '<$0.01' for very small cost", () => {
|
||||
expect(humanFormatSessionCost(0.001)).toBe("<$0.01")
|
||||
})
|
||||
|
||||
it("formats exactly $0.01 as dollar string (not less-than-cent)", () => {
|
||||
const result = humanFormatSessionCost(0.01)
|
||||
expect(result).toBe("$0.01")
|
||||
})
|
||||
|
||||
it("formats $0.12 correctly", () => {
|
||||
expect(humanFormatSessionCost(0.12)).toBe("$0.12")
|
||||
})
|
||||
|
||||
it("formats $1.00 correctly", () => {
|
||||
expect(humanFormatSessionCost(1.0)).toBe("$1.00")
|
||||
})
|
||||
|
||||
it("formats $1.005 rounded to 2 decimal places", () => {
|
||||
const result = humanFormatSessionCost(1.005)
|
||||
expect(result.startsWith("$")).toBe(true)
|
||||
expect(result).toMatch(/^\$\d+\.\d{2}$/)
|
||||
})
|
||||
|
||||
it("formats $0.009 as '<$0.01'", () => {
|
||||
expect(humanFormatSessionCost(0.009)).toBe("<$0.01")
|
||||
})
|
||||
})
|
||||
|
||||
describe("formatTime", () => {
|
||||
it("contains at least two colon-separated time components", () => {
|
||||
const result = formatTime(Date.now())
|
||||
expect(result).toMatch(/\d+:\d+/)
|
||||
})
|
||||
|
||||
it("formats a known timestamp with correct hour and minute", () => {
|
||||
const ts = new Date("2024-01-15T14:30:45").getTime()
|
||||
const result = formatTime(ts)
|
||||
expect(result).toMatch(/30/)
|
||||
expect(result).toMatch(/45/)
|
||||
})
|
||||
|
||||
it("produces different output for different timestamps", () => {
|
||||
const ts1 = new Date("2024-01-01T10:00:00").getTime()
|
||||
const ts2 = new Date("2024-01-01T15:30:00").getTime()
|
||||
expect(formatTime(ts1)).not.toBe(formatTime(ts2))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
getSuggestionKey,
|
||||
insertWithLRUEviction,
|
||||
} from "../../src/services/autocomplete/classic-auto-complete/telemetry-utils"
|
||||
|
||||
describe("getSuggestionKey", () => {
|
||||
it("combines prefix, suffix, and text with pipe separators", () => {
|
||||
const key = getSuggestionKey({ prefix: "hello ", suffix: "\n}", text: "world" })
|
||||
expect(key).toBe("hello |\n}|world")
|
||||
})
|
||||
|
||||
it("produces unique keys for different suggestions", () => {
|
||||
const k1 = getSuggestionKey({ prefix: "a", suffix: "c", text: "b" })
|
||||
const k2 = getSuggestionKey({ prefix: "a", suffix: "c", text: "x" })
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it("same content produces same key (stable)", () => {
|
||||
const s = { prefix: "const x = ", suffix: ";", text: "42" }
|
||||
expect(getSuggestionKey(s)).toBe(getSuggestionKey(s))
|
||||
})
|
||||
|
||||
it("different prefix produces different key", () => {
|
||||
const k1 = getSuggestionKey({ prefix: "a", suffix: "", text: "t" })
|
||||
const k2 = getSuggestionKey({ prefix: "b", suffix: "", text: "t" })
|
||||
expect(k1).not.toBe(k2)
|
||||
})
|
||||
|
||||
it("handles empty strings", () => {
|
||||
const key = getSuggestionKey({ prefix: "", suffix: "", text: "" })
|
||||
expect(key).toBe("||")
|
||||
})
|
||||
})
|
||||
|
||||
describe("insertWithLRUEviction", () => {
|
||||
it("inserts key into map", () => {
|
||||
const map = new Map<string, true>()
|
||||
insertWithLRUEviction(map, "k1", 5)
|
||||
expect(map.has("k1")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not evict when under limit", () => {
|
||||
const map = new Map<string, true>()
|
||||
insertWithLRUEviction(map, "k1", 3)
|
||||
insertWithLRUEviction(map, "k2", 3)
|
||||
insertWithLRUEviction(map, "k3", 3)
|
||||
expect(map.size).toBe(3)
|
||||
expect(map.has("k1")).toBe(true)
|
||||
})
|
||||
|
||||
it("evicts oldest key when limit exceeded", () => {
|
||||
const map = new Map<string, true>()
|
||||
insertWithLRUEviction(map, "k1", 3)
|
||||
insertWithLRUEviction(map, "k2", 3)
|
||||
insertWithLRUEviction(map, "k3", 3)
|
||||
insertWithLRUEviction(map, "k4", 3)
|
||||
expect(map.size).toBe(3)
|
||||
expect(map.has("k1")).toBe(false)
|
||||
expect(map.has("k4")).toBe(true)
|
||||
})
|
||||
|
||||
it("handles maxSize of 1", () => {
|
||||
const map = new Map<string, true>()
|
||||
insertWithLRUEviction(map, "k1", 1)
|
||||
insertWithLRUEviction(map, "k2", 1)
|
||||
expect(map.size).toBe(1)
|
||||
expect(map.has("k2")).toBe(true)
|
||||
expect(map.has("k1")).toBe(false)
|
||||
})
|
||||
|
||||
it("updating existing key does not increase size", () => {
|
||||
const map = new Map<string, true>()
|
||||
insertWithLRUEviction(map, "k1", 2)
|
||||
insertWithLRUEviction(map, "k1", 2)
|
||||
expect(map.size).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
finalizeChatSuggestion,
|
||||
buildChatPrefix,
|
||||
} from "../../src/services/autocomplete/chat-autocomplete/chat-autocomplete-utils"
|
||||
|
||||
describe("finalizeChatSuggestion", () => {
|
||||
it("returns empty string for empty input", () => {
|
||||
expect(finalizeChatSuggestion("")).toBe("")
|
||||
})
|
||||
|
||||
it("filters suggestions starting with // (JS comment)", () => {
|
||||
expect(finalizeChatSuggestion("// this is a comment")).toBe("")
|
||||
})
|
||||
|
||||
it("filters suggestions starting with /* (block comment)", () => {
|
||||
expect(finalizeChatSuggestion("/* block */")).toBe("")
|
||||
})
|
||||
|
||||
it("filters suggestions starting with * (JSDoc line)", () => {
|
||||
expect(finalizeChatSuggestion("* @param foo")).toBe("")
|
||||
})
|
||||
|
||||
it("filters suggestions starting with # (shell/Python comment)", () => {
|
||||
expect(finalizeChatSuggestion("# python comment")).toBe("")
|
||||
})
|
||||
|
||||
it("returns the suggestion as-is for normal text", () => {
|
||||
expect(finalizeChatSuggestion("hello world")).toBe("hello world")
|
||||
})
|
||||
|
||||
it("truncates at first newline", () => {
|
||||
expect(finalizeChatSuggestion("first line\nsecond line")).toBe("first line")
|
||||
})
|
||||
|
||||
it("trims trailing whitespace", () => {
|
||||
expect(finalizeChatSuggestion("hello ")).toBe("hello")
|
||||
})
|
||||
|
||||
it("truncates AND trims", () => {
|
||||
expect(finalizeChatSuggestion("first line \nsecond")).toBe("first line")
|
||||
})
|
||||
|
||||
it("handles single word without newline", () => {
|
||||
expect(finalizeChatSuggestion("world")).toBe("world")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildChatPrefix", () => {
|
||||
it("includes user message without editor context", () => {
|
||||
const result = buildChatPrefix("fix this bug")
|
||||
expect(result).toContain("fix this bug")
|
||||
expect(result).toContain("User's message")
|
||||
})
|
||||
|
||||
it("does not include editor header when no editors provided", () => {
|
||||
const result = buildChatPrefix("hello")
|
||||
expect(result).not.toContain("Code visible in editor")
|
||||
})
|
||||
|
||||
it("includes editor context when editors provided", () => {
|
||||
const editors = [
|
||||
{
|
||||
filePath: "/workspace/src/foo.ts",
|
||||
languageId: "typescript",
|
||||
visibleRanges: [{ content: "const x = 1" }],
|
||||
},
|
||||
]
|
||||
const result = buildChatPrefix("fix this", editors)
|
||||
expect(result).toContain("Code visible in editor")
|
||||
expect(result).toContain("foo.ts (typescript)")
|
||||
expect(result).toContain("const x = 1")
|
||||
expect(result).toContain("fix this")
|
||||
})
|
||||
|
||||
it("includes multiple editors", () => {
|
||||
const editors = [
|
||||
{ filePath: "/a.ts", languageId: "typescript", visibleRanges: [{ content: "code a" }] },
|
||||
{ filePath: "/b.py", languageId: "python", visibleRanges: [{ content: "code b" }] },
|
||||
]
|
||||
const result = buildChatPrefix("question", editors)
|
||||
expect(result).toContain("a.ts")
|
||||
expect(result).toContain("b.py")
|
||||
expect(result).toContain("code a")
|
||||
expect(result).toContain("code b")
|
||||
})
|
||||
|
||||
it("uses last segment of file path as filename", () => {
|
||||
const editors = [{ filePath: "/deep/path/to/myfile.ts", languageId: "typescript", visibleRanges: [] }]
|
||||
const result = buildChatPrefix("q", editors)
|
||||
expect(result).toContain("myfile.ts")
|
||||
expect(result).not.toContain("deep/path")
|
||||
})
|
||||
|
||||
it("handles empty editors array as if no context", () => {
|
||||
const result = buildChatPrefix("hi", [])
|
||||
expect(result).not.toContain("Code visible in editor")
|
||||
expect(result).toContain("hi")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, mock } from "bun:test"
|
||||
|
||||
const makeAction = (title: string, kind: string) => ({ title, kind })
|
||||
const makeKind = (value: string) => ({
|
||||
value,
|
||||
append: (v: string) => makeKind(`${value}.${v}`),
|
||||
})
|
||||
|
||||
const QuickFix = makeKind("quickfix")
|
||||
const RefactorRewrite = makeKind("refactor.rewrite")
|
||||
|
||||
const mockVscode = {
|
||||
CodeAction: class {
|
||||
command?: { command: string; title: string }
|
||||
isPreferred?: boolean
|
||||
constructor(
|
||||
public title: string,
|
||||
public kind: { value: string },
|
||||
) {}
|
||||
},
|
||||
CodeActionKind: {
|
||||
QuickFix,
|
||||
RefactorRewrite,
|
||||
},
|
||||
}
|
||||
|
||||
mock.module("vscode", () => mockVscode)
|
||||
|
||||
const { KiloCodeActionProvider } = await import("../../src/services/code-actions/code-action-provider")
|
||||
|
||||
const provider = new KiloCodeActionProvider()
|
||||
|
||||
function makeRange(isEmpty: boolean) {
|
||||
return { isEmpty }
|
||||
}
|
||||
|
||||
function makeContext(diagnosticCount: number) {
|
||||
return { diagnostics: Array.from({ length: diagnosticCount }) }
|
||||
}
|
||||
|
||||
describe("KiloCodeActionProvider", () => {
|
||||
describe("provideCodeActions", () => {
|
||||
it("returns empty array when range is empty", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(true) as never, makeContext(0) as never)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
it("returns empty array when range is empty even with diagnostics", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(true) as never, makeContext(3) as never)
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
describe("non-empty range, no diagnostics", () => {
|
||||
it("returns Add, Explain, Improve actions", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never)
|
||||
const titles = result.map((a) => a.title)
|
||||
expect(titles).toContain("Add to Kilo Code")
|
||||
expect(titles).toContain("Explain with Kilo Code")
|
||||
expect(titles).toContain("Improve with Kilo Code")
|
||||
})
|
||||
|
||||
it("does not include Fix action", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never)
|
||||
expect(result.map((a) => a.title)).not.toContain("Fix with Kilo Code")
|
||||
})
|
||||
|
||||
it("returns exactly 3 actions", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never)
|
||||
expect(result).toHaveLength(3)
|
||||
})
|
||||
|
||||
it("uses correct command IDs", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never)
|
||||
const commands = result.map((a) => a.command?.command)
|
||||
expect(commands).toContain("kilo-code.new.addToContext")
|
||||
expect(commands).toContain("kilo-code.new.explainCode")
|
||||
expect(commands).toContain("kilo-code.new.improveCode")
|
||||
})
|
||||
|
||||
it("no action is preferred", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never)
|
||||
expect(result.every((a) => !a.isPreferred)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("non-empty range, with diagnostics", () => {
|
||||
it("returns Add and Fix actions", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(2) as never)
|
||||
const titles = result.map((a) => a.title)
|
||||
expect(titles).toContain("Add to Kilo Code")
|
||||
expect(titles).toContain("Fix with Kilo Code")
|
||||
})
|
||||
|
||||
it("does not include Explain or Improve actions", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never)
|
||||
const titles = result.map((a) => a.title)
|
||||
expect(titles).not.toContain("Explain with Kilo Code")
|
||||
expect(titles).not.toContain("Improve with Kilo Code")
|
||||
})
|
||||
|
||||
it("returns exactly 2 actions", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never)
|
||||
expect(result).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("Fix action is preferred", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never)
|
||||
const fix = result.find((a) => a.title === "Fix with Kilo Code")
|
||||
expect(fix?.isPreferred).toBe(true)
|
||||
})
|
||||
|
||||
it("Fix action uses QuickFix kind", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never)
|
||||
const fix = result.find((a) => a.title === "Fix with Kilo Code")
|
||||
expect(fix?.kind.value).toBe("quickfix")
|
||||
})
|
||||
|
||||
it("uses correct Fix command ID", () => {
|
||||
const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never)
|
||||
const fix = result.find((a) => a.title === "Fix with Kilo Code")
|
||||
expect(fix?.command?.command).toBe("kilo-code.new.fixCode")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { resolveEventSessionId } from "../../src/services/cli-backend/connection-utils"
|
||||
import type { SSEEvent } from "../../src/services/cli-backend/types"
|
||||
|
||||
const noLookup = (_: string) => undefined
|
||||
|
||||
describe("resolveEventSessionId", () => {
|
||||
it("returns session id from session.created", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "session.created",
|
||||
properties: {
|
||||
info: { id: "s1", title: "", directory: "", time: { created: 0, updated: 0 } },
|
||||
},
|
||||
}
|
||||
expect(resolveEventSessionId(event, noLookup)).toBe("s1")
|
||||
})
|
||||
|
||||
it("returns session id from session.updated", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "session.updated",
|
||||
properties: {
|
||||
info: { id: "s2", title: "", directory: "", time: { created: 0, updated: 0 } },
|
||||
},
|
||||
}
|
||||
expect(resolveEventSessionId(event, noLookup)).toBe("s2")
|
||||
})
|
||||
|
||||
it("returns sessionID from session.status", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "session.status",
|
||||
properties: { sessionID: "s3", status: { type: "idle" } },
|
||||
}
|
||||
expect(resolveEventSessionId(event, noLookup)).toBe("s3")
|
||||
})
|
||||
|
||||
it("returns sessionID from todo.updated", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "todo.updated",
|
||||
properties: { sessionID: "s4", items: [] },
|
||||
}
|
||||
expect(resolveEventSessionId(event, noLookup)).toBe("s4")
|
||||
})
|
||||
|
||||
it("returns sessionID from message.updated and calls onMessageUpdated", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "m1", sessionID: "s5", role: "assistant", time: { created: 0 } },
|
||||
},
|
||||
}
|
||||
const recorded: [string, string][] = []
|
||||
const result = resolveEventSessionId(event, noLookup, (mid, sid) => recorded.push([mid, sid]))
|
||||
expect(result).toBe("s5")
|
||||
expect(recorded).toEqual([["m1", "s5"]])
|
||||
})
|
||||
|
||||
it("message.updated does not require onMessageUpdated callback", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "message.updated",
|
||||
properties: {
|
||||
info: { id: "m1", sessionID: "s5", role: "assistant", time: { created: 0 } },
|
||||
},
|
||||
}
|
||||
expect(() => resolveEventSessionId(event, noLookup)).not.toThrow()
|
||||
})
|
||||
|
||||
it("returns sessionID directly from message.part.updated when part has sessionID", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: { type: "text", id: "p1", text: "", sessionID: "s6", messageID: "m1" },
|
||||
},
|
||||
}
|
||||
expect(resolveEventSessionId(event, noLookup)).toBe("s6")
|
||||
})
|
||||
|
||||
it("falls back to lookup when message.part.updated has no sessionID but has messageID", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: { type: "text", id: "p1", text: "", messageID: "m2" },
|
||||
},
|
||||
}
|
||||
const lookup = (id: string) => (id === "m2" ? "s7" : undefined)
|
||||
expect(resolveEventSessionId(event, lookup)).toBe("s7")
|
||||
})
|
||||
|
||||
it("returns undefined for message.part.updated with no sessionID and messageID not in map", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: { type: "text", id: "p1", text: "", messageID: "unknown" },
|
||||
},
|
||||
}
|
||||
expect(resolveEventSessionId(event, noLookup)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined for message.part.updated with no messageID and no sessionID", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "message.part.updated",
|
||||
properties: {
|
||||
part: { type: "text", id: "p1", text: "" },
|
||||
},
|
||||
}
|
||||
expect(resolveEventSessionId(event, noLookup)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns sessionID from permission.asked", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "permission.asked",
|
||||
properties: {
|
||||
id: "p1",
|
||||
sessionID: "s8",
|
||||
permission: "read_file",
|
||||
patterns: [],
|
||||
metadata: {},
|
||||
always: [],
|
||||
},
|
||||
}
|
||||
expect(resolveEventSessionId(event, noLookup)).toBe("s8")
|
||||
})
|
||||
|
||||
it("returns sessionID from question.asked", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "question.asked",
|
||||
properties: { id: "q1", sessionID: "s9", questions: [] },
|
||||
}
|
||||
expect(resolveEventSessionId(event, noLookup)).toBe("s9")
|
||||
})
|
||||
|
||||
it("returns sessionID from question.replied", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "question.replied",
|
||||
properties: { sessionID: "s10", requestID: "r1", answers: [] },
|
||||
}
|
||||
expect(resolveEventSessionId(event, noLookup)).toBe("s10")
|
||||
})
|
||||
|
||||
it("returns sessionID from question.rejected", () => {
|
||||
const event: SSEEvent = {
|
||||
type: "question.rejected",
|
||||
properties: { sessionID: "s11", requestID: "r2" },
|
||||
}
|
||||
expect(resolveEventSessionId(event, noLookup)).toBe("s11")
|
||||
})
|
||||
|
||||
it("returns undefined for server.connected (global event)", () => {
|
||||
const event: SSEEvent = { type: "server.connected", properties: {} }
|
||||
expect(resolveEventSessionId(event, noLookup)).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined for server.heartbeat (global event)", () => {
|
||||
const event: SSEEvent = { type: "server.heartbeat", properties: {} }
|
||||
expect(resolveEventSessionId(event, noLookup)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
shouldSkipAutocomplete,
|
||||
getTerminatorsForLanguage,
|
||||
} from "../../src/services/autocomplete/classic-auto-complete/contextualSkip"
|
||||
|
||||
describe("getTerminatorsForLanguage", () => {
|
||||
it("returns c-like terminators for typescript", () => {
|
||||
const t = getTerminatorsForLanguage("typescript")
|
||||
expect(t).toContain(";")
|
||||
expect(t).toContain("}")
|
||||
expect(t).toContain(")")
|
||||
expect(t).not.toContain(",")
|
||||
})
|
||||
|
||||
it("returns python terminators (brackets, no semicolon)", () => {
|
||||
const t = getTerminatorsForLanguage("python")
|
||||
expect(t).toContain(")")
|
||||
expect(t).toContain("]")
|
||||
expect(t).toContain("}")
|
||||
expect(t).not.toContain(";")
|
||||
})
|
||||
|
||||
it("returns empty terminators for html", () => {
|
||||
expect(getTerminatorsForLanguage("html")).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("returns shell terminators including fi and done", () => {
|
||||
const t = getTerminatorsForLanguage("shellscript")
|
||||
expect(t).toContain(";")
|
||||
expect(t).toContain("fi")
|
||||
expect(t).toContain("done")
|
||||
})
|
||||
|
||||
it("returns default terminators for unknown language", () => {
|
||||
const t = getTerminatorsForLanguage("unknown-lang")
|
||||
expect(t).toContain(";")
|
||||
expect(t).toContain("}")
|
||||
expect(t).toContain(")")
|
||||
})
|
||||
|
||||
it("returns default terminators when languageId is undefined", () => {
|
||||
const t = getTerminatorsForLanguage(undefined)
|
||||
expect(t).toContain(";")
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldSkipAutocomplete - end of statement", () => {
|
||||
it("skips after semicolon in typescript", () => {
|
||||
expect(shouldSkipAutocomplete("const x = 5;", "\n", "typescript")).toBe(true)
|
||||
})
|
||||
|
||||
it("skips after closing brace in typescript", () => {
|
||||
expect(shouldSkipAutocomplete("}", "\n", "typescript")).toBe(true)
|
||||
})
|
||||
|
||||
it("skips after closing paren in typescript", () => {
|
||||
expect(shouldSkipAutocomplete("myFunction()", "\n", "typescript")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not skip after colon in typescript", () => {
|
||||
expect(shouldSkipAutocomplete(" key:", "\n", "typescript")).toBe(false)
|
||||
})
|
||||
|
||||
it("does not skip after opening brace", () => {
|
||||
expect(shouldSkipAutocomplete("if (condition) {", "\n", "typescript")).toBe(false)
|
||||
})
|
||||
|
||||
it("does not skip when suffix has non-whitespace on same line", () => {
|
||||
expect(shouldSkipAutocomplete("const x = ", " + 1;\n", "typescript")).toBe(false)
|
||||
})
|
||||
|
||||
it("does not skip on empty line", () => {
|
||||
expect(shouldSkipAutocomplete("", "\n", "typescript")).toBe(false)
|
||||
})
|
||||
|
||||
it("skips after semicolon with trailing whitespace", () => {
|
||||
expect(shouldSkipAutocomplete("const x = 5; ", "\n", "typescript")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not skip in python after colon (block start)", () => {
|
||||
expect(shouldSkipAutocomplete("def foo():", "\n", "python")).toBe(false)
|
||||
})
|
||||
|
||||
it("skips in python after closing paren", () => {
|
||||
expect(shouldSkipAutocomplete("print('hello')", "\n", "python")).toBe(true)
|
||||
})
|
||||
|
||||
it("skips in html due to mid-word typing (not terminator)", () => {
|
||||
expect(shouldSkipAutocomplete("<div", "\n", "html")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not skip in html after closing tag", () => {
|
||||
expect(shouldSkipAutocomplete("</div>", "\n", "html")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldSkipAutocomplete - mid-word typing", () => {
|
||||
it("skips when typing a word longer than 2 chars", () => {
|
||||
expect(shouldSkipAutocomplete("myVariable", "\n", "typescript")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not skip for 1-2 char word", () => {
|
||||
expect(shouldSkipAutocomplete("my", "\n", "typescript")).toBe(false)
|
||||
expect(shouldSkipAutocomplete("x", "\n", "typescript")).toBe(false)
|
||||
})
|
||||
|
||||
it("skips when suffix starts with word character", () => {
|
||||
expect(shouldSkipAutocomplete("if (", "condition) {\n", "typescript")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not skip when prefix is empty", () => {
|
||||
expect(shouldSkipAutocomplete("", "", "typescript")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("shouldSkipAutocomplete - defaults", () => {
|
||||
it("uses default terminators when no languageId provided", () => {
|
||||
expect(shouldSkipAutocomplete("const x = 5;", "\n")).toBe(true)
|
||||
expect(shouldSkipAutocomplete("}", "\n")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not skip on empty input with no language", () => {
|
||||
expect(shouldSkipAutocomplete("", "")).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { formatRelativeDate } from "../../webview-ui/src/utils/date"
|
||||
|
||||
function ago(ms: number): string {
|
||||
return new Date(Date.now() - ms).toISOString()
|
||||
}
|
||||
|
||||
const SEC = 1000
|
||||
const MIN = 60 * SEC
|
||||
const HOUR = 60 * MIN
|
||||
const DAY = 24 * HOUR
|
||||
const MONTH = 30 * DAY
|
||||
|
||||
describe("formatRelativeDate", () => {
|
||||
it("returns 'just now' for future timestamps", () => {
|
||||
const future = new Date(Date.now() + 5000).toISOString()
|
||||
expect(formatRelativeDate(future)).toBe("just now")
|
||||
})
|
||||
|
||||
it("returns 'just now' for 0 seconds ago", () => {
|
||||
expect(formatRelativeDate(new Date().toISOString())).toBe("just now")
|
||||
})
|
||||
|
||||
it("returns 'just now' for 30 seconds ago", () => {
|
||||
expect(formatRelativeDate(ago(30 * SEC))).toBe("just now")
|
||||
})
|
||||
|
||||
it("returns 'just now' for 59 seconds ago", () => {
|
||||
expect(formatRelativeDate(ago(59 * SEC))).toBe("just now")
|
||||
})
|
||||
|
||||
it("returns '1 min ago' for exactly 1 minute ago", () => {
|
||||
expect(formatRelativeDate(ago(MIN))).toBe("1 min ago")
|
||||
})
|
||||
|
||||
it("returns '5 min ago' for 5 minutes ago", () => {
|
||||
expect(formatRelativeDate(ago(5 * MIN))).toBe("5 min ago")
|
||||
})
|
||||
|
||||
it("returns '59 min ago' for 59 minutes ago", () => {
|
||||
expect(formatRelativeDate(ago(59 * MIN))).toBe("59 min ago")
|
||||
})
|
||||
|
||||
it("returns '1h ago' for exactly 1 hour ago", () => {
|
||||
expect(formatRelativeDate(ago(HOUR))).toBe("1h ago")
|
||||
})
|
||||
|
||||
it("returns '12h ago' for 12 hours ago", () => {
|
||||
expect(formatRelativeDate(ago(12 * HOUR))).toBe("12h ago")
|
||||
})
|
||||
|
||||
it("returns '23h ago' for 23 hours ago", () => {
|
||||
expect(formatRelativeDate(ago(23 * HOUR))).toBe("23h ago")
|
||||
})
|
||||
|
||||
it("returns '1d ago' for exactly 1 day ago", () => {
|
||||
expect(formatRelativeDate(ago(DAY))).toBe("1d ago")
|
||||
})
|
||||
|
||||
it("returns '7d ago' for 7 days ago", () => {
|
||||
expect(formatRelativeDate(ago(7 * DAY))).toBe("7d ago")
|
||||
})
|
||||
|
||||
it("returns '29d ago' for 29 days ago", () => {
|
||||
expect(formatRelativeDate(ago(29 * DAY))).toBe("29d ago")
|
||||
})
|
||||
|
||||
it("returns '1mo ago' for exactly 30 days ago", () => {
|
||||
expect(formatRelativeDate(ago(MONTH))).toBe("1mo ago")
|
||||
})
|
||||
|
||||
it("returns '6mo ago' for 6 months ago", () => {
|
||||
expect(formatRelativeDate(ago(6 * MONTH))).toBe("6mo ago")
|
||||
})
|
||||
|
||||
it("returns 'just now' for invalid ISO string (fallback to now)", () => {
|
||||
expect(formatRelativeDate("not-a-date")).toBe("just now")
|
||||
})
|
||||
|
||||
it("returns 'just now' for empty string (fallback to now)", () => {
|
||||
expect(formatRelativeDate("")).toBe("just now")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import {
|
||||
AT_PATTERN,
|
||||
syncMentionedPaths,
|
||||
buildTextAfterMentionSelect,
|
||||
buildFileAttachments,
|
||||
} from "../../webview-ui/src/hooks/file-mention-utils"
|
||||
|
||||
describe("AT_PATTERN", () => {
|
||||
it("matches @mention at start of string", () => {
|
||||
expect(AT_PATTERN.test("@foo")).toBe(true)
|
||||
})
|
||||
|
||||
it("matches @mention after whitespace", () => {
|
||||
expect(AT_PATTERN.test("hello @foo")).toBe(true)
|
||||
})
|
||||
|
||||
it("does not match @mention in middle of word", () => {
|
||||
expect(AT_PATTERN.test("hello@foo")).toBe(false)
|
||||
})
|
||||
|
||||
it("captures the path after @", () => {
|
||||
const match = "hello @path/to/file.ts".match(AT_PATTERN)
|
||||
expect(match?.[1]).toBe("path/to/file.ts")
|
||||
})
|
||||
|
||||
it("matches empty @", () => {
|
||||
expect(AT_PATTERN.test("@")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("syncMentionedPaths", () => {
|
||||
it("keeps paths still referenced in text", () => {
|
||||
const paths = new Set(["foo.ts", "bar.ts"])
|
||||
const result = syncMentionedPaths(paths, "see @foo.ts for details")
|
||||
expect(result.has("foo.ts")).toBe(true)
|
||||
expect(result.has("bar.ts")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns empty set when text has no @references", () => {
|
||||
const paths = new Set(["foo.ts"])
|
||||
const result = syncMentionedPaths(paths, "no references here")
|
||||
expect(result.size).toBe(0)
|
||||
})
|
||||
|
||||
it("keeps multiple paths that are all referenced", () => {
|
||||
const paths = new Set(["a.ts", "b.ts"])
|
||||
const result = syncMentionedPaths(paths, "@a.ts and @b.ts are both here")
|
||||
expect(result.size).toBe(2)
|
||||
})
|
||||
|
||||
it("does not mutate the original set", () => {
|
||||
const paths = new Set(["foo.ts"])
|
||||
syncMentionedPaths(paths, "no references")
|
||||
expect(paths.has("foo.ts")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildTextAfterMentionSelect", () => {
|
||||
it("replaces @mention with selected path", () => {
|
||||
const before = "hello @par"
|
||||
const after = " world"
|
||||
const result = buildTextAfterMentionSelect(before, after, "src/component.ts")
|
||||
expect(result).toBe("hello @src/component.ts world")
|
||||
})
|
||||
|
||||
it("handles @mention at start of string", () => {
|
||||
const result = buildTextAfterMentionSelect("@par", "", "foo.ts")
|
||||
expect(result).toBe("@foo.ts")
|
||||
})
|
||||
|
||||
it("preserves space prefix before @mention", () => {
|
||||
const result = buildTextAfterMentionSelect("text @par", "", "foo.ts")
|
||||
expect(result).toBe("text @foo.ts")
|
||||
})
|
||||
|
||||
it("appends suffix after replacement", () => {
|
||||
const result = buildTextAfterMentionSelect("before @q", " after text", "file.ts")
|
||||
expect(result).toContain("after text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("buildFileAttachments", () => {
|
||||
it("returns empty array for empty paths set", () => {
|
||||
expect(buildFileAttachments("hello @foo.ts", new Set(), "/workspace")).toEqual([])
|
||||
})
|
||||
|
||||
it("returns attachment for mentioned path", () => {
|
||||
const paths = new Set(["src/foo.ts"])
|
||||
const result = buildFileAttachments("check @src/foo.ts", paths, "/workspace")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.mime).toBe("text/plain")
|
||||
expect(result[0]!.url).toContain("file://")
|
||||
expect(result[0]!.url).toContain("src/foo.ts")
|
||||
})
|
||||
|
||||
it("skips paths not in text", () => {
|
||||
const paths = new Set(["foo.ts", "bar.ts"])
|
||||
const result = buildFileAttachments("only @foo.ts here", paths, "/workspace")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.url).toContain("foo.ts")
|
||||
})
|
||||
|
||||
it("handles absolute paths directly", () => {
|
||||
const paths = new Set(["/abs/path/file.ts"])
|
||||
const result = buildFileAttachments("@/abs/path/file.ts", paths, "/workspace")
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]!.url).toContain("/abs/path/file.ts")
|
||||
})
|
||||
|
||||
it("normalizes Windows backslashes in workspaceDir", () => {
|
||||
const paths = new Set(["foo.ts"])
|
||||
const result = buildFileAttachments("@foo.ts", paths, "C:\\Users\\workspace")
|
||||
expect(result[0]!.url).not.toContain("\\")
|
||||
})
|
||||
})
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { parseAutocompleteResponse } from "../../src/services/autocomplete/classic-auto-complete/hole-filler-utils"
|
||||
|
||||
describe("parseAutocompleteResponse", () => {
|
||||
const prefix = "function foo() {\n "
|
||||
const suffix = "\n}"
|
||||
|
||||
it("extracts content between COMPLETION tags", () => {
|
||||
const result = parseAutocompleteResponse("<COMPLETION>return 42;</COMPLETION>", prefix, suffix)
|
||||
expect(result.text).toBe("return 42;")
|
||||
expect(result.prefix).toBe(prefix)
|
||||
expect(result.suffix).toBe(suffix)
|
||||
})
|
||||
|
||||
it("returns empty text when no COMPLETION tags", () => {
|
||||
const result = parseAutocompleteResponse("return 42;", prefix, suffix)
|
||||
expect(result.text).toBe("")
|
||||
})
|
||||
|
||||
it("handles multiline completion content", () => {
|
||||
const result = parseAutocompleteResponse("<COMPLETION>const x = 1;\nreturn x;</COMPLETION>", prefix, suffix)
|
||||
expect(result.text).toBe("const x = 1;\nreturn x;")
|
||||
})
|
||||
|
||||
it("handles case-insensitive tags", () => {
|
||||
const result = parseAutocompleteResponse("<completion>return x;</completion>", prefix, suffix)
|
||||
expect(result.text).toBe("return x;")
|
||||
})
|
||||
|
||||
it("handles empty COMPLETION tags", () => {
|
||||
const result = parseAutocompleteResponse("<COMPLETION></COMPLETION>", prefix, suffix)
|
||||
expect(result.text).toBe("")
|
||||
})
|
||||
|
||||
it("handles whitespace-only content in tags", () => {
|
||||
const result = parseAutocompleteResponse("<COMPLETION> </COMPLETION>", prefix, suffix)
|
||||
expect(result.text).toBe(" ")
|
||||
})
|
||||
|
||||
it("handles response with prose before and after tags", () => {
|
||||
const response = "Here is your completion:\n<COMPLETION>return value;</COMPLETION>\nHope that helps!"
|
||||
const result = parseAutocompleteResponse(response, prefix, suffix)
|
||||
expect(result.text).toBe("return value;")
|
||||
})
|
||||
|
||||
it("removes accidentally captured tag remnants", () => {
|
||||
const result = parseAutocompleteResponse("<COMPLETION><COMPLETION>inner</COMPLETION></COMPLETION>", prefix, suffix)
|
||||
expect(result.text).toBe("inner")
|
||||
})
|
||||
|
||||
it("returns empty text for empty response", () => {
|
||||
const result = parseAutocompleteResponse("", prefix, suffix)
|
||||
expect(result.text).toBe("")
|
||||
})
|
||||
|
||||
it("preserves prefix and suffix in result", () => {
|
||||
const result = parseAutocompleteResponse("<COMPLETION>x</COMPLETION>", "pre", "suf")
|
||||
expect(result.prefix).toBe("pre")
|
||||
expect(result.suffix).toBe("suf")
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from "bun:test"
|
||||
import { extractHttpErrorMessage, parseSSEDataLine } from "../../src/services/cli-backend/http-utils"
|
||||
|
||||
describe("extractHttpErrorMessage", () => {
|
||||
it("extracts error field from JSON", () => {
|
||||
const result = extractHttpErrorMessage("Bad Request", '{"error":"invalid token"}')
|
||||
expect(result).toBe("invalid token")
|
||||
})
|
||||
|
||||
it("extracts message field from JSON when error is absent", () => {
|
||||
const result = extractHttpErrorMessage("Not Found", '{"message":"resource not found"}')
|
||||
expect(result).toBe("resource not found")
|
||||
})
|
||||
|
||||
it("prefers error over message when both present", () => {
|
||||
const result = extractHttpErrorMessage("Bad Request", '{"error":"err","message":"msg"}')
|
||||
expect(result).toBe("err")
|
||||
})
|
||||
|
||||
it("falls back to statusText when JSON has neither error nor message", () => {
|
||||
const result = extractHttpErrorMessage("Bad Request", '{"code":400}')
|
||||
expect(result).toBe("Bad Request")
|
||||
})
|
||||
|
||||
it("falls back to raw text when JSON parse fails", () => {
|
||||
const result = extractHttpErrorMessage("Internal Server Error", "not json at all")
|
||||
expect(result).toBe("not json at all")
|
||||
})
|
||||
|
||||
it("returns statusText when rawText is empty", () => {
|
||||
expect(extractHttpErrorMessage("Unauthorized", "")).toBe("Unauthorized")
|
||||
})
|
||||
|
||||
it("returns statusText when rawText is whitespace only", () => {
|
||||
expect(extractHttpErrorMessage("Forbidden", " ")).toBe("Forbidden")
|
||||
})
|
||||
|
||||
it("falls back to statusText when error field is falsy empty string", () => {
|
||||
const result = extractHttpErrorMessage("Bad Request", '{"error":""}')
|
||||
expect(result).toBe("Bad Request")
|
||||
})
|
||||
})
|
||||
|
||||
describe("parseSSEDataLine", () => {
|
||||
it("returns null for non-data lines", () => {
|
||||
expect(parseSSEDataLine("event: message")).toBeNull()
|
||||
expect(parseSSEDataLine("id: 123")).toBeNull()
|
||||
expect(parseSSEDataLine(": heartbeat")).toBeNull()
|
||||
expect(parseSSEDataLine("")).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for [DONE] sentinel", () => {
|
||||
expect(parseSSEDataLine("data: [DONE]")).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for malformed JSON", () => {
|
||||
expect(parseSSEDataLine("data: {not json}")).toBeNull()
|
||||
})
|
||||
|
||||
it("extracts content from choices delta", () => {
|
||||
const line = 'data: {"choices":[{"delta":{"content":"hello"}}]}'
|
||||
const result = parseSSEDataLine(line)
|
||||
expect(result?.content).toBe("hello")
|
||||
})
|
||||
|
||||
it("omits content when delta content is empty string", () => {
|
||||
const line = 'data: {"choices":[{"delta":{"content":""}}]}'
|
||||
const result = parseSSEDataLine(line)
|
||||
expect(result?.content).toBeUndefined()
|
||||
})
|
||||
|
||||
it("omits content when choices array is empty", () => {
|
||||
const line = 'data: {"choices":[]}'
|
||||
const result = parseSSEDataLine(line)
|
||||
expect(result?.content).toBeUndefined()
|
||||
})
|
||||
|
||||
it("extracts usage tokens", () => {
|
||||
const line = 'data: {"usage":{"prompt_tokens":10,"completion_tokens":20}}'
|
||||
const result = parseSSEDataLine(line)
|
||||
expect(result?.inputTokens).toBe(10)
|
||||
expect(result?.outputTokens).toBe(20)
|
||||
})
|
||||
|
||||
it("defaults token counts to 0 when usage fields are missing", () => {
|
||||
const line = 'data: {"usage":{}}'
|
||||
const result = parseSSEDataLine(line)
|
||||
expect(result?.inputTokens).toBe(0)
|
||||
expect(result?.outputTokens).toBe(0)
|
||||
})
|
||||
|
||||
it("extracts cost", () => {
|
||||
const line = 'data: {"cost":0.0042}'
|
||||
const result = parseSSEDataLine(line)
|
||||
expect(result?.cost).toBe(0.0042)
|
||||
})
|
||||
|
||||
it("extracts all fields in one chunk", () => {
|
||||
const line =
|
||||
'data: {"choices":[{"delta":{"content":"world"}}],"usage":{"prompt_tokens":5,"completion_tokens":3},"cost":0.001}'
|
||||
const result = parseSSEDataLine(line)
|
||||
expect(result?.content).toBe("world")
|
||||
expect(result?.inputTokens).toBe(5)
|
||||
expect(result?.outputTokens).toBe(3)
|
||||
expect(result?.cost).toBe(0.001)
|
||||
})
|
||||
|
||||
it("returns empty object for valid JSON with no recognized fields", () => {
|
||||
const line = 'data: {"id":"abc"}'
|
||||
const result = parseSSEDataLine(line)
|
||||
expect(result).not.toBeNull()
|
||||
expect(result?.content).toBeUndefined()
|
||||
expect(result?.cost).toBeUndefined()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user