Merge branch 'dev' into catrielmuller/320-notifications-vscode
This commit is contained in:
@@ -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`.
|
- The default branch in this repo is `dev`.
|
||||||
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
|
- 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.
|
- 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
|
## Build and Dev
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,53 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://kilo.ai">
|
<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>
|
||||||
<img src="logo.png" alt="Kilo CLI logo">
|
<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>
|
<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>
|
||||||
</p>
|
<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>
|
||||||
<p align="center">The open source AI coding agent.</p>
|
<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 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>
|
|
||||||
</p>
|
</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
|
```bash
|
||||||
# npm
|
# npm
|
||||||
@@ -24,19 +59,6 @@ npx @kilocode/cli
|
|||||||
|
|
||||||
Then run `kilo` in any project directory to start.
|
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)
|
### 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:
|
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.
|
**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 |
|
## Code of Conduct
|
||||||
| -------------------------------------------- | -------------------------------------------- |
|
|
||||||
| 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 |
|
|
||||||
|
|
||||||
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
|
This project is licensed under the MIT License.
|
||||||
- `architect` → `plan` agent
|
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:
|
### Where did Kilo CLI come from?
|
||||||
|
|
||||||
- [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?
|
|
||||||
|
|
||||||
Kilo CLI is a fork of [OpenCode](https://github.com/anomalyco/opencode), enhanced to work within the Kilo agentic engineering platform.
|
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": {
|
"packages/app": {
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kilocode/kilo-i18n": "workspace:*",
|
"@kilocode/kilo-i18n": "workspace:*",
|
||||||
"@kilocode/kilo-ui": "workspace:*",
|
"@kilocode/kilo-ui": "workspace:*",
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@opencode-ai/app": "workspace:*",
|
"@opencode-ai/app": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
},
|
},
|
||||||
"packages/kilo-docs": {
|
"packages/kilo-docs": {
|
||||||
"name": "@kilocode/kilo-docs",
|
"name": "@kilocode/kilo-docs",
|
||||||
"version": "1.0.0",
|
"version": "1.0.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@docsearch/css": "^4",
|
"@docsearch/css": "^4",
|
||||||
"@docsearch/js": "^4",
|
"@docsearch/js": "^4",
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
},
|
},
|
||||||
"packages/kilo-gateway": {
|
"packages/kilo-gateway": {
|
||||||
"name": "@kilocode/kilo-gateway",
|
"name": "@kilocode/kilo-gateway",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@clack/prompts": "1.0.0-alpha.1",
|
"@clack/prompts": "1.0.0-alpha.1",
|
||||||
"@kilocode/plugin": "workspace:*",
|
"@kilocode/plugin": "workspace:*",
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
},
|
},
|
||||||
"packages/kilo-i18n": {
|
"packages/kilo-i18n": {
|
||||||
"name": "@kilocode/kilo-i18n",
|
"name": "@kilocode/kilo-i18n",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
},
|
},
|
||||||
"packages/kilo-telemetry": {
|
"packages/kilo-telemetry": {
|
||||||
"name": "@kilocode/kilo-telemetry",
|
"name": "@kilocode/kilo-telemetry",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kilocode/kilo-gateway": "workspace:*",
|
"@kilocode/kilo-gateway": "workspace:*",
|
||||||
"@opentelemetry/api": "1.9.0",
|
"@opentelemetry/api": "1.9.0",
|
||||||
@@ -198,7 +198,7 @@
|
|||||||
},
|
},
|
||||||
"packages/kilo-ui": {
|
"packages/kilo-ui": {
|
||||||
"name": "@kilocode/kilo-ui",
|
"name": "@kilocode/kilo-ui",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kobalte/core": "0.13.11",
|
"@kobalte/core": "0.13.11",
|
||||||
},
|
},
|
||||||
@@ -221,13 +221,14 @@
|
|||||||
},
|
},
|
||||||
"packages/kilo-vscode": {
|
"packages/kilo-vscode": {
|
||||||
"name": "kilo-code",
|
"name": "kilo-code",
|
||||||
"version": "7.0.23",
|
"version": "7.0.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.39.0",
|
"@anthropic-ai/sdk": "^0.39.0",
|
||||||
"@kilocode/kilo-i18n": "workspace:*",
|
"@kilocode/kilo-i18n": "workspace:*",
|
||||||
"@kilocode/kilo-ui": "workspace:*",
|
"@kilocode/kilo-ui": "workspace:*",
|
||||||
"@kilocode/sdk": "workspace:*",
|
"@kilocode/sdk": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"eventsource": "^2.0.2",
|
"eventsource": "^2.0.2",
|
||||||
@@ -257,13 +258,14 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"ts-morph": "27.0.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.54.0",
|
"typescript-eslint": "^8.54.0",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/opencode": {
|
"packages/opencode": {
|
||||||
"name": "@kilocode/cli",
|
"name": "@kilocode/cli",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"bin": {
|
"bin": {
|
||||||
"kilo": "./bin/kilo",
|
"kilo": "./bin/kilo",
|
||||||
"kilocode": "./bin/kilocode",
|
"kilocode": "./bin/kilocode",
|
||||||
@@ -373,7 +375,7 @@
|
|||||||
},
|
},
|
||||||
"packages/plugin": {
|
"packages/plugin": {
|
||||||
"name": "@kilocode/plugin",
|
"name": "@kilocode/plugin",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kilocode/sdk": "workspace:*",
|
"@kilocode/sdk": "workspace:*",
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
@@ -387,14 +389,14 @@
|
|||||||
},
|
},
|
||||||
"packages/script": {
|
"packages/script": {
|
||||||
"name": "@opencode-ai/script",
|
"name": "@opencode-ai/script",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/bun": "catalog:",
|
"@types/bun": "catalog:",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages/sdk/js": {
|
"packages/sdk/js": {
|
||||||
"name": "@kilocode/sdk",
|
"name": "@kilocode/sdk",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@hey-api/openapi-ts": "0.90.10",
|
"@hey-api/openapi-ts": "0.90.10",
|
||||||
"@tsconfig/node22": "catalog:",
|
"@tsconfig/node22": "catalog:",
|
||||||
@@ -405,7 +407,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@kilocode/sdk": "workspace:*",
|
"@kilocode/sdk": "workspace:*",
|
||||||
"@kobalte/core": "catalog:",
|
"@kobalte/core": "catalog:",
|
||||||
@@ -447,7 +449,7 @@
|
|||||||
},
|
},
|
||||||
"packages/util": {
|
"packages/util": {
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"zod": "catalog:",
|
"zod": "catalog:",
|
||||||
},
|
},
|
||||||
@@ -1582,6 +1584,8 @@
|
|||||||
|
|
||||||
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
|
"@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/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=="],
|
"@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=="],
|
"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-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=="],
|
"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=="],
|
"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-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
"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-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=="],
|
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
|
"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=="],
|
"@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=="],
|
"@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=="],
|
"@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=="],
|
"@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/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=="],
|
"@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=="],
|
"@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/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=="],
|
"@vscode/test-cli/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
|
||||||
|
|||||||
+4
-4
@@ -1,8 +1,8 @@
|
|||||||
{
|
{
|
||||||
"nodeModules": {
|
"nodeModules": {
|
||||||
"x86_64-linux": "sha256-fj1ktaxzXPVZux/28NKWF9iMi//HBEO5rFivjpqugSI=",
|
"x86_64-linux": "sha256-V1SML9naH7HShbdovoQIUc46rH6YNgFF9MSTw95k0fU=",
|
||||||
"aarch64-linux": "sha256-c/BBYlA3DsO9nfc5pEtD4P1uQuQvs8pZrwDj2eiliIE=",
|
"aarch64-linux": "sha256-wddgxfbK7v7O/zsdbx0e/TNYAw0KzzxyQbuUdwudf/Y=",
|
||||||
"aarch64-darwin": "sha256-pWJd/x5Yx7xP+kY0yrF1py3MiJZCs2gPp6Rwe/xCSzc=",
|
"aarch64-darwin": "sha256-y7bMXWMSiHTHfKKiJ+c/2WRJmc7mCw2o601Ny8W5Zzs=",
|
||||||
"x86_64-darwin": "sha256-WgYhTWgGP/z14vA+eLnDwflWiKx6iDX9DUM1Bqw8CI0="
|
"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",
|
"@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"
|
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
|
||||||
},
|
},
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"peerDependencies": {}
|
"peerDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/app",
|
"name": "@opencode-ai/app",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"description": "",
|
"description": "",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/desktop",
|
"name": "@opencode-ai/desktop",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
id = "kilo"
|
id = "kilo"
|
||||||
name = "Kilo"
|
name = "Kilo"
|
||||||
description = "The open source coding agent."
|
description = "The open source coding agent."
|
||||||
version = "1.0.23"
|
version = "1.0.24"
|
||||||
schema_version = 1
|
schema_version = 1
|
||||||
authors = ["Anomaly"]
|
authors = ["Anomaly"]
|
||||||
repository = "https://github.com/Kilo-Org/kilo"
|
repository = "https://github.com/Kilo-Org/kilo"
|
||||||
@@ -11,26 +11,26 @@ name = "Kilo"
|
|||||||
icon = "./icons/opencode.svg"
|
icon = "./icons/opencode.svg"
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-aarch64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.darwin-x86_64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-aarch64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.linux-x86_64]
|
[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"
|
cmd = "./opencode"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|
||||||
[agent_servers.opencode.targets.windows-x86_64]
|
[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"
|
cmd = "./opencode.exe"
|
||||||
args = ["acp"]
|
args = ["acp"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kilocode/kilo-docs",
|
"name": "@kilocode/kilo-docs",
|
||||||
"version": "1.0.0",
|
"version": "1.0.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --webpack --port 3002",
|
"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:
|
Once configured, the Review Agent runs automatically on PR/MR events. For platform-specific setup, see:
|
||||||
|
|
||||||
- [GitHub Code Reviews](./github.md)
|
- [GitHub Code Reviews](./github)
|
||||||
- [GitLab Code Reviews](./gitlab.md)
|
- [GitLab Code Reviews](./gitlab)
|
||||||
|
|
||||||
## Local Code Reviews
|
## Local Code Reviews
|
||||||
|
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ For most permissions, you can use an object to apply different actions based on
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://app.kilo.ai/config.json",
|
||||||
"permission": {
|
"permission": {
|
||||||
"bash": {
|
"bash": {
|
||||||
"*": "ask",
|
"*": "ask",
|
||||||
@@ -305,7 +305,7 @@ Project-level configuration takes precedence over global settings.
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"$schema": "https://opencode.ai/config.json",
|
"$schema": "https://app.kilo.ai/config.json",
|
||||||
"model": "anthropic/claude-sonnet-4-20250514",
|
"model": "anthropic/claude-sonnet-4-20250514",
|
||||||
"provider": {
|
"provider": {
|
||||||
"anthropic": {
|
"anthropic": {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ const kilo = createOpenAI({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: kilo("anthropic/claude-sonnet-4.5"),
|
model: kilo.chat("anthropic/claude-sonnet-4.5"),
|
||||||
prompt: "Why is the sky blue?",
|
prompt: "Why is the sky blue?",
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Models are identified using the format `provider/model-name`. Pass this as the `
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: kilo("anthropic/claude-sonnet-4.5"),
|
model: kilo.chat("anthropic/claude-sonnet-4.5"),
|
||||||
prompt: "Hello!",
|
prompt: "Hello!",
|
||||||
})
|
})
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const kilo = createOpenAI({
|
|||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const result = streamText({
|
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.",
|
prompt: "Invent a new holiday and describe its traditions.",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const kilo = createOpenAI({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: kilo("anthropic/claude-sonnet-4.5"),
|
model: kilo.chat("anthropic/claude-sonnet-4.5"),
|
||||||
prompt: "Write a haiku about programming.",
|
prompt: "Write a haiku about programming.",
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ const kilo = createOpenAI({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const result = streamText({
|
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?",
|
prompt: "What is the weather in San Francisco?",
|
||||||
tools: {
|
tools: {
|
||||||
getWeather: tool({
|
getWeather: tool({
|
||||||
@@ -86,7 +86,7 @@ export async function POST(request: Request) {
|
|||||||
const { messages } = await request.json()
|
const { messages } = await request.json()
|
||||||
|
|
||||||
const result = streamText({
|
const result = streamText({
|
||||||
model: kilo("anthropic/claude-sonnet-4.5"),
|
model: kilo.chat("anthropic/claude-sonnet-4.5"),
|
||||||
messages,
|
messages,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ const kilo = createOpenAI({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const result = streamText({
|
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.",
|
prompt: "Write a short story about a robot.",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@kilocode/kilo-gateway",
|
"name": "@kilocode/kilo-gateway",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
|
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const openRouterModelSchema = z.object({
|
|||||||
architecture: openRouterArchitectureSchema.optional(),
|
architecture: openRouterArchitectureSchema.optional(),
|
||||||
top_provider: z.object({ max_completion_tokens: z.number().nullish() }).optional(),
|
top_provider: z.object({ max_completion_tokens: z.number().nullish() }).optional(),
|
||||||
supported_parameters: z.array(z.string()).optional(),
|
supported_parameters: z.array(z.string()).optional(),
|
||||||
|
preferredIndex: z.number().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
const openRouterModelsResponseSchema = z.object({
|
const openRouterModelsResponseSchema = z.object({
|
||||||
@@ -167,6 +168,10 @@ function transformToModelDevFormat(model: OpenRouterModel): any {
|
|||||||
output: mapModalities(outputModalities),
|
output: mapModalities(outputModalities),
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
...(model.preferredIndex !== undefined && {
|
||||||
|
recommended: true,
|
||||||
|
recommendedIndex: model.preferredIndex,
|
||||||
|
}),
|
||||||
options: {
|
options: {
|
||||||
...(model.description && { description: model.description }),
|
...(model.description && { description: model.description }),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@kilocode/kilo-i18n",
|
"name": "@kilocode/kilo-i18n",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "Kilo-specific i18n translations and overrides",
|
"description": "Kilo-specific i18n translations and overrides",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@kilocode/kilo-telemetry",
|
"name": "@kilocode/kilo-telemetry",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
|
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
|
||||||
|
|||||||
@@ -142,6 +142,8 @@ export namespace Telemetry {
|
|||||||
outputTokens?: number
|
outputTokens?: number
|
||||||
cacheReadTokens?: number
|
cacheReadTokens?: number
|
||||||
cacheWriteTokens?: number
|
cacheWriteTokens?: number
|
||||||
|
cost?: number
|
||||||
|
completionTime?: number
|
||||||
duration?: number
|
duration?: number
|
||||||
}) {
|
}) {
|
||||||
track(TelemetryEvent.LLM_COMPLETION, properties)
|
track(TelemetryEvent.LLM_COMPLETION, properties)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kilocode/kilo-ui",
|
"name": "@kilocode/kilo-ui",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
bin/
|
bin/
|
||||||
out/
|
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.
|
- 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.
|
- **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.
|
- **`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.
|
- **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
|
## 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
|
## Location
|
||||||
|
|
||||||
- [`webview-ui/src/components/common/ContextMenu.tsx`](../../webview-ui/src/components/common/ContextMenu.tsx:1)
|
- [`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
|
These are presentation-layer affordances; the CLI backend is not involved. kilo-ui already provides the tooltip infrastructure needed.
|
||||||
- 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.
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -72,41 +72,41 @@ The rebuild has a working foundation:
|
|||||||
|
|
||||||
## Non-Agent Feature Parity
|
## Non-Agent Feature Parity
|
||||||
|
|
||||||
| Feature | Status | Details | Backend | Priority |
|
| 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 |
|
| 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 |
|
| [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 |
|
| [Auto-Purge](non-agent-features/auto-purge.md) | ❌ Not started | No scheduled cleanup of old session/task storage. | Extension-side (storage ownership TBD) | P3 |
|
||||||
| Autocomplete / Ghost | ✅ Done | `AutocompleteInlineCompletionProvider` implements `vscode.InlineCompletionItemProvider` with FIM completions via Kilo Gateway. Includes debouncing, LRU cache, tree-sitter context, bracket matching, and stream-based completion. `AutocompleteServiceManager` registers/unregisters the provider. [#164](https://github.com/Kilo-Org/kilo/issues/164) | Extension-side (VS Code InlineCompletionProvider) | P1 |
|
| Autocomplete / Ghost | ✅ Done | `AutocompleteInlineCompletionProvider` implements `vscode.InlineCompletionItemProvider` with FIM completions via Kilo Gateway. Includes debouncing, LRU cache, tree-sitter context, bracket matching, and stream-based completion. `AutocompleteServiceManager` registers/unregisters the provider. [#164](https://github.com/Kilo-Org/kilo/issues/164) | Extension-side (VS Code InlineCompletionProvider) | P1 |
|
||||||
| [Browser Automation & URL Ingestion](non-agent-features/browser-automation-url-ingestion.md) | 🔨 Partial | Playwright MCP integration implemented: settings toggle, BrowserAutomationService, CLI MCP hub registration, BrowserTab settings UI. Missing: URL-to-markdown ingestion, screenshot viewing in chat. | CLI MCP hub (POST /mcp); extension manages lifecycle | P3 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [Contribution Tracking](non-agent-features/contribution-tracking.md) | ❌ Not started | No AI attribution tracking, line fingerprinting, or reporting. | Extension-side | P3 |
|
||||||
| [Custom Commands](non-agent-features/custom-command-system.md) | ❌ Not started | No slash commands, project-level command discovery, or YAML frontmatter support. | CLI has custom commands; extension provides UI entry points | P2 |
|
| [Custom Commands](non-agent-features/custom-command-system.md) | ❌ Not started | No slash commands, project-level command discovery, or YAML frontmatter support. | CLI has custom commands; extension provides UI entry points | P2 |
|
||||||
| [Deploy & Secure Surfaces](non-agent-features/deploy-and-secure-surfaces.md) | ❌ Not started | No deploy workflows, managed indexing UI, or security review surfaces. | Extension-side | P3 |
|
| [Deploy & Secure Surfaces](non-agent-features/deploy-and-secure-surfaces.md) | ❌ Not started | No deploy workflows, managed indexing UI, or security review surfaces. | Extension-side | P3 |
|
||||||
| [Fast Edits](non-agent-features/fast-edits.md) | ❌ Not started | No fast edit mode for quick inline code changes. | CLI fast-edit runtime; extension provides UI | P2 |
|
| [Fast Edits](non-agent-features/fast-edits.md) | ❌ Not started | No fast edit mode for quick inline code changes. | CLI fast-edit runtime; extension provides UI | P2 |
|
||||||
| [Git Commit Message Generation](non-agent-features/git-commit-message-generation.md) | ❌ Not started | No AI commit message generation or VS Code Source Control integration. [#165](https://github.com/Kilo-Org/kilo/issues/165) | Extension-side (VS Code Git API) | P2 |
|
| [Git Commit Message Generation](non-agent-features/git-commit-message-generation.md) | ❌ Not started | No AI commit message generation or VS Code Source Control integration. [#165](https://github.com/Kilo-Org/kilo/issues/165) | Extension-side (VS Code Git API) | P2 |
|
||||||
| [Integrations](non-agent-features/integrations.md) | ❌ Not started | No external system integrations (GitHub, etc.) beyond basic auth. | CLI plugin system (partial); extension for IDE hooks | P3 |
|
| [Integrations](non-agent-features/integrations.md) | ❌ Not started | No external system integrations (GitHub, etc.) beyond basic auth. | CLI plugin system (partial); extension for IDE hooks | P3 |
|
||||||
| Kilo Gateway | ✅ Done | Kilo Gateway (`KILO_GATEWAY_ID = "kilo"`) is the default provider, listed first in ModelSelector via `PROVIDER_ORDER`. Login/logout/org switching fully implemented in `KiloProvider.ts` and `ProfileView.tsx`. Default model selection in ProvidersTab. [#176](https://github.com/Kilo-Org/kilo/issues/176) | CLI handles gateway connection; extension provides config UI | P0 |
|
| Kilo Gateway | ✅ Done | Kilo Gateway (`KILO_GATEWAY_ID = "kilo"`) is the default provider, listed first in ModelSelector via `PROVIDER_ORDER`. Login/logout/org switching fully implemented in `KiloProvider.ts` and `ProfileView.tsx`. Default model selection in ProvidersTab. [#176](https://github.com/Kilo-Org/kilo/issues/176) | CLI handles gateway connection; extension provides config UI | P0 |
|
||||||
| Localization | ✅ Done | Full i18n system with 16 locales in [`i18n/`](../webview-ui/src/i18n/), three-layer dict merging, locale auto-detection, LanguageTab settings UI with locale selector. | Extension + webview; CLI locale mapping needed | P3 |
|
| 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 |
|
| [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 |
|
| [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 |
|
| [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 | ✅ 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 |
|
| [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 | ✅ 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 |
|
| [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 |
|
| [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 |
|
| [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 |
|
| [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 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 |
|
| [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 |
|
| [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 |
|
| [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 | ✅ 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 |
|
| [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 |
|
| [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",
|
"name": "kilo-code",
|
||||||
"displayName": "Kilo Code",
|
"displayName": "Kilo Code",
|
||||||
"description": "Kilo Code AI Agent & Autocomplete",
|
"description": "Kilo Code AI Agent & Autocomplete",
|
||||||
"version": "7.0.23",
|
"version": "7.0.24",
|
||||||
"publisher": "kilocode",
|
"publisher": "kilocode",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -100,32 +100,97 @@
|
|||||||
"command": "kilo-code.new.agentManager.nextTab",
|
"command": "kilo-code.new.agentManager.nextTab",
|
||||||
"title": "Agent Manager: Next Tab",
|
"title": "Agent Manager: Next Tab",
|
||||||
"category": "Kilo Code"
|
"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",
|
"id": "kilo-code.new.editorContextMenu",
|
||||||
"key": "ctrl+up",
|
"label": "Kilo Code"
|
||||||
"mac": "cmd+up",
|
|
||||||
"when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"command": "kilo-code.new.agentManager.nextSession",
|
"id": "kilo-code.new.terminalContextMenu",
|
||||||
"key": "ctrl+down",
|
"label": "Kilo Code"
|
||||||
"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'"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"menus": {
|
"menus": {
|
||||||
@@ -161,14 +226,143 @@
|
|||||||
"when": "view == kilo-code.new.sidebarView"
|
"when": "view == kilo-code.new.sidebarView"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"scm/title": [
|
||||||
|
{
|
||||||
|
"command": "kilo-code.new.generateCommitMessage",
|
||||||
|
"group": "navigation",
|
||||||
|
"when": "scmProvider == git"
|
||||||
|
}
|
||||||
|
],
|
||||||
"editor/title": [
|
"editor/title": [
|
||||||
{
|
{
|
||||||
"command": "kilo-code.new.openInTab",
|
"command": "kilo-code.new.openInTab",
|
||||||
"group": "navigation",
|
"group": "navigation",
|
||||||
"when": "true"
|
"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": {
|
"configuration": {
|
||||||
"title": "Kilo Code",
|
"title": "Kilo Code",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -331,6 +525,7 @@
|
|||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"ts-morph": "27.0.2",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
"typescript-eslint": "^8.54.0"
|
"typescript-eslint": "^8.54.0"
|
||||||
},
|
},
|
||||||
@@ -340,6 +535,7 @@
|
|||||||
"@kilocode/kilo-ui": "workspace:*",
|
"@kilocode/kilo-ui": "workspace:*",
|
||||||
"@kilocode/sdk": "workspace:*",
|
"@kilocode/sdk": "workspace:*",
|
||||||
"@opencode-ai/ui": "workspace:*",
|
"@opencode-ai/ui": "workspace:*",
|
||||||
|
"@thisbeyond/solid-dnd": "0.7.5",
|
||||||
"diff": "^7.0.0",
|
"diff": "^7.0.0",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
"eventsource": "^2.0.2",
|
"eventsource": "^2.0.2",
|
||||||
|
|||||||
@@ -378,7 +378,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
|||||||
case "requestFileSearch": {
|
case "requestFileSearch": {
|
||||||
const client = this.httpClient
|
const client = this.httpClient
|
||||||
if (client) {
|
if (client) {
|
||||||
const dir = this.getWorkspaceDirectory()
|
const dir = this.getWorkspaceDirectory(this.currentSession?.id)
|
||||||
void client
|
void client
|
||||||
.findFiles(message.query, dir)
|
.findFiles(message.query, dir)
|
||||||
.then((paths) => {
|
.then((paths) => {
|
||||||
@@ -596,6 +596,23 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
|||||||
})
|
})
|
||||||
.catch((err) => console.error("[Kilo New] KiloProvider: Failed to fetch session for tracking:", err))
|
.catch((err) => console.error("[Kilo New] KiloProvider: Failed to fetch session for tracking:", err))
|
||||||
|
|
||||||
|
// Fetch current session status so the webview has the correct busy/idle
|
||||||
|
// state after switching tabs (SSE events may have been missed).
|
||||||
|
this.httpClient
|
||||||
|
.getSessionStatuses(workspaceDir)
|
||||||
|
.then((statuses) => {
|
||||||
|
for (const [sid, info] of Object.entries(statuses)) {
|
||||||
|
if (!this.trackedSessionIds.has(sid)) continue
|
||||||
|
this.postMessage({
|
||||||
|
type: "sessionStatus",
|
||||||
|
sessionID: sid,
|
||||||
|
status: info.type,
|
||||||
|
...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", err))
|
||||||
|
|
||||||
// Convert to webview format, including cost/tokens for assistant messages
|
// Convert to webview format, including cost/tokens for assistant messages
|
||||||
const messages = messagesData.map((m) => ({
|
const messages = messagesData.map((m) => ({
|
||||||
id: m.info.id,
|
id: m.info.id,
|
||||||
@@ -1411,13 +1428,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
|
|||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
|
||||||
case "session.status":
|
case "session.status": {
|
||||||
|
const info = event.properties.status
|
||||||
this.postMessage({
|
this.postMessage({
|
||||||
type: "sessionStatus",
|
type: "sessionStatus",
|
||||||
sessionID: event.properties.sessionID,
|
sessionID: event.properties.sessionID,
|
||||||
status: event.properties.status.type,
|
status: info.type,
|
||||||
|
...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}),
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
|
}
|
||||||
|
|
||||||
case "permission.asked":
|
case "permission.asked":
|
||||||
this.postMessage({
|
this.postMessage({
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { KiloProvider } from "../KiloProvider"
|
|||||||
import { buildWebviewHtml } from "../utils"
|
import { buildWebviewHtml } from "../utils"
|
||||||
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
|
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
|
||||||
import { WorktreeStateManager } from "./WorktreeStateManager"
|
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.
|
* AgentManagerProvider opens the Agent Manager panel.
|
||||||
@@ -21,12 +25,18 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
private outputChannel: vscode.OutputChannel
|
private outputChannel: vscode.OutputChannel
|
||||||
private worktrees: WorktreeManager | undefined
|
private worktrees: WorktreeManager | undefined
|
||||||
private state: WorktreeStateManager | undefined
|
private state: WorktreeStateManager | undefined
|
||||||
|
private setupScript: SetupScriptService | undefined
|
||||||
|
private terminalManager: SessionTerminalManager
|
||||||
|
private stateReady: Promise<void> | undefined
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly extensionUri: vscode.Uri,
|
private readonly extensionUri: vscode.Uri,
|
||||||
private readonly connectionService: KiloConnectionService,
|
private readonly connectionService: KiloConnectionService,
|
||||||
) {
|
) {
|
||||||
this.outputChannel = vscode.window.createOutputChannel("Kilo Agent Manager")
|
this.outputChannel = vscode.window.createOutputChannel("Kilo Agent Manager")
|
||||||
|
this.terminalManager = new SessionTerminalManager((msg) =>
|
||||||
|
this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private log(...args: unknown[]) {
|
private log(...args: unknown[]) {
|
||||||
@@ -65,8 +75,9 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
onBeforeMessage: (msg) => this.onMessage(msg),
|
onBeforeMessage: (msg) => this.onMessage(msg),
|
||||||
})
|
})
|
||||||
|
|
||||||
void this.initializeState()
|
this.stateReady = this.initializeState()
|
||||||
void this.sendRepoInfo()
|
void this.sendRepoInfo()
|
||||||
|
this.sendKeybindings()
|
||||||
|
|
||||||
this.panel.onDidDispose(() => {
|
this.panel.onDidDispose(() => {
|
||||||
this.log("Panel disposed")
|
this.log("Panel disposed")
|
||||||
@@ -124,10 +135,36 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
return this.onAddSessionToWorktree(msg.worktreeId)
|
return this.onAddSessionToWorktree(msg.worktreeId)
|
||||||
if (type === "agentManager.closeSession" && typeof msg.sessionId === "string")
|
if (type === "agentManager.closeSession" && typeof msg.sessionId === "string")
|
||||||
return this.onCloseSession(msg.sessionId)
|
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") {
|
if (type === "agentManager.requestRepoInfo") {
|
||||||
void this.sendRepoInfo()
|
void this.sendRepoInfo()
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
if (type === "agentManager.requestState") {
|
||||||
|
void this.stateReady
|
||||||
|
?.then(() => this.pushState())
|
||||||
|
.catch((err) => {
|
||||||
|
this.log("initializeState failed, pushing partial state:", err)
|
||||||
|
this.pushState()
|
||||||
|
})
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
if (type === "agentManager.setTabOrder" && typeof msg.key === "string" && Array.isArray(msg.order)) {
|
||||||
|
this.state?.setTabOrder(msg.key as string, msg.order as string[])
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// When switching sessions, show existing terminal if one is open
|
||||||
|
if (type === "loadMessages" && typeof msg.sessionID === "string") {
|
||||||
|
this.terminalManager.showExisting(msg.sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
// After clearSession, re-register worktree sessions so SSE events keep flowing
|
// After clearSession, re-register worktree sessions so SSE events keep flowing
|
||||||
if (type === "clearSession") {
|
if (type === "clearSession") {
|
||||||
@@ -154,7 +191,11 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
const manager = this.getWorktreeManager()
|
const manager = this.getWorktreeManager()
|
||||||
const state = this.getStateManager()
|
const state = this.getStateManager()
|
||||||
if (!manager || !state) {
|
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
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,11 +205,11 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
try {
|
try {
|
||||||
result = await manager.createWorktree({ prompt: "kilo" })
|
result = await manager.createWorktree({ prompt: "kilo" })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const err = error instanceof Error ? error.message : String(error)
|
const msg = error instanceof Error ? error.message : String(error)
|
||||||
this.postToWebview({
|
this.postToWebview({
|
||||||
type: "agentManager.worktreeSetup",
|
type: "agentManager.worktreeSetup",
|
||||||
status: "error",
|
status: "error",
|
||||||
message: `Failed to create worktree: ${err}`,
|
message: msg,
|
||||||
})
|
})
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
@@ -240,6 +281,9 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
const created = await this.createWorktreeOnDisk()
|
const created = await this.createWorktreeOnDisk()
|
||||||
if (!created) return null
|
if (!created) return null
|
||||||
|
|
||||||
|
// Run setup script for new worktree (blocks until complete, shows in overlay)
|
||||||
|
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
|
||||||
|
|
||||||
const session = await this.createSessionInWorktree(created.result.path, created.result.branch)
|
const session = await this.createSessionInWorktree(created.result.path, created.result.branch)
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const state = this.getStateManager()
|
const state = this.getStateManager()
|
||||||
@@ -289,6 +333,9 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
const created = await this.createWorktreeOnDisk()
|
const created = await this.createWorktreeOnDisk()
|
||||||
if (!created) return null
|
if (!created) return null
|
||||||
|
|
||||||
|
// Run setup script for new worktree (blocks until complete, shows in overlay)
|
||||||
|
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
|
||||||
|
|
||||||
const state = this.getStateManager()!
|
const state = this.getStateManager()!
|
||||||
if (!state.getSession(sessionId)) {
|
if (!state.getSession(sessionId)) {
|
||||||
state.addSession(sessionId, created.worktree.id)
|
state.addSession(sessionId, created.worktree.id)
|
||||||
@@ -308,11 +355,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
try {
|
try {
|
||||||
client = this.connectionService.getHttpClient()
|
client = this.connectionService.getHttpClient()
|
||||||
} catch {
|
} catch {
|
||||||
this.postToWebview({
|
this.postToWebview({ type: "error", message: "Not connected to CLI backend" })
|
||||||
type: "agentManager.worktreeSetup",
|
|
||||||
status: "error",
|
|
||||||
message: "Not connected to CLI backend",
|
|
||||||
})
|
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,11 +381,9 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
this.registerWorktreeSession(session.id, worktree.path)
|
this.registerWorktreeSession(session.id, worktree.path)
|
||||||
this.pushState()
|
this.pushState()
|
||||||
this.postToWebview({
|
this.postToWebview({
|
||||||
type: "agentManager.worktreeSetup",
|
type: "agentManager.sessionAdded",
|
||||||
status: "ready",
|
|
||||||
message: "Session created",
|
|
||||||
sessionId: session.id,
|
sessionId: session.id,
|
||||||
branch: worktree.branch,
|
worktreeId,
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this.provider) {
|
if (this.provider) {
|
||||||
@@ -364,6 +405,64 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Keybindings
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
private sendKeybindings(): void {
|
||||||
|
const ext = vscode.extensions.getExtension("kilocode.kilo-code")
|
||||||
|
const keybindings: Array<{ command: string; key?: string; mac?: string }> =
|
||||||
|
ext?.packageJSON?.contributes?.keybindings ?? []
|
||||||
|
|
||||||
|
const mac = process.platform === "darwin"
|
||||||
|
const prefix = "kilo-code.new.agentManager."
|
||||||
|
const bindings: Record<string, string> = {}
|
||||||
|
for (const kb of keybindings) {
|
||||||
|
if (!kb.command.startsWith(prefix)) continue
|
||||||
|
const action = kb.command.slice(prefix.length)
|
||||||
|
const raw = mac ? (kb.mac ?? kb.key) : kb.key
|
||||||
|
if (raw) bindings[action] = formatKeybinding(raw, mac)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.postToWebview({ type: "agentManager.keybindings", bindings })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Setup script
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** Open the worktree setup script in the editor for user configuration. */
|
||||||
|
private async configureSetupScript(): Promise<void> {
|
||||||
|
const service = this.getSetupScriptService()
|
||||||
|
if (!service) return
|
||||||
|
try {
|
||||||
|
await service.openInEditor()
|
||||||
|
} catch (error) {
|
||||||
|
this.log(`Failed to open setup script: ${error}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the worktree setup script if configured. Blocks until complete. Shows progress in overlay. */
|
||||||
|
private async runSetupScriptForWorktree(worktreePath: string, branch?: string): Promise<void> {
|
||||||
|
const root = this.getWorkspaceRoot()
|
||||||
|
if (!root) return
|
||||||
|
try {
|
||||||
|
const service = this.getSetupScriptService()
|
||||||
|
if (!service || !service.hasScript()) return
|
||||||
|
this.postToWebview({
|
||||||
|
type: "agentManager.worktreeSetup",
|
||||||
|
status: "creating",
|
||||||
|
message: "Running setup script...",
|
||||||
|
branch,
|
||||||
|
})
|
||||||
|
const runner = new SetupScriptRunner(this.outputChannel, service)
|
||||||
|
await runner.runIfConfigured({ worktreePath, repoPath: root })
|
||||||
|
} catch (error) {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error)
|
||||||
|
this.outputChannel.appendLine(`[AgentManager] Setup script error: ${msg}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Repo info
|
// Repo info
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -396,6 +495,7 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
type: "agentManager.state",
|
type: "agentManager.state",
|
||||||
worktrees: state.getWorktrees(),
|
worktrees: state.getWorktrees(),
|
||||||
sessions: state.getSessions(),
|
sessions: state.getSessions(),
|
||||||
|
tabOrder: state.getTabOrder(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -431,6 +531,17 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
return this.state
|
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
|
// Helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -449,11 +560,29 @@ export class AgentManagerProvider implements vscode.Disposable {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show terminal for the currently active session (triggered by keyboard shortcut).
|
||||||
|
* Posts an action to the webview which will respond with the session ID.
|
||||||
|
*/
|
||||||
|
public showTerminalForCurrentSession(): void {
|
||||||
|
this.postToWebview({ type: "action", action: "showTerminal" })
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reveal the Agent Manager panel and focus the prompt input.
|
||||||
|
* Used for the keyboard shortcut to switch back from terminal.
|
||||||
|
*/
|
||||||
|
public focusPanel(): void {
|
||||||
|
if (!this.panel) return
|
||||||
|
this.panel.reveal(vscode.ViewColumn.One, false)
|
||||||
|
}
|
||||||
|
|
||||||
public postMessage(message: unknown): void {
|
public postMessage(message: unknown): void {
|
||||||
this.panel?.webview.postMessage(message)
|
this.panel?.webview.postMessage(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
public dispose(): void {
|
public dispose(): void {
|
||||||
|
this.terminalManager.dispose()
|
||||||
this.provider?.dispose()
|
this.provider?.dispose()
|
||||||
this.panel?.dispose()
|
this.panel?.dispose()
|
||||||
this.outputChannel.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> {
|
async createWorktree(params: { prompt?: string; existingBranch?: string }): Promise<CreateWorktreeResult> {
|
||||||
const repo = await this.git.checkIsRepo()
|
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.ensureDir()
|
||||||
await this.ensureGitExclude()
|
await this.ensureGitExclude()
|
||||||
@@ -182,6 +185,7 @@ export class WorktreeManager {
|
|||||||
const excludePath = path.join(gitDir, "info", "exclude")
|
const excludePath = path.join(gitDir, "info", "exclude")
|
||||||
await this.addExcludeEntry(excludePath, ".kilocode/worktrees/", "Kilo Code agent worktrees")
|
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/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> {
|
private async ensureWorktreeExclude(worktreePath: string): Promise<void> {
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface ManagedSession {
|
|||||||
interface StateFile {
|
interface StateFile {
|
||||||
worktrees: Record<string, Omit<Worktree, "id">>
|
worktrees: Record<string, Omit<Worktree, "id">>
|
||||||
sessions: Record<string, Omit<ManagedSession, "id">>
|
sessions: Record<string, Omit<ManagedSession, "id">>
|
||||||
|
tabOrder?: Record<string, string[]>
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATE_FILE = "agent-manager.json"
|
const STATE_FILE = "agent-manager.json"
|
||||||
@@ -44,6 +45,7 @@ export class WorktreeStateManager {
|
|||||||
private readonly file: string
|
private readonly file: string
|
||||||
private worktrees = new Map<string, Worktree>()
|
private worktrees = new Map<string, Worktree>()
|
||||||
private sessions = new Map<string, ManagedSession>()
|
private sessions = new Map<string, ManagedSession>()
|
||||||
|
private tabOrder: Record<string, string[]> = {}
|
||||||
private readonly log: (msg: string) => void
|
private readonly log: (msg: string) => void
|
||||||
private saving: Promise<void> | undefined
|
private saving: Promise<void> | undefined
|
||||||
private pendingSave = false
|
private pendingSave = false
|
||||||
@@ -125,6 +127,9 @@ export class WorktreeStateManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clean up tab order for this worktree
|
||||||
|
delete this.tabOrder[id]
|
||||||
|
|
||||||
this.log(`Removed worktree ${id}, orphaned ${orphaned.length} sessions`)
|
this.log(`Removed worktree ${id}, orphaned ${orphaned.length} sessions`)
|
||||||
void this.save()
|
void this.save()
|
||||||
return orphaned
|
return orphaned
|
||||||
@@ -149,6 +154,34 @@ export class WorktreeStateManager {
|
|||||||
|
|
||||||
removeSession(id: string): void {
|
removeSession(id: string): void {
|
||||||
this.sessions.delete(id)
|
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()
|
void this.save()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +195,7 @@ export class WorktreeStateManager {
|
|||||||
const data = JSON.parse(content) as StateFile
|
const data = JSON.parse(content) as StateFile
|
||||||
this.worktrees.clear()
|
this.worktrees.clear()
|
||||||
this.sessions.clear()
|
this.sessions.clear()
|
||||||
|
this.tabOrder = {}
|
||||||
|
|
||||||
for (const [id, wt] of Object.entries(data.worktrees ?? {})) {
|
for (const [id, wt] of Object.entries(data.worktrees ?? {})) {
|
||||||
this.worktrees.set(id, { id, ...wt })
|
this.worktrees.set(id, { id, ...wt })
|
||||||
@@ -169,6 +203,9 @@ export class WorktreeStateManager {
|
|||||||
for (const [id, s] of Object.entries(data.sessions ?? {})) {
|
for (const [id, s] of Object.entries(data.sessions ?? {})) {
|
||||||
this.sessions.set(id, { id, ...s })
|
this.sessions.set(id, { id, ...s })
|
||||||
}
|
}
|
||||||
|
if (data.tabOrder) {
|
||||||
|
this.tabOrder = data.tabOrder
|
||||||
|
}
|
||||||
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
|
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code = (error as NodeJS.ErrnoException).code
|
const code = (error as NodeJS.ErrnoException).code
|
||||||
@@ -229,6 +266,9 @@ export class WorktreeStateManager {
|
|||||||
const { id: _, ...rest } = s
|
const { id: _, ...rest } = s
|
||||||
data.sessions[id] = rest
|
data.sessions[id] = rest
|
||||||
}
|
}
|
||||||
|
if (Object.keys(this.tabOrder).length > 0) {
|
||||||
|
data.tabOrder = this.tabOrder
|
||||||
|
}
|
||||||
|
|
||||||
const dir = path.dirname(this.file)
|
const dir = path.dirname(this.file)
|
||||||
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true })
|
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 { registerAutocompleteProvider } from "./services/autocomplete"
|
||||||
import { BrowserAutomationService } from "./services/browser-automation"
|
import { BrowserAutomationService } from "./services/browser-automation"
|
||||||
import { TelemetryProxy } from "./services/telemetry"
|
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) {
|
export function activate(context: vscode.ExtensionContext) {
|
||||||
console.log("Kilo Code extension is now active")
|
console.log("Kilo Code extension is now active")
|
||||||
@@ -80,11 +82,45 @@ export function activate(context: vscode.ExtensionContext) {
|
|||||||
vscode.commands.registerCommand("kilo-code.new.agentManager.nextTab", () => {
|
vscode.commands.registerCommand("kilo-code.new.agentManager.nextTab", () => {
|
||||||
agentManagerProvider.postMessage({ type: "action", action: "tabNext" })
|
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
|
// Register autocomplete provider
|
||||||
registerAutocompleteProvider(context, connectionService)
|
registerAutocompleteProvider(context, connectionService)
|
||||||
|
|
||||||
|
// Register commit message generation
|
||||||
|
registerCommitMessageService(context, connectionService)
|
||||||
|
|
||||||
|
// Register code actions (editor context menus, terminal context menus, keyboard shortcuts)
|
||||||
|
registerCodeActions(context, provider)
|
||||||
|
registerTerminalActions(context, provider)
|
||||||
|
|
||||||
|
// Register CodeActionProvider (lightbulb quick fixes)
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.languages.registerCodeActionsProvider(
|
||||||
|
{ scheme: "file" },
|
||||||
|
new KiloCodeActionProvider(),
|
||||||
|
KiloCodeActionProvider.metadata,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
// Dispose services when extension deactivates (kills the server)
|
// Dispose services when extension deactivates (kills the server)
|
||||||
context.subscriptions.push({
|
context.subscriptions.push({
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
|
|||||||
@@ -92,12 +92,17 @@ export class BrowserAutomationService implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const status = await httpClient.addMcpServer(BrowserAutomationService.MCP_SERVER_NAME, {
|
const directory = this.getWorkspaceDirectory()
|
||||||
type: "local",
|
const status = await httpClient.addMcpServer(
|
||||||
command,
|
BrowserAutomationService.MCP_SERVER_NAME,
|
||||||
enabled: true,
|
{
|
||||||
timeout: 60000,
|
type: "local",
|
||||||
})
|
command,
|
||||||
|
enabled: true,
|
||||||
|
timeout: 60000,
|
||||||
|
},
|
||||||
|
directory,
|
||||||
|
)
|
||||||
|
|
||||||
const serverStatus = status[BrowserAutomationService.MCP_SERVER_NAME]
|
const serverStatus = status[BrowserAutomationService.MCP_SERVER_NAME]
|
||||||
if (serverStatus?.status === "connected") {
|
if (serverStatus?.status === "connected") {
|
||||||
@@ -128,7 +133,8 @@ export class BrowserAutomationService implements vscode.Disposable {
|
|||||||
const httpClient = this.getHttpClient()
|
const httpClient = this.getHttpClient()
|
||||||
if (httpClient) {
|
if (httpClient) {
|
||||||
try {
|
try {
|
||||||
await httpClient.disconnectMcpServer(BrowserAutomationService.MCP_SERVER_NAME)
|
const directory = this.getWorkspaceDirectory()
|
||||||
|
await httpClient.disconnectMcpServer(BrowserAutomationService.MCP_SERVER_NAME, directory)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[Kilo New] BrowserAutomationService: Failed to disconnect MCP server:", error)
|
console.error("[Kilo New] BrowserAutomationService: Failed to disconnect MCP server:", error)
|
||||||
}
|
}
|
||||||
@@ -147,7 +153,8 @@ export class BrowserAutomationService implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const allStatus = await httpClient.getMcpStatus()
|
const directory = this.getWorkspaceDirectory()
|
||||||
|
const allStatus = await httpClient.getMcpStatus(directory)
|
||||||
return allStatus[BrowserAutomationService.MCP_SERVER_NAME] ?? null
|
return allStatus[BrowserAutomationService.MCP_SERVER_NAME] ?? null
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
return null
|
||||||
@@ -162,6 +169,14 @@ export class BrowserAutomationService implements vscode.Disposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getWorkspaceDirectory(): string {
|
||||||
|
const folders = vscode.workspace.workspaceFolders
|
||||||
|
if (folders && folders.length > 0) {
|
||||||
|
return folders[0].uri.fsPath
|
||||||
|
}
|
||||||
|
return process.cwd()
|
||||||
|
}
|
||||||
|
|
||||||
private setState(state: BrowserAutomationState): void {
|
private setState(state: BrowserAutomationState): void {
|
||||||
if (this.state === state) {
|
if (this.state === state) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
ServerConfig,
|
ServerConfig,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
|
SessionStatusInfo,
|
||||||
MessageInfo,
|
MessageInfo,
|
||||||
MessagePart,
|
MessagePart,
|
||||||
AgentInfo,
|
AgentInfo,
|
||||||
@@ -24,8 +25,8 @@ export class HttpClient {
|
|||||||
|
|
||||||
constructor(config: ServerConfig) {
|
constructor(config: ServerConfig) {
|
||||||
this.baseUrl = config.baseUrl
|
this.baseUrl = config.baseUrl
|
||||||
// Auth header format: Basic base64("opencode:password")
|
// Auth header format: Basic base64("kilo:password")
|
||||||
// NOTE: The CLI server expects a non-empty username ("opencode"). Using an empty username
|
// NOTE: The CLI server expects a non-empty username ("kilo"). Using an empty username
|
||||||
// (":password") results in 401 for both REST and SSE endpoints.
|
// (":password") results in 401 for both REST and SSE endpoints.
|
||||||
this.authHeader = `Basic ${Buffer.from(`${this.authUsername}:${config.password}`).toString("base64")}`
|
this.authHeader = `Basic ${Buffer.from(`${this.authUsername}:${config.password}`).toString("base64")}`
|
||||||
|
|
||||||
@@ -140,6 +141,14 @@ export class HttpClient {
|
|||||||
return this.request<SessionInfo[]>("GET", "/session", undefined, { directory })
|
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.
|
* Delete a session permanently.
|
||||||
*/
|
*/
|
||||||
@@ -482,6 +491,22 @@ export class HttpClient {
|
|||||||
return this.request<string[]>("GET", `/find/file?${params.toString()}`, undefined, { directory })
|
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
|
// MCP Methods
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -489,28 +514,28 @@ export class HttpClient {
|
|||||||
/**
|
/**
|
||||||
* Get the status of all MCP servers.
|
* Get the status of all MCP servers.
|
||||||
*/
|
*/
|
||||||
async getMcpStatus(): Promise<Record<string, McpStatus>> {
|
async getMcpStatus(directory: string): Promise<Record<string, McpStatus>> {
|
||||||
return this.request<Record<string, McpStatus>>("GET", "/mcp")
|
return this.request<Record<string, McpStatus>>("GET", "/mcp", undefined, { directory })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add or update an MCP server configuration.
|
* Add or update an MCP server configuration.
|
||||||
*/
|
*/
|
||||||
async addMcpServer(name: string, config: McpConfig): Promise<Record<string, McpStatus>> {
|
async addMcpServer(name: string, config: McpConfig, directory: string): Promise<Record<string, McpStatus>> {
|
||||||
return this.request<Record<string, McpStatus>>("POST", "/mcp", { name, config })
|
return this.request<Record<string, McpStatus>>("POST", "/mcp", { name, config }, { directory })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Connect an MCP server by name.
|
* Connect an MCP server by name.
|
||||||
*/
|
*/
|
||||||
async connectMcpServer(name: string): Promise<boolean> {
|
async connectMcpServer(name: string, directory: string): Promise<boolean> {
|
||||||
return this.request<boolean>("POST", `/mcp/${encodeURIComponent(name)}/connect`)
|
return this.request<boolean>("POST", `/mcp/${encodeURIComponent(name)}/connect`, undefined, { directory })
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Disconnect an MCP server by name.
|
* Disconnect an MCP server by name.
|
||||||
*/
|
*/
|
||||||
async disconnectMcpServer(name: string): Promise<boolean> {
|
async disconnectMcpServer(name: string, directory: string): Promise<boolean> {
|
||||||
return this.request<boolean>("POST", `/mcp/${encodeURIComponent(name)}/disconnect`)
|
return this.request<boolean>("POST", `/mcp/${encodeURIComponent(name)}/disconnect`, undefined, { directory })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import * as vscode from "vscode"
|
||||||
|
|
||||||
|
export class KiloCodeActionProvider implements vscode.CodeActionProvider {
|
||||||
|
static readonly metadata: vscode.CodeActionProviderMetadata = {
|
||||||
|
providedCodeActionKinds: [vscode.CodeActionKind.QuickFix, vscode.CodeActionKind.RefactorRewrite],
|
||||||
|
}
|
||||||
|
|
||||||
|
provideCodeActions(
|
||||||
|
document: vscode.TextDocument,
|
||||||
|
range: vscode.Range | vscode.Selection,
|
||||||
|
context: vscode.CodeActionContext,
|
||||||
|
): vscode.CodeAction[] {
|
||||||
|
if (range.isEmpty) return []
|
||||||
|
|
||||||
|
const actions: vscode.CodeAction[] = []
|
||||||
|
|
||||||
|
const add = new vscode.CodeAction("Add to Kilo Code", vscode.CodeActionKind.RefactorRewrite)
|
||||||
|
add.command = { command: "kilo-code.new.addToContext", title: "Add to Kilo Code" }
|
||||||
|
actions.push(add)
|
||||||
|
|
||||||
|
const hasDiagnostics = context.diagnostics.length > 0
|
||||||
|
|
||||||
|
if (hasDiagnostics) {
|
||||||
|
const fix = new vscode.CodeAction("Fix with Kilo Code", vscode.CodeActionKind.QuickFix)
|
||||||
|
fix.command = { command: "kilo-code.new.fixCode", title: "Fix with Kilo Code" }
|
||||||
|
fix.isPreferred = true
|
||||||
|
actions.push(fix)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasDiagnostics) {
|
||||||
|
const explain = new vscode.CodeAction("Explain with Kilo Code", vscode.CodeActionKind.RefactorRewrite)
|
||||||
|
explain.command = { command: "kilo-code.new.explainCode", title: "Explain with Kilo Code" }
|
||||||
|
actions.push(explain)
|
||||||
|
|
||||||
|
const improve = new vscode.CodeAction("Improve with Kilo Code", vscode.CodeActionKind.RefactorRewrite)
|
||||||
|
improve.command = { command: "kilo-code.new.improveCode", title: "Improve with Kilo Code" }
|
||||||
|
actions.push(improve)
|
||||||
|
}
|
||||||
|
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import * as vscode from "vscode"
|
||||||
|
|
||||||
|
export interface EditorContext {
|
||||||
|
filePath: string
|
||||||
|
selectedText: string
|
||||||
|
startLine: number
|
||||||
|
endLine: number
|
||||||
|
diagnostics: vscode.Diagnostic[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEditorContext(): EditorContext | undefined {
|
||||||
|
const editor = vscode.window.activeTextEditor
|
||||||
|
if (!editor) return undefined
|
||||||
|
const selection = editor.selection
|
||||||
|
if (selection.isEmpty) return undefined
|
||||||
|
const doc = editor.document
|
||||||
|
return {
|
||||||
|
filePath: vscode.workspace.asRelativePath(doc.uri),
|
||||||
|
selectedText: doc.getText(selection),
|
||||||
|
startLine: selection.start.line + 1,
|
||||||
|
endLine: selection.end.line + 1,
|
||||||
|
diagnostics: vscode.languages.getDiagnostics(doc.uri).filter((d) => d.range.intersection(selection) !== undefined),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { registerCodeActions } from "./register-code-actions"
|
||||||
|
export { registerTerminalActions } from "./register-terminal-actions"
|
||||||
|
export { KiloCodeActionProvider } from "./code-action-provider"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import * as vscode from "vscode"
|
||||||
|
import type { KiloProvider } from "../../KiloProvider"
|
||||||
|
import { getEditorContext } from "./editor-utils"
|
||||||
|
import { createPrompt } from "./support-prompt"
|
||||||
|
|
||||||
|
export function registerCodeActions(context: vscode.ExtensionContext, provider: KiloProvider): void {
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand("kilo-code.new.explainCode", () => {
|
||||||
|
const ctx = getEditorContext()
|
||||||
|
if (!ctx) return
|
||||||
|
const prompt = createPrompt("EXPLAIN", {
|
||||||
|
filePath: ctx.filePath,
|
||||||
|
startLine: String(ctx.startLine),
|
||||||
|
endLine: String(ctx.endLine),
|
||||||
|
selectedText: ctx.selectedText,
|
||||||
|
userInput: "",
|
||||||
|
})
|
||||||
|
provider.postMessage({ type: "triggerTask", text: prompt })
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand("kilo-code.new.fixCode", () => {
|
||||||
|
const ctx = getEditorContext()
|
||||||
|
if (!ctx) return
|
||||||
|
const prompt = createPrompt("FIX", {
|
||||||
|
filePath: ctx.filePath,
|
||||||
|
startLine: String(ctx.startLine),
|
||||||
|
endLine: String(ctx.endLine),
|
||||||
|
selectedText: ctx.selectedText,
|
||||||
|
diagnostics: ctx.diagnostics,
|
||||||
|
userInput: "",
|
||||||
|
})
|
||||||
|
provider.postMessage({ type: "triggerTask", text: prompt })
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand("kilo-code.new.improveCode", () => {
|
||||||
|
const ctx = getEditorContext()
|
||||||
|
if (!ctx) return
|
||||||
|
const prompt = createPrompt("IMPROVE", {
|
||||||
|
filePath: ctx.filePath,
|
||||||
|
startLine: String(ctx.startLine),
|
||||||
|
endLine: String(ctx.endLine),
|
||||||
|
selectedText: ctx.selectedText,
|
||||||
|
userInput: "",
|
||||||
|
})
|
||||||
|
provider.postMessage({ type: "triggerTask", text: prompt })
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand("kilo-code.new.addToContext", () => {
|
||||||
|
const ctx = getEditorContext()
|
||||||
|
if (!ctx) return
|
||||||
|
const prompt = createPrompt("ADD_TO_CONTEXT", {
|
||||||
|
filePath: ctx.filePath,
|
||||||
|
startLine: String(ctx.startLine),
|
||||||
|
endLine: String(ctx.endLine),
|
||||||
|
selectedText: ctx.selectedText,
|
||||||
|
})
|
||||||
|
provider.postMessage({ type: "setChatBoxMessage", text: prompt })
|
||||||
|
provider.postMessage({ type: "action", action: "focusInput" })
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand("kilo-code.new.focusChatInput", () => {
|
||||||
|
provider.postMessage({ type: "action", action: "focusInput" })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as vscode from "vscode"
|
||||||
|
import type { KiloProvider } from "../../KiloProvider"
|
||||||
|
import { createPrompt } from "./support-prompt"
|
||||||
|
|
||||||
|
function getTerminalSelection(): string {
|
||||||
|
const terminal = vscode.window.activeTerminal
|
||||||
|
if (!terminal) return ""
|
||||||
|
// VS Code terminal API doesn't expose buffer contents directly.
|
||||||
|
// Terminal selection is available via clipboard in some cases.
|
||||||
|
// For now, this is a placeholder — full implementation requires
|
||||||
|
// VS Code shell integration API (terminal.shellIntegration).
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerTerminalActions(context: vscode.ExtensionContext, provider: KiloProvider): void {
|
||||||
|
context.subscriptions.push(
|
||||||
|
vscode.commands.registerCommand("kilo-code.new.terminalAddToContext", () => {
|
||||||
|
const content = getTerminalSelection()
|
||||||
|
if (!content) {
|
||||||
|
vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const prompt = createPrompt("TERMINAL_ADD_TO_CONTEXT", {
|
||||||
|
terminalContent: content,
|
||||||
|
userInput: "",
|
||||||
|
})
|
||||||
|
provider.postMessage({ type: "setChatBoxMessage", text: prompt })
|
||||||
|
provider.postMessage({ type: "action", action: "focusInput" })
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand("kilo-code.new.terminalFixCommand", () => {
|
||||||
|
const content = getTerminalSelection()
|
||||||
|
if (!content) {
|
||||||
|
vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const prompt = createPrompt("TERMINAL_FIX", {
|
||||||
|
terminalContent: content,
|
||||||
|
userInput: "",
|
||||||
|
})
|
||||||
|
provider.postMessage({ type: "triggerTask", text: prompt })
|
||||||
|
}),
|
||||||
|
|
||||||
|
vscode.commands.registerCommand("kilo-code.new.terminalExplainCommand", () => {
|
||||||
|
const content = getTerminalSelection()
|
||||||
|
if (!content) {
|
||||||
|
vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const prompt = createPrompt("TERMINAL_EXPLAIN", {
|
||||||
|
terminalContent: content,
|
||||||
|
userInput: "",
|
||||||
|
})
|
||||||
|
provider.postMessage({ type: "triggerTask", text: prompt })
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
type Params = Record<string, string | any[]>
|
||||||
|
|
||||||
|
function diagnosticText(diagnostics?: any[]) {
|
||||||
|
if (!diagnostics?.length) return ""
|
||||||
|
return `\nCurrent problems detected:\n${diagnostics
|
||||||
|
.map((d) => `- [${d.source || "Error"}] ${d.message}${d.code ? ` (${d.code})` : ""}`)
|
||||||
|
.join("\n")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill(template: string, params: Params): string {
|
||||||
|
return template.replace(/\${(.*?)}/g, (_, key) => {
|
||||||
|
if (key === "diagnosticText") return diagnosticText(params["diagnostics"] as any[])
|
||||||
|
if (key in params) return String(params[key] ?? "")
|
||||||
|
return ""
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromptType =
|
||||||
|
| "EXPLAIN"
|
||||||
|
| "FIX"
|
||||||
|
| "IMPROVE"
|
||||||
|
| "ADD_TO_CONTEXT"
|
||||||
|
| "TERMINAL_ADD_TO_CONTEXT"
|
||||||
|
| "TERMINAL_FIX"
|
||||||
|
| "TERMINAL_EXPLAIN"
|
||||||
|
|
||||||
|
const templates: Record<PromptType, string> = {
|
||||||
|
EXPLAIN: `Explain the following code from file path \${filePath}:\${startLine}-\${endLine}
|
||||||
|
\${userInput}
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
\${selectedText}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Please provide a clear and concise explanation of what this code does, including:
|
||||||
|
1. The purpose and functionality
|
||||||
|
2. Key components and their interactions
|
||||||
|
3. Important patterns or techniques used`,
|
||||||
|
|
||||||
|
FIX: `Fix any issues in the following code from file path \${filePath}:\${startLine}-\${endLine}
|
||||||
|
\${diagnosticText}
|
||||||
|
\${userInput}
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
\${selectedText}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Please:
|
||||||
|
1. Address all detected problems listed above (if any)
|
||||||
|
2. Identify any other potential bugs or issues
|
||||||
|
3. Provide corrected code
|
||||||
|
4. Explain what was fixed and why`,
|
||||||
|
|
||||||
|
IMPROVE: `Improve the following code from file path \${filePath}:\${startLine}-\${endLine}
|
||||||
|
\${userInput}
|
||||||
|
|
||||||
|
\`\`\`
|
||||||
|
\${selectedText}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Please suggest improvements for:
|
||||||
|
1. Code readability and maintainability
|
||||||
|
2. Performance optimization
|
||||||
|
3. Best practices and patterns
|
||||||
|
4. Error handling and edge cases
|
||||||
|
|
||||||
|
Provide the improved code along with explanations for each enhancement.`,
|
||||||
|
|
||||||
|
ADD_TO_CONTEXT: `\${filePath}:\${startLine}-\${endLine}
|
||||||
|
\`\`\`
|
||||||
|
\${selectedText}
|
||||||
|
\`\`\``,
|
||||||
|
|
||||||
|
TERMINAL_ADD_TO_CONTEXT: `\${userInput}
|
||||||
|
Terminal output:
|
||||||
|
\`\`\`
|
||||||
|
\${terminalContent}
|
||||||
|
\`\`\``,
|
||||||
|
|
||||||
|
TERMINAL_FIX: `\${userInput}
|
||||||
|
Fix this terminal command:
|
||||||
|
\`\`\`
|
||||||
|
\${terminalContent}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Please:
|
||||||
|
1. Identify any issues in the command
|
||||||
|
2. Provide the corrected command
|
||||||
|
3. Explain what was fixed and why`,
|
||||||
|
|
||||||
|
TERMINAL_EXPLAIN: `\${userInput}
|
||||||
|
Explain this terminal command:
|
||||||
|
\`\`\`
|
||||||
|
\${terminalContent}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Please provide:
|
||||||
|
1. What the command does
|
||||||
|
2. Explanation of each part/flag
|
||||||
|
3. Expected output and behavior`,
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createPrompt(type: PromptType, params: Params): string {
|
||||||
|
return fill(templates[type], params)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { PromptType }
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||||
|
|
||||||
|
// Mock vscode following the pattern from AutocompleteServiceManager.spec.ts
|
||||||
|
vi.mock("vscode", () => {
|
||||||
|
const disposable = { dispose: vi.fn() }
|
||||||
|
|
||||||
|
return {
|
||||||
|
commands: {
|
||||||
|
registerCommand: vi.fn((_command: string, _callback: (...args: any[]) => any) => disposable),
|
||||||
|
},
|
||||||
|
window: {
|
||||||
|
showErrorMessage: vi.fn(),
|
||||||
|
withProgress: vi.fn(),
|
||||||
|
},
|
||||||
|
workspace: {
|
||||||
|
workspaceFolders: [
|
||||||
|
{
|
||||||
|
uri: { fsPath: "/test/workspace" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
extensions: {
|
||||||
|
getExtension: vi.fn(),
|
||||||
|
},
|
||||||
|
ProgressLocation: {
|
||||||
|
SourceControl: 1,
|
||||||
|
},
|
||||||
|
Uri: {
|
||||||
|
parse: (s: string) => ({ fsPath: s }),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
import * as vscode from "vscode"
|
||||||
|
import { registerCommitMessageService } from "../index"
|
||||||
|
import type { KiloConnectionService } from "../../cli-backend/connection-service"
|
||||||
|
|
||||||
|
describe("commit-message service", () => {
|
||||||
|
let mockContext: vscode.ExtensionContext
|
||||||
|
let mockConnectionService: KiloConnectionService
|
||||||
|
let mockHttpClient: { generateCommitMessage: ReturnType<typeof vi.fn> }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
|
||||||
|
mockContext = {
|
||||||
|
subscriptions: [],
|
||||||
|
} as any
|
||||||
|
|
||||||
|
mockHttpClient = {
|
||||||
|
generateCommitMessage: vi.fn().mockResolvedValue("feat: add new feature"),
|
||||||
|
}
|
||||||
|
|
||||||
|
mockConnectionService = {
|
||||||
|
getHttpClient: vi.fn().mockReturnValue(mockHttpClient),
|
||||||
|
} as any
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("registerCommitMessageService", () => {
|
||||||
|
it("returns an array of disposables", () => {
|
||||||
|
const disposables = registerCommitMessageService(mockContext, mockConnectionService)
|
||||||
|
|
||||||
|
expect(Array.isArray(disposables)).toBe(true)
|
||||||
|
expect(disposables.length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("registers the kilo-code.new.generateCommitMessage command", () => {
|
||||||
|
registerCommitMessageService(mockContext, mockConnectionService)
|
||||||
|
|
||||||
|
expect(vscode.commands.registerCommand).toHaveBeenCalledWith(
|
||||||
|
"kilo-code.new.generateCommitMessage",
|
||||||
|
expect.any(Function),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("pushes the command disposable to context.subscriptions", () => {
|
||||||
|
registerCommitMessageService(mockContext, mockConnectionService)
|
||||||
|
|
||||||
|
expect(mockContext.subscriptions.length).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("command execution", () => {
|
||||||
|
let commandCallback: (...args: any[]) => Promise<void>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
registerCommitMessageService(mockContext, mockConnectionService)
|
||||||
|
|
||||||
|
// Extract the registered command callback
|
||||||
|
const registerCall = vi.mocked(vscode.commands.registerCommand).mock.calls[0]!
|
||||||
|
commandCallback = registerCall[1] as (...args: any[]) => Promise<void>
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows error when git extension is not found", async () => {
|
||||||
|
vi.mocked(vscode.extensions.getExtension).mockReturnValue(undefined)
|
||||||
|
|
||||||
|
await commandCallback()
|
||||||
|
|
||||||
|
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Git extension not found")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows error when no git repository is found", async () => {
|
||||||
|
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
|
||||||
|
isActive: true,
|
||||||
|
activate: vi.fn().mockResolvedValue(undefined),
|
||||||
|
exports: {
|
||||||
|
getAPI: () => ({ repositories: [] }),
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
await commandCallback()
|
||||||
|
|
||||||
|
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No Git repository found")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows error when backend is not connected", async () => {
|
||||||
|
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
|
||||||
|
isActive: true,
|
||||||
|
activate: vi.fn().mockResolvedValue(undefined),
|
||||||
|
exports: {
|
||||||
|
getAPI: () => ({
|
||||||
|
repositories: [{ inputBox: { value: "" }, rootUri: { fsPath: "/repo" } }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
vi.mocked(mockConnectionService.getHttpClient as any).mockImplementation(() => {
|
||||||
|
throw new Error("Not connected")
|
||||||
|
})
|
||||||
|
|
||||||
|
await commandCallback()
|
||||||
|
|
||||||
|
expect(vscode.window.showErrorMessage).toHaveBeenCalledWith(
|
||||||
|
"Kilo backend is not connected. Please wait for the connection to establish.",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("calls generateCommitMessage on the HTTP client with repository root path", async () => {
|
||||||
|
const mockInputBox = { value: "" }
|
||||||
|
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
|
||||||
|
isActive: true,
|
||||||
|
activate: vi.fn().mockResolvedValue(undefined),
|
||||||
|
exports: {
|
||||||
|
getAPI: () => ({
|
||||||
|
repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
// Make withProgress execute its callback
|
||||||
|
vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => {
|
||||||
|
await task({} as any, {} as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
await commandCallback()
|
||||||
|
|
||||||
|
expect(mockHttpClient.generateCommitMessage).toHaveBeenCalledWith("/repo", undefined, undefined)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets the generated message on the repository inputBox", async () => {
|
||||||
|
const mockInputBox = { value: "" }
|
||||||
|
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
|
||||||
|
isActive: true,
|
||||||
|
activate: vi.fn().mockResolvedValue(undefined),
|
||||||
|
exports: {
|
||||||
|
getAPI: () => ({
|
||||||
|
repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => {
|
||||||
|
await task({} as any, {} as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
await commandCallback()
|
||||||
|
|
||||||
|
expect(mockInputBox.value).toBe("feat: add new feature")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows progress in SourceControl location", async () => {
|
||||||
|
const mockInputBox = { value: "" }
|
||||||
|
vi.mocked(vscode.extensions.getExtension).mockReturnValue({
|
||||||
|
isActive: true,
|
||||||
|
activate: vi.fn().mockResolvedValue(undefined),
|
||||||
|
exports: {
|
||||||
|
getAPI: () => ({
|
||||||
|
repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
} as any)
|
||||||
|
|
||||||
|
vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => {
|
||||||
|
await task({} as any, {} as any)
|
||||||
|
})
|
||||||
|
|
||||||
|
await commandCallback()
|
||||||
|
|
||||||
|
expect(vscode.window.withProgress).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
location: vscode.ProgressLocation.SourceControl,
|
||||||
|
title: "Generating commit message...",
|
||||||
|
}),
|
||||||
|
expect.any(Function),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import * as vscode from "vscode"
|
||||||
|
import type { KiloConnectionService } from "../cli-backend/connection-service"
|
||||||
|
import type { HttpClient } from "../cli-backend/http-client"
|
||||||
|
|
||||||
|
let lastGeneratedMessage: string | undefined
|
||||||
|
let lastWorkspacePath: string | undefined
|
||||||
|
|
||||||
|
interface GitRepository {
|
||||||
|
inputBox: { value: string }
|
||||||
|
rootUri: vscode.Uri
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitAPI {
|
||||||
|
repositories: GitRepository[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GitExtensionExports {
|
||||||
|
getAPI(version: number): GitAPI
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerCommitMessageService(
|
||||||
|
context: vscode.ExtensionContext,
|
||||||
|
connectionService: KiloConnectionService,
|
||||||
|
): vscode.Disposable[] {
|
||||||
|
const command = vscode.commands.registerCommand("kilo-code.new.generateCommitMessage", async () => {
|
||||||
|
const extension = vscode.extensions.getExtension<GitExtensionExports>("vscode.git")
|
||||||
|
if (!extension) {
|
||||||
|
vscode.window.showErrorMessage("Git extension not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!extension.isActive) {
|
||||||
|
await extension.activate()
|
||||||
|
}
|
||||||
|
|
||||||
|
const git = extension.exports?.getAPI(1)
|
||||||
|
const repository = git?.repositories[0]
|
||||||
|
if (!repository) {
|
||||||
|
vscode.window.showErrorMessage("No Git repository found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let client: HttpClient | undefined
|
||||||
|
try {
|
||||||
|
client = connectionService.getHttpClient()
|
||||||
|
} catch {
|
||||||
|
vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!client) {
|
||||||
|
vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = repository.rootUri.fsPath
|
||||||
|
|
||||||
|
const previousMessage = lastWorkspacePath === path ? lastGeneratedMessage : undefined
|
||||||
|
|
||||||
|
await vscode.window
|
||||||
|
.withProgress(
|
||||||
|
{ location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." },
|
||||||
|
async () => {
|
||||||
|
const message = await client.generateCommitMessage(path, undefined, previousMessage)
|
||||||
|
repository.inputBox.value = message
|
||||||
|
lastGeneratedMessage = message
|
||||||
|
lastWorkspacePath = path
|
||||||
|
console.log("[Kilo New] Commit message generated successfully")
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(undefined, (error: unknown) => {
|
||||||
|
const msg = error instanceof Error ? error.message : String(error)
|
||||||
|
console.error("[Kilo New] Failed to generate commit message:", msg)
|
||||||
|
vscode.window.showErrorMessage(`Failed to generate commit message: ${msg}`)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
context.subscriptions.push(command)
|
||||||
|
return [command]
|
||||||
|
}
|
||||||
@@ -3,16 +3,27 @@
|
|||||||
*
|
*
|
||||||
* The agent manager runs in the same webview context as other UI.
|
* The agent manager runs in the same webview context as other UI.
|
||||||
* All its CSS classes must be prefixed with "am-" to avoid conflicts.
|
* 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 { describe, it, expect } from "bun:test"
|
||||||
import fs from "node:fs"
|
import fs from "node:fs"
|
||||||
import path from "node:path"
|
import path from "node:path"
|
||||||
|
import { Project, SyntaxKind } from "ts-morph"
|
||||||
|
|
||||||
const ROOT = path.resolve(import.meta.dir, "../..")
|
const ROOT = path.resolve(import.meta.dir, "../..")
|
||||||
const CSS_FILE = path.join(ROOT, "webview-ui/agent-manager/agent-manager.css")
|
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", () => {
|
describe("Agent Manager CSS Prefix", () => {
|
||||||
it("all class selectors should use am- prefix", () => {
|
it("all class selectors should use am- prefix", () => {
|
||||||
@@ -51,7 +62,7 @@ describe("Agent Manager CSS Prefix", () => {
|
|||||||
describe("Agent Manager CSS/TSX Consistency", () => {
|
describe("Agent Manager CSS/TSX Consistency", () => {
|
||||||
it("all classes used in TSX should be defined in CSS", () => {
|
it("all classes used in TSX should be defined in CSS", () => {
|
||||||
const css = fs.readFileSync(CSS_FILE, "utf-8")
|
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
|
// Extract am- classes defined in CSS
|
||||||
const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)]
|
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", () => {
|
it("all am- classes defined in CSS should be used in TSX", () => {
|
||||||
const css = fs.readFileSync(CSS_FILE, "utf-8")
|
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
|
// Extract am- classes defined in CSS
|
||||||
const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)]
|
const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)]
|
||||||
@@ -79,3 +90,29 @@ describe("Agent Manager CSS/TSX Consistency", () => {
|
|||||||
expect(unused, `Classes defined in CSS but not used in TSX: ${unused.join(", ")}`).toEqual([])
|
expect(unused, `Classes defined in CSS but not used in TSX: ${unused.join(", ")}`).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("Agent Manager Provider Messages", () => {
|
||||||
|
function getMethodBody(name: string): string {
|
||||||
|
const project = new Project({ compilerOptions: { allowJs: true } })
|
||||||
|
const source = project.addSourceFileAtPath(PROVIDER_FILE)
|
||||||
|
const cls = source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration)
|
||||||
|
const method = cls?.getMethod(name)
|
||||||
|
expect(method, `method ${name} not found in AgentManagerProvider`).toBeTruthy()
|
||||||
|
return method!.getText()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: onAddSessionToWorktree must NOT send agentManager.worktreeSetup
|
||||||
|
* because that triggers a full-screen overlay with a spinner. Adding a session
|
||||||
|
* to an existing worktree should use agentManager.sessionAdded instead.
|
||||||
|
*/
|
||||||
|
it("onAddSessionToWorktree should not send worktreeSetup messages", () => {
|
||||||
|
const body = getMethodBody("onAddSessionToWorktree")
|
||||||
|
expect(body).not.toContain("agentManager.worktreeSetup")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("onAddSessionToWorktree should send sessionAdded message", () => {
|
||||||
|
const body = getMethodBody("onAddSessionToWorktree")
|
||||||
|
expect(body).toContain("agentManager.sessionAdded")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { describe, it, expect } from "bun:test"
|
||||||
|
import { formatKeybinding } from "../../src/agent-manager/format-keybinding"
|
||||||
|
|
||||||
|
describe("formatKeybinding", () => {
|
||||||
|
describe("mac", () => {
|
||||||
|
it("formats cmd as ⌘", () => {
|
||||||
|
expect(formatKeybinding("cmd+w", true)).toBe("⌘W")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats cmd+shift as ⌘⇧", () => {
|
||||||
|
expect(formatKeybinding("cmd+shift+w", true)).toBe("⌘⇧W")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats ctrl as ⌃", () => {
|
||||||
|
expect(formatKeybinding("ctrl+c", true)).toBe("⌃C")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats alt as ⌥", () => {
|
||||||
|
expect(formatKeybinding("alt+f", true)).toBe("⌥F")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats arrow keys as symbols", () => {
|
||||||
|
expect(formatKeybinding("cmd+left", true)).toBe("⌘←")
|
||||||
|
expect(formatKeybinding("cmd+right", true)).toBe("⌘→")
|
||||||
|
expect(formatKeybinding("cmd+up", true)).toBe("⌘↑")
|
||||||
|
expect(formatKeybinding("cmd+down", true)).toBe("⌘↓")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats special keys", () => {
|
||||||
|
expect(formatKeybinding("cmd+backspace", true)).toBe("⌘⌫")
|
||||||
|
expect(formatKeybinding("cmd+enter", true)).toBe("⌘↵")
|
||||||
|
expect(formatKeybinding("escape", true)).toBe("Esc")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("joins without separator on mac", () => {
|
||||||
|
expect(formatKeybinding("cmd+shift+alt+t", true)).toBe("⌘⇧⌥T")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats plain key", () => {
|
||||||
|
expect(formatKeybinding("cmd+/", true)).toBe("⌘/")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("windows/linux", () => {
|
||||||
|
it("formats cmd as Ctrl", () => {
|
||||||
|
expect(formatKeybinding("cmd+w", false)).toBe("Ctrl+W")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats ctrl as Ctrl", () => {
|
||||||
|
expect(formatKeybinding("ctrl+w", false)).toBe("Ctrl+W")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats ctrl+shift", () => {
|
||||||
|
expect(formatKeybinding("ctrl+shift+w", false)).toBe("Ctrl+Shift+W")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats alt as Alt", () => {
|
||||||
|
expect(formatKeybinding("alt+f", false)).toBe("Alt+F")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("formats arrow keys as symbols", () => {
|
||||||
|
expect(formatKeybinding("ctrl+left", false)).toBe("Ctrl+←")
|
||||||
|
expect(formatKeybinding("ctrl+right", false)).toBe("Ctrl+→")
|
||||||
|
expect(formatKeybinding("ctrl+up", false)).toBe("Ctrl+↑")
|
||||||
|
expect(formatKeybinding("ctrl+down", false)).toBe("Ctrl+↓")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("joins with + separator on non-mac", () => {
|
||||||
|
expect(formatKeybinding("ctrl+shift+alt+t", false)).toBe("Ctrl+Shift+Alt+T")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from "bun:test"
|
import { describe, it, expect } from "bun:test"
|
||||||
import { resolveNavigation, validateLocalSession } from "../../webview-ui/agent-manager/navigate"
|
import { resolveNavigation, validateLocalSession, adjacentHint, LOCAL } from "../../webview-ui/agent-manager/navigate"
|
||||||
|
|
||||||
const ids = ["a", "b", "c", "d"]
|
const ids = ["a", "b", "c", "d"]
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ describe("resolveNavigation", () => {
|
|||||||
|
|
||||||
describe("from first session", () => {
|
describe("from first session", () => {
|
||||||
it("up → local", () => {
|
it("up → local", () => {
|
||||||
expect(resolveNavigation("up", "a", ids)).toEqual({ action: "local" })
|
expect(resolveNavigation("up", "a", ids)).toEqual({ action: LOCAL })
|
||||||
})
|
})
|
||||||
|
|
||||||
it("down → selects second session", () => {
|
it("down → selects second session", () => {
|
||||||
@@ -68,7 +68,7 @@ describe("resolveNavigation", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it("up from only session → local", () => {
|
it("up from only session → local", () => {
|
||||||
expect(resolveNavigation("up", "x", ["x"])).toEqual({ action: "local" })
|
expect(resolveNavigation("up", "x", ["x"])).toEqual({ action: LOCAL })
|
||||||
})
|
})
|
||||||
|
|
||||||
it("down from only session → none", () => {
|
it("down from only session → none", () => {
|
||||||
@@ -95,20 +95,20 @@ describe("resolveNavigation", () => {
|
|||||||
expect(trail).toEqual(["s1", "s2", "s3"])
|
expect(trail).toEqual(["s1", "s2", "s3"])
|
||||||
|
|
||||||
// Navigate back up through all sessions to local
|
// Navigate back up through all sessions to local
|
||||||
const upTrail: (string | "local")[] = []
|
const upTrail: (string | typeof LOCAL)[] = []
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 0; i < 4; i++) {
|
||||||
const result = resolveNavigation("up", current, sessions)
|
const result = resolveNavigation("up", current, sessions)
|
||||||
if (result.action === "select") {
|
if (result.action === "select") {
|
||||||
current = result.id
|
current = result.id
|
||||||
upTrail.push(current)
|
upTrail.push(current)
|
||||||
} else if (result.action === "local") {
|
} else if (result.action === LOCAL) {
|
||||||
current = undefined
|
current = undefined
|
||||||
upTrail.push("local")
|
upTrail.push(LOCAL)
|
||||||
} else {
|
} else {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
expect(upTrail).toEqual(["s2", "s1", "local"])
|
expect(upTrail).toEqual(["s2", "s1", LOCAL])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -134,3 +134,49 @@ describe("validateLocalSession", () => {
|
|||||||
expect(validateLocalSession(undefined, [])).toBeUndefined()
|
expect(validateLocalSession(undefined, [])).toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("adjacentHint", () => {
|
||||||
|
const flat = [LOCAL, "wt1", "wt2", "wt3", "s1"]
|
||||||
|
|
||||||
|
it("returns prev hint when item is directly above active", () => {
|
||||||
|
expect(adjacentHint("wt1", "wt2", flat, "⌘↑", "⌘↓")).toBe("⌘↑")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns next hint when item is directly below active", () => {
|
||||||
|
expect(adjacentHint("wt3", "wt2", flat, "⌘↑", "⌘↓")).toBe("⌘↓")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty string for the active item itself", () => {
|
||||||
|
expect(adjacentHint("wt2", "wt2", flat, "⌘↑", "⌘↓")).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty string for non-adjacent items", () => {
|
||||||
|
expect(adjacentHint("wt1", "wt3", flat, "⌘↑", "⌘↓")).toBe("")
|
||||||
|
expect(adjacentHint("s1", "wt1", flat, "⌘↑", "⌘↓")).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty string when active is undefined", () => {
|
||||||
|
expect(adjacentHint("wt1", undefined, flat, "⌘↑", "⌘↓")).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty string when active is not in list", () => {
|
||||||
|
expect(adjacentHint("wt1", "unknown", flat, "⌘↑", "⌘↓")).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty string when item is not in list", () => {
|
||||||
|
expect(adjacentHint("unknown", "wt2", flat, "⌘↑", "⌘↓")).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("works at boundaries — first item with LOCAL active", () => {
|
||||||
|
expect(adjacentHint("wt1", LOCAL, flat, "⌘↑", "⌘↓")).toBe("⌘↓")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("works at boundaries — LOCAL with first item active", () => {
|
||||||
|
expect(adjacentHint(LOCAL, "wt1", flat, "⌘↑", "⌘↓")).toBe("⌘↑")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("works with single-item list", () => {
|
||||||
|
expect(adjacentHint("a", "b", ["a", "b"], "prev", "next")).toBe("prev")
|
||||||
|
expect(adjacentHint("b", "a", ["a", "b"], "prev", "next")).toBe("next")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, it, expect } from "bun:test"
|
||||||
|
import { nextSelectionAfterDelete, LOCAL } from "../../webview-ui/agent-manager/navigate"
|
||||||
|
|
||||||
|
describe("nextSelectionAfterDelete", () => {
|
||||||
|
it("selects the worktree below when deleting from the middle", () => {
|
||||||
|
expect(nextSelectionAfterDelete("b", ["a", "b", "c"])).toBe("c")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("selects the worktree above when deleting the last item", () => {
|
||||||
|
expect(nextSelectionAfterDelete("c", ["a", "b", "c"])).toBe("b")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("selects the worktree below when deleting the first item", () => {
|
||||||
|
expect(nextSelectionAfterDelete("a", ["a", "b", "c"])).toBe("b")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to LOCAL when deleting the only worktree", () => {
|
||||||
|
expect(nextSelectionAfterDelete("a", ["a"])).toBe(LOCAL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to LOCAL when ID is not found", () => {
|
||||||
|
expect(nextSelectionAfterDelete("x", ["a", "b"])).toBe(LOCAL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to LOCAL when list is empty", () => {
|
||||||
|
expect(nextSelectionAfterDelete("a", [])).toBe(LOCAL)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles two-item list deleting first", () => {
|
||||||
|
expect(nextSelectionAfterDelete("a", ["a", "b"])).toBe("b")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles two-item list deleting second", () => {
|
||||||
|
expect(nextSelectionAfterDelete("b", ["a", "b"])).toBe("a")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { describe, expect, it } from "bun:test"
|
||||||
|
import { removeSessionPermissions, upsertPermission } from "../../webview-ui/src/context/permission-queue"
|
||||||
|
import type { PermissionRequest } from "../../webview-ui/src/types/messages"
|
||||||
|
|
||||||
|
const permission = (input: Partial<PermissionRequest> = {}): PermissionRequest => ({
|
||||||
|
id: input.id ?? "perm-1",
|
||||||
|
sessionID: input.sessionID ?? "session-1",
|
||||||
|
toolName: input.toolName ?? "read",
|
||||||
|
patterns: input.patterns ?? ["/tmp/*"],
|
||||||
|
args: input.args ?? {},
|
||||||
|
message: input.message,
|
||||||
|
tool: input.tool,
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("permission queue", () => {
|
||||||
|
it("appends a new permission id", () => {
|
||||||
|
const result = upsertPermission([], permission({ id: "perm-1" }))
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe("perm-1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("updates an existing permission id instead of duplicating", () => {
|
||||||
|
const existing = permission({ id: "perm-1", toolName: "read", patterns: ["a"] })
|
||||||
|
const incoming = permission({ id: "perm-1", toolName: "write", patterns: ["b"] })
|
||||||
|
|
||||||
|
const result = upsertPermission([existing], incoming)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0]).toEqual(incoming)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("keeps other permission entries when updating one id", () => {
|
||||||
|
const first = permission({ id: "perm-1", sessionID: "session-1" })
|
||||||
|
const second = permission({ id: "perm-2", sessionID: "session-2" })
|
||||||
|
const incoming = permission({ id: "perm-1", toolName: "edit" })
|
||||||
|
|
||||||
|
const result = upsertPermission([first, second], incoming)
|
||||||
|
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result.find((item) => item.id === "perm-1")).toEqual(incoming)
|
||||||
|
expect(result.find((item) => item.id === "perm-2")).toEqual(second)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes only permissions from the deleted session", () => {
|
||||||
|
const first = permission({ id: "perm-1", sessionID: "session-1" })
|
||||||
|
const second = permission({ id: "perm-2", sessionID: "session-2" })
|
||||||
|
const third = permission({ id: "perm-3", sessionID: "session-1" })
|
||||||
|
|
||||||
|
const result = removeSessionPermissions([first, second, third], "session-1")
|
||||||
|
|
||||||
|
expect(result).toEqual([second])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
import { describe, it, expect } from "bun:test"
|
||||||
|
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "../../webview-ui/agent-manager/tab-order"
|
||||||
|
|
||||||
|
describe("reorderTabs", () => {
|
||||||
|
const tabs = ["a", "b", "c", "d"]
|
||||||
|
|
||||||
|
it("moves an item forward", () => {
|
||||||
|
expect(reorderTabs(tabs, "a", "c")).toEqual(["b", "c", "a", "d"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("moves an item backward", () => {
|
||||||
|
expect(reorderTabs(tabs, "c", "a")).toEqual(["c", "a", "b", "d"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("swaps adjacent items forward", () => {
|
||||||
|
expect(reorderTabs(tabs, "a", "b")).toEqual(["b", "a", "c", "d"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("swaps adjacent items backward", () => {
|
||||||
|
expect(reorderTabs(tabs, "b", "a")).toEqual(["b", "a", "c", "d"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("moves first to last", () => {
|
||||||
|
expect(reorderTabs(tabs, "a", "d")).toEqual(["b", "c", "d", "a"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("moves last to first", () => {
|
||||||
|
expect(reorderTabs(tabs, "d", "a")).toEqual(["d", "a", "b", "c"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined when from equals to", () => {
|
||||||
|
expect(reorderTabs(tabs, "a", "a")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined when from is not found", () => {
|
||||||
|
expect(reorderTabs(tabs, "x", "a")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined when to is not found", () => {
|
||||||
|
expect(reorderTabs(tabs, "a", "x")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns undefined when both are missing", () => {
|
||||||
|
expect(reorderTabs(tabs, "x", "y")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles a two-item list", () => {
|
||||||
|
expect(reorderTabs(["a", "b"], "a", "b")).toEqual(["b", "a"])
|
||||||
|
expect(reorderTabs(["a", "b"], "b", "a")).toEqual(["b", "a"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles a single-item list (from === to)", () => {
|
||||||
|
expect(reorderTabs(["a"], "a", "a")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles empty list", () => {
|
||||||
|
expect(reorderTabs([], "a", "b")).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not mutate the original array", () => {
|
||||||
|
const original = ["a", "b", "c"]
|
||||||
|
reorderTabs(original, "a", "c")
|
||||||
|
expect(original).toEqual(["a", "b", "c"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves unrelated items", () => {
|
||||||
|
const result = reorderTabs(["a", "b", "c", "d", "e"], "b", "d")!
|
||||||
|
expect(result).toEqual(["a", "c", "d", "b", "e"])
|
||||||
|
expect(result.sort()).toEqual(["a", "b", "c", "d", "e"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("round-trip: moving forward then back restores original order", () => {
|
||||||
|
const moved = reorderTabs(tabs, "a", "c")!
|
||||||
|
const restored = reorderTabs(moved, "a", "b")!
|
||||||
|
expect(restored).toEqual(["a", "b", "c", "d"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("applyTabOrder", () => {
|
||||||
|
const items = [
|
||||||
|
{ id: "a", name: "Alice" },
|
||||||
|
{ id: "b", name: "Bob" },
|
||||||
|
{ id: "c", name: "Carol" },
|
||||||
|
]
|
||||||
|
|
||||||
|
it("reorders items according to custom order", () => {
|
||||||
|
const result = applyTabOrder(items, ["c", "a", "b"])
|
||||||
|
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("appends items not in the order", () => {
|
||||||
|
const result = applyTabOrder(items, ["b"])
|
||||||
|
expect(result.map((i) => i.id)).toEqual(["b", "a", "c"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("skips order IDs that are not in items", () => {
|
||||||
|
const result = applyTabOrder(items, ["x", "c", "y", "a"])
|
||||||
|
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns original array when order is undefined", () => {
|
||||||
|
const result = applyTabOrder(items, undefined)
|
||||||
|
expect(result).toBe(items)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns original array when order is empty", () => {
|
||||||
|
const result = applyTabOrder(items, [])
|
||||||
|
expect(result).toBe(items)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles empty items", () => {
|
||||||
|
expect(applyTabOrder([], ["a", "b"])).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves item properties", () => {
|
||||||
|
const result = applyTabOrder(items, ["b", "a", "c"])
|
||||||
|
expect(result[0]).toEqual({ id: "b", name: "Bob" })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("firstOrderedTitle", () => {
|
||||||
|
const items = [{ id: "a", title: "Alpha" }, { id: "b", title: "Beta" }, { id: "c", title: "" }, { id: "d" }]
|
||||||
|
|
||||||
|
it("returns first titled item from custom order", () => {
|
||||||
|
expect(firstOrderedTitle(items, ["b", "a"], "fallback")).toBe("Beta")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("skips items without titles in order", () => {
|
||||||
|
expect(firstOrderedTitle(items, ["d", "c", "b"], "fallback")).toBe("Beta")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to first titled item when order has no matches", () => {
|
||||||
|
expect(firstOrderedTitle(items, ["x", "y"], "fallback")).toBe("Alpha")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to first titled item when order is undefined", () => {
|
||||||
|
expect(firstOrderedTitle(items, undefined, "fallback")).toBe("Alpha")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns fallback when no items have titles", () => {
|
||||||
|
expect(firstOrderedTitle([{ id: "a" }, { id: "b", title: "" }], ["a", "b"], "fallback")).toBe("fallback")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns fallback for empty items", () => {
|
||||||
|
expect(firstOrderedTitle([], ["a"], "fallback")).toBe("fallback")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Helper: simulate reconciliation the same way handleDragOver does
|
||||||
|
function reconcile(current: string[], stored: string[]): string[] {
|
||||||
|
return applyTabOrder(
|
||||||
|
current.map((id) => ({ id })),
|
||||||
|
stored,
|
||||||
|
).map((item) => item.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("applyTabOrder as reconciliation (string IDs)", () => {
|
||||||
|
it("returns stored order unchanged when it matches current IDs", () => {
|
||||||
|
expect(reconcile(["a", "b", "c"], ["a", "b", "c"])).toEqual(["a", "b", "c"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("appends new IDs not in stored order", () => {
|
||||||
|
expect(reconcile(["a", "b", "c"], ["a", "b"])).toEqual(["a", "b", "c"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes stale IDs no longer in current", () => {
|
||||||
|
expect(reconcile(["a", "c"], ["a", "b", "c"])).toEqual(["a", "c"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves custom ordering while adding new tabs", () => {
|
||||||
|
expect(reconcile(["a", "b", "c"], ["b", "a"])).toEqual(["b", "a", "c"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns current IDs when stored order is undefined", () => {
|
||||||
|
expect(applyTabOrder([{ id: "a" }, { id: "b" }], undefined).map((i) => i.id)).toEqual(["a", "b"])
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("regression: reorder a newly added tab immediately", () => {
|
||||||
|
it("new tab should be reorderable after reconcile via applyTabOrder", () => {
|
||||||
|
// Stored order from a previous drag: [s2, s1]
|
||||||
|
// A third session s3 was just added to the worktree
|
||||||
|
const stored = ["s2", "s1"]
|
||||||
|
const current = ["s2", "s1", "s3"]
|
||||||
|
|
||||||
|
const reconciled = reconcile(current, stored)
|
||||||
|
expect(reconciled).toEqual(["s2", "s1", "s3"])
|
||||||
|
|
||||||
|
// Now the user drags s3 to position of s2 — this must succeed
|
||||||
|
const reordered = reorderTabs(reconciled, "s3", "s2")
|
||||||
|
expect(reordered).toEqual(["s3", "s2", "s1"])
|
||||||
|
expect(reordered).not.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("without reconcile, reorderTabs fails on the new tab", () => {
|
||||||
|
const stored = ["s2", "s1"]
|
||||||
|
const reordered = reorderTabs(stored, "s3", "s2")
|
||||||
|
expect(reordered).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -174,6 +174,81 @@ describe("WorktreeStateManager", () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe("tab order", () => {
|
||||||
|
it("sets and gets tab order for a key", () => {
|
||||||
|
manager.setTabOrder("wt-1", ["s1", "s2", "s3"])
|
||||||
|
expect(manager.getTabOrder()["wt-1"]).toEqual(["s1", "s2", "s3"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("overwrites existing tab order", () => {
|
||||||
|
manager.setTabOrder("wt-1", ["s1", "s2"])
|
||||||
|
manager.setTabOrder("wt-1", ["s2", "s1"])
|
||||||
|
expect(manager.getTabOrder()["wt-1"]).toEqual(["s2", "s1"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes tab order for a key", () => {
|
||||||
|
manager.setTabOrder("wt-1", ["s1"])
|
||||||
|
manager.removeTabOrder("wt-1")
|
||||||
|
expect(manager.getTabOrder()["wt-1"]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removeTabOrder is a no-op for missing key", () => {
|
||||||
|
manager.removeTabOrder("nonexistent")
|
||||||
|
expect(Object.keys(manager.getTabOrder())).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("cleans up tab order when worktree is removed", () => {
|
||||||
|
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
|
||||||
|
manager.addSession("s1", wt.id)
|
||||||
|
manager.setTabOrder(wt.id, ["s1"])
|
||||||
|
|
||||||
|
manager.removeWorktree(wt.id)
|
||||||
|
expect(manager.getTabOrder()[wt.id]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes session from tab order arrays when session is removed", () => {
|
||||||
|
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
|
||||||
|
manager.addSession("s1", wt.id)
|
||||||
|
manager.addSession("s2", wt.id)
|
||||||
|
manager.setTabOrder(wt.id, ["s1", "s2"])
|
||||||
|
|
||||||
|
manager.removeSession("s1")
|
||||||
|
expect(manager.getTabOrder()[wt.id]).toEqual(["s2"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("removes tab order entry when last session in order is removed", () => {
|
||||||
|
manager.addSession("s1", null)
|
||||||
|
manager.setTabOrder("local", ["s1"])
|
||||||
|
|
||||||
|
manager.removeSession("s1")
|
||||||
|
expect(manager.getTabOrder()["local"]).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("persists and loads tab order", async () => {
|
||||||
|
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
|
||||||
|
manager.setTabOrder(wt.id, ["s2", "s1"])
|
||||||
|
manager.setTabOrder("local", ["s3", "s4"])
|
||||||
|
await manager.flush()
|
||||||
|
await manager.save()
|
||||||
|
|
||||||
|
const loaded = new WorktreeStateManager(root, () => {})
|
||||||
|
await loaded.load()
|
||||||
|
|
||||||
|
expect(loaded.getTabOrder()[wt.id]).toEqual(["s2", "s1"])
|
||||||
|
expect(loaded.getTabOrder()["local"]).toEqual(["s3", "s4"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not persist empty tab order", async () => {
|
||||||
|
manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
|
||||||
|
await manager.flush()
|
||||||
|
await manager.save()
|
||||||
|
|
||||||
|
const content = fs.readFileSync(path.join(root, ".kilocode", "agent-manager.json"), "utf-8")
|
||||||
|
const data = JSON.parse(content)
|
||||||
|
expect(data.tabOrder).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe("validate", () => {
|
describe("validate", () => {
|
||||||
it("removes worktrees whose directories do not exist", async () => {
|
it("removes worktrees whose directories do not exist", async () => {
|
||||||
const existing = path.join(root, "wt-exists")
|
const existing = path.join(root, "wt-exists")
|
||||||
|
|||||||
@@ -1,28 +1,45 @@
|
|||||||
// Agent Manager root component
|
// Agent Manager root component
|
||||||
|
|
||||||
import { Component, For, Show, createSignal, createMemo, createEffect, onMount, onCleanup } from "solid-js"
|
import {
|
||||||
|
Component,
|
||||||
|
For,
|
||||||
|
Show,
|
||||||
|
createSignal,
|
||||||
|
createMemo,
|
||||||
|
createEffect,
|
||||||
|
onMount,
|
||||||
|
onCleanup,
|
||||||
|
type Accessor,
|
||||||
|
} from "solid-js"
|
||||||
import type {
|
import type {
|
||||||
ExtensionMessage,
|
ExtensionMessage,
|
||||||
AgentManagerRepoInfoMessage,
|
AgentManagerRepoInfoMessage,
|
||||||
AgentManagerWorktreeSetupMessage,
|
AgentManagerWorktreeSetupMessage,
|
||||||
AgentManagerStateMessage,
|
AgentManagerStateMessage,
|
||||||
|
AgentManagerKeybindingsMessage,
|
||||||
WorktreeState,
|
WorktreeState,
|
||||||
ManagedSessionState,
|
ManagedSessionState,
|
||||||
SessionInfo,
|
SessionInfo,
|
||||||
} from "../src/types/messages"
|
} from "../src/types/messages"
|
||||||
|
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||||
|
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||||
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
|
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
|
||||||
import { DialogProvider } from "@kilocode/kilo-ui/context/dialog"
|
import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog"
|
||||||
|
import { Dialog } from "@kilocode/kilo-ui/dialog"
|
||||||
import { MarkedProvider } from "@kilocode/kilo-ui/context/marked"
|
import { MarkedProvider } from "@kilocode/kilo-ui/context/marked"
|
||||||
import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code"
|
import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code"
|
||||||
import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff"
|
import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff"
|
||||||
import { Code } from "@kilocode/kilo-ui/code"
|
import { Code } from "@kilocode/kilo-ui/code"
|
||||||
import { Diff } from "@kilocode/kilo-ui/diff"
|
import { Diff } from "@kilocode/kilo-ui/diff"
|
||||||
import { Toast } from "@kilocode/kilo-ui/toast"
|
import { Toast } from "@kilocode/kilo-ui/toast"
|
||||||
|
import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle"
|
||||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||||
import { Button } from "@kilocode/kilo-ui/button"
|
import { Button } from "@kilocode/kilo-ui/button"
|
||||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||||
|
import { HoverCard } from "@kilocode/kilo-ui/hover-card"
|
||||||
|
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
|
||||||
import { VSCodeProvider, useVSCode } from "../src/context/vscode"
|
import { VSCodeProvider, useVSCode } from "../src/context/vscode"
|
||||||
import { ServerProvider } from "../src/context/server"
|
import { ServerProvider } from "../src/context/server"
|
||||||
import { ProviderProvider } from "../src/context/provider"
|
import { ProviderProvider } from "../src/context/provider"
|
||||||
@@ -32,7 +49,9 @@ import { WorktreeModeProvider } from "../src/context/worktree-mode"
|
|||||||
import { ChatView } from "../src/components/chat"
|
import { ChatView } from "../src/components/chat"
|
||||||
import { LanguageBridge, DataBridge } from "../src/App"
|
import { LanguageBridge, DataBridge } from "../src/App"
|
||||||
import { formatRelativeDate } from "../src/utils/date"
|
import { formatRelativeDate } from "../src/utils/date"
|
||||||
import { validateLocalSession } from "./navigate"
|
import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, LOCAL } from "./navigate"
|
||||||
|
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
|
||||||
|
import { ConstrainDragYAxis, SortableTab } from "./sortable-tab"
|
||||||
import "./agent-manager.css"
|
import "./agent-manager.css"
|
||||||
|
|
||||||
interface SetupState {
|
interface SetupState {
|
||||||
@@ -42,30 +61,129 @@ interface SetupState {
|
|||||||
error?: boolean
|
error?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sidebar selection: "local" for workspace, worktree ID for a worktree, or null for an unassigned session. */
|
/** Sidebar selection: LOCAL for workspace, worktree ID for a worktree, or null for an unassigned session. */
|
||||||
type SidebarSelection = "local" | string | null
|
type SidebarSelection = typeof LOCAL | string | null
|
||||||
|
|
||||||
|
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
|
||||||
|
|
||||||
|
// Fallback keybindings before extension sends resolved ones
|
||||||
|
const defaultBindings: Record<string, string> = {
|
||||||
|
previousSession: isMac ? "⌘↑" : "Ctrl+↑",
|
||||||
|
nextSession: isMac ? "⌘↓" : "Ctrl+↓",
|
||||||
|
previousTab: isMac ? "⌘←" : "Ctrl+←",
|
||||||
|
nextTab: isMac ? "⌘→" : "Ctrl+→",
|
||||||
|
showTerminal: isMac ? "⌘/" : "Ctrl+/",
|
||||||
|
newTab: isMac ? "⌘T" : "Ctrl+T",
|
||||||
|
closeTab: isMac ? "⌘W" : "Ctrl+W",
|
||||||
|
newWorktree: isMac ? "⌘N" : "Ctrl+N",
|
||||||
|
closeWorktree: isMac ? "⌘⇧W" : "Ctrl+Shift+W",
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Manages horizontal scroll for the tab list: hides the scrollbar, converts
|
||||||
|
* vertical wheel events to horizontal scroll, tracks overflow to show/hide
|
||||||
|
* fade indicators, and auto-scrolls the active tab into view. */
|
||||||
|
function useTabScroll(activeTabs: Accessor<SessionInfo[]>, activeId: Accessor<string | undefined>) {
|
||||||
|
const [ref, setRef] = createSignal<HTMLDivElement | undefined>()
|
||||||
|
const [showLeft, setShowLeft] = createSignal(false)
|
||||||
|
const [showRight, setShowRight] = createSignal(false)
|
||||||
|
|
||||||
|
const update = () => {
|
||||||
|
const el = ref()
|
||||||
|
if (!el) return
|
||||||
|
setShowLeft(el.scrollLeft > 2)
|
||||||
|
setShowRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wheel → horizontal scroll conversion
|
||||||
|
const onWheel = (e: WheelEvent) => {
|
||||||
|
const el = ref()
|
||||||
|
if (!el) return
|
||||||
|
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
|
||||||
|
e.preventDefault()
|
||||||
|
el.scrollLeft += e.deltaY > 0 ? 60 : -60
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recalculate on scroll, resize, or tab changes
|
||||||
|
createEffect(() => {
|
||||||
|
const el = ref()
|
||||||
|
if (!el) return
|
||||||
|
el.addEventListener("scroll", update, { passive: true })
|
||||||
|
el.addEventListener("wheel", onWheel, { passive: false })
|
||||||
|
const ro = new ResizeObserver(update)
|
||||||
|
ro.observe(el)
|
||||||
|
const mo = new MutationObserver(update)
|
||||||
|
mo.observe(el, { childList: true, subtree: true })
|
||||||
|
onCleanup(() => {
|
||||||
|
el.removeEventListener("scroll", update)
|
||||||
|
el.removeEventListener("wheel", onWheel)
|
||||||
|
ro.disconnect()
|
||||||
|
mo.disconnect()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-scroll active tab into view
|
||||||
|
createEffect(() => {
|
||||||
|
const id = activeId()
|
||||||
|
const el = ref()
|
||||||
|
// depend on tabs length to trigger on tab add/remove
|
||||||
|
activeTabs()
|
||||||
|
if (!id || !el) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const tab = el.querySelector(`[data-tab-id="${id}"]`) as HTMLElement | null
|
||||||
|
if (!tab) return
|
||||||
|
const left = tab.offsetLeft
|
||||||
|
const right = left + tab.offsetWidth
|
||||||
|
if (left < el.scrollLeft) {
|
||||||
|
el.scrollTo({ left: left - 8, behavior: "smooth" })
|
||||||
|
} else if (right > el.scrollLeft + el.clientWidth) {
|
||||||
|
el.scrollTo({ left: right - el.clientWidth + 8, behavior: "smooth" })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return { setRef, showLeft, showRight }
|
||||||
|
}
|
||||||
|
|
||||||
const AgentManagerContent: Component = () => {
|
const AgentManagerContent: Component = () => {
|
||||||
const session = useSession()
|
const session = useSession()
|
||||||
const vscode = useVSCode()
|
const vscode = useVSCode()
|
||||||
|
const dialog = useDialog()
|
||||||
|
|
||||||
|
const [kb, setKb] = createSignal<Record<string, string>>(defaultBindings)
|
||||||
|
|
||||||
const [setup, setSetup] = createSignal<SetupState>({ active: false, message: "" })
|
const [setup, setSetup] = createSignal<SetupState>({ active: false, message: "" })
|
||||||
const [worktrees, setWorktrees] = createSignal<WorktreeState[]>([])
|
const [worktrees, setWorktrees] = createSignal<WorktreeState[]>([])
|
||||||
const [managedSessions, setManagedSessions] = createSignal<ManagedSessionState[]>([])
|
const [managedSessions, setManagedSessions] = createSignal<ManagedSessionState[]>([])
|
||||||
const [selection, setSelection] = createSignal<SidebarSelection>("local")
|
const [selection, setSelection] = createSignal<SidebarSelection>(LOCAL)
|
||||||
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
|
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
|
||||||
|
const [deletingWorktrees, setDeletingWorktrees] = createSignal<Set<string>>(new Set())
|
||||||
|
const [worktreesLoaded, setWorktreesLoaded] = createSignal(false)
|
||||||
|
const [sessionsLoaded, setSessionsLoaded] = createSignal(false)
|
||||||
|
|
||||||
|
const DEFAULT_SIDEBAR_WIDTH = 260
|
||||||
|
const MIN_SIDEBAR_WIDTH = 200
|
||||||
|
const MAX_SIDEBAR_WIDTH_RATIO = 0.4
|
||||||
|
|
||||||
// Recover persisted local session IDs from webview state
|
// Recover persisted local session IDs from webview state
|
||||||
const persisted = vscode.getState<{ localSessionIDs?: string[] }>()
|
const persisted = vscode.getState<{ localSessionIDs?: string[]; sidebarWidth?: number }>()
|
||||||
const [localSessionIDs, setLocalSessionIDs] = createSignal<string[]>(persisted?.localSessionIDs ?? [])
|
const [localSessionIDs, setLocalSessionIDs] = createSignal<string[]>(persisted?.localSessionIDs ?? [])
|
||||||
|
const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH)
|
||||||
|
|
||||||
// Pending local tab counter for generating unique IDs
|
// Pending local tab counter for generating unique IDs
|
||||||
let pendingCounter = 0
|
let pendingCounter = 0
|
||||||
const PENDING_PREFIX = "pending:"
|
const PENDING_PREFIX = "pending:"
|
||||||
const [activePendingId, setActivePendingId] = createSignal<string | undefined>()
|
const [activePendingId, setActivePendingId] = createSignal<string | undefined>()
|
||||||
|
|
||||||
|
// Per-context tab memory: maps sidebar selection key -> last active session/pending ID
|
||||||
|
const [tabMemory, setTabMemory] = createSignal<Record<string, string>>({})
|
||||||
|
|
||||||
const isPending = (id: string) => id.startsWith(PENDING_PREFIX)
|
const isPending = (id: string) => id.startsWith(PENDING_PREFIX)
|
||||||
|
|
||||||
|
// Drag-and-drop state for tab reordering
|
||||||
|
const [draggingTab, setDraggingTab] = createSignal<string | undefined>()
|
||||||
|
// Tab ordering: context key → ordered session ID array (recovered from extension state)
|
||||||
|
const [worktreeTabOrder, setWorktreeTabOrder] = createSignal<Record<string, string[]>>({})
|
||||||
|
|
||||||
const addPendingTab = () => {
|
const addPendingTab = () => {
|
||||||
const id = `${PENDING_PREFIX}${++pendingCounter}`
|
const id = `${PENDING_PREFIX}${++pendingCounter}`
|
||||||
setLocalSessionIDs((prev) => [...prev, id])
|
setLocalSessionIDs((prev) => [...prev, id])
|
||||||
@@ -74,11 +192,25 @@ const AgentManagerContent: Component = () => {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist local session IDs to webview state for recovery (exclude pending tabs)
|
// Persist local session IDs and sidebar width to webview state for recovery (exclude pending tabs)
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
vscode.setState({ localSessionIDs: localSessionIDs().filter((id) => !isPending(id)) })
|
vscode.setState({
|
||||||
|
localSessionIDs: localSessionIDs().filter((id) => !isPending(id)),
|
||||||
|
sidebarWidth: sidebarWidth(),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Save the currently active tab for the current sidebar context before switching away
|
||||||
|
const saveTabMemory = () => {
|
||||||
|
const sel = selection()
|
||||||
|
if (sel === null) return
|
||||||
|
const key = sel === LOCAL ? LOCAL : sel
|
||||||
|
const active = session.currentSessionID() ?? activePendingId()
|
||||||
|
if (active) {
|
||||||
|
setTabMemory((prev) => (prev[key] === active ? prev : { ...prev, [key]: active }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Invalidate local session IDs if they no longer exist (preserve pending tabs)
|
// Invalidate local session IDs if they no longer exist (preserve pending tabs)
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const all = session.sessions()
|
const all = session.sessions()
|
||||||
@@ -126,22 +258,23 @@ const AgentManagerContent: Component = () => {
|
|||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
// Sessions for the currently selected worktree (tab bar), sorted by creation date
|
// Sessions for the currently selected worktree (tab bar), respecting custom order if set
|
||||||
const activeWorktreeSessions = createMemo((): SessionInfo[] => {
|
const activeWorktreeSessions = createMemo((): SessionInfo[] => {
|
||||||
const sel = selection()
|
const sel = selection()
|
||||||
if (!sel || sel === "local") return []
|
if (!sel || sel === LOCAL) return []
|
||||||
const managed = managedSessions().filter((ms) => ms.worktreeId === sel)
|
const managed = managedSessions().filter((ms) => ms.worktreeId === sel)
|
||||||
const ids = new Set(managed.map((ms) => ms.id))
|
const ids = new Set(managed.map((ms) => ms.id))
|
||||||
return session
|
const sessions = session
|
||||||
.sessions()
|
.sessions()
|
||||||
.filter((s) => ids.has(s.id))
|
.filter((s) => ids.has(s.id))
|
||||||
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
|
||||||
|
return applyTabOrder(sessions, worktreeTabOrder()[sel])
|
||||||
})
|
})
|
||||||
|
|
||||||
// Active tab sessions: local sessions when on "local", worktree sessions otherwise
|
// Active tab sessions: local sessions when on "local", worktree sessions otherwise
|
||||||
const activeTabs = createMemo((): SessionInfo[] => {
|
const activeTabs = createMemo((): SessionInfo[] => {
|
||||||
const sel = selection()
|
const sel = selection()
|
||||||
if (sel === "local") return localSessions()
|
if (sel === LOCAL) return localSessions()
|
||||||
if (sel) return activeWorktreeSessions()
|
if (sel) return activeWorktreeSessions()
|
||||||
return []
|
return []
|
||||||
})
|
})
|
||||||
@@ -149,7 +282,7 @@ const AgentManagerContent: Component = () => {
|
|||||||
// Whether the selected context has zero sessions
|
// Whether the selected context has zero sessions
|
||||||
const contextEmpty = createMemo(() => {
|
const contextEmpty = createMemo(() => {
|
||||||
const sel = selection()
|
const sel = selection()
|
||||||
if (sel === "local") return localSessionIDs().length === 0
|
if (sel === LOCAL) return localSessionIDs().length === 0
|
||||||
if (sel) return activeWorktreeSessions().length === 0
|
if (sel) return activeWorktreeSessions().length === 0
|
||||||
return false
|
return false
|
||||||
})
|
})
|
||||||
@@ -157,12 +290,16 @@ const AgentManagerContent: Component = () => {
|
|||||||
// Read-only mode: viewing an unassigned session (not in a worktree or local)
|
// Read-only mode: viewing an unassigned session (not in a worktree or local)
|
||||||
const readOnly = createMemo(() => selection() === null && !!session.currentSessionID())
|
const readOnly = createMemo(() => selection() === null && !!session.currentSessionID())
|
||||||
|
|
||||||
// Display name for worktree
|
// Tab scroll: hidden scrollbar with fade overflow indicators
|
||||||
|
const visibleTabId = createMemo(() => session.currentSessionID() ?? activePendingId())
|
||||||
|
const tabScroll = useTabScroll(activeTabs, visibleTabId)
|
||||||
|
|
||||||
|
// Display name for worktree — uses first tab in custom order when available
|
||||||
const worktreeLabel = (wt: WorktreeState): string => {
|
const worktreeLabel = (wt: WorktreeState): string => {
|
||||||
const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id)
|
const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id)
|
||||||
const ids = new Set(managed.map((ms) => ms.id))
|
const ids = new Set(managed.map((ms) => ms.id))
|
||||||
const first = session.sessions().find((s) => ids.has(s.id))
|
const sessions = session.sessions().filter((s) => ids.has(s.id))
|
||||||
return first?.title || wt.branch
|
return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollIntoView = (el: HTMLElement) => {
|
const scrollIntoView = (el: HTMLElement) => {
|
||||||
@@ -171,8 +308,8 @@ const AgentManagerContent: Component = () => {
|
|||||||
|
|
||||||
// Navigate sidebar items with arrow keys
|
// Navigate sidebar items with arrow keys
|
||||||
const navigate = (direction: "up" | "down") => {
|
const navigate = (direction: "up" | "down") => {
|
||||||
const flat: { type: "local" | "wt" | "session"; id: string }[] = [
|
const flat: { type: typeof LOCAL | "wt" | "session"; id: string }[] = [
|
||||||
{ type: "local", id: "local" },
|
{ type: LOCAL, id: LOCAL },
|
||||||
...worktrees().map((wt) => ({ type: "wt" as const, id: wt.id })),
|
...worktrees().map((wt) => ({ type: "wt" as const, id: wt.id })),
|
||||||
...unassignedSessions().map((s) => ({ type: "session" as const, id: s.id })),
|
...unassignedSessions().map((s) => ({ type: "session" as const, id: s.id })),
|
||||||
]
|
]
|
||||||
@@ -184,11 +321,12 @@ const AgentManagerContent: Component = () => {
|
|||||||
if (next < 0 || next >= flat.length) return
|
if (next < 0 || next >= flat.length) return
|
||||||
|
|
||||||
const item = flat[next]!
|
const item = flat[next]!
|
||||||
if (item.type === "local") {
|
if (item.type === LOCAL) {
|
||||||
selectLocal()
|
selectLocal()
|
||||||
} else if (item.type === "wt") {
|
} else if (item.type === "wt") {
|
||||||
selectWorktree(item.id)
|
selectWorktree(item.id)
|
||||||
} else {
|
} else {
|
||||||
|
saveTabMemory()
|
||||||
setSelection(null)
|
setSelection(null)
|
||||||
session.selectSession(item.id)
|
session.selectSession(item.id)
|
||||||
}
|
}
|
||||||
@@ -217,15 +355,18 @@ const AgentManagerContent: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectLocal = () => {
|
const selectLocal = () => {
|
||||||
setSelection("local")
|
saveTabMemory()
|
||||||
|
setSelection(LOCAL)
|
||||||
vscode.postMessage({ type: "agentManager.requestRepoInfo" })
|
vscode.postMessage({ type: "agentManager.requestRepoInfo" })
|
||||||
const locals = localSessions()
|
const locals = localSessions()
|
||||||
const first = locals[0]
|
const remembered = tabMemory()[LOCAL]
|
||||||
if (first && !isPending(first.id)) {
|
const target = remembered ? locals.find((s) => s.id === remembered) : undefined
|
||||||
|
const fallback = target ?? locals[0]
|
||||||
|
if (fallback && !isPending(fallback.id)) {
|
||||||
setActivePendingId(undefined)
|
setActivePendingId(undefined)
|
||||||
session.selectSession(first.id)
|
session.selectSession(fallback.id)
|
||||||
} else if (first && isPending(first.id)) {
|
} else if (fallback && isPending(fallback.id)) {
|
||||||
setActivePendingId(first.id)
|
setActivePendingId(fallback.id)
|
||||||
session.clearCurrentSession()
|
session.clearCurrentSession()
|
||||||
} else {
|
} else {
|
||||||
setActivePendingId(undefined)
|
setActivePendingId(undefined)
|
||||||
@@ -234,12 +375,16 @@ const AgentManagerContent: Component = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const selectWorktree = (worktreeId: string) => {
|
const selectWorktree = (worktreeId: string) => {
|
||||||
|
saveTabMemory()
|
||||||
setSelection(worktreeId)
|
setSelection(worktreeId)
|
||||||
const managed = managedSessions().filter((ms) => ms.worktreeId === worktreeId)
|
const managed = managedSessions().filter((ms) => ms.worktreeId === worktreeId)
|
||||||
const ids = new Set(managed.map((ms) => ms.id))
|
const ids = new Set(managed.map((ms) => ms.id))
|
||||||
const first = session.sessions().find((s) => ids.has(s.id))
|
const sessions = session.sessions().filter((s) => ids.has(s.id))
|
||||||
if (first) {
|
const remembered = tabMemory()[worktreeId]
|
||||||
session.selectSession(first.id)
|
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
|
||||||
|
const fallback = target ?? sessions[0]
|
||||||
|
if (fallback) {
|
||||||
|
session.selectSession(fallback.id)
|
||||||
} else {
|
} else {
|
||||||
session.setCurrentSessionID(undefined)
|
session.setCurrentSessionID(undefined)
|
||||||
}
|
}
|
||||||
@@ -253,17 +398,32 @@ const AgentManagerContent: Component = () => {
|
|||||||
else if (msg.action === "sessionNext") navigate("down")
|
else if (msg.action === "sessionNext") navigate("down")
|
||||||
else if (msg.action === "tabPrevious") navigateTab("left")
|
else if (msg.action === "tabPrevious") navigateTab("left")
|
||||||
else if (msg.action === "tabNext") navigateTab("right")
|
else if (msg.action === "tabNext") navigateTab("right")
|
||||||
|
else if (msg.action === "showTerminal") {
|
||||||
|
const id = session.currentSessionID()
|
||||||
|
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
|
||||||
|
} else if (msg.action === "newTab") handleNewTabForCurrentSelection()
|
||||||
|
else if (msg.action === "closeTab") closeActiveTab()
|
||||||
|
else if (msg.action === "newWorktree") handleNewWorktreeOrPromote()
|
||||||
|
else if (msg.action === "closeWorktree") closeSelectedWorktree()
|
||||||
}
|
}
|
||||||
window.addEventListener("message", handler)
|
window.addEventListener("message", handler)
|
||||||
|
|
||||||
// Prevent Cmd+Up/Down/Left/Right from triggering native scroll
|
// Prevent Cmd+Arrow/T/W/N from triggering native browser actions
|
||||||
const preventScroll = (e: KeyboardEvent) => {
|
const preventDefaults = (e: KeyboardEvent) => {
|
||||||
if (!(e.metaKey || e.ctrlKey)) return
|
if (!(e.metaKey || e.ctrlKey)) return
|
||||||
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
}
|
||||||
|
// Prevent browser defaults for our shortcuts (new tab, close tab, new window)
|
||||||
|
if (["t", "w", "n"].includes(e.key.toLowerCase()) && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
// Prevent defaults for shift variants (close worktree)
|
||||||
|
if (e.key.toLowerCase() === "w" && e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
window.addEventListener("keydown", preventScroll)
|
window.addEventListener("keydown", preventDefaults)
|
||||||
|
|
||||||
// When the panel regains focus (e.g. returning from terminal), focus the prompt
|
// When the panel regains focus (e.g. returning from terminal), focus the prompt
|
||||||
const onWindowFocus = () => window.dispatchEvent(new Event("focusPrompt"))
|
const onWindowFocus = () => window.dispatchEvent(new Event("focusPrompt"))
|
||||||
@@ -272,7 +432,7 @@ const AgentManagerContent: Component = () => {
|
|||||||
// When a session is created while on local, replace the current pending tab with the real session.
|
// When a session is created while on local, replace the current pending tab with the real session.
|
||||||
// Guard against duplicate sessionCreated events (HTTP response + SSE can both fire).
|
// Guard against duplicate sessionCreated events (HTTP response + SSE can both fire).
|
||||||
const unsubCreate = vscode.onMessage((msg) => {
|
const unsubCreate = vscode.onMessage((msg) => {
|
||||||
if (msg.type === "sessionCreated" && selection() === "local") {
|
if (msg.type === "sessionCreated" && selection() === LOCAL) {
|
||||||
const created = msg as { type: string; session: { id: string } }
|
const created = msg as { type: string; session: { id: string } }
|
||||||
if (localSessionIDs().includes(created.session.id)) return
|
if (localSessionIDs().includes(created.session.id)) return
|
||||||
const pending = activePendingId()
|
const pending = activePendingId()
|
||||||
@@ -285,6 +445,11 @@ const AgentManagerContent: Component = () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Mark sessions loaded as soon as the session context receives data (even if empty)
|
||||||
|
const unsubSessions = vscode.onMessage((msg) => {
|
||||||
|
if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true)
|
||||||
|
})
|
||||||
|
|
||||||
const unsub = vscode.onMessage((msg) => {
|
const unsub = vscode.onMessage((msg) => {
|
||||||
if (msg.type === "agentManager.repoInfo") {
|
if (msg.type === "agentManager.repoInfo") {
|
||||||
const info = msg as AgentManagerRepoInfoMessage
|
const info = msg as AgentManagerRepoInfoMessage
|
||||||
@@ -299,29 +464,60 @@ const AgentManagerContent: Component = () => {
|
|||||||
globalThis.setTimeout(() => setSetup({ active: false, message: "" }), error ? 3000 : 500)
|
globalThis.setTimeout(() => setSetup({ active: false, message: "" }), error ? 3000 : 500)
|
||||||
if (!error && ev.sessionId) {
|
if (!error && ev.sessionId) {
|
||||||
session.selectSession(ev.sessionId)
|
session.selectSession(ev.sessionId)
|
||||||
|
// Auto-switch sidebar to the worktree containing this session
|
||||||
|
const ms = managedSessions().find((s) => s.id === ev.sessionId)
|
||||||
|
if (ms?.worktreeId) setSelection(ms.worktreeId)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setSetup({ active: true, message: ev.message, branch: ev.branch })
|
setSetup({ active: true, message: ev.message, branch: ev.branch })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (msg.type === "agentManager.sessionAdded") {
|
||||||
|
const ev = msg as { type: string; sessionId: string; worktreeId: string }
|
||||||
|
session.selectSession(ev.sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.type === "agentManager.keybindings") {
|
||||||
|
const ev = msg as AgentManagerKeybindingsMessage
|
||||||
|
setKb(ev.bindings)
|
||||||
|
}
|
||||||
|
|
||||||
if (msg.type === "agentManager.state") {
|
if (msg.type === "agentManager.state") {
|
||||||
const state = msg as AgentManagerStateMessage
|
const state = msg as AgentManagerStateMessage
|
||||||
setWorktrees(state.worktrees)
|
setWorktrees(state.worktrees)
|
||||||
setManagedSessions(state.sessions)
|
setManagedSessions(state.sessions)
|
||||||
|
if (!worktreesLoaded()) setWorktreesLoaded(true)
|
||||||
|
if (state.tabOrder) setWorktreeTabOrder(state.tabOrder)
|
||||||
const current = session.currentSessionID()
|
const current = session.currentSessionID()
|
||||||
if (current) {
|
if (current) {
|
||||||
const ms = state.sessions.find((s) => s.id === current)
|
const ms = state.sessions.find((s) => s.id === current)
|
||||||
if (ms?.worktreeId) setSelection(ms.worktreeId)
|
if (ms?.worktreeId) setSelection(ms.worktreeId)
|
||||||
}
|
}
|
||||||
|
// Recover local tab order from persisted state
|
||||||
|
const localOrder = state.tabOrder?.[LOCAL]
|
||||||
|
if (localOrder && localSessionIDs().length > 0) {
|
||||||
|
const reordered = applyTabOrder(
|
||||||
|
localSessionIDs().map((id) => ({ id })),
|
||||||
|
localOrder,
|
||||||
|
).map((item) => item.id)
|
||||||
|
setLocalSessionIDs(reordered)
|
||||||
|
}
|
||||||
|
// Clear deleting state for worktrees that have been removed
|
||||||
|
const ids = new Set(state.worktrees.map((wt) => wt.id))
|
||||||
|
setDeletingWorktrees((prev) => {
|
||||||
|
const next = new Set([...prev].filter((id) => ids.has(id)))
|
||||||
|
return next.size === prev.size ? prev : next
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
onCleanup(() => {
|
onCleanup(() => {
|
||||||
window.removeEventListener("message", handler)
|
window.removeEventListener("message", handler)
|
||||||
window.removeEventListener("keydown", preventScroll)
|
window.removeEventListener("keydown", preventDefaults)
|
||||||
window.removeEventListener("focus", onWindowFocus)
|
window.removeEventListener("focus", onWindowFocus)
|
||||||
unsubCreate()
|
unsubCreate()
|
||||||
|
unsubSessions()
|
||||||
unsub()
|
unsub()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -329,20 +525,71 @@ const AgentManagerContent: Component = () => {
|
|||||||
// Always select local on mount to initialize branch info and session state
|
// Always select local on mount to initialize branch info and session state
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
selectLocal()
|
selectLocal()
|
||||||
|
// Request worktree/session state from extension — handles race where
|
||||||
|
// initializeState() pushState fires before the webview is mounted
|
||||||
|
vscode.postMessage({ type: "agentManager.requestState" })
|
||||||
// Open a pending "New Session" tab if there are no persisted local sessions
|
// Open a pending "New Session" tab if there are no persisted local sessions
|
||||||
if (localSessionIDs().length === 0) {
|
if (localSessionIDs().length === 0) {
|
||||||
addPendingTab()
|
addPendingTab()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const handleConfigureSetupScript = () => {
|
||||||
|
vscode.postMessage({ type: "agentManager.configureSetupScript" })
|
||||||
|
}
|
||||||
|
|
||||||
const handleCreateWorktree = () => {
|
const handleCreateWorktree = () => {
|
||||||
vscode.postMessage({ type: "agentManager.createWorktree" })
|
vscode.postMessage({ type: "agentManager.createWorktree" })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const confirmDeleteWorktree = (worktreeId: string) => {
|
||||||
|
const wt = worktrees().find((w) => w.id === worktreeId)
|
||||||
|
if (!wt) return
|
||||||
|
const doDelete = () => {
|
||||||
|
setDeletingWorktrees((prev) => new Set([...prev, wt.id]))
|
||||||
|
vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId: wt.id })
|
||||||
|
if (selection() === wt.id) {
|
||||||
|
const next = nextSelectionAfterDelete(
|
||||||
|
wt.id,
|
||||||
|
worktrees().map((w) => w.id),
|
||||||
|
)
|
||||||
|
if (next === LOCAL) selectLocal()
|
||||||
|
else selectWorktree(next)
|
||||||
|
}
|
||||||
|
dialog.close()
|
||||||
|
}
|
||||||
|
const onKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||||
|
e.preventDefault()
|
||||||
|
doDelete()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dialog.show(() => (
|
||||||
|
<Dialog title="Delete Worktree" fit>
|
||||||
|
<div class="am-confirm" onKeyDown={onKeyDown}>
|
||||||
|
<div class="am-confirm-message">
|
||||||
|
<Icon name="trash" size="small" />
|
||||||
|
<span>
|
||||||
|
Delete worktree <code class="am-confirm-branch">{wt.branch}</code>? This removes the worktree from disk
|
||||||
|
and dissociates all sessions.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="am-confirm-actions">
|
||||||
|
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" size="large" class="am-confirm-delete" onClick={doDelete} autofocus>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
const handleDeleteWorktree = (worktreeId: string, e: MouseEvent) => {
|
const handleDeleteWorktree = (worktreeId: string, e: MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId })
|
confirmDeleteWorktree(worktreeId)
|
||||||
if (selection() === worktreeId) selectLocal()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePromote = (sessionId: string, e: MouseEvent) => {
|
const handlePromote = (sessionId: string, e: MouseEvent) => {
|
||||||
@@ -352,7 +599,7 @@ const AgentManagerContent: Component = () => {
|
|||||||
|
|
||||||
const handleAddSession = () => {
|
const handleAddSession = () => {
|
||||||
const sel = selection()
|
const sel = selection()
|
||||||
if (sel === "local") {
|
if (sel === LOCAL) {
|
||||||
addPendingTab()
|
addPendingTab()
|
||||||
} else if (sel) {
|
} else if (sel) {
|
||||||
vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel })
|
vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel })
|
||||||
@@ -394,12 +641,115 @@ const AgentManagerContent: Component = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Drag-and-drop handlers for tab reordering
|
||||||
|
const tabIds = createMemo(() => activeTabs().map((s) => s.id))
|
||||||
|
|
||||||
|
const handleDragStart = (event: DragEvent) => {
|
||||||
|
const id = event.draggable?.id
|
||||||
|
if (typeof id === "string") setDraggingTab(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragOver = (event: DragEvent) => {
|
||||||
|
const from = event.draggable?.id
|
||||||
|
const to = event.droppable?.id
|
||||||
|
if (typeof from !== "string" || typeof to !== "string") return
|
||||||
|
const sel = selection()
|
||||||
|
if (sel === LOCAL) {
|
||||||
|
setLocalSessionIDs((prev) => reorderTabs(prev, from, to) ?? prev)
|
||||||
|
} else if (sel) {
|
||||||
|
setWorktreeTabOrder((prev) => {
|
||||||
|
const ids = applyTabOrder(
|
||||||
|
tabIds().map((id) => ({ id })),
|
||||||
|
prev[sel],
|
||||||
|
).map((item) => item.id)
|
||||||
|
const reordered = reorderTabs(ids, from, to)
|
||||||
|
if (!reordered) return prev
|
||||||
|
return { ...prev, [sel]: reordered }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDragEnd = () => {
|
||||||
|
setDraggingTab(undefined)
|
||||||
|
// Persist the new tab order to the extension
|
||||||
|
const sel = selection()
|
||||||
|
if (sel === LOCAL) {
|
||||||
|
const order = localSessionIDs().filter((id) => !isPending(id))
|
||||||
|
if (order.length > 0) vscode.postMessage({ type: "agentManager.setTabOrder", key: LOCAL, order })
|
||||||
|
} else if (sel) {
|
||||||
|
const order = worktreeTabOrder()[sel]
|
||||||
|
if (order) vscode.postMessage({ type: "agentManager.setTabOrder", key: sel, order })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const draggedTab = createMemo(() => {
|
||||||
|
const id = draggingTab()
|
||||||
|
if (!id) return undefined
|
||||||
|
return activeTabs().find((s) => s.id === id)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Close the currently active tab via keyboard shortcut.
|
||||||
|
// If no tabs remain, fall through to close the selected worktree.
|
||||||
|
const closeActiveTab = () => {
|
||||||
|
const tabs = activeTabs()
|
||||||
|
if (tabs.length === 0) {
|
||||||
|
closeSelectedWorktree()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const current = session.currentSessionID()
|
||||||
|
const pending = activePendingId()
|
||||||
|
const target = current
|
||||||
|
? tabs.find((s) => s.id === current)
|
||||||
|
: pending
|
||||||
|
? tabs.find((s) => s.id === pending)
|
||||||
|
: undefined
|
||||||
|
if (!target) return
|
||||||
|
const synthetic = new MouseEvent("click")
|
||||||
|
handleCloseTab(target.id, synthetic)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd+T: add a new tab strictly to the current selection (no side effects)
|
||||||
|
const handleNewTabForCurrentSelection = () => {
|
||||||
|
const sel = selection()
|
||||||
|
if (sel === LOCAL) {
|
||||||
|
addPendingTab()
|
||||||
|
} else if (sel) {
|
||||||
|
// Pass the captured worktree ID directly to avoid race conditions
|
||||||
|
vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd+N: if an unassigned session is selected, promote it; otherwise create a new worktree
|
||||||
|
const handleNewWorktreeOrPromote = () => {
|
||||||
|
const sel = selection()
|
||||||
|
const sid = session.currentSessionID()
|
||||||
|
if (sel === null && sid && !worktreeSessionIds().has(sid)) {
|
||||||
|
vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleCreateWorktree()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close the currently selected worktree with a confirmation dialog
|
||||||
|
const closeSelectedWorktree = () => {
|
||||||
|
const sel = selection()
|
||||||
|
if (!sel || sel === LOCAL) return
|
||||||
|
confirmDeleteWorktree(sel)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div class="am-layout">
|
<div class="am-layout">
|
||||||
<div class="am-sidebar">
|
<div class="am-sidebar" style={{ width: `${sidebarWidth()}px` }}>
|
||||||
|
<ResizeHandle
|
||||||
|
direction="horizontal"
|
||||||
|
size={sidebarWidth()}
|
||||||
|
min={MIN_SIDEBAR_WIDTH}
|
||||||
|
max={9999}
|
||||||
|
onResize={(width) => setSidebarWidth(Math.min(width, window.innerWidth * MAX_SIDEBAR_WIDTH_RATIO))}
|
||||||
|
/>
|
||||||
{/* Local workspace item */}
|
{/* Local workspace item */}
|
||||||
<button
|
<button
|
||||||
class={`am-local-item ${selection() === "local" ? "am-local-item-active" : ""}`}
|
class={`am-local-item ${selection() === LOCAL ? "am-local-item-active" : ""}`}
|
||||||
data-sidebar-id="local"
|
data-sidebar-id="local"
|
||||||
onClick={() => selectLocal()}
|
onClick={() => selectLocal()}
|
||||||
>
|
>
|
||||||
@@ -420,36 +770,141 @@ const AgentManagerContent: Component = () => {
|
|||||||
<div class="am-section">
|
<div class="am-section">
|
||||||
<div class="am-section-header">
|
<div class="am-section-header">
|
||||||
<span class="am-section-label">WORKTREES</span>
|
<span class="am-section-label">WORKTREES</span>
|
||||||
<IconButton icon="plus" size="small" variant="ghost" label="New Worktree" onClick={handleCreateWorktree} />
|
<div class="am-section-actions">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenu.Trigger
|
||||||
|
as={IconButton}
|
||||||
|
icon="settings-gear"
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
label="Worktree settings"
|
||||||
|
/>
|
||||||
|
<DropdownMenu.Portal>
|
||||||
|
<DropdownMenu.Content>
|
||||||
|
<DropdownMenu.Item onSelect={handleConfigureSetupScript}>
|
||||||
|
<DropdownMenu.ItemLabel>Worktree Setup Script</DropdownMenu.ItemLabel>
|
||||||
|
</DropdownMenu.Item>
|
||||||
|
</DropdownMenu.Content>
|
||||||
|
</DropdownMenu.Portal>
|
||||||
|
</DropdownMenu>
|
||||||
|
<TooltipKeybind title="New worktree" keybind={kb().newWorktree ?? ""} placement="bottom">
|
||||||
|
<IconButton
|
||||||
|
icon="plus"
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
label="New worktree"
|
||||||
|
onClick={handleCreateWorktree}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="am-worktree-list">
|
<div class="am-worktree-list">
|
||||||
<For each={worktrees()}>
|
<Show
|
||||||
{(wt) => (
|
when={worktreesLoaded() && sessionsLoaded()}
|
||||||
<div
|
fallback={
|
||||||
class={`am-worktree-item ${selection() === wt.id ? "am-worktree-item-active" : ""}`}
|
<div class="am-skeleton-list">
|
||||||
data-sidebar-id={wt.id}
|
<div class="am-skeleton-wt">
|
||||||
onClick={() => selectWorktree(wt.id)}
|
<div class="am-skeleton-wt-icon" />
|
||||||
>
|
<div class="am-skeleton-wt-text" style={{ width: "60%" }} />
|
||||||
<Icon name="branch" size="small" />
|
</div>
|
||||||
<span class="am-worktree-branch" title={wt.branch}>
|
|
||||||
{worktreeLabel(wt)}
|
|
||||||
</span>
|
|
||||||
<IconButton
|
|
||||||
icon="close-small"
|
|
||||||
size="small"
|
|
||||||
variant="ghost"
|
|
||||||
label="Close worktree"
|
|
||||||
class="am-worktree-close"
|
|
||||||
onClick={(e: MouseEvent) => handleDeleteWorktree(wt.id, e)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
}
|
||||||
</For>
|
>
|
||||||
<Show when={worktrees().length === 0}>
|
{(() => {
|
||||||
<button class="am-worktree-create" onClick={handleCreateWorktree}>
|
const [hoveredWt, setHoveredWt] = createSignal<string | null>(null)
|
||||||
<Icon name="plus" size="small" />
|
const [overClose, setOverClose] = createSignal(false)
|
||||||
<span>New Worktree</span>
|
return (
|
||||||
</button>
|
<For each={worktrees()}>
|
||||||
|
{(wt, wtIdx) => {
|
||||||
|
const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id))
|
||||||
|
const navHint = () => {
|
||||||
|
const flat = [
|
||||||
|
LOCAL as string,
|
||||||
|
...worktrees().map((w) => w.id),
|
||||||
|
...unassignedSessions().map((s) => s.id),
|
||||||
|
]
|
||||||
|
const active = selection() ?? session.currentSessionID() ?? ""
|
||||||
|
return adjacentHint(wt.id, active, flat, kb().previousSession ?? "", kb().nextSession ?? "")
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<HoverCard
|
||||||
|
openDelay={100}
|
||||||
|
closeDelay={100}
|
||||||
|
placement="right-start"
|
||||||
|
gutter={8}
|
||||||
|
open={hoveredWt() === wt.id && !overClose()}
|
||||||
|
onOpenChange={(open) => setHoveredWt(open ? wt.id : null)}
|
||||||
|
trigger={
|
||||||
|
<div
|
||||||
|
class={`am-worktree-item ${selection() === wt.id ? "am-worktree-item-active" : ""}`}
|
||||||
|
data-sidebar-id={wt.id}
|
||||||
|
onClick={() => selectWorktree(wt.id)}
|
||||||
|
>
|
||||||
|
<Icon name="branch" size="small" />
|
||||||
|
<span class="am-worktree-branch">{worktreeLabel(wt)}</span>
|
||||||
|
<Show
|
||||||
|
when={!deletingWorktrees().has(wt.id)}
|
||||||
|
fallback={<Spinner class="am-worktree-spinner" />}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="am-worktree-close"
|
||||||
|
onMouseEnter={() => setOverClose(true)}
|
||||||
|
onMouseLeave={() => setOverClose(false)}
|
||||||
|
>
|
||||||
|
<TooltipKeybind
|
||||||
|
title="Delete worktree"
|
||||||
|
keybind={kb().closeWorktree ?? ""}
|
||||||
|
placement="top"
|
||||||
|
>
|
||||||
|
<IconButton
|
||||||
|
icon="close-small"
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
label="Delete worktree"
|
||||||
|
onClick={(e: MouseEvent) => handleDeleteWorktree(wt.id, e)}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div class="am-hover-card">
|
||||||
|
<div class="am-hover-card-header">
|
||||||
|
<div>
|
||||||
|
<div class="am-hover-card-label">BRANCH</div>
|
||||||
|
<div class="am-hover-card-branch">{wt.branch}</div>
|
||||||
|
<div class="am-hover-card-meta">{formatRelativeDate(wt.createdAt)}</div>
|
||||||
|
</div>
|
||||||
|
<Show when={navHint()}>
|
||||||
|
<span class="am-hover-card-keybind">{navHint()}</span>
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
<Show when={wt.parentBranch}>
|
||||||
|
<div class="am-hover-card-divider" />
|
||||||
|
<div class="am-hover-card-row">
|
||||||
|
<span class="am-hover-card-row-label">Base</span>
|
||||||
|
<span class="am-hover-card-row-value">{wt.parentBranch}</span>
|
||||||
|
</div>
|
||||||
|
</Show>
|
||||||
|
<div class="am-hover-card-divider" />
|
||||||
|
<div class="am-hover-card-row">
|
||||||
|
<span class="am-hover-card-row-label">Sessions</span>
|
||||||
|
<span class="am-hover-card-row-value">{sessions().length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</HoverCard>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
<Show when={worktrees().length === 0}>
|
||||||
|
<button class="am-worktree-create" onClick={handleCreateWorktree}>
|
||||||
|
<Icon name="plus" size="small" />
|
||||||
|
<span>New Worktree</span>
|
||||||
|
</button>
|
||||||
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -460,29 +915,53 @@ const AgentManagerContent: Component = () => {
|
|||||||
<span class="am-section-label">SESSIONS</span>
|
<span class="am-section-label">SESSIONS</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="am-list">
|
<div class="am-list">
|
||||||
<For each={unassignedSessions()}>
|
<Show
|
||||||
{(s) => (
|
when={sessionsLoaded()}
|
||||||
<button
|
fallback={
|
||||||
class={`am-item ${s.id === session.currentSessionID() && selection() === null ? "am-item-active" : ""}`}
|
<div class="am-skeleton-list">
|
||||||
data-sidebar-id={s.id}
|
<div class="am-skeleton-session">
|
||||||
onClick={() => {
|
<div class="am-skeleton-session-title" style={{ width: "70%" }} />
|
||||||
setSelection(null)
|
<div class="am-skeleton-session-time" />
|
||||||
session.selectSession(s.id)
|
</div>
|
||||||
}}
|
<div class="am-skeleton-session">
|
||||||
>
|
<div class="am-skeleton-session-title" style={{ width: "55%" }} />
|
||||||
<span class="am-item-title">{s.title || "Untitled"}</span>
|
<div class="am-skeleton-session-time" />
|
||||||
<span class="am-item-time">{formatRelativeDate(s.updatedAt)}</span>
|
</div>
|
||||||
<IconButton
|
<div class="am-skeleton-session">
|
||||||
icon="branch"
|
<div class="am-skeleton-session-title" style={{ width: "65%" }} />
|
||||||
size="small"
|
<div class="am-skeleton-session-time" />
|
||||||
variant="ghost"
|
</div>
|
||||||
label="Open in worktree"
|
</div>
|
||||||
class="am-item-promote"
|
}
|
||||||
onClick={(e: MouseEvent) => handlePromote(s.id, e)}
|
>
|
||||||
/>
|
<For each={unassignedSessions()}>
|
||||||
</button>
|
{(s) => (
|
||||||
)}
|
<button
|
||||||
</For>
|
class={`am-item ${s.id === session.currentSessionID() && selection() === null ? "am-item-active" : ""}`}
|
||||||
|
data-sidebar-id={s.id}
|
||||||
|
onClick={() => {
|
||||||
|
saveTabMemory()
|
||||||
|
setSelection(null)
|
||||||
|
session.selectSession(s.id)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="am-item-title">{s.title || "Untitled"}</span>
|
||||||
|
<span class="am-item-time">{formatRelativeDate(s.updatedAt)}</span>
|
||||||
|
<div class="am-item-promote">
|
||||||
|
<TooltipKeybind title="Open in worktree" keybind={kb().newWorktree ?? ""} placement="right">
|
||||||
|
<IconButton
|
||||||
|
icon="branch"
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
label="Open in worktree"
|
||||||
|
onClick={(e: MouseEvent) => handlePromote(s.id, e)}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</For>
|
||||||
|
</Show>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -490,53 +969,92 @@ const AgentManagerContent: Component = () => {
|
|||||||
<div class="am-detail">
|
<div class="am-detail">
|
||||||
{/* Tab bar — visible when a section is selected and has tabs or a pending new session */}
|
{/* Tab bar — visible when a section is selected and has tabs or a pending new session */}
|
||||||
<Show when={selection() !== null && !contextEmpty()}>
|
<Show when={selection() !== null && !contextEmpty()}>
|
||||||
<div class="am-tab-bar">
|
<DragDropProvider
|
||||||
<div class="am-tab-list">
|
onDragStart={handleDragStart}
|
||||||
<For each={activeTabs()}>
|
onDragEnd={handleDragEnd}
|
||||||
{(s) => {
|
onDragOver={handleDragOver}
|
||||||
const pending = isPending(s.id)
|
collisionDetector={closestCenter}
|
||||||
const active = () =>
|
>
|
||||||
pending
|
<DragDropSensors />
|
||||||
? s.id === activePendingId() && !session.currentSessionID()
|
<ConstrainDragYAxis />
|
||||||
: s.id === session.currentSessionID()
|
<div class="am-tab-bar">
|
||||||
return (
|
<div class="am-tab-scroll-area">
|
||||||
<Tooltip value={s.title || "Untitled"} placement="bottom">
|
<div class={`am-tab-fade am-tab-fade-left ${tabScroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
|
||||||
<div
|
<div class="am-tab-list" ref={tabScroll.setRef}>
|
||||||
class={`am-tab ${active() ? "am-tab-active" : ""}`}
|
<SortableProvider ids={tabIds()}>
|
||||||
onClick={() => {
|
<For each={activeTabs()}>
|
||||||
if (pending) {
|
{(s) => {
|
||||||
setActivePendingId(s.id)
|
const pending = isPending(s.id)
|
||||||
session.clearCurrentSession()
|
const active = () =>
|
||||||
} else {
|
pending
|
||||||
setActivePendingId(undefined)
|
? s.id === activePendingId() && !session.currentSessionID()
|
||||||
session.selectSession(s.id)
|
: s.id === session.currentSessionID()
|
||||||
}
|
const tabDirection = () => {
|
||||||
}}
|
if (active()) return ""
|
||||||
onMouseDown={(e: MouseEvent) => handleTabMouseDown(s.id, e)}
|
const ids = activeTabs().map((t) => t.id)
|
||||||
>
|
const activeId = session.currentSessionID() ?? activePendingId() ?? ""
|
||||||
<span class="am-tab-label">{s.title || "Untitled"}</span>
|
return adjacentHint(s.id, activeId, ids, kb().previousTab ?? "", kb().nextTab ?? "")
|
||||||
<button
|
}
|
||||||
class="am-tab-close"
|
return (
|
||||||
onClick={(e: MouseEvent) => handleCloseTab(s.id, e)}
|
<SortableTab
|
||||||
aria-label="Close tab"
|
tab={s}
|
||||||
>
|
active={active()}
|
||||||
×
|
keybind={tabDirection()}
|
||||||
</button>
|
closeKeybind={kb().closeTab ?? ""}
|
||||||
</div>
|
onSelect={() => {
|
||||||
</Tooltip>
|
if (pending) {
|
||||||
)
|
setActivePendingId(s.id)
|
||||||
}}
|
session.clearCurrentSession()
|
||||||
</For>
|
} else {
|
||||||
|
setActivePendingId(undefined)
|
||||||
|
session.selectSession(s.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onMiddleClick={(e: MouseEvent) => handleTabMouseDown(s.id, e)}
|
||||||
|
onClose={(e: MouseEvent) => handleCloseTab(s.id, e)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
</For>
|
||||||
|
</SortableProvider>
|
||||||
|
</div>
|
||||||
|
<div class={`am-tab-fade am-tab-fade-right ${tabScroll.showRight() ? "am-tab-fade-visible" : ""}`} />
|
||||||
|
</div>
|
||||||
|
<TooltipKeybind title="New session" keybind={kb().newTab ?? ""} placement="bottom">
|
||||||
|
<IconButton
|
||||||
|
icon="plus"
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
label="New session"
|
||||||
|
class="am-tab-add"
|
||||||
|
onClick={handleAddSession}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
<div class="am-tab-terminal">
|
||||||
|
<TooltipKeybind title="Terminal" keybind={kb().showTerminal ?? ""} placement="bottom">
|
||||||
|
<IconButton
|
||||||
|
icon="console"
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
label="Open Terminal"
|
||||||
|
onClick={() => {
|
||||||
|
const id = session.currentSessionID()
|
||||||
|
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<IconButton
|
<DragOverlay>
|
||||||
icon="plus"
|
<Show when={draggedTab()}>
|
||||||
size="small"
|
{(tab) => (
|
||||||
variant="ghost"
|
<div class="am-tab am-tab-overlay">
|
||||||
label="New session"
|
<span class="am-tab-label">{tab().title || "Untitled"}</span>
|
||||||
class="am-tab-add"
|
</div>
|
||||||
onClick={handleAddSession}
|
)}
|
||||||
/>
|
</Show>
|
||||||
</div>
|
</DragOverlay>
|
||||||
|
</DragDropProvider>
|
||||||
</Show>
|
</Show>
|
||||||
|
|
||||||
{/* Empty worktree state */}
|
{/* Empty worktree state */}
|
||||||
@@ -548,6 +1066,7 @@ const AgentManagerContent: Component = () => {
|
|||||||
<div class="am-empty-state-text">No sessions open</div>
|
<div class="am-empty-state-text">No sessions open</div>
|
||||||
<Button variant="primary" size="small" onClick={handleAddSession}>
|
<Button variant="primary" size="small" onClick={handleAddSession}>
|
||||||
New session
|
New session
|
||||||
|
<span class="am-shortcut-hint">{kb().newTab ?? ""}</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
</Show>
|
||||||
@@ -556,7 +1075,7 @@ const AgentManagerContent: Component = () => {
|
|||||||
<div class="am-setup-overlay">
|
<div class="am-setup-overlay">
|
||||||
<div class="am-setup-card">
|
<div class="am-setup-card">
|
||||||
<Icon name="branch" size="large" />
|
<Icon name="branch" size="large" />
|
||||||
<div class="am-setup-title">Setting up workspace</div>
|
<div class="am-setup-title">{setup().error ? "Workspace setup failed" : "Setting up workspace"}</div>
|
||||||
<Show when={setup().branch}>
|
<Show when={setup().branch}>
|
||||||
<div class="am-setup-branch">{setup().branch}</div>
|
<div class="am-setup-branch">{setup().branch}</div>
|
||||||
</Show>
|
</Show>
|
||||||
|
|||||||
@@ -7,17 +7,21 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.am-sidebar {
|
.am-sidebar {
|
||||||
width: 260px;
|
position: relative;
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
border-right: 1px solid var(--border-weak-base, var(--vscode-panel-border, #333));
|
border-right: 1px solid var(--border-weak-base);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: var(--surface-base, var(--vscode-sideBar-background, #181818));
|
background: var(--surface-base);
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.am-sidebar > [data-component="resize-handle"]::after {
|
||||||
|
background: var(--surface-interactive-base);
|
||||||
|
}
|
||||||
|
|
||||||
/* Fixed local workspace item */
|
/* Fixed local workspace item */
|
||||||
|
|
||||||
.am-local-item {
|
.am-local-item {
|
||||||
@@ -25,32 +29,34 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--text-base, var(--vscode-foreground));
|
color: var(--text-base);
|
||||||
font-size: 13px;
|
font-size: var(--font-size-base);
|
||||||
font-family: inherit;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-local-item:hover {
|
.am-local-item:hover {
|
||||||
background: var(--surface-inset-base-hover, rgba(255, 255, 255, 0.05));
|
background: var(--surface-inset-base-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-local-item:active {
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-local-item-active {
|
.am-local-item-active {
|
||||||
background: var(--surface-base-active, rgba(255, 255, 255, 0.08));
|
background: var(--surface-base-active);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-local-icon {
|
.am-local-icon {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
color: var(--text-weaker, var(--vscode-descriptionForeground, #666));
|
color: var(--text-weaker);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-local-text {
|
.am-local-text {
|
||||||
@@ -61,14 +67,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.am-local-label {
|
.am-local-label {
|
||||||
font-size: 13px;
|
font-size: var(--font-size-base);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-local-branch {
|
.am-local-branch {
|
||||||
font-size: 11px;
|
font-size: var(--font-size-small);
|
||||||
color: var(--text-weaker, var(--vscode-descriptionForeground, #777));
|
color: var(--text-weaker);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -96,11 +102,17 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.am-section-label {
|
.am-section-label {
|
||||||
font-size: 11px;
|
font-size: var(--font-size-small);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
|
color: var(--text-weak);
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-section-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Worktree list */
|
/* Worktree list */
|
||||||
@@ -110,29 +122,41 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
max-height: 50vh;
|
max-height: 50vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Worktree item — larger card style */
|
/* Worktree item — larger card style */
|
||||||
|
|
||||||
|
.am-worktree-list [data-slot="hover-card-trigger"] {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.am-worktree-item {
|
.am-worktree-item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 10px 10px;
|
padding: 10px 10px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 13px;
|
font-size: var(--font-size-base);
|
||||||
color: var(--text-base, var(--vscode-foreground));
|
color: var(--text-base);
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-worktree-item:hover {
|
.am-worktree-item:hover {
|
||||||
background: var(--surface-inset-base-hover, var(--vscode-list-hoverBackground, #2a2d2e));
|
background: var(--surface-inset-base-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-worktree-item:active {
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-worktree-item-active {
|
.am-worktree-item-active {
|
||||||
background: var(--surface-interactive-base, var(--vscode-list-activeSelectionBackground, #04395e));
|
background: var(--surface-interactive-base);
|
||||||
color: var(--text-on-interactive-base, var(--vscode-list-activeSelectionForeground, #fff));
|
color: var(--text-on-interactive-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-worktree-branch {
|
.am-worktree-branch {
|
||||||
@@ -145,6 +169,24 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.am-worktree-close {
|
.am-worktree-close {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-worktree-item:hover .am-worktree-branch {
|
||||||
|
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent calc(100% - 16px));
|
||||||
|
-webkit-mask-image: linear-gradient(to right, black calc(100% - 48px), transparent calc(100% - 16px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-worktree-item:hover .am-worktree-close {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-worktree-spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,30 +204,33 @@
|
|||||||
/* Session items */
|
/* Session items */
|
||||||
|
|
||||||
.am-item {
|
.am-item {
|
||||||
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
color: var(--text-base, var(--vscode-foreground));
|
color: var(--text-base);
|
||||||
font-size: 13px;
|
font-size: var(--font-size-base);
|
||||||
font-family: inherit;
|
|
||||||
transition: background-color 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-item:hover {
|
.am-item:hover {
|
||||||
background: var(--surface-inset-base-hover, rgba(255, 255, 255, 0.05));
|
background: var(--surface-inset-base-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-item:active {
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-item-active {
|
.am-item-active {
|
||||||
background: var(--surface-base-active, rgba(255, 255, 255, 0.08));
|
background: var(--surface-base-active);
|
||||||
color: var(--text-base, var(--vscode-foreground));
|
color: var(--text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-item-title {
|
.am-item-title {
|
||||||
@@ -198,22 +243,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.am-item-time {
|
.am-item-time {
|
||||||
font-size: 11px;
|
font-size: var(--font-size-small);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
color: var(--text-weaker, var(--vscode-descriptionForeground, #888));
|
color: var(--text-weaker);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-item-active .am-item-time {
|
.am-item-active .am-item-time {
|
||||||
color: var(--text-weaker, var(--vscode-descriptionForeground, #888));
|
color: var(--text-weaker);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Promote button on session rows */
|
/* Promote button on session rows */
|
||||||
|
|
||||||
.am-item-promote {
|
.am-item-promote {
|
||||||
|
position: absolute;
|
||||||
|
right: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: opacity 0.1s ease;
|
}
|
||||||
|
|
||||||
|
.am-item:hover .am-item-time {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-item:hover .am-item-promote {
|
.am-item:hover .am-item-promote {
|
||||||
@@ -229,18 +279,21 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: 1px dashed var(--border-weak-base, var(--vscode-panel-border, #444));
|
border: 1px dashed var(--border-weak-base);
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
|
color: var(--text-weak);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-worktree-create:hover {
|
.am-worktree-create:hover {
|
||||||
background: var(--surface-inset-base-hover, var(--vscode-list-hoverBackground, #2a2d2e));
|
background: var(--surface-inset-base-hover);
|
||||||
color: var(--text-base, var(--vscode-foreground));
|
color: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-worktree-create:active {
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Detail pane */
|
/* Detail pane */
|
||||||
@@ -259,8 +312,8 @@
|
|||||||
.am-tab-bar {
|
.am-tab-bar {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border-bottom: 1px solid var(--border-weak-base, var(--vscode-panel-border, #333));
|
border-bottom: 1px solid var(--border-weak-base);
|
||||||
background: var(--surface-base, var(--vscode-editorGroupHeader-tabsBackground, #1e1e1e));
|
background: var(--surface-base);
|
||||||
height: 36px;
|
height: 36px;
|
||||||
padding: 0 4px;
|
padding: 0 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -272,6 +325,51 @@
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
|
/* Hide scrollbar while keeping scroll functionality */
|
||||||
|
scrollbar-width: none;
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-tab-list::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Wrapper that positions overflow fade indicators */
|
||||||
|
.am-tab-scroll-area {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
flex-shrink: 1;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fade indicators for overflow */
|
||||||
|
.am-tab-fade {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 32px;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-tab-fade-visible {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-tab-fade-left {
|
||||||
|
left: 0;
|
||||||
|
background: linear-gradient(to right, var(--surface-base) 0%, transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-tab-fade-right {
|
||||||
|
right: 0;
|
||||||
|
background: linear-gradient(to left, var(--surface-base) 0%, transparent 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-tab {
|
.am-tab {
|
||||||
@@ -280,9 +378,8 @@
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
background: none;
|
background: none;
|
||||||
color: var(--text-weak, var(--vscode-tab-inactiveForeground, #888));
|
color: var(--text-weak);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-family: inherit;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border: none;
|
border: none;
|
||||||
@@ -292,12 +389,12 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.am-tab:hover {
|
.am-tab:hover {
|
||||||
color: var(--text-base, var(--vscode-tab-activeForeground, #fff));
|
color: var(--text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-tab-active {
|
.am-tab-active {
|
||||||
color: var(--text-base, var(--vscode-tab-activeForeground, #fff));
|
color: var(--text-base);
|
||||||
border-bottom-color: var(--surface-interactive-base, var(--vscode-focusBorder, #007acc));
|
border-bottom-color: var(--surface-interactive-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-tab-label {
|
.am-tab-label {
|
||||||
@@ -308,21 +405,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.am-tab-close {
|
.am-tab-close {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 18px;
|
|
||||||
height: 18px;
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
color: inherit;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
border-radius: 3px;
|
|
||||||
cursor: pointer;
|
|
||||||
opacity: 0;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
transition: opacity 0.1s ease;
|
}
|
||||||
|
|
||||||
|
.am-tab .am-tab-close {
|
||||||
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-tab:hover .am-tab-close {
|
.am-tab:hover .am-tab-close {
|
||||||
@@ -335,7 +422,29 @@
|
|||||||
|
|
||||||
.am-tab-close:hover {
|
.am-tab-close:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
background: var(--surface-inset-base-hover, rgba(255, 255, 255, 0.1));
|
}
|
||||||
|
|
||||||
|
/* Drag-and-drop sortable tab wrapper */
|
||||||
|
|
||||||
|
.am-tab-sortable {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-tab-dragging {
|
||||||
|
opacity: 0.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drag overlay tab (follows the cursor) */
|
||||||
|
|
||||||
|
.am-tab-overlay {
|
||||||
|
background: var(--surface-base);
|
||||||
|
border: 1px solid var(--border-weak-base);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
|
pointer-events: none;
|
||||||
|
color: var(--text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-tab-add {
|
.am-tab-add {
|
||||||
@@ -343,6 +452,13 @@
|
|||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.am-tab-terminal {
|
||||||
|
flex-shrink: 0;
|
||||||
|
align-self: center;
|
||||||
|
margin-left: auto;
|
||||||
|
padding-right: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
/* Empty worktree state */
|
/* Empty worktree state */
|
||||||
|
|
||||||
.am-empty-state {
|
.am-empty-state {
|
||||||
@@ -360,15 +476,15 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 16px;
|
border-radius: var(--radius-xl);
|
||||||
background: var(--surface-inset-base, var(--vscode-input-background, #252526));
|
background: var(--surface-inset-base);
|
||||||
color: var(--text-weaker, var(--vscode-descriptionForeground, #555));
|
color: var(--text-weaker);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-empty-state-text {
|
.am-empty-state-text {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
|
color: var(--text-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Read-only session wrapper + banner */
|
/* Read-only session wrapper + banner */
|
||||||
@@ -388,13 +504,13 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
border-top: 1px solid var(--border-weak-base, var(--vscode-panel-border, #333));
|
border-top: 1px solid var(--border-weak-base);
|
||||||
background: var(--surface-inset-base, var(--vscode-input-background, #252526));
|
background: var(--surface-inset-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-readonly-text {
|
.am-readonly-text {
|
||||||
font-size: 13px;
|
font-size: var(--font-size-base);
|
||||||
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
|
color: var(--text-weak);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Setup overlay */
|
/* Setup overlay */
|
||||||
@@ -406,7 +522,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: var(--surface-base, var(--vscode-editor-background, #1e1e1e));
|
background: var(--surface-base);
|
||||||
animation: am-fade-in 0.15s ease;
|
animation: am-fade-in 0.15s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,36 +540,38 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
max-width: 300px;
|
max-width: 400px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-setup-card > [data-component="icon"] {
|
.am-setup-card > [data-component="icon"] {
|
||||||
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
|
color: var(--text-weak);
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-setup-title {
|
.am-setup-title {
|
||||||
font-size: 16px;
|
font-size: var(--font-size-large);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-base, var(--vscode-foreground));
|
color: var(--text-base);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-setup-branch {
|
.am-setup-branch {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: var(--text-weak, var(--vscode-descriptionForeground, #888));
|
color: var(--text-weak);
|
||||||
font-family: var(--vscode-editor-font-family, monospace);
|
font-family: var(--font-family-mono, monospace);
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-setup-status {
|
.am-setup-status {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 6px;
|
border-radius: var(--radius-sm);
|
||||||
background: var(--surface-inset-base, var(--vscode-input-background, #252526));
|
background: var(--surface-inset-base);
|
||||||
color: var(--text-base, var(--vscode-foreground));
|
color: var(--text-base);
|
||||||
font-size: 13px;
|
font-size: var(--font-size-base);
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.am-setup-spinner {
|
.am-setup-spinner {
|
||||||
@@ -461,3 +579,226 @@
|
|||||||
height: 16px;
|
height: 16px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Confirmation dialog */
|
||||||
|
|
||||||
|
.am-confirm {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 0 24px 16px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-confirm-message {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: var(--font-size-base);
|
||||||
|
color: var(--text-base);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-confirm-message > [data-component="icon"] {
|
||||||
|
flex-shrink: 0;
|
||||||
|
color: var(--icon-critical-base);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-confirm-branch {
|
||||||
|
font-family: var(--font-family-mono, monospace);
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--surface-inset-base);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-confirm-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-confirm-delete {
|
||||||
|
background: var(--surface-critical-strong) !important;
|
||||||
|
color: var(--text-on-brand-base) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-confirm-delete:hover {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Keyboard shortcut hint inside buttons */
|
||||||
|
|
||||||
|
.am-shortcut-hint {
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-left: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* HoverCard popover for worktree items */
|
||||||
|
|
||||||
|
.am-hover-card {
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-width: 160px;
|
||||||
|
max-width: 240px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-hover-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-hover-card-label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-weaker);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-hover-card-branch {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-strong);
|
||||||
|
line-height: 1.4;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-hover-card-meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-weaker);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-hover-card-keybind {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--surface-inset-base);
|
||||||
|
border: 1px solid var(--border-weak-base);
|
||||||
|
font-family: var(--font-family-sans);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
line-height: 1;
|
||||||
|
color: var(--text-weak);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-hover-card-divider {
|
||||||
|
height: 1px;
|
||||||
|
background: var(--border-weak-base);
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-hover-card-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-hover-card-row-label {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-weaker);
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-hover-card-row-value {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skeleton loading states */
|
||||||
|
|
||||||
|
@keyframes am-skeleton-pulse {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 0.12;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.28;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-skeleton-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
animation: am-fade-in 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Worktree skeleton — matches .am-worktree-item layout */
|
||||||
|
.am-skeleton-wt {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-skeleton-wt-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--text-base);
|
||||||
|
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-skeleton-wt-text {
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--text-base);
|
||||||
|
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-skeleton-wt:nth-child(2) .am-skeleton-wt-icon,
|
||||||
|
.am-skeleton-wt:nth-child(2) .am-skeleton-wt-text {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Session skeleton — matches .am-item layout */
|
||||||
|
.am-skeleton-session {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-skeleton-session-title {
|
||||||
|
height: 13px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--text-base);
|
||||||
|
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-skeleton-session-time {
|
||||||
|
height: 10px;
|
||||||
|
width: 52px;
|
||||||
|
border-radius: 3px;
|
||||||
|
background: var(--text-base);
|
||||||
|
animation: am-skeleton-pulse 1.5s ease-in-out infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-skeleton-session:nth-child(2) .am-skeleton-session-title,
|
||||||
|
.am-skeleton-session:nth-child(2) .am-skeleton-session-time {
|
||||||
|
animation-delay: 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.am-skeleton-session:nth-child(3) .am-skeleton-session-title,
|
||||||
|
.am-skeleton-session:nth-child(3) .am-skeleton-session-time {
|
||||||
|
animation-delay: 0.3s;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@
|
|||||||
* Returns the action to take: select a session by ID, go to local, or do nothing.
|
* Returns the action to take: select a session by ID, go to local, or do nothing.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type NavResult = { action: "select"; id: string } | { action: "local" } | { action: "none" }
|
/** Sentinel value for the local workspace selection. */
|
||||||
|
export const LOCAL = "local" as const
|
||||||
|
|
||||||
|
export type NavResult = { action: "select"; id: string } | { action: typeof LOCAL } | { action: "none" }
|
||||||
|
|
||||||
export function resolveNavigation(direction: "up" | "down", current: string | undefined, ids: string[]): NavResult {
|
export function resolveNavigation(direction: "up" | "down", current: string | undefined, ids: string[]): NavResult {
|
||||||
// Determine current position: -1 = local, 0..N-1 = session index
|
// Determine current position: -1 = local, 0..N-1 = session index
|
||||||
@@ -25,7 +28,7 @@ export function resolveNavigation(direction: "up" | "down", current: string | un
|
|||||||
const next = direction === "up" ? idx - 1 : idx + 1
|
const next = direction === "up" ? idx - 1 : idx + 1
|
||||||
|
|
||||||
// Moving up past the first session → go to local
|
// Moving up past the first session → go to local
|
||||||
if (next === -1) return { action: "local" }
|
if (next === -1) return { action: LOCAL }
|
||||||
|
|
||||||
// At the bottom boundary
|
// At the bottom boundary
|
||||||
if (next >= ids.length) return { action: "none" }
|
if (next >= ids.length) return { action: "none" }
|
||||||
@@ -42,3 +45,44 @@ export function validateLocalSession(persisted: string | undefined, ids: string[
|
|||||||
if (ids.indexOf(persisted) === -1) return undefined
|
if (ids.indexOf(persisted) === -1) return undefined
|
||||||
return persisted
|
return persisted
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the keybinding hint for an item adjacent to the active item.
|
||||||
|
* Only returns a hint when the item is exactly one step away in the flat list.
|
||||||
|
* Returns empty string for non-adjacent items or the active item itself.
|
||||||
|
*
|
||||||
|
* @param itemId - The item being hovered
|
||||||
|
* @param activeId - The currently selected/active item (or undefined for LOCAL)
|
||||||
|
* @param flatIds - The full ordered sidebar list (LOCAL first, then worktrees, then sessions)
|
||||||
|
* @param prev - Display string for "go up" (e.g. "⌘↑" or keybinding)
|
||||||
|
* @param next - Display string for "go down" (e.g. "⌘↓" or keybinding)
|
||||||
|
*/
|
||||||
|
export function adjacentHint(
|
||||||
|
itemId: string,
|
||||||
|
activeId: string | undefined,
|
||||||
|
flatIds: string[],
|
||||||
|
prev: string,
|
||||||
|
next: string,
|
||||||
|
): string {
|
||||||
|
if (!activeId || itemId === activeId) return ""
|
||||||
|
const activeIdx = flatIds.indexOf(activeId)
|
||||||
|
const itemIdx = flatIds.indexOf(itemId)
|
||||||
|
if (activeIdx === -1 || itemIdx === -1) return ""
|
||||||
|
const diff = itemIdx - activeIdx
|
||||||
|
if (diff === -1) return prev
|
||||||
|
if (diff === 1) return next
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After removing a worktree, pick the nearest remaining sidebar neighbor.
|
||||||
|
* Order: the worktree just below → the one above → LOCAL.
|
||||||
|
*/
|
||||||
|
export function nextSelectionAfterDelete(deletedId: string, worktreeIds: string[]): typeof LOCAL | string {
|
||||||
|
const idx = worktreeIds.indexOf(deletedId)
|
||||||
|
if (idx === -1) return LOCAL
|
||||||
|
const remaining = worktreeIds.filter((id) => id !== deletedId)
|
||||||
|
if (remaining.length === 0) return LOCAL
|
||||||
|
// Prefer the item that was below (same index in the shortened list), else the one above
|
||||||
|
return remaining[Math.min(idx, remaining.length - 1)]!
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* Drag-and-drop sortable tab components for the agent manager tab bar.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Component, onCleanup } from "solid-js"
|
||||||
|
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd"
|
||||||
|
import type { Transformer } from "@thisbeyond/solid-dnd"
|
||||||
|
import { createRoot } from "solid-js"
|
||||||
|
import type { SessionInfo } from "../src/types/messages"
|
||||||
|
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||||
|
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
|
||||||
|
|
||||||
|
/** Lock drag movement to the X axis (horizontal-only tab dragging). */
|
||||||
|
export const ConstrainDragYAxis: Component = () => {
|
||||||
|
const context = useDragDropContext()
|
||||||
|
if (!context) return null
|
||||||
|
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
|
||||||
|
const transformer: Transformer = { id: "constrain-y-axis", order: 100, callback: (t) => ({ ...t, y: 0 }) }
|
||||||
|
const dispose = createRoot((dispose) => {
|
||||||
|
onDragStart(({ draggable }) => {
|
||||||
|
if (draggable) addTransformer("draggables", draggable.id as string, transformer)
|
||||||
|
})
|
||||||
|
onDragEnd(({ draggable }) => {
|
||||||
|
if (draggable) removeTransformer("draggables", draggable.id as string, transformer.id)
|
||||||
|
})
|
||||||
|
return dispose
|
||||||
|
})
|
||||||
|
onCleanup(dispose)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Individual sortable tab wrapper using the `use:sortable` directive. */
|
||||||
|
export const SortableTab: Component<{
|
||||||
|
tab: SessionInfo
|
||||||
|
active: boolean
|
||||||
|
keybind?: string
|
||||||
|
closeKeybind?: string
|
||||||
|
onSelect: () => void
|
||||||
|
onMiddleClick: (e: MouseEvent) => void
|
||||||
|
onClose: (e: MouseEvent) => void
|
||||||
|
}> = (props) => {
|
||||||
|
const sortable = createSortable(props.tab.id)
|
||||||
|
// Prevent tree-shaking of the directive reference used by `use:sortable`
|
||||||
|
void sortable
|
||||||
|
return (
|
||||||
|
// @ts-ignore - use:sortable is a SolidJS directive compiled by esbuild-plugin-solid
|
||||||
|
<div
|
||||||
|
use:sortable
|
||||||
|
class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`}
|
||||||
|
data-tab-id={props.tab.id}
|
||||||
|
>
|
||||||
|
<TooltipKeybind
|
||||||
|
title={props.tab.title || "Untitled"}
|
||||||
|
keybind={props.keybind ?? ""}
|
||||||
|
placement="bottom"
|
||||||
|
inactive={props.active}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class={`am-tab ${props.active ? "am-tab-active" : ""}`}
|
||||||
|
onClick={props.onSelect}
|
||||||
|
onMouseDown={props.onMiddleClick}
|
||||||
|
>
|
||||||
|
<span class="am-tab-label">{props.tab.title || "Untitled"}</span>
|
||||||
|
<TooltipKeybind title="Close" keybind={props.closeKeybind ?? ""} placement="bottom">
|
||||||
|
<IconButton
|
||||||
|
icon="close-small"
|
||||||
|
size="small"
|
||||||
|
variant="ghost"
|
||||||
|
label="Close tab"
|
||||||
|
class="am-tab-close"
|
||||||
|
onClick={props.onClose}
|
||||||
|
/>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
</TooltipKeybind>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
/**
|
||||||
|
* Pure tab-ordering logic for the agent manager.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reorder an array by moving the item at `from` to the position of `to`.
|
||||||
|
* Returns a new array, or undefined if either ID is not found or they are equal.
|
||||||
|
*/
|
||||||
|
export function reorderTabs(tabs: readonly string[], from: string, to: string): string[] | undefined {
|
||||||
|
if (from === to) return undefined
|
||||||
|
const fi = tabs.indexOf(from)
|
||||||
|
const ti = tabs.indexOf(to)
|
||||||
|
if (fi === -1 || ti === -1) return undefined
|
||||||
|
const result = [...tabs]
|
||||||
|
result.splice(fi, 1)
|
||||||
|
result.splice(ti, 0, from)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply a custom ordering to a list of items.
|
||||||
|
*
|
||||||
|
* Items are returned in `order` sequence (skipping IDs not in `items`),
|
||||||
|
* followed by any items not present in `order`.
|
||||||
|
* Returns the original array unchanged if `order` is undefined or empty.
|
||||||
|
*/
|
||||||
|
export function applyTabOrder<T extends { id: string }>(items: T[], order: string[] | undefined): T[] {
|
||||||
|
if (!order || order.length === 0) return items
|
||||||
|
const lookup = new Map(items.map((item) => [item.id, item]))
|
||||||
|
const ordered: T[] = []
|
||||||
|
for (const id of order) {
|
||||||
|
const item = lookup.get(id)
|
||||||
|
if (item) {
|
||||||
|
ordered.push(item)
|
||||||
|
lookup.delete(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const item of lookup.values()) ordered.push(item)
|
||||||
|
return ordered
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find the title of the first item according to a custom order.
|
||||||
|
*
|
||||||
|
* Falls back to the first titled item in `items` if the order
|
||||||
|
* doesn't produce a match, then to `fallback`.
|
||||||
|
*/
|
||||||
|
export function firstOrderedTitle(
|
||||||
|
items: { id: string; title?: string }[],
|
||||||
|
order: string[] | undefined,
|
||||||
|
fallback: string,
|
||||||
|
): string {
|
||||||
|
if (order) {
|
||||||
|
const lookup = new Map(items.map((item) => [item.id, item]))
|
||||||
|
for (const id of order) {
|
||||||
|
const item = lookup.get(id)
|
||||||
|
if (item?.title) return item.title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const first = items.find((item) => item.title)
|
||||||
|
return first?.title || fallback
|
||||||
|
}
|
||||||
@@ -51,15 +51,21 @@ export const DataBridge: Component<{ children: any }> = (props) => {
|
|||||||
|
|
||||||
const data = createMemo(() => {
|
const data = createMemo(() => {
|
||||||
const id = session.currentSessionID()
|
const id = session.currentSessionID()
|
||||||
const msgs = session.allMessages()
|
const perms = id ? session.permissions().filter((p) => p.sessionID === id) : []
|
||||||
const parts = session.allParts()
|
|
||||||
return {
|
return {
|
||||||
session: session.sessions().map((s) => ({ ...s, id: s.id, role: "user" as const })) as unknown as any[],
|
session: session.sessions().map((s) => ({ ...s, id: s.id, role: "user" as const })) as unknown as any[],
|
||||||
session_status: {} as Record<string, any>,
|
session_status: {} as Record<string, any>,
|
||||||
session_diff: {} as Record<string, any[]>,
|
session_diff: {} as Record<string, any[]>,
|
||||||
message: msgs as unknown as Record<string, SDKMessage[]>,
|
message: id ? { [id]: session.messages() as unknown as SDKMessage[] } : {},
|
||||||
part: parts as unknown as Record<string, SDKPart[]>,
|
part: id
|
||||||
permission: id ? { [id]: session.permissions() as unknown as any[] } : {},
|
? Object.fromEntries(
|
||||||
|
session
|
||||||
|
.messages()
|
||||||
|
.map((msg) => [msg.id, session.getParts(msg.id) as unknown as SDKPart[]])
|
||||||
|
.filter(([, parts]) => (parts as SDKPart[]).length > 0),
|
||||||
|
)
|
||||||
|
: {},
|
||||||
|
permission: id ? { [id]: perms as unknown as any[] } : {},
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
|
|||||||
const sessionPermissions = () => session.permissions().filter((p) => p.sessionID === id())
|
const sessionPermissions = () => session.permissions().filter((p) => p.sessionID === id())
|
||||||
|
|
||||||
const questionRequest = () => sessionQuestions()[0]
|
const questionRequest = () => sessionQuestions()[0]
|
||||||
const permissionRequest = () => sessionPermissions()[0]
|
const permissionRequest = () => sessionPermissions().find((p) => !p.tool)
|
||||||
const blocked = () => sessionPermissions().length > 0 || sessionQuestions().length > 0
|
const blocked = () => sessionPermissions().length > 0 || sessionQuestions().length > 0
|
||||||
|
|
||||||
const [responding, setResponding] = createSignal(false)
|
const [responding, setResponding] = createSignal(false)
|
||||||
|
|||||||
@@ -70,10 +70,30 @@ export const PromptInput: Component = () => {
|
|||||||
const canSend = () => (text().trim().length > 0 || imageAttach.images().length > 0) && !isBusy() && !isDisabled()
|
const canSend = () => (text().trim().length > 0 || imageAttach.images().length > 0) && !isBusy() && !isDisabled()
|
||||||
|
|
||||||
const unsubscribe = vscode.onMessage((message) => {
|
const unsubscribe = vscode.onMessage((message) => {
|
||||||
if (message.type !== "chatCompletionResult") return
|
if (message.type === "chatCompletionResult") {
|
||||||
const result = message as { type: "chatCompletionResult"; text: string; requestId: string }
|
const result = message as { type: "chatCompletionResult"; text: string; requestId: string }
|
||||||
if (result.requestId === `chat-ac-${requestCounter}` && result.text) {
|
if (result.requestId === `chat-ac-${requestCounter}` && result.text) {
|
||||||
setGhostText(result.text)
|
setGhostText(result.text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "setChatBoxMessage") {
|
||||||
|
setText(message.text)
|
||||||
|
setGhostText("")
|
||||||
|
if (textareaRef) {
|
||||||
|
textareaRef.value = message.text
|
||||||
|
adjustHeight()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "triggerTask") {
|
||||||
|
if (isBusy() || isDisabled()) return
|
||||||
|
const sel = session.selected()
|
||||||
|
session.sendMessage(message.text, sel?.providerID, sel?.modelID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.type === "action" && message.action === "focusInput") {
|
||||||
|
textareaRef?.focus()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { PermissionRequest } from "../types/messages"
|
||||||
|
|
||||||
|
export function upsertPermission(list: PermissionRequest[], permission: PermissionRequest) {
|
||||||
|
const idx = list.findIndex((item) => item.id === permission.id)
|
||||||
|
if (idx === -1) return [...list, permission]
|
||||||
|
const next = list.slice()
|
||||||
|
next[idx] = permission
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeSessionPermissions(list: PermissionRequest[], sessionID: string) {
|
||||||
|
return list.filter((item) => item.sessionID !== sessionID)
|
||||||
|
}
|
||||||
@@ -37,6 +37,7 @@ import type {
|
|||||||
ExtensionMessage,
|
ExtensionMessage,
|
||||||
FileAttachment,
|
FileAttachment,
|
||||||
} from "../types/messages"
|
} from "../types/messages"
|
||||||
|
import { removeSessionPermissions, upsertPermission } from "./permission-queue"
|
||||||
|
|
||||||
// Derive human-readable status from the last streaming part
|
// Derive human-readable status from the last streaming part
|
||||||
function computeStatus(
|
function computeStatus(
|
||||||
@@ -163,13 +164,22 @@ export const SessionProvider: ParentComponent = (props) => {
|
|||||||
// Current session ID
|
// Current session ID
|
||||||
const [currentSessionID, setCurrentSessionID] = createSignal<string | undefined>()
|
const [currentSessionID, setCurrentSessionID] = createSignal<string | undefined>()
|
||||||
|
|
||||||
// Session status — store full info object, derive simple string for compat
|
// Per-session status map — keyed by sessionID
|
||||||
const [statusInfo, setStatusInfo] = createSignal<SessionStatusInfo>({ type: "idle" })
|
const [statusMap, setStatusMap] = createStore<Record<string, SessionStatusInfo>>({})
|
||||||
const status = () => statusInfo().type as SessionStatus
|
const [busySinceMap, setBusySinceMap] = createStore<Record<string, number>>({})
|
||||||
const [loading, setLoading] = createSignal(false)
|
|
||||||
|
|
||||||
// Track when the agent started working
|
// Derived accessors for the current session (backwards compatible)
|
||||||
const [busySince, setBusySince] = createSignal<number | undefined>()
|
const statusInfo = () => {
|
||||||
|
const id = currentSessionID()
|
||||||
|
return id ? (statusMap[id] ?? { type: "idle" }) : { type: "idle" }
|
||||||
|
}
|
||||||
|
const status = () => statusInfo().type as SessionStatus
|
||||||
|
const busySince = () => {
|
||||||
|
const id = currentSessionID()
|
||||||
|
return id ? busySinceMap[id] : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const [loading, setLoading] = createSignal(false)
|
||||||
|
|
||||||
// Pending permissions
|
// Pending permissions
|
||||||
const [permissions, setPermissions] = createSignal<PermissionRequest[]>([])
|
const [permissions, setPermissions] = createSignal<PermissionRequest[]>([])
|
||||||
@@ -479,24 +489,27 @@ export const SessionProvider: ParentComponent = (props) => {
|
|||||||
message?: string,
|
message?: string,
|
||||||
next?: number,
|
next?: number,
|
||||||
) {
|
) {
|
||||||
if (sessionID !== currentSessionID()) return
|
const prev = statusMap[sessionID] ?? { type: "idle" }
|
||||||
const prev = statusInfo()
|
|
||||||
const info: SessionStatusInfo =
|
const info: SessionStatusInfo =
|
||||||
newStatus === "retry"
|
newStatus === "retry"
|
||||||
? { type: "retry", attempt: attempt ?? 0, message: message ?? "", next: next ?? 0 }
|
? { type: "retry", attempt: attempt ?? 0, message: message ?? "", next: next ?? 0 }
|
||||||
: { type: newStatus }
|
: { type: newStatus }
|
||||||
setStatusInfo(info)
|
setStatusMap(sessionID, info)
|
||||||
// Track busy start time
|
// Track busy start time
|
||||||
if (prev.type === "idle" && newStatus !== "idle") {
|
if (prev.type === "idle" && newStatus !== "idle") {
|
||||||
setBusySince(Date.now())
|
setBusySinceMap(sessionID, Date.now())
|
||||||
}
|
}
|
||||||
if (newStatus === "idle") {
|
if (newStatus === "idle") {
|
||||||
setBusySince(undefined)
|
setBusySinceMap(
|
||||||
|
produce((map) => {
|
||||||
|
delete map[sessionID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handlePermissionRequest(permission: PermissionRequest) {
|
function handlePermissionRequest(permission: PermissionRequest) {
|
||||||
setPermissions((prev) => [...prev, permission])
|
setPermissions((prev) => upsertPermission(prev, permission))
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleQuestionRequest(question: QuestionRequest) {
|
function handleQuestionRequest(question: QuestionRequest) {
|
||||||
@@ -591,10 +604,19 @@ export const SessionProvider: ParentComponent = (props) => {
|
|||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
setPermissions((prev) => removeSessionPermissions(prev, sessionID))
|
||||||
|
setStatusMap(
|
||||||
|
produce((map) => {
|
||||||
|
delete map[sessionID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
setBusySinceMap(
|
||||||
|
produce((map) => {
|
||||||
|
delete map[sessionID]
|
||||||
|
}),
|
||||||
|
)
|
||||||
if (currentSessionID() === sessionID) {
|
if (currentSessionID() === sessionID) {
|
||||||
setCurrentSessionID(undefined)
|
setCurrentSessionID(undefined)
|
||||||
setStatusInfo({ type: "idle" })
|
|
||||||
setBusySince(undefined)
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -734,8 +756,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
|||||||
|
|
||||||
function clearCurrentSession() {
|
function clearCurrentSession() {
|
||||||
setCurrentSessionID(undefined)
|
setCurrentSessionID(undefined)
|
||||||
setStatusInfo({ type: "idle" })
|
|
||||||
setBusySince(undefined)
|
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
setPermissions([])
|
setPermissions([])
|
||||||
setQuestions([])
|
setQuestions([])
|
||||||
@@ -760,8 +780,6 @@ export const SessionProvider: ParentComponent = (props) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
setCurrentSessionID(id)
|
setCurrentSessionID(id)
|
||||||
setStatusInfo({ type: "idle" })
|
|
||||||
setBusySince(undefined)
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
vscode.postMessage({ type: "loadMessages", sessionID: id })
|
vscode.postMessage({ type: "loadMessages", sessionID: id })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,6 +401,16 @@ export interface ActionMessage {
|
|||||||
action: string
|
action: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SetChatBoxMessage {
|
||||||
|
type: "setChatBoxMessage"
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TriggerTaskMessage {
|
||||||
|
type: "triggerTask"
|
||||||
|
text: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface ProfileDataMessage {
|
export interface ProfileDataMessage {
|
||||||
type: "profileData"
|
type: "profileData"
|
||||||
data: ProfileData | null
|
data: ProfileData | null
|
||||||
@@ -561,11 +571,25 @@ export interface ManagedSessionState {
|
|||||||
createdAt: string
|
createdAt: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Agent Manager session added to an existing worktree (no setup overlay needed)
|
||||||
|
export interface AgentManagerSessionAddedMessage {
|
||||||
|
type: "agentManager.sessionAdded"
|
||||||
|
sessionId: string
|
||||||
|
worktreeId: string
|
||||||
|
}
|
||||||
|
|
||||||
// Full state push from extension to webview
|
// Full state push from extension to webview
|
||||||
export interface AgentManagerStateMessage {
|
export interface AgentManagerStateMessage {
|
||||||
type: "agentManager.state"
|
type: "agentManager.state"
|
||||||
worktrees: WorktreeState[]
|
worktrees: WorktreeState[]
|
||||||
sessions: ManagedSessionState[]
|
sessions: ManagedSessionState[]
|
||||||
|
tabOrder?: Record<string, string[]>
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved keybindings for agent manager actions
|
||||||
|
export interface AgentManagerKeybindingsMessage {
|
||||||
|
type: "agentManager.keybindings"
|
||||||
|
bindings: Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ExtensionMessage =
|
export type ExtensionMessage =
|
||||||
@@ -605,7 +629,11 @@ export type ExtensionMessage =
|
|||||||
| AgentManagerSessionMetaMessage
|
| AgentManagerSessionMetaMessage
|
||||||
| AgentManagerRepoInfoMessage
|
| AgentManagerRepoInfoMessage
|
||||||
| AgentManagerWorktreeSetupMessage
|
| AgentManagerWorktreeSetupMessage
|
||||||
|
| AgentManagerSessionAddedMessage
|
||||||
| AgentManagerStateMessage
|
| AgentManagerStateMessage
|
||||||
|
| AgentManagerKeybindingsMessage
|
||||||
|
| SetChatBoxMessage
|
||||||
|
| TriggerTaskMessage
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Messages FROM webview TO extension
|
// Messages FROM webview TO extension
|
||||||
@@ -843,6 +871,28 @@ export interface RequestRepoInfoMessage {
|
|||||||
type: "agentManager.requestRepoInfo"
|
type: "agentManager.requestRepoInfo"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface RequestStateMessage {
|
||||||
|
type: "agentManager.requestState"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure worktree setup script
|
||||||
|
export interface ConfigureSetupScriptRequest {
|
||||||
|
type: "agentManager.configureSetupScript"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show terminal for a session
|
||||||
|
export interface ShowTerminalRequest {
|
||||||
|
type: "agentManager.showTerminal"
|
||||||
|
sessionId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Persist tab order for a context (worktree ID or "local")
|
||||||
|
export interface SetTabOrderRequest {
|
||||||
|
type: "agentManager.setTabOrder"
|
||||||
|
key: string
|
||||||
|
order: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export type WebviewMessage =
|
export type WebviewMessage =
|
||||||
| SendMessageRequest
|
| SendMessageRequest
|
||||||
| AbortRequest
|
| AbortRequest
|
||||||
@@ -888,6 +938,10 @@ export type WebviewMessage =
|
|||||||
| CloseSessionRequest
|
| CloseSessionRequest
|
||||||
| TelemetryRequest
|
| TelemetryRequest
|
||||||
| RequestRepoInfoMessage
|
| RequestRepoInfoMessage
|
||||||
|
| RequestStateMessage
|
||||||
|
| ConfigureSetupScriptRequest
|
||||||
|
| ShowTerminalRequest
|
||||||
|
| SetTabOrderRequest
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// VS Code API type
|
// VS Code API type
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"name": "@kilocode/cli",
|
"name": "@kilocode/cli",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -8,20 +8,6 @@ import { createDialogProviderOptions, DialogProvider } from "./dialog-provider"
|
|||||||
import { useKeybind } from "../context/keybind"
|
import { useKeybind } from "../context/keybind"
|
||||||
import * as fuzzysort from "fuzzysort"
|
import * as fuzzysort from "fuzzysort"
|
||||||
|
|
||||||
// kilocode_change start - Recommended models for Kilo Gateway (order determines display priority)
|
|
||||||
const KILO_RECOMMENDED_MODELS = [
|
|
||||||
"kilo/auto",
|
|
||||||
"anthropic/claude-sonnet-4.5",
|
|
||||||
"anthropic/claude-opus-4.5",
|
|
||||||
"anthropic/claude-haiku-4.5",
|
|
||||||
"openai/gpt-5.2",
|
|
||||||
"openai/gpt-5.2-codex",
|
|
||||||
"google/gemini-3-pro-preview",
|
|
||||||
"google/gemini-3-flash-preview",
|
|
||||||
"mistralai/devstral-2512",
|
|
||||||
]
|
|
||||||
// kilocode_change end
|
|
||||||
|
|
||||||
export function useConnected() {
|
export function useConnected() {
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
return createMemo(() =>
|
return createMemo(() =>
|
||||||
@@ -100,7 +86,7 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
: undefined,
|
: undefined,
|
||||||
// kilocode_change start
|
// kilocode_change start
|
||||||
category: connected()
|
category: connected()
|
||||||
? provider.id === "kilo" && KILO_RECOMMENDED_MODELS.includes(model)
|
? provider.id === "kilo" && info.recommended
|
||||||
? "Recommended"
|
? "Recommended"
|
||||||
: provider.name
|
: provider.name
|
||||||
: undefined,
|
: undefined,
|
||||||
@@ -123,11 +109,11 @@ export function DialogModel(props: { providerID?: string }) {
|
|||||||
sortBy(
|
sortBy(
|
||||||
// kilocode_change start - Sort recommended models first for Kilo Gateway
|
// kilocode_change start - Sort recommended models first for Kilo Gateway
|
||||||
(x) => {
|
(x) => {
|
||||||
if (x.value.providerID === "kilo") {
|
if (x.value.providerID !== "kilo") return 0
|
||||||
const idx = KILO_RECOMMENDED_MODELS.indexOf(x.value.modelID)
|
const provider = sync.data.provider.find((p) => p.id === "kilo")
|
||||||
return idx >= 0 ? idx : KILO_RECOMMENDED_MODELS.length + 1
|
const model = provider?.models[x.value.modelID]
|
||||||
}
|
if (model?.recommendedIndex !== undefined) return model.recommendedIndex
|
||||||
return 0
|
return Object.keys(provider?.models ?? {}).length
|
||||||
},
|
},
|
||||||
// kilocode_change end
|
// kilocode_change end
|
||||||
(x) => x.footer !== "Free",
|
(x) => x.footer !== "Free",
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { describe, expect, test, mock, beforeEach } from "bun:test"
|
||||||
|
import type { GitContext } from "../types"
|
||||||
|
|
||||||
|
// Mock dependencies before importing the module under test
|
||||||
|
|
||||||
|
let mockGitContext: GitContext = {
|
||||||
|
branch: "main",
|
||||||
|
recentCommits: ["abc1234 initial commit"],
|
||||||
|
files: [{ status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" }],
|
||||||
|
}
|
||||||
|
|
||||||
|
mock.module("../git-context", () => ({
|
||||||
|
getGitContext: async () => mockGitContext,
|
||||||
|
}))
|
||||||
|
|
||||||
|
let mockStreamText = "feat(src): add hello world logging"
|
||||||
|
|
||||||
|
mock.module("@/provider/provider", () => ({
|
||||||
|
Provider: {
|
||||||
|
defaultModel: async () => ({ providerID: "test", modelID: "test-model" }),
|
||||||
|
getSmallModel: async () => ({
|
||||||
|
providerID: "test",
|
||||||
|
id: "test-small-model",
|
||||||
|
}),
|
||||||
|
getModel: async () => ({ providerID: "test", id: "test-model" }),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/session/llm", () => ({
|
||||||
|
LLM: {
|
||||||
|
stream: async () => ({
|
||||||
|
text: Promise.resolve(mockStreamText),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/agent/agent", () => ({
|
||||||
|
Agent: {},
|
||||||
|
}))
|
||||||
|
|
||||||
|
mock.module("@/util/log", () => ({
|
||||||
|
Log: {
|
||||||
|
create: () => ({
|
||||||
|
info: () => {},
|
||||||
|
error: () => {},
|
||||||
|
warn: () => {},
|
||||||
|
debug: () => {},
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { generateCommitMessage } from "../generate"
|
||||||
|
|
||||||
|
describe("commit-message.generate", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGitContext = {
|
||||||
|
branch: "main",
|
||||||
|
recentCommits: ["abc1234 initial commit"],
|
||||||
|
files: [{ status: "modified" as const, path: "src/index.ts", diff: "+console.log('hello')" }],
|
||||||
|
}
|
||||||
|
mockStreamText = "feat(src): add hello world logging"
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("prompt construction", () => {
|
||||||
|
test("passes path to getGitContext", async () => {
|
||||||
|
const result = await generateCommitMessage({ path: "/my/repo" })
|
||||||
|
// If getGitContext is called, it returns our mock context and generates a message
|
||||||
|
expect(result.message).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("generates message from git context with multiple files", async () => {
|
||||||
|
mockGitContext = {
|
||||||
|
branch: "feature/api",
|
||||||
|
recentCommits: ["abc feat: add api", "def fix: typo"],
|
||||||
|
files: [
|
||||||
|
{ status: "added" as const, path: "src/api.ts", diff: "+export function api() {}" },
|
||||||
|
{ status: "modified" as const, path: "src/index.ts", diff: "+import { api } from './api'" },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
mockStreamText = "feat(api): add api module"
|
||||||
|
|
||||||
|
const result = await generateCommitMessage({ path: "/repo" })
|
||||||
|
expect(result.message).toBe("feat(api): add api module")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("response cleaning", () => {
|
||||||
|
test("strips code block markers from response", async () => {
|
||||||
|
mockStreamText = "```\nfeat: add feature\n```"
|
||||||
|
|
||||||
|
const result = await generateCommitMessage({ path: "/repo" })
|
||||||
|
expect(result.message).toBe("feat: add feature")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strips code block markers with language tag", async () => {
|
||||||
|
mockStreamText = "```text\nfix(auth): resolve token refresh\n```"
|
||||||
|
|
||||||
|
const result = await generateCommitMessage({ path: "/repo" })
|
||||||
|
expect(result.message).toBe("fix(auth): resolve token refresh")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strips surrounding double quotes", async () => {
|
||||||
|
mockStreamText = '"feat: add new feature"'
|
||||||
|
|
||||||
|
const result = await generateCommitMessage({ path: "/repo" })
|
||||||
|
expect(result.message).toBe("feat: add new feature")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strips surrounding single quotes", async () => {
|
||||||
|
mockStreamText = "'fix: resolve bug'"
|
||||||
|
|
||||||
|
const result = await generateCommitMessage({ path: "/repo" })
|
||||||
|
expect(result.message).toBe("fix: resolve bug")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strips whitespace around the message", async () => {
|
||||||
|
mockStreamText = " \n chore: update deps \n "
|
||||||
|
|
||||||
|
const result = await generateCommitMessage({ path: "/repo" })
|
||||||
|
expect(result.message).toBe("chore: update deps")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("strips code blocks AND quotes together", async () => {
|
||||||
|
mockStreamText = '```\n"refactor: simplify logic"\n```'
|
||||||
|
|
||||||
|
const result = await generateCommitMessage({ path: "/repo" })
|
||||||
|
expect(result.message).toBe("refactor: simplify logic")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns clean message when no markers present", async () => {
|
||||||
|
mockStreamText = "docs: update readme"
|
||||||
|
|
||||||
|
const result = await generateCommitMessage({ path: "/repo" })
|
||||||
|
expect(result.message).toBe("docs: update readme")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("error on no changes", () => {
|
||||||
|
test("throws when no git changes are found", async () => {
|
||||||
|
mockGitContext = {
|
||||||
|
branch: "main",
|
||||||
|
recentCommits: [],
|
||||||
|
files: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
await expect(generateCommitMessage({ path: "/repo" })).rejects.toThrow(
|
||||||
|
"No changes found to generate a commit message for",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("selectedFiles pass-through", () => {
|
||||||
|
test("passes selectedFiles to getGitContext", async () => {
|
||||||
|
// This verifies the function doesn't crash when selectedFiles is provided
|
||||||
|
const result = await generateCommitMessage({
|
||||||
|
path: "/repo",
|
||||||
|
selectedFiles: ["src/a.ts"],
|
||||||
|
})
|
||||||
|
expect(result.message).toBeTruthy()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,484 @@
|
|||||||
|
import { describe, expect, test, beforeEach, mock } from "bun:test"
|
||||||
|
|
||||||
|
// Mock Bun.spawnSync via mock.module so it integrates properly with bun:test
|
||||||
|
// and doesn't conflict with other test files that mock "../git-context".
|
||||||
|
const spawnSyncResults: Record<string, string> = {}
|
||||||
|
|
||||||
|
function setGitOutput(args: string, output: string) {
|
||||||
|
spawnSyncResults[args] = output
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGitOutputs() {
|
||||||
|
for (const key of Object.keys(spawnSyncResults)) {
|
||||||
|
delete spawnSyncResults[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the git-context module with a version that uses our mock spawnSync.
|
||||||
|
// This avoids conflicts with generate.test.ts which also mocks this module.
|
||||||
|
mock.module("../git-context", () => {
|
||||||
|
function git(args: string[], cwd: string): string {
|
||||||
|
const key = args.join(" ")
|
||||||
|
return spawnSyncResults[key] ?? ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const LOCK_FILES = new Set([
|
||||||
|
"package-lock.json",
|
||||||
|
"npm-shrinkwrap.json",
|
||||||
|
"yarn.lock",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"shrinkwrap.yaml",
|
||||||
|
"bun.lockb",
|
||||||
|
"bun.lock",
|
||||||
|
".pnp.js",
|
||||||
|
".pnp.cjs",
|
||||||
|
"jspm.lock",
|
||||||
|
"Pipfile.lock",
|
||||||
|
"poetry.lock",
|
||||||
|
"pdm.lock",
|
||||||
|
".pdm-lock.toml",
|
||||||
|
"uv.lock",
|
||||||
|
"conda-lock.yml",
|
||||||
|
"pylock.toml",
|
||||||
|
"Gemfile.lock",
|
||||||
|
"composer.lock",
|
||||||
|
"gradle.lockfile",
|
||||||
|
"lockfile.json",
|
||||||
|
"dependency-lock.json",
|
||||||
|
"dependency-reduced-pom.xml",
|
||||||
|
"coursier.lock",
|
||||||
|
"build.sbt.lock",
|
||||||
|
"packages.lock.json",
|
||||||
|
"paket.lock",
|
||||||
|
"project.assets.json",
|
||||||
|
"Cargo.lock",
|
||||||
|
"go.sum",
|
||||||
|
"Gopkg.lock",
|
||||||
|
"glide.lock",
|
||||||
|
"build.zig.zon.lock",
|
||||||
|
"dune.lock",
|
||||||
|
"opam.lock",
|
||||||
|
"Package.resolved",
|
||||||
|
"Podfile.lock",
|
||||||
|
"Cartfile.resolved",
|
||||||
|
"pubspec.lock",
|
||||||
|
"mix.lock",
|
||||||
|
"rebar.lock",
|
||||||
|
"stack.yaml.lock",
|
||||||
|
"cabal.project.freeze",
|
||||||
|
"exact-dependencies.json",
|
||||||
|
"shard.lock",
|
||||||
|
"Manifest.toml",
|
||||||
|
"JuliaManifest.toml",
|
||||||
|
"renv.lock",
|
||||||
|
"packrat.lock",
|
||||||
|
"nimble.lock",
|
||||||
|
"dub.selections.json",
|
||||||
|
"rocks.lock",
|
||||||
|
"carton.lock",
|
||||||
|
"cpanfile.snapshot",
|
||||||
|
"conan.lock",
|
||||||
|
"vcpkg-lock.json",
|
||||||
|
".terraform.lock.hcl",
|
||||||
|
"Berksfile.lock",
|
||||||
|
"Puppetfile.lock",
|
||||||
|
"MODULE.bazel.lock",
|
||||||
|
"flake.lock",
|
||||||
|
"deno.lock",
|
||||||
|
"devcontainer.lock.json",
|
||||||
|
])
|
||||||
|
|
||||||
|
const MAX_DIFF_LENGTH = 4000
|
||||||
|
|
||||||
|
function isLockFile(filepath: string): boolean {
|
||||||
|
const name = filepath.split("/").pop() ?? filepath
|
||||||
|
return LOCK_FILES.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNameStatus(output: string): Array<{ status: string; path: string }> {
|
||||||
|
if (!output) return []
|
||||||
|
return output.split("\n").map((line) => {
|
||||||
|
const [status, ...rest] = line.split("\t")
|
||||||
|
const path = status!.startsWith("R") ? (rest[1] ?? rest[0]) : rest.join("\t")
|
||||||
|
return { status: status!, path }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePorcelain(output: string): Array<{ status: string; path: string }> {
|
||||||
|
if (!output) return []
|
||||||
|
return output
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.map((line) => {
|
||||||
|
const xy = line.slice(0, 2)
|
||||||
|
const filepath = line.slice(3)
|
||||||
|
return { status: xy.trim(), path: filepath }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileStatus = "added" | "modified" | "deleted" | "renamed"
|
||||||
|
|
||||||
|
function mapStatus(code: string): FileStatus {
|
||||||
|
if (code.startsWith("R")) return "renamed"
|
||||||
|
if (code === "A" || code === "??" || code === "?") return "added"
|
||||||
|
if (code === "D") return "deleted"
|
||||||
|
if (code === "M") return "modified"
|
||||||
|
return "modified"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUntracked(code: string): boolean {
|
||||||
|
return code === "??" || code === "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getGitContext(repoPath: string, selectedFiles?: string[]) {
|
||||||
|
const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
|
||||||
|
const log = git(["log", "--oneline", "-5"], repoPath)
|
||||||
|
const recentCommits = log ? log.split("\n") : []
|
||||||
|
|
||||||
|
const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
|
||||||
|
const useStaged = staged.length > 0
|
||||||
|
const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
|
||||||
|
|
||||||
|
const selected = selectedFiles ? new Set(selectedFiles) : undefined
|
||||||
|
|
||||||
|
const files: Array<{ status: FileStatus; path: string; diff: string }> = []
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (isLockFile(entry.path)) continue
|
||||||
|
if (selected && !selected.has(entry.path)) continue
|
||||||
|
|
||||||
|
const status = mapStatus(entry.status)
|
||||||
|
const untracked = isUntracked(entry.status)
|
||||||
|
|
||||||
|
let diff: string
|
||||||
|
if (untracked) {
|
||||||
|
diff = `New untracked file: ${entry.path}`
|
||||||
|
} else if (status === "deleted") {
|
||||||
|
diff = useStaged
|
||||||
|
? git(["diff", "--cached", "--", entry.path], repoPath)
|
||||||
|
: git(["diff", "--", entry.path], repoPath)
|
||||||
|
} else {
|
||||||
|
const raw = useStaged
|
||||||
|
? git(["diff", "--cached", "--", entry.path], repoPath)
|
||||||
|
: git(["diff", "--", entry.path], repoPath)
|
||||||
|
if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
|
||||||
|
diff = `Binary file ${entry.path} has been modified`
|
||||||
|
} else {
|
||||||
|
diff = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff.length > MAX_DIFF_LENGTH) {
|
||||||
|
diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push({ status, path: entry.path, diff })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { branch, recentCommits, files }
|
||||||
|
}
|
||||||
|
|
||||||
|
return { getGitContext }
|
||||||
|
})
|
||||||
|
|
||||||
|
import { getGitContext } from "../git-context"
|
||||||
|
|
||||||
|
describe("commit-message.git-context", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearGitOutputs()
|
||||||
|
// Defaults
|
||||||
|
setGitOutput("branch --show-current", "main")
|
||||||
|
setGitOutput("log --oneline -5", "abc1234 initial commit")
|
||||||
|
setGitOutput("diff --name-status --cached", "")
|
||||||
|
setGitOutput("status --porcelain", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
// NOTE: git() trims stdout, which eats the leading space of the first
|
||||||
|
// porcelain line. We use staged (--name-status) tests for path-sensitive
|
||||||
|
// assertions and only use porcelain for behavior tests where this is acceptable.
|
||||||
|
|
||||||
|
describe("lock file filtering", () => {
|
||||||
|
test("filters out package-lock.json from staged changes", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/index.ts\nM\tpackage-lock.json")
|
||||||
|
setGitOutput("diff --cached -- src/index.ts", "+console.log('hello')")
|
||||||
|
setGitOutput("diff --cached -- package-lock.json", "+lots of lock content")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.path).toBe("src/index.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("filters out yarn.lock from staged changes", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/app.ts\nM\tyarn.lock")
|
||||||
|
setGitOutput("diff --cached -- src/app.ts", "+import x")
|
||||||
|
setGitOutput("diff --cached -- yarn.lock", "+lock data")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.path).toBe("src/app.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("filters out pnpm-lock.yaml from staged changes", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\treadme.md\nM\tpnpm-lock.yaml")
|
||||||
|
setGitOutput("diff --cached -- pnpm-lock.yaml", "+lock")
|
||||||
|
setGitOutput("diff --cached -- readme.md", "+docs")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.path).toBe("readme.md")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("filters lock files in subdirectories", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tpackages/api/package-lock.json\nM\tpackages/api/src/index.ts")
|
||||||
|
setGitOutput("diff --cached -- packages/api/package-lock.json", "+lock stuff")
|
||||||
|
setGitOutput("diff --cached -- packages/api/src/index.ts", "+code")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.path).toBe("packages/api/src/index.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("filters out bun.lockb, go.sum, Cargo.lock, poetry.lock", async () => {
|
||||||
|
setGitOutput(
|
||||||
|
"diff --name-status --cached",
|
||||||
|
"M\tbun.lockb\nM\tgo.sum\nM\tCargo.lock\nM\tpoetry.lock\nM\tsrc/main.rs",
|
||||||
|
)
|
||||||
|
setGitOutput("diff --cached -- bun.lockb", "binary")
|
||||||
|
setGitOutput("diff --cached -- go.sum", "+hash")
|
||||||
|
setGitOutput("diff --cached -- Cargo.lock", "+lock")
|
||||||
|
setGitOutput("diff --cached -- poetry.lock", "+lock")
|
||||||
|
setGitOutput("diff --cached -- src/main.rs", "+fn main() {}")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.path).toBe("src/main.rs")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("status parsing", () => {
|
||||||
|
test("parses staged added files", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "A\tsrc/new-file.ts")
|
||||||
|
setGitOutput("diff --cached -- src/new-file.ts", "+new content")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.status).toBe("added")
|
||||||
|
expect(ctx.files[0]!.path).toBe("src/new-file.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses staged modified files", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/existing.ts")
|
||||||
|
setGitOutput("diff --cached -- src/existing.ts", "+changed line")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.status).toBe("modified")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses staged deleted files", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "D\tsrc/removed.ts")
|
||||||
|
setGitOutput("diff --cached -- src/removed.ts", "-deleted content")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.status).toBe("deleted")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses staged renamed files", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "R100\told-name.ts\tnew-name.ts")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.status).toBe("renamed")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses untracked files from porcelain", async () => {
|
||||||
|
setGitOutput("status --porcelain", "?? src/brand-new.ts")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.status).toBe("added")
|
||||||
|
expect(ctx.files[0]!.diff).toBe("New untracked file: src/brand-new.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("parses porcelain modified files", async () => {
|
||||||
|
// Use staged to avoid porcelain trim edge case
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/changed.ts")
|
||||||
|
setGitOutput("diff --cached -- src/changed.ts", "+line")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.status).toBe("modified")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("prefers staged changes over unstaged", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/staged.ts")
|
||||||
|
setGitOutput("diff --cached -- src/staged.ts", "+staged change")
|
||||||
|
// unstaged also exists but should be ignored when staged is present
|
||||||
|
setGitOutput("status --porcelain", " M src/unstaged.ts")
|
||||||
|
setGitOutput("diff -- src/unstaged.ts", "+unstaged change")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.path).toBe("src/staged.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("mapStatus returns 'modified' for unknown codes", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "X\tsrc/weird.ts")
|
||||||
|
setGitOutput("diff --cached -- src/weird.ts", "+stuff")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files[0]!.status).toBe("modified")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("diff truncation", () => {
|
||||||
|
test("truncates diffs exceeding 4000 characters", async () => {
|
||||||
|
const longDiff = "x".repeat(5000)
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/big.ts")
|
||||||
|
setGitOutput("diff --cached -- src/big.ts", longDiff)
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.diff.length).toBeLessThan(5000)
|
||||||
|
expect(ctx.files[0]!.diff).toContain("... [truncated]")
|
||||||
|
// 4000 chars + "\n... [truncated]"
|
||||||
|
expect(ctx.files[0]!.diff.length).toBe(4000 + "\n... [truncated]".length)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not truncate diffs at exactly 4000 characters", async () => {
|
||||||
|
const exactDiff = "y".repeat(4000)
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/exact.ts")
|
||||||
|
setGitOutput("diff --cached -- src/exact.ts", exactDiff)
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files[0]!.diff).toBe(exactDiff)
|
||||||
|
expect(ctx.files[0]!.diff).not.toContain("... [truncated]")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not truncate diffs under 4000 characters", async () => {
|
||||||
|
const shortDiff = "z".repeat(100)
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/small.ts")
|
||||||
|
setGitOutput("diff --cached -- src/small.ts", shortDiff)
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files[0]!.diff).toBe(shortDiff)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("binary file detection", () => {
|
||||||
|
test("detects 'Binary files' in diff output", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tassets/logo.png")
|
||||||
|
setGitOutput("diff --cached -- assets/logo.png", "Binary files a/assets/logo.png and b/assets/logo.png differ")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.diff).toBe("Binary file assets/logo.png has been modified")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("detects 'GIT binary patch' in diff output", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tassets/icon.ico")
|
||||||
|
setGitOutput("diff --cached -- assets/icon.ico", "GIT binary patch\nliteral 1234\ndata...")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(1)
|
||||||
|
expect(ctx.files[0]!.diff).toBe("Binary file assets/icon.ico has been modified")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("does not flag normal diffs as binary", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/code.ts")
|
||||||
|
setGitOutput("diff --cached -- src/code.ts", "+const x = 1")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files[0]!.diff).toBe("+const x = 1")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("selected files filtering", () => {
|
||||||
|
test("only includes files in selectedFiles set", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts\nM\tsrc/c.ts")
|
||||||
|
setGitOutput("diff --cached -- src/a.ts", "+a")
|
||||||
|
setGitOutput("diff --cached -- src/b.ts", "+b")
|
||||||
|
setGitOutput("diff --cached -- src/c.ts", "+c")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo", ["src/a.ts", "src/c.ts"])
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(2)
|
||||||
|
const paths = ctx.files.map((f) => f.path)
|
||||||
|
expect(paths).toContain("src/a.ts")
|
||||||
|
expect(paths).toContain("src/c.ts")
|
||||||
|
expect(paths).not.toContain("src/b.ts")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes all files when selectedFiles is undefined", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts\nM\tsrc/b.ts")
|
||||||
|
setGitOutput("diff --cached -- src/a.ts", "+a")
|
||||||
|
setGitOutput("diff --cached -- src/b.ts", "+b")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns empty files when selectedFiles has no matches", async () => {
|
||||||
|
setGitOutput("diff --name-status --cached", "M\tsrc/a.ts")
|
||||||
|
setGitOutput("diff --cached -- src/a.ts", "+a")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo", ["src/nonexistent.ts"])
|
||||||
|
|
||||||
|
expect(ctx.files).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("branch and recent commits", () => {
|
||||||
|
test("returns current branch name", async () => {
|
||||||
|
setGitOutput("branch --show-current", "feature/my-branch")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.branch).toBe("feature/my-branch")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("falls back to HEAD when branch is empty", async () => {
|
||||||
|
setGitOutput("branch --show-current", "")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.branch).toBe("HEAD")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns recent commits as array", async () => {
|
||||||
|
setGitOutput("log --oneline -5", "abc1234 first\ndef5678 second\nghi9012 third")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.recentCommits).toEqual(["abc1234 first", "def5678 second", "ghi9012 third"])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("returns empty array when no commits", async () => {
|
||||||
|
setGitOutput("log --oneline -5", "")
|
||||||
|
|
||||||
|
const ctx = await getGitContext("/repo")
|
||||||
|
|
||||||
|
expect(ctx.recentCommits).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Provider } from "@/provider/provider"
|
||||||
|
import { LLM } from "@/session/llm"
|
||||||
|
import { Agent } from "@/agent/agent"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
import type { CommitMessageRequest, CommitMessageResponse, GitContext } from "./types"
|
||||||
|
import { getGitContext } from "./git-context"
|
||||||
|
|
||||||
|
const log = Log.create({ service: "commit-message" })
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are an expert Git commit message generator that creates conventional commit messages based on staged changes. Analyze the provided git diff output and generate an appropriate conventional commit message following the specification.
|
||||||
|
|
||||||
|
## Conventional Commits Format
|
||||||
|
Generate commit messages following this exact structure:
|
||||||
|
\`\`\`
|
||||||
|
<type>[optional scope]: <description>
|
||||||
|
|
||||||
|
[optional body]
|
||||||
|
|
||||||
|
[optional footer(s)]
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
### Core Types (Required)
|
||||||
|
- **feat**: New feature or functionality (MINOR version bump)
|
||||||
|
- **fix**: Bug fix or error correction (PATCH version bump)
|
||||||
|
|
||||||
|
### Additional Types (Extended)
|
||||||
|
- **docs**: Documentation changes only
|
||||||
|
- **style**: Code style changes (whitespace, formatting, semicolons, etc.)
|
||||||
|
- **refactor**: Code refactoring without feature changes or bug fixes
|
||||||
|
- **perf**: Performance improvements
|
||||||
|
- **test**: Adding or fixing tests
|
||||||
|
- **build**: Build system or external dependency changes
|
||||||
|
- **ci**: CI/CD configuration changes
|
||||||
|
- **chore**: Maintenance tasks, tooling changes
|
||||||
|
- **revert**: Reverting previous commits
|
||||||
|
|
||||||
|
### Scope Guidelines
|
||||||
|
- Use parentheses: \`feat(api):\`, \`fix(ui):\`
|
||||||
|
- Common scopes: \`api\`, \`ui\`, \`auth\`, \`db\`, \`config\`, \`deps\`, \`docs\`
|
||||||
|
- For monorepos: package or module names
|
||||||
|
- Keep scope concise and lowercase
|
||||||
|
|
||||||
|
### Description Rules
|
||||||
|
- Use imperative mood ("add" not "added" or "adds")
|
||||||
|
- Start with lowercase letter
|
||||||
|
- No period at the end
|
||||||
|
- Maximum 72 characters
|
||||||
|
- Be concise but descriptive
|
||||||
|
|
||||||
|
### Body Guidelines (Optional)
|
||||||
|
- Start one blank line after description
|
||||||
|
- Explain the "what" and "why", not the "how"
|
||||||
|
- Wrap at 72 characters per line
|
||||||
|
- Use for complex changes requiring explanation
|
||||||
|
|
||||||
|
### Footer Guidelines (Optional)
|
||||||
|
- Start one blank line after body
|
||||||
|
- **Breaking Changes**: \`BREAKING CHANGE: description\`
|
||||||
|
|
||||||
|
## Analysis Instructions
|
||||||
|
When analyzing staged changes:
|
||||||
|
1. Determine Primary Type based on the nature of changes
|
||||||
|
2. Identify Scope from modified directories or modules
|
||||||
|
3. Craft Description focusing on the most significant change
|
||||||
|
4. Determine if there are Breaking Changes
|
||||||
|
5. For complex changes, include a detailed body explaining what and why
|
||||||
|
6. Add appropriate footers for issue references or breaking changes
|
||||||
|
|
||||||
|
For significant changes, include a detailed body explaining the changes.
|
||||||
|
|
||||||
|
Return ONLY the commit message in the conventional format, nothing else.`
|
||||||
|
|
||||||
|
function buildUserMessage(ctx: GitContext): string {
|
||||||
|
const fileList = ctx.files.map((f) => `${f.status} ${f.path}`).join("\n")
|
||||||
|
const diffs = ctx.files
|
||||||
|
.filter((f) => f.diff)
|
||||||
|
.map((f) => `--- ${f.path} ---\n${f.diff}`)
|
||||||
|
.join("\n\n")
|
||||||
|
|
||||||
|
return `Generate a commit message for the following changes:
|
||||||
|
|
||||||
|
Branch: ${ctx.branch}
|
||||||
|
Recent commits:
|
||||||
|
${ctx.recentCommits.join("\n")}
|
||||||
|
|
||||||
|
Changed files:
|
||||||
|
${fileList}
|
||||||
|
|
||||||
|
Diffs:
|
||||||
|
${diffs}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function clean(text: string): string {
|
||||||
|
let result = text.trim()
|
||||||
|
// Strip code block markers
|
||||||
|
if (result.startsWith("```")) {
|
||||||
|
const first = result.indexOf("\n")
|
||||||
|
if (first !== -1) {
|
||||||
|
result = result.slice(first + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (result.endsWith("```")) {
|
||||||
|
result = result.slice(0, -3)
|
||||||
|
}
|
||||||
|
result = result.trim()
|
||||||
|
// Strip surrounding quotes
|
||||||
|
if ((result.startsWith('"') && result.endsWith('"')) || (result.startsWith("'") && result.endsWith("'"))) {
|
||||||
|
result = result.slice(1, -1)
|
||||||
|
}
|
||||||
|
return result.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateCommitMessage(request: CommitMessageRequest): Promise<CommitMessageResponse> {
|
||||||
|
const ctx = await getGitContext(request.path, request.selectedFiles)
|
||||||
|
if (ctx.files.length === 0) {
|
||||||
|
throw new Error("No changes found to generate a commit message for")
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("generating", {
|
||||||
|
branch: ctx.branch,
|
||||||
|
files: ctx.files.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
const defaultModel = await Provider.defaultModel()
|
||||||
|
const model =
|
||||||
|
(await Provider.getSmallModel(defaultModel.providerID)) ??
|
||||||
|
(await Provider.getModel(defaultModel.providerID, defaultModel.modelID))
|
||||||
|
|
||||||
|
const agent: Agent.Info = {
|
||||||
|
name: "commit-message",
|
||||||
|
mode: "primary",
|
||||||
|
hidden: true,
|
||||||
|
options: {},
|
||||||
|
permission: [],
|
||||||
|
prompt: SYSTEM_PROMPT,
|
||||||
|
temperature: 0.3,
|
||||||
|
}
|
||||||
|
|
||||||
|
let userMessage = buildUserMessage(ctx)
|
||||||
|
if (request.previousMessage) {
|
||||||
|
userMessage = `IMPORTANT: Generate a COMPLETELY DIFFERENT commit message from the previous one. The previous message was: "${request.previousMessage}". Use a different type, scope, or description approach.\n\n${userMessage}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await LLM.stream({
|
||||||
|
agent,
|
||||||
|
user: {
|
||||||
|
id: "commit-message",
|
||||||
|
sessionID: "commit-message",
|
||||||
|
role: "user",
|
||||||
|
model: {
|
||||||
|
providerID: model.providerID,
|
||||||
|
modelID: model.id,
|
||||||
|
},
|
||||||
|
time: {
|
||||||
|
created: Date.now(),
|
||||||
|
completed: Date.now(),
|
||||||
|
},
|
||||||
|
} as any,
|
||||||
|
tools: {},
|
||||||
|
model,
|
||||||
|
small: true,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user" as const,
|
||||||
|
content: userMessage,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
abort: new AbortController().signal,
|
||||||
|
sessionID: "commit-message",
|
||||||
|
system: [],
|
||||||
|
retries: 3,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await stream.text
|
||||||
|
log.info("generated", { message: result })
|
||||||
|
|
||||||
|
return { message: clean(result) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,230 @@
|
|||||||
|
import type { GitContext, FileChange } from "./types"
|
||||||
|
|
||||||
|
const LOCK_FILES = new Set([
|
||||||
|
// --- JavaScript / Node.js ---
|
||||||
|
"package-lock.json",
|
||||||
|
"npm-shrinkwrap.json",
|
||||||
|
"yarn.lock",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"shrinkwrap.yaml",
|
||||||
|
"bun.lockb",
|
||||||
|
"bun.lock",
|
||||||
|
".pnp.js",
|
||||||
|
".pnp.cjs",
|
||||||
|
"jspm.lock",
|
||||||
|
|
||||||
|
// --- Python ---
|
||||||
|
"Pipfile.lock",
|
||||||
|
"poetry.lock",
|
||||||
|
"pdm.lock",
|
||||||
|
".pdm-lock.toml",
|
||||||
|
"uv.lock",
|
||||||
|
"conda-lock.yml",
|
||||||
|
"pylock.toml",
|
||||||
|
|
||||||
|
// --- Ruby ---
|
||||||
|
"Gemfile.lock",
|
||||||
|
|
||||||
|
// --- PHP ---
|
||||||
|
"composer.lock",
|
||||||
|
|
||||||
|
// --- Java / JVM ---
|
||||||
|
"gradle.lockfile",
|
||||||
|
"lockfile.json",
|
||||||
|
"dependency-lock.json",
|
||||||
|
"dependency-reduced-pom.xml",
|
||||||
|
"coursier.lock",
|
||||||
|
|
||||||
|
// --- Scala ---
|
||||||
|
"build.sbt.lock",
|
||||||
|
|
||||||
|
// --- .NET ---
|
||||||
|
"packages.lock.json",
|
||||||
|
"paket.lock",
|
||||||
|
"project.assets.json",
|
||||||
|
|
||||||
|
// --- Rust ---
|
||||||
|
"Cargo.lock",
|
||||||
|
|
||||||
|
// --- Go ---
|
||||||
|
"go.sum",
|
||||||
|
"Gopkg.lock",
|
||||||
|
"glide.lock",
|
||||||
|
|
||||||
|
// --- Zig ---
|
||||||
|
"build.zig.zon.lock",
|
||||||
|
|
||||||
|
// --- OCaml ---
|
||||||
|
"dune.lock",
|
||||||
|
"opam.lock",
|
||||||
|
|
||||||
|
// --- Swift / iOS ---
|
||||||
|
"Package.resolved",
|
||||||
|
"Podfile.lock",
|
||||||
|
"Cartfile.resolved",
|
||||||
|
|
||||||
|
// --- Dart / Flutter ---
|
||||||
|
"pubspec.lock",
|
||||||
|
|
||||||
|
// --- Elixir / Erlang ---
|
||||||
|
"mix.lock",
|
||||||
|
"rebar.lock",
|
||||||
|
|
||||||
|
// --- Haskell ---
|
||||||
|
"stack.yaml.lock",
|
||||||
|
"cabal.project.freeze",
|
||||||
|
|
||||||
|
// --- Elm ---
|
||||||
|
"exact-dependencies.json",
|
||||||
|
|
||||||
|
// --- Crystal ---
|
||||||
|
"shard.lock",
|
||||||
|
|
||||||
|
// --- Julia ---
|
||||||
|
"Manifest.toml",
|
||||||
|
"JuliaManifest.toml",
|
||||||
|
|
||||||
|
// --- R ---
|
||||||
|
"renv.lock",
|
||||||
|
"packrat.lock",
|
||||||
|
|
||||||
|
// --- Nim ---
|
||||||
|
"nimble.lock",
|
||||||
|
|
||||||
|
// --- D ---
|
||||||
|
"dub.selections.json",
|
||||||
|
|
||||||
|
// --- Lua ---
|
||||||
|
"rocks.lock",
|
||||||
|
|
||||||
|
// --- Perl ---
|
||||||
|
"carton.lock",
|
||||||
|
"cpanfile.snapshot",
|
||||||
|
|
||||||
|
// --- C/C++ ---
|
||||||
|
"conan.lock",
|
||||||
|
"vcpkg-lock.json",
|
||||||
|
|
||||||
|
// --- Infrastructure as Code ---
|
||||||
|
".terraform.lock.hcl",
|
||||||
|
"Berksfile.lock",
|
||||||
|
"Puppetfile.lock",
|
||||||
|
"MODULE.bazel.lock",
|
||||||
|
|
||||||
|
// --- Nix ---
|
||||||
|
"flake.lock",
|
||||||
|
|
||||||
|
// --- Deno ---
|
||||||
|
"deno.lock",
|
||||||
|
|
||||||
|
// --- DevContainers ---
|
||||||
|
"devcontainer.lock.json",
|
||||||
|
])
|
||||||
|
|
||||||
|
const MAX_DIFF_LENGTH = 4000
|
||||||
|
|
||||||
|
function isLockFile(filepath: string): boolean {
|
||||||
|
const name = filepath.split("/").pop() ?? filepath
|
||||||
|
return LOCK_FILES.has(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
function git(args: string[], cwd: string): string {
|
||||||
|
const result = Bun.spawnSync(["git", ...args], {
|
||||||
|
cwd,
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe",
|
||||||
|
})
|
||||||
|
return result.stdout.toString().trimEnd()
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNameStatus(output: string): Array<{ status: string; path: string }> {
|
||||||
|
if (!output) return []
|
||||||
|
return output.split("\n").map((line) => {
|
||||||
|
const [status, ...rest] = line.split("\t")
|
||||||
|
let path: string
|
||||||
|
if (status!.startsWith("R")) {
|
||||||
|
// Rename: rest = ["old.ts", "new.ts"], use the new path
|
||||||
|
path = rest[1] ?? rest[0]
|
||||||
|
} else {
|
||||||
|
path = rest.join("\t")
|
||||||
|
}
|
||||||
|
return { status: status!, path }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePorcelain(output: string): Array<{ status: string; path: string }> {
|
||||||
|
if (!output) return []
|
||||||
|
return output
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.length > 0)
|
||||||
|
.map((line) => {
|
||||||
|
const xy = line.slice(0, 2)
|
||||||
|
const filepath = line.slice(3)
|
||||||
|
return { status: xy.trim(), path: filepath }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapStatus(code: string): FileChange["status"] {
|
||||||
|
if (code.startsWith("R")) return "renamed"
|
||||||
|
if (code === "A" || code === "??" || code === "?") return "added"
|
||||||
|
if (code === "D") return "deleted"
|
||||||
|
if (code === "M") return "modified"
|
||||||
|
return "modified"
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUntracked(code: string): boolean {
|
||||||
|
return code === "??" || code === "?"
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getGitContext(repoPath: string, selectedFiles?: string[]): Promise<GitContext> {
|
||||||
|
const branch = git(["branch", "--show-current"], repoPath) || "HEAD"
|
||||||
|
const log = git(["log", "--oneline", "-5"], repoPath)
|
||||||
|
const recentCommits = log ? log.split("\n") : []
|
||||||
|
|
||||||
|
// Check staged files first
|
||||||
|
const staged = parseNameStatus(git(["diff", "--name-status", "--cached"], repoPath))
|
||||||
|
const useStaged = staged.length > 0
|
||||||
|
|
||||||
|
// Fall back to all changes if nothing staged
|
||||||
|
const raw = useStaged ? staged : parsePorcelain(git(["status", "--porcelain"], repoPath))
|
||||||
|
|
||||||
|
const selected = selectedFiles ? new Set(selectedFiles) : undefined
|
||||||
|
|
||||||
|
const files: FileChange[] = []
|
||||||
|
for (const entry of raw) {
|
||||||
|
if (isLockFile(entry.path)) continue
|
||||||
|
if (selected && !selected.has(entry.path)) continue
|
||||||
|
|
||||||
|
const status = mapStatus(entry.status)
|
||||||
|
const untracked = isUntracked(entry.status)
|
||||||
|
|
||||||
|
let diff: string
|
||||||
|
if (untracked) {
|
||||||
|
diff = `New untracked file: ${entry.path}`
|
||||||
|
} else if (status === "deleted") {
|
||||||
|
diff = useStaged
|
||||||
|
? git(["diff", "--cached", "--", entry.path], repoPath)
|
||||||
|
: git(["diff", "--", entry.path], repoPath)
|
||||||
|
} else {
|
||||||
|
const raw = useStaged
|
||||||
|
? git(["diff", "--cached", "--", entry.path], repoPath)
|
||||||
|
: git(["diff", "--", entry.path], repoPath)
|
||||||
|
|
||||||
|
// Detect binary files
|
||||||
|
if (raw.includes("Binary files") || raw.includes("GIT binary patch")) {
|
||||||
|
diff = `Binary file ${entry.path} has been modified`
|
||||||
|
} else {
|
||||||
|
diff = raw
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Truncate large diffs
|
||||||
|
if (diff.length > MAX_DIFF_LENGTH) {
|
||||||
|
diff = diff.slice(0, MAX_DIFF_LENGTH) + "\n... [truncated]"
|
||||||
|
}
|
||||||
|
|
||||||
|
files.push({ status, path: entry.path, diff })
|
||||||
|
}
|
||||||
|
|
||||||
|
return { branch, recentCommits, files }
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { generateCommitMessage } from "./generate"
|
||||||
|
export type { CommitMessageRequest, CommitMessageResponse, GitContext, FileChange } from "./types"
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
export interface CommitMessageRequest {
|
||||||
|
/** Workspace/repo path */
|
||||||
|
path: string
|
||||||
|
/** Optional subset of files to include */
|
||||||
|
selectedFiles?: string[]
|
||||||
|
/** Previously generated message — when set, the LLM is asked to produce a different one */
|
||||||
|
previousMessage?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CommitMessageResponse {
|
||||||
|
/** The generated commit message */
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GitContext {
|
||||||
|
/** Current branch name */
|
||||||
|
branch: string
|
||||||
|
/** Last 5 commit summaries */
|
||||||
|
recentCommits: string[]
|
||||||
|
/** File changes with status and diff content */
|
||||||
|
files: FileChange[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileChange {
|
||||||
|
status: "added" | "modified" | "deleted" | "renamed"
|
||||||
|
path: string
|
||||||
|
/** Diff content, or placeholder for binary/untracked files */
|
||||||
|
diff: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
import { Agent } from "@/agent/agent"
|
||||||
|
import { Bus } from "@/bus"
|
||||||
|
import { TuiEvent } from "@/cli/cmd/tui/event"
|
||||||
|
import { Identifier } from "@/id/id"
|
||||||
|
import { Provider } from "@/provider/provider"
|
||||||
|
import { Question } from "@/question"
|
||||||
|
import { Session } from "@/session"
|
||||||
|
import { LLM } from "@/session/llm"
|
||||||
|
import { MessageV2 } from "@/session/message-v2"
|
||||||
|
import { Todo } from "@/session/todo"
|
||||||
|
import { Log } from "@/util/log"
|
||||||
|
|
||||||
|
function toText(item: MessageV2.WithParts): string {
|
||||||
|
return item.parts
|
||||||
|
.filter((part): part is MessageV2.TextPart => part.type === "text")
|
||||||
|
.map((part) => part.text)
|
||||||
|
.join("\n")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const HANDOVER_PROMPT = `You are summarizing a planning session to hand off to an implementation session.
|
||||||
|
|
||||||
|
The plan itself will be provided separately — do NOT repeat it. Instead, focus on information discovered during planning that would help the implementing agent but is NOT already in the plan text.
|
||||||
|
|
||||||
|
Produce a concise summary using this template:
|
||||||
|
---
|
||||||
|
## Discoveries
|
||||||
|
|
||||||
|
[Key findings from code exploration — architecture patterns, gotchas, edge cases, relevant existing code that the plan references but doesn't fully explain]
|
||||||
|
|
||||||
|
## Relevant Files
|
||||||
|
|
||||||
|
[Structured list of files/directories that were read or discussed, with brief notes on what's relevant in each]
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
[Any important context: conventions to follow, potential pitfalls, dependencies between steps, things the implementing agent should watch out for]
|
||||||
|
---
|
||||||
|
|
||||||
|
If there is nothing useful to add beyond what the plan already says, respond with an empty string.
|
||||||
|
Keep the summary concise — focus on high-entropy information that would save the implementing agent time.`
|
||||||
|
|
||||||
|
export function formatTodos(todos: Todo.Info[]): string {
|
||||||
|
if (!todos.length) return ""
|
||||||
|
const icons: Record<string, string> = {
|
||||||
|
completed: "[x]",
|
||||||
|
in_progress: "[~]",
|
||||||
|
cancelled: "[-]",
|
||||||
|
}
|
||||||
|
return todos.map((t) => `- ${icons[t.status] ?? "[ ]"} ${t.content}`).join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function generateHandover(input: {
|
||||||
|
messages: MessageV2.WithParts[]
|
||||||
|
model: MessageV2.User["model"]
|
||||||
|
abort?: AbortSignal
|
||||||
|
}): Promise<string> {
|
||||||
|
const log = Log.create({ service: "plan.followup" })
|
||||||
|
try {
|
||||||
|
const agent = await Agent.get("compaction")
|
||||||
|
const model = agent?.model
|
||||||
|
? await Provider.getModel(agent.model.providerID, agent.model.modelID)
|
||||||
|
: await Provider.getModel(input.model.providerID, input.model.modelID)
|
||||||
|
|
||||||
|
const sessionID = Identifier.ascending("session")
|
||||||
|
const userMsg: MessageV2.User = {
|
||||||
|
id: Identifier.ascending("message"),
|
||||||
|
sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
agent: "plan",
|
||||||
|
model: input.model,
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = await LLM.stream({
|
||||||
|
agent: agent ?? {
|
||||||
|
name: "compaction",
|
||||||
|
mode: "subagent",
|
||||||
|
permission: [],
|
||||||
|
options: {},
|
||||||
|
},
|
||||||
|
user: userMsg,
|
||||||
|
tools: {},
|
||||||
|
model,
|
||||||
|
small: true,
|
||||||
|
messages: [
|
||||||
|
...MessageV2.toModelMessages(input.messages, model),
|
||||||
|
{
|
||||||
|
role: "user" as const,
|
||||||
|
content: HANDOVER_PROMPT,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
abort: input.abort ? AbortSignal.any([input.abort, AbortSignal.timeout(60_000)]) : AbortSignal.timeout(60_000),
|
||||||
|
sessionID,
|
||||||
|
system: [],
|
||||||
|
retries: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result = await stream.text
|
||||||
|
return result.trim()
|
||||||
|
} catch (error) {
|
||||||
|
log.error("handover generation failed", { error })
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace PlanFollowup {
|
||||||
|
const log = Log.create({ service: "plan.followup" })
|
||||||
|
|
||||||
|
export const ANSWER_NEW_SESSION = "Start new session"
|
||||||
|
export const ANSWER_CONTINUE = "Continue here"
|
||||||
|
|
||||||
|
async function inject(input: {
|
||||||
|
sessionID: string
|
||||||
|
agent: string
|
||||||
|
model: MessageV2.User["model"]
|
||||||
|
text: string
|
||||||
|
synthetic?: boolean
|
||||||
|
}) {
|
||||||
|
const msg: MessageV2.User = {
|
||||||
|
id: Identifier.ascending("message"),
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
role: "user",
|
||||||
|
time: {
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
agent: input.agent,
|
||||||
|
model: input.model,
|
||||||
|
}
|
||||||
|
await Session.updateMessage(msg)
|
||||||
|
await Session.updatePart({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
messageID: msg.id,
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
type: "text",
|
||||||
|
text: input.text,
|
||||||
|
synthetic: input.synthetic ?? true,
|
||||||
|
} satisfies MessageV2.TextPart)
|
||||||
|
}
|
||||||
|
|
||||||
|
function prompt(input: { sessionID: string; abort: AbortSignal }) {
|
||||||
|
const promise = Question.ask({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
questions: [
|
||||||
|
{
|
||||||
|
question: "Ready to implement?",
|
||||||
|
header: "Implement",
|
||||||
|
custom: true,
|
||||||
|
options: [
|
||||||
|
{
|
||||||
|
label: ANSWER_NEW_SESSION,
|
||||||
|
description: "Implement in a fresh session with a clean context",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: ANSWER_CONTINUE,
|
||||||
|
description: "Implement the plan in this session",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const listener = () =>
|
||||||
|
Question.list().then((qs) => {
|
||||||
|
const match = qs.find((q) => q.sessionID === input.sessionID)
|
||||||
|
if (match) Question.reject(match.id)
|
||||||
|
})
|
||||||
|
input.abort.addEventListener("abort", listener, { once: true })
|
||||||
|
|
||||||
|
return promise
|
||||||
|
.catch((error) => {
|
||||||
|
if (error instanceof Question.RejectedError) return undefined
|
||||||
|
throw error
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
input.abort.removeEventListener("abort", listener)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startNew(input: {
|
||||||
|
sessionID: string
|
||||||
|
plan: string
|
||||||
|
messages: MessageV2.WithParts[]
|
||||||
|
model: MessageV2.User["model"]
|
||||||
|
abort?: AbortSignal
|
||||||
|
}) {
|
||||||
|
const [handover, todos] = await Promise.all([
|
||||||
|
generateHandover({ messages: input.messages, model: input.model, abort: input.abort }),
|
||||||
|
Todo.get(input.sessionID),
|
||||||
|
])
|
||||||
|
|
||||||
|
const sections = [`Implement the following plan:\n\n${input.plan}`]
|
||||||
|
|
||||||
|
if (handover) {
|
||||||
|
sections.push(`## Handover from Planning Session\n\n${handover}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const todoList = formatTodos(todos)
|
||||||
|
if (todoList) {
|
||||||
|
sections.push(`## Todo List\n\n${todoList}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = await Session.create({})
|
||||||
|
await inject({
|
||||||
|
sessionID: next.id,
|
||||||
|
agent: "code",
|
||||||
|
model: input.model,
|
||||||
|
text: sections.join("\n\n"),
|
||||||
|
synthetic: false,
|
||||||
|
})
|
||||||
|
if (todos.length) {
|
||||||
|
await Todo.update({ sessionID: next.id, todos })
|
||||||
|
}
|
||||||
|
await Bus.publish(TuiEvent.SessionSelect, { sessionID: next.id })
|
||||||
|
void import("@/session/prompt")
|
||||||
|
.then((item) => item.SessionPrompt.loop({ sessionID: next.id }))
|
||||||
|
.catch((error) => {
|
||||||
|
log.error("failed to start follow-up session", { sessionID: next.id, error })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ask(input: {
|
||||||
|
sessionID: string
|
||||||
|
messages: MessageV2.WithParts[]
|
||||||
|
abort: AbortSignal
|
||||||
|
}): Promise<"continue" | "break"> {
|
||||||
|
if (input.abort.aborted) return "break"
|
||||||
|
|
||||||
|
const latest = input.messages.slice().reverse()
|
||||||
|
const assistant = latest.find((msg) => msg.info.role === "assistant")
|
||||||
|
if (!assistant) return "break"
|
||||||
|
|
||||||
|
const plan = toText(assistant)
|
||||||
|
if (!plan) return "break"
|
||||||
|
|
||||||
|
const user = latest.find((msg) => msg.info.role === "user")?.info
|
||||||
|
if (!user || user.role !== "user" || !user.model) return "break"
|
||||||
|
|
||||||
|
const answers = await prompt({ sessionID: input.sessionID, abort: input.abort })
|
||||||
|
if (!answers) return "break"
|
||||||
|
|
||||||
|
const answer = answers[0]?.[0]?.trim()
|
||||||
|
if (!answer) return "break"
|
||||||
|
|
||||||
|
if (answer === ANSWER_NEW_SESSION) {
|
||||||
|
await startNew({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
plan,
|
||||||
|
messages: input.messages,
|
||||||
|
model: user.model,
|
||||||
|
abort: input.abort,
|
||||||
|
})
|
||||||
|
return "break"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answer === ANSWER_CONTINUE) {
|
||||||
|
await inject({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: "code",
|
||||||
|
model: user.model,
|
||||||
|
text: "Implement the plan above.",
|
||||||
|
})
|
||||||
|
return "continue"
|
||||||
|
}
|
||||||
|
|
||||||
|
await inject({
|
||||||
|
sessionID: input.sessionID,
|
||||||
|
agent: "plan",
|
||||||
|
model: user.model,
|
||||||
|
text: answer,
|
||||||
|
})
|
||||||
|
return "continue"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,8 @@ export namespace ModelsDev {
|
|||||||
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
output: z.array(z.enum(["text", "audio", "image", "video", "pdf"])),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
recommended: z.boolean().optional(), // kilocode_change
|
||||||
|
recommendedIndex: z.number().optional(), // kilocode_change
|
||||||
experimental: z.boolean().optional(),
|
experimental: z.boolean().optional(),
|
||||||
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
status: z.enum(["alpha", "beta", "deprecated"]).optional(),
|
||||||
options: z.record(z.string(), z.any()),
|
options: z.record(z.string(), z.any()),
|
||||||
|
|||||||
@@ -621,6 +621,8 @@ export namespace Provider {
|
|||||||
headers: z.record(z.string(), z.string()),
|
headers: z.record(z.string(), z.string()),
|
||||||
release_date: z.string(),
|
release_date: z.string(),
|
||||||
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
variants: z.record(z.string(), z.record(z.string(), z.any())).optional(),
|
||||||
|
recommended: z.boolean().optional(), // kilocode_change
|
||||||
|
recommendedIndex: z.number().optional(), // kilocode_change
|
||||||
})
|
})
|
||||||
.meta({
|
.meta({
|
||||||
ref: "Model",
|
ref: "Model",
|
||||||
@@ -702,6 +704,8 @@ export namespace Provider {
|
|||||||
},
|
},
|
||||||
release_date: model.release_date,
|
release_date: model.release_date,
|
||||||
variants: {},
|
variants: {},
|
||||||
|
recommended: model.recommended, // kilocode_change
|
||||||
|
recommendedIndex: model.recommendedIndex, // kilocode_change
|
||||||
}
|
}
|
||||||
|
|
||||||
m.variants = mapValues(ProviderTransform.variants(m), (v) => v)
|
m.variants = mapValues(ProviderTransform.variants(m), (v) => v)
|
||||||
@@ -845,6 +849,8 @@ export namespace Provider {
|
|||||||
family: model.family ?? existingModel?.family ?? "",
|
family: model.family ?? existingModel?.family ?? "",
|
||||||
release_date: model.release_date ?? existingModel?.release_date ?? "",
|
release_date: model.release_date ?? existingModel?.release_date ?? "",
|
||||||
variants: {},
|
variants: {},
|
||||||
|
recommended: model.recommended ?? existingModel?.recommended, // kilocode_change
|
||||||
|
recommendedIndex: model.recommendedIndex ?? existingModel?.recommendedIndex, // kilocode_change
|
||||||
}
|
}
|
||||||
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
|
const merged = mergeDeep(ProviderTransform.variants(parsedModel), model.variants ?? {})
|
||||||
parsedModel.variants = mapValues(
|
parsedModel.variants = mapValues(
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Hono } from "hono"
|
||||||
|
import { describeRoute, resolver, validator } from "hono-openapi"
|
||||||
|
import z from "zod"
|
||||||
|
import { generateCommitMessage } from "../../commit-message"
|
||||||
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { errors } from "../error"
|
||||||
|
|
||||||
|
export const CommitMessageRoutes = lazy(() =>
|
||||||
|
new Hono().post(
|
||||||
|
"/",
|
||||||
|
describeRoute({
|
||||||
|
summary: "Generate commit message",
|
||||||
|
description: "Generate a commit message using AI based on the current git diff.",
|
||||||
|
operationId: "commitMessage.generate",
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Generated commit message",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: resolver(z.object({ message: z.string() })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
...errors(400),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
validator(
|
||||||
|
"json",
|
||||||
|
z.object({
|
||||||
|
path: z.string().meta({ description: "Workspace/repo path" }),
|
||||||
|
selectedFiles: z.array(z.string()).optional().meta({ description: "Optional subset of files to include" }),
|
||||||
|
previousMessage: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.meta({ description: "Previously generated message — triggers regeneration with a different result" }),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
async (c) => {
|
||||||
|
const body = c.req.valid("json")
|
||||||
|
const result = await generateCommitMessage(body)
|
||||||
|
return c.json({ message: result.message })
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -38,6 +38,7 @@ import type { ContentfulStatusCode } from "hono/utils/http-status"
|
|||||||
import { websocket } from "hono/bun"
|
import { websocket } from "hono/bun"
|
||||||
import { HTTPException } from "hono/http-exception"
|
import { HTTPException } from "hono/http-exception"
|
||||||
import { errors } from "./error"
|
import { errors } from "./error"
|
||||||
|
import { CommitMessageRoutes } from "./routes/commit-message"
|
||||||
import { QuestionRoutes } from "./routes/question"
|
import { QuestionRoutes } from "./routes/question"
|
||||||
import { PermissionRoutes } from "./routes/permission"
|
import { PermissionRoutes } from "./routes/permission"
|
||||||
import { GlobalRoutes } from "./routes/global"
|
import { GlobalRoutes } from "./routes/global"
|
||||||
@@ -235,6 +236,7 @@ export namespace Server {
|
|||||||
.route("/question", QuestionRoutes())
|
.route("/question", QuestionRoutes())
|
||||||
.route("/provider", ProviderRoutes())
|
.route("/provider", ProviderRoutes())
|
||||||
.route("/telemetry", TelemetryRoutes()) // kilocode_change
|
.route("/telemetry", TelemetryRoutes()) // kilocode_change
|
||||||
|
.route("/commit-message", CommitMessageRoutes()) // kilocode_change
|
||||||
// kilocode_change start - Kilo Gateway routes
|
// kilocode_change start - Kilo Gateway routes
|
||||||
.route(
|
.route(
|
||||||
"/kilo",
|
"/kilo",
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import { DEFAULT_HEADERS } from "@/kilocode/const" // kilocode_change
|
|||||||
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
|
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
|
||||||
// kilocode_change start
|
// kilocode_change start
|
||||||
import { getKiloProjectId } from "@/kilocode/project-id"
|
import { getKiloProjectId } from "@/kilocode/project-id"
|
||||||
import { HEADER_PROJECTID, HEADER_MACHINEID } from "@kilocode/kilo-gateway"
|
import { HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway"
|
||||||
import { Identity } from "@kilocode/kilo-telemetry"
|
import { Identity } from "@kilocode/kilo-telemetry"
|
||||||
// kilocode_change end
|
// kilocode_change end
|
||||||
|
|
||||||
@@ -239,9 +239,10 @@ export namespace LLM {
|
|||||||
? DEFAULT_HEADERS // kilocode_change
|
? DEFAULT_HEADERS // kilocode_change
|
||||||
: undefined),
|
: undefined),
|
||||||
...(isKilo && input.agent.name ? { "x-kilocode-mode": input.agent.name.toLowerCase() } : {}),
|
...(isKilo && input.agent.name ? { "x-kilocode-mode": input.agent.name.toLowerCase() } : {}),
|
||||||
// kilocode_change start - add project ID and machine ID headers for kilo provider
|
// kilocode_change start - add project ID, machine ID, and task ID headers for kilo provider
|
||||||
...(isKilo && kiloProjectId ? { [HEADER_PROJECTID]: kiloProjectId } : {}),
|
...(isKilo && kiloProjectId ? { [HEADER_PROJECTID]: kiloProjectId } : {}),
|
||||||
...(isKilo && machineId ? { [HEADER_MACHINEID]: machineId } : {}),
|
...(isKilo && machineId ? { [HEADER_MACHINEID]: machineId } : {}),
|
||||||
|
...(isKilo ? { [HEADER_TASKID]: input.sessionID } : {}),
|
||||||
// kilocode_change end
|
// kilocode_change end
|
||||||
...input.model.headers,
|
...input.model.headers,
|
||||||
...headers,
|
...headers,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Config } from "@/config/config"
|
|||||||
import { SessionCompaction } from "./compaction"
|
import { SessionCompaction } from "./compaction"
|
||||||
import { PermissionNext } from "@/permission/next"
|
import { PermissionNext } from "@/permission/next"
|
||||||
import { Question } from "@/question"
|
import { Question } from "@/question"
|
||||||
|
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
|
||||||
|
|
||||||
export namespace SessionProcessor {
|
export namespace SessionProcessor {
|
||||||
const DOOM_LOOP_THRESHOLD = 3
|
const DOOM_LOOP_THRESHOLD = 3
|
||||||
@@ -34,6 +35,7 @@ export namespace SessionProcessor {
|
|||||||
let blocked = false
|
let blocked = false
|
||||||
let attempt = 0
|
let attempt = 0
|
||||||
let needsCompaction = false
|
let needsCompaction = false
|
||||||
|
let stepStart = 0 // kilocode_change
|
||||||
|
|
||||||
const result = {
|
const result = {
|
||||||
get message() {
|
get message() {
|
||||||
@@ -223,6 +225,7 @@ export namespace SessionProcessor {
|
|||||||
throw value.error
|
throw value.error
|
||||||
|
|
||||||
case "start-step":
|
case "start-step":
|
||||||
|
stepStart = performance.now() // kilocode_change
|
||||||
snapshot = await Snapshot.track()
|
snapshot = await Snapshot.track()
|
||||||
await Session.updatePart({
|
await Session.updatePart({
|
||||||
id: Identifier.ascending("part"),
|
id: Identifier.ascending("part"),
|
||||||
@@ -239,6 +242,26 @@ export namespace SessionProcessor {
|
|||||||
usage: value.usage,
|
usage: value.usage,
|
||||||
metadata: value.providerMetadata,
|
metadata: value.providerMetadata,
|
||||||
})
|
})
|
||||||
|
// kilocode_change start
|
||||||
|
if (
|
||||||
|
usage.tokens.input > 0 ||
|
||||||
|
usage.tokens.output > 0 ||
|
||||||
|
usage.tokens.cache.write > 0 ||
|
||||||
|
usage.tokens.cache.read > 0
|
||||||
|
) {
|
||||||
|
Telemetry.trackLlmCompletion({
|
||||||
|
sessionId: input.sessionID,
|
||||||
|
provider: input.model.providerID,
|
||||||
|
model: input.model.id,
|
||||||
|
inputTokens: usage.tokens.input,
|
||||||
|
outputTokens: usage.tokens.output,
|
||||||
|
cacheReadTokens: usage.tokens.cache.read,
|
||||||
|
cacheWriteTokens: usage.tokens.cache.write,
|
||||||
|
cost: usage.cost,
|
||||||
|
completionTime: Math.round(performance.now() - stepStart),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// kilocode_change end
|
||||||
input.assistantMessage.finish = value.finishReason
|
input.assistantMessage.finish = value.finishReason
|
||||||
input.assistantMessage.cost += usage.cost
|
input.assistantMessage.cost += usage.cost
|
||||||
input.assistantMessage.tokens = usage.tokens
|
input.assistantMessage.tokens = usage.tokens
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { LLM } from "./llm"
|
|||||||
import { iife } from "@/util/iife"
|
import { iife } from "@/util/iife"
|
||||||
import { Shell } from "@/shell/shell"
|
import { Shell } from "@/shell/shell"
|
||||||
import { Truncate } from "@/tool/truncation"
|
import { Truncate } from "@/tool/truncation"
|
||||||
|
import { PlanFollowup } from "@/kilocode/plan-followup" // kilocode_change
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
globalThis.AI_SDK_LOG_WARNINGS = false
|
globalThis.AI_SDK_LOG_WARNINGS = false
|
||||||
@@ -336,6 +337,12 @@ export namespace SessionPrompt {
|
|||||||
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
|
!["tool-calls", "unknown"].includes(lastAssistant.finish) &&
|
||||||
lastUser.id < lastAssistant.id
|
lastUser.id < lastAssistant.id
|
||||||
) {
|
) {
|
||||||
|
// kilocode_change start - ask follow-up after plan agent completes
|
||||||
|
if (lastUser.agent === "plan" && !abort.aborted && ["cli", "vscode"].includes(Flag.KILO_CLIENT)) {
|
||||||
|
const action = await PlanFollowup.ask({ sessionID, messages: msgs, abort })
|
||||||
|
if (action === "continue") continue
|
||||||
|
}
|
||||||
|
// kilocode_change end
|
||||||
log.info("exiting loop", { sessionID })
|
log.info("exiting loop", { sessionID })
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,547 @@
|
|||||||
|
import { describe, expect, spyOn, test } from "bun:test"
|
||||||
|
import { Agent } from "../../src/agent/agent"
|
||||||
|
import { Bus } from "../../src/bus"
|
||||||
|
import { TuiEvent } from "../../src/cli/cmd/tui/event"
|
||||||
|
import { Identifier } from "../../src/id/id"
|
||||||
|
import { formatTodos, generateHandover, PlanFollowup } from "../../src/kilocode/plan-followup"
|
||||||
|
import { Instance } from "../../src/project/instance"
|
||||||
|
import { Provider } from "../../src/provider/provider"
|
||||||
|
import { Question } from "../../src/question"
|
||||||
|
import { Session } from "../../src/session"
|
||||||
|
import { LLM } from "../../src/session/llm"
|
||||||
|
import { MessageV2 } from "../../src/session/message-v2"
|
||||||
|
import { SessionPrompt } from "../../src/session/prompt"
|
||||||
|
import { Todo } from "../../src/session/todo"
|
||||||
|
import { Log } from "../../src/util/log"
|
||||||
|
import { tmpdir } from "../fixture/fixture"
|
||||||
|
|
||||||
|
Log.init({ print: false })
|
||||||
|
|
||||||
|
const model = {
|
||||||
|
providerID: "openai",
|
||||||
|
modelID: "gpt-4",
|
||||||
|
}
|
||||||
|
|
||||||
|
async function withInstance(fn: () => Promise<void>) {
|
||||||
|
await using tmp = await tmpdir({ git: true })
|
||||||
|
await Instance.provide({ directory: tmp.path, fn })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seed(input: {
|
||||||
|
text: string
|
||||||
|
tools?: Array<{ tool: string; input: Record<string, unknown>; output: string }>
|
||||||
|
}) {
|
||||||
|
const session = await Session.create({})
|
||||||
|
const user = await Session.updateMessage({
|
||||||
|
id: Identifier.ascending("message"),
|
||||||
|
role: "user",
|
||||||
|
sessionID: session.id,
|
||||||
|
time: {
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
agent: "plan",
|
||||||
|
model,
|
||||||
|
})
|
||||||
|
await Session.updatePart({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
messageID: user.id,
|
||||||
|
sessionID: session.id,
|
||||||
|
type: "text",
|
||||||
|
text: "Create a plan",
|
||||||
|
})
|
||||||
|
|
||||||
|
const assistant: MessageV2.Assistant = {
|
||||||
|
id: Identifier.ascending("message"),
|
||||||
|
role: "assistant",
|
||||||
|
sessionID: session.id,
|
||||||
|
time: {
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
parentID: user.id,
|
||||||
|
modelID: model.modelID,
|
||||||
|
providerID: model.providerID,
|
||||||
|
mode: "plan",
|
||||||
|
agent: "plan",
|
||||||
|
path: {
|
||||||
|
cwd: Instance.directory,
|
||||||
|
root: Instance.worktree,
|
||||||
|
},
|
||||||
|
cost: 0,
|
||||||
|
tokens: {
|
||||||
|
total: 0,
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
finish: "end_turn",
|
||||||
|
}
|
||||||
|
await Session.updateMessage(assistant)
|
||||||
|
await Session.updatePart({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
messageID: assistant.id,
|
||||||
|
sessionID: session.id,
|
||||||
|
type: "text",
|
||||||
|
text: input.text,
|
||||||
|
})
|
||||||
|
|
||||||
|
for (const t of input.tools ?? []) {
|
||||||
|
await Session.updatePart({
|
||||||
|
id: Identifier.ascending("part"),
|
||||||
|
messageID: assistant.id,
|
||||||
|
sessionID: session.id,
|
||||||
|
type: "tool",
|
||||||
|
callID: Identifier.ascending("tool"),
|
||||||
|
tool: t.tool,
|
||||||
|
state: {
|
||||||
|
status: "completed",
|
||||||
|
input: t.input,
|
||||||
|
output: t.output,
|
||||||
|
title: t.tool,
|
||||||
|
metadata: {},
|
||||||
|
time: { start: Date.now(), end: Date.now() },
|
||||||
|
},
|
||||||
|
} satisfies MessageV2.ToolPart)
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = await Session.messages({ sessionID: session.id })
|
||||||
|
return {
|
||||||
|
sessionID: session.id,
|
||||||
|
messages,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function latestUser(sessionID: string) {
|
||||||
|
const messages = await Session.messages({ sessionID })
|
||||||
|
return messages
|
||||||
|
.slice()
|
||||||
|
.reverse()
|
||||||
|
.find((item) => item.info.role === "user")
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sessions() {
|
||||||
|
return Array.fromAsync(Session.list())
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeAgent: Agent.Info = {
|
||||||
|
name: "compaction",
|
||||||
|
mode: "subagent",
|
||||||
|
permission: [],
|
||||||
|
options: {},
|
||||||
|
}
|
||||||
|
|
||||||
|
const fakeModel = {
|
||||||
|
id: "gpt-4",
|
||||||
|
providerID: "openai",
|
||||||
|
limit: { context: 128000, input: 0 },
|
||||||
|
api: { id: "openai", npm: "@ai-sdk/openai" },
|
||||||
|
capabilities: {},
|
||||||
|
} as Provider.Model
|
||||||
|
|
||||||
|
function mockHandoverDeps(text: string, opts?: { agent?: Agent.Info | null }) {
|
||||||
|
const agentSpy = spyOn(Agent, "get").mockResolvedValue(
|
||||||
|
(opts?.agent === null ? undefined : (opts?.agent ?? fakeAgent)) as any,
|
||||||
|
)
|
||||||
|
const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
|
||||||
|
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
|
||||||
|
text: Promise.resolve(text),
|
||||||
|
} as any)
|
||||||
|
return {
|
||||||
|
agentSpy,
|
||||||
|
modelSpy,
|
||||||
|
llmSpy,
|
||||||
|
[Symbol.dispose]() {
|
||||||
|
agentSpy.mockRestore()
|
||||||
|
modelSpy.mockRestore()
|
||||||
|
llmSpy.mockRestore()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("plan follow-up", () => {
|
||||||
|
test("ask - returns break when dismissed", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
const seeded = await seed({ text: "1. Step one\n2. Step two" })
|
||||||
|
const pending = PlanFollowup.ask({
|
||||||
|
sessionID: seeded.sessionID,
|
||||||
|
messages: seeded.messages,
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await Question.list()
|
||||||
|
expect(list).toHaveLength(1)
|
||||||
|
await Question.reject(list[0].id)
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBe("break")
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("ask - returns continue and creates code message on Continue here", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
const seeded = await seed({ text: "1. Build\n2. Test" })
|
||||||
|
const pending = PlanFollowup.ask({
|
||||||
|
sessionID: seeded.sessionID,
|
||||||
|
messages: seeded.messages,
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await Question.list()
|
||||||
|
await Question.reply({
|
||||||
|
requestID: list[0].id,
|
||||||
|
answers: [[PlanFollowup.ANSWER_CONTINUE]],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBe("continue")
|
||||||
|
|
||||||
|
const user = await latestUser(seeded.sessionID)
|
||||||
|
expect(user?.info.role).toBe("user")
|
||||||
|
if (!user || user.info.role !== "user") return
|
||||||
|
expect(user.info.agent).toBe("code")
|
||||||
|
|
||||||
|
const part = user.parts.find((item) => item.type === "text")
|
||||||
|
expect(part?.type).toBe("text")
|
||||||
|
if (!part || part.type !== "text") return
|
||||||
|
expect(part.text).toBe("Implement the plan above.")
|
||||||
|
expect(part.synthetic).toBe(true)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("ask - returns continue and creates plan message for custom text", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
const seeded = await seed({ text: "1. Build\n2. Test" })
|
||||||
|
const pending = PlanFollowup.ask({
|
||||||
|
sessionID: seeded.sessionID,
|
||||||
|
messages: seeded.messages,
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await Question.reply({
|
||||||
|
requestID: (await Question.list())[0].id,
|
||||||
|
answers: [["Add rollback support too"]],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBe("continue")
|
||||||
|
|
||||||
|
const user = await latestUser(seeded.sessionID)
|
||||||
|
expect(user?.info.role).toBe("user")
|
||||||
|
if (!user || user.info.role !== "user") return
|
||||||
|
expect(user.info.agent).toBe("plan")
|
||||||
|
|
||||||
|
const part = user.parts.find((item) => item.type === "text")
|
||||||
|
expect(part?.type).toBe("text")
|
||||||
|
if (!part || part.type !== "text") return
|
||||||
|
expect(part.text).toBe("Add rollback support too")
|
||||||
|
expect(part.synthetic).toBe(true)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("ask - creates a new session on Start new session with handover and todos", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({
|
||||||
|
info: {
|
||||||
|
id: "msg_test",
|
||||||
|
role: "assistant",
|
||||||
|
sessionID: "ses_test",
|
||||||
|
time: {
|
||||||
|
created: Date.now(),
|
||||||
|
},
|
||||||
|
parentID: "msg_parent",
|
||||||
|
modelID: "test",
|
||||||
|
providerID: "test",
|
||||||
|
mode: "code",
|
||||||
|
agent: "code",
|
||||||
|
path: {
|
||||||
|
cwd: "/tmp",
|
||||||
|
root: "/tmp",
|
||||||
|
},
|
||||||
|
cost: 0,
|
||||||
|
tokens: {
|
||||||
|
total: 0,
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: {
|
||||||
|
read: 0,
|
||||||
|
write: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parts: [],
|
||||||
|
})
|
||||||
|
using _mocks = mockHandoverDeps(
|
||||||
|
"## Discoveries\n\nFound REST endpoints in src/api.ts\n\n## Relevant Files\n\n- src/api.ts: REST endpoints\n- src/db.ts: Database layer",
|
||||||
|
)
|
||||||
|
using _loop = {
|
||||||
|
[Symbol.dispose]() {
|
||||||
|
loop.mockRestore()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const seeded = await seed({
|
||||||
|
text: "1. Add API\n2. Add tests",
|
||||||
|
})
|
||||||
|
|
||||||
|
await Todo.update({
|
||||||
|
sessionID: seeded.sessionID,
|
||||||
|
todos: [
|
||||||
|
{ id: "1", content: "Add API endpoint", status: "completed", priority: "high" },
|
||||||
|
{ id: "2", content: "Write tests", status: "pending", priority: "medium" },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
const before = await sessions()
|
||||||
|
const created = [] as string[]
|
||||||
|
const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => {
|
||||||
|
created.push(event.properties.sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pending = PlanFollowup.ask({
|
||||||
|
sessionID: seeded.sessionID,
|
||||||
|
messages: seeded.messages,
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await Question.reply({
|
||||||
|
requestID: (await Question.list())[0].id,
|
||||||
|
answers: [[PlanFollowup.ANSWER_NEW_SESSION]],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBe("break")
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
const after = await sessions()
|
||||||
|
const prev = new Set(before.map((item) => item.id))
|
||||||
|
const added = after.filter((item) => !prev.has(item.id))
|
||||||
|
expect(added).toHaveLength(1)
|
||||||
|
expect(created).toHaveLength(1)
|
||||||
|
expect(loop).toHaveBeenCalledTimes(1)
|
||||||
|
expect(_mocks.llmSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
const newSessionID = created[0]
|
||||||
|
expect(added[0].id).toBe(newSessionID)
|
||||||
|
const messages = await Session.messages({ sessionID: newSessionID })
|
||||||
|
const user = messages.find((item) => item.info.role === "user")
|
||||||
|
expect(user?.info.role).toBe("user")
|
||||||
|
if (!user || user.info.role !== "user") throw new Error("expected seeded user message")
|
||||||
|
expect(user.info.agent).toBe("code")
|
||||||
|
|
||||||
|
const part = user.parts.find((item) => item.type === "text")
|
||||||
|
expect(part?.type).toBe("text")
|
||||||
|
if (!part || part.type !== "text") throw new Error("expected text part")
|
||||||
|
expect(part.text).toContain("Implement the following plan:")
|
||||||
|
expect(part.text).toContain("1. Add API\n2. Add tests")
|
||||||
|
expect(part.text).toContain("## Handover from Planning Session")
|
||||||
|
expect(part.text).toContain("Found REST endpoints in src/api.ts")
|
||||||
|
expect(part.text).toContain("## Todo List")
|
||||||
|
expect(part.text).toContain("[x] Add API endpoint")
|
||||||
|
expect(part.text).toContain("[ ] Write tests")
|
||||||
|
expect(part.synthetic).toBe(false)
|
||||||
|
|
||||||
|
const newTodos = await Todo.get(newSessionID)
|
||||||
|
expect(newTodos).toHaveLength(2)
|
||||||
|
expect(newTodos).toContainEqual({ id: "1", content: "Add API endpoint", status: "completed", priority: "high" })
|
||||||
|
expect(newTodos).toContainEqual({ id: "2", content: "Write tests", status: "pending", priority: "medium" })
|
||||||
|
|
||||||
|
SessionPrompt.cancel(newSessionID)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("ask - new session omits handover section when LLM returns empty", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
const loop = spyOn(SessionPrompt, "loop").mockResolvedValue({
|
||||||
|
info: {
|
||||||
|
id: "msg_test",
|
||||||
|
role: "assistant",
|
||||||
|
sessionID: "ses_test",
|
||||||
|
time: { created: Date.now() },
|
||||||
|
parentID: "msg_parent",
|
||||||
|
modelID: "test",
|
||||||
|
providerID: "test",
|
||||||
|
mode: "code",
|
||||||
|
agent: "code",
|
||||||
|
path: { cwd: "/tmp", root: "/tmp" },
|
||||||
|
cost: 0,
|
||||||
|
tokens: {
|
||||||
|
total: 0,
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
reasoning: 0,
|
||||||
|
cache: { read: 0, write: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
parts: [],
|
||||||
|
})
|
||||||
|
using _mocks = mockHandoverDeps("")
|
||||||
|
using _loop = {
|
||||||
|
[Symbol.dispose]() {
|
||||||
|
loop.mockRestore()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const seeded = await seed({ text: "1. Add API\n2. Add tests" })
|
||||||
|
const created = [] as string[]
|
||||||
|
const unsub = Bus.subscribe(TuiEvent.SessionSelect, (event) => {
|
||||||
|
created.push(event.properties.sessionID)
|
||||||
|
})
|
||||||
|
|
||||||
|
const pending = PlanFollowup.ask({
|
||||||
|
sessionID: seeded.sessionID,
|
||||||
|
messages: seeded.messages,
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await Question.reply({
|
||||||
|
requestID: (await Question.list())[0].id,
|
||||||
|
answers: [[PlanFollowup.ANSWER_NEW_SESSION]],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBe("break")
|
||||||
|
unsub()
|
||||||
|
|
||||||
|
const messages = await Session.messages({ sessionID: created[0] })
|
||||||
|
const user = messages.find((item) => item.info.role === "user")
|
||||||
|
if (!user || user.info.role !== "user") throw new Error("expected user message")
|
||||||
|
const part = user.parts.find((item) => item.type === "text")
|
||||||
|
if (!part || part.type !== "text") throw new Error("expected text part")
|
||||||
|
expect(part.text).toContain("Implement the following plan:")
|
||||||
|
expect(part.text).not.toContain("## Handover from Planning Session")
|
||||||
|
expect(part.text).not.toContain("## Todo List")
|
||||||
|
|
||||||
|
SessionPrompt.cancel(created[0])
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("ask - returns break when assistant text is empty", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
const seeded = await seed({ text: " " })
|
||||||
|
const result = await PlanFollowup.ask({
|
||||||
|
sessionID: seeded.sessionID,
|
||||||
|
messages: seeded.messages,
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe("break")
|
||||||
|
expect(await Question.list()).toHaveLength(0)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("ask - returns break when already aborted", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
const abort = new AbortController()
|
||||||
|
abort.abort()
|
||||||
|
|
||||||
|
const result = await PlanFollowup.ask({
|
||||||
|
sessionID: "ses_test",
|
||||||
|
messages: [],
|
||||||
|
abort: abort.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result).toBe("break")
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("ask - returns break when aborted while question is pending", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
const abort = new AbortController()
|
||||||
|
const seeded = await seed({ text: "1. Step one\n2. Step two" })
|
||||||
|
const pending = PlanFollowup.ask({
|
||||||
|
sessionID: seeded.sessionID,
|
||||||
|
messages: seeded.messages,
|
||||||
|
abort: abort.signal,
|
||||||
|
})
|
||||||
|
|
||||||
|
const list = await Question.list()
|
||||||
|
expect(list).toHaveLength(1)
|
||||||
|
|
||||||
|
abort.abort()
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBe("break")
|
||||||
|
expect(await Question.list()).toHaveLength(0)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("ask - returns break for blank custom answer", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
const seeded = await seed({ text: "1. Build\n2. Test" })
|
||||||
|
const pending = PlanFollowup.ask({
|
||||||
|
sessionID: seeded.sessionID,
|
||||||
|
messages: seeded.messages,
|
||||||
|
abort: AbortSignal.any([]),
|
||||||
|
})
|
||||||
|
|
||||||
|
await Question.reply({
|
||||||
|
requestID: (await Question.list())[0].id,
|
||||||
|
answers: [[" "]],
|
||||||
|
})
|
||||||
|
|
||||||
|
await expect(pending).resolves.toBe("break")
|
||||||
|
expect((await Session.messages({ sessionID: seeded.sessionID })).length).toBe(2)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("formatTodos - returns empty string for no todos", () => {
|
||||||
|
expect(formatTodos([])).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("formatTodos - formats todos with status icons", () => {
|
||||||
|
const todos: Todo.Info[] = [
|
||||||
|
{ id: "1", content: "Set up project", status: "completed", priority: "high" },
|
||||||
|
{ id: "2", content: "Write code", status: "in_progress", priority: "high" },
|
||||||
|
{ id: "3", content: "Add tests", status: "pending", priority: "medium" },
|
||||||
|
{ id: "4", content: "Dropped task", status: "cancelled", priority: "low" },
|
||||||
|
]
|
||||||
|
const result = formatTodos(todos)
|
||||||
|
expect(result).toBe("- [x] Set up project\n- [~] Write code\n- [ ] Add tests\n- [-] Dropped task")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("generateHandover - returns empty string on LLM.stream failure", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
const agentSpy = spyOn(Agent, "get").mockResolvedValue(fakeAgent)
|
||||||
|
const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
|
||||||
|
const llmSpy = spyOn(LLM, "stream").mockRejectedValue(new Error("provider unavailable"))
|
||||||
|
using _ = {
|
||||||
|
[Symbol.dispose]() {
|
||||||
|
agentSpy.mockRestore()
|
||||||
|
modelSpy.mockRestore()
|
||||||
|
llmSpy.mockRestore()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const seeded = await seed({ text: "1. Build\n2. Test" })
|
||||||
|
const result = await generateHandover({ messages: seeded.messages, model })
|
||||||
|
expect(result).toBe("")
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("generateHandover - returns empty string on stream.text rejection", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
const agentSpy = spyOn(Agent, "get").mockResolvedValue(fakeAgent)
|
||||||
|
const modelSpy = spyOn(Provider, "getModel").mockResolvedValue(fakeModel)
|
||||||
|
const textPromise = new Promise<string>((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error("stream aborted")), 0)
|
||||||
|
})
|
||||||
|
textPromise.catch(() => {})
|
||||||
|
const llmSpy = spyOn(LLM, "stream").mockResolvedValue({
|
||||||
|
text: textPromise,
|
||||||
|
} as any)
|
||||||
|
using _ = {
|
||||||
|
[Symbol.dispose]() {
|
||||||
|
agentSpy.mockRestore()
|
||||||
|
modelSpy.mockRestore()
|
||||||
|
llmSpy.mockRestore()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const seeded = await seed({ text: "1. Build\n2. Test" })
|
||||||
|
const result = await generateHandover({ messages: seeded.messages, model })
|
||||||
|
expect(result).toBe("")
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("generateHandover - uses fallback agent when compaction agent is not configured", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
using mocks = mockHandoverDeps("## Discoveries\n\nFallback works", { agent: null })
|
||||||
|
const seeded = await seed({ text: "1. Build\n2. Test" })
|
||||||
|
const result = await generateHandover({ messages: seeded.messages, model })
|
||||||
|
expect(result).toBe("## Discoveries\n\nFallback works")
|
||||||
|
expect(mocks.agentSpy).toHaveBeenCalledWith("compaction")
|
||||||
|
expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
|
||||||
|
}))
|
||||||
|
|
||||||
|
test("generateHandover - returns LLM output on success", () =>
|
||||||
|
withInstance(async () => {
|
||||||
|
using mocks = mockHandoverDeps("## Discoveries\n\nKey finding here")
|
||||||
|
const seeded = await seed({ text: "1. Build\n2. Test" })
|
||||||
|
const result = await generateHandover({ messages: seeded.messages, model })
|
||||||
|
expect(result).toBe("## Discoveries\n\nKey finding here")
|
||||||
|
expect(mocks.llmSpy).toHaveBeenCalledTimes(1)
|
||||||
|
}))
|
||||||
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@kilocode/plugin",
|
"name": "@kilocode/plugin",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
"exports": {
|
"exports": {
|
||||||
".": "./src/index.ts"
|
".": "./src/index.ts"
|
||||||
},
|
},
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"dependencies": {},
|
"dependencies": {},
|
||||||
"peerDependencies": {}
|
"peerDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/package.json",
|
"$schema": "https://json.schemastore.org/package.json",
|
||||||
"name": "@kilocode/sdk",
|
"name": "@kilocode/sdk",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import type {
|
|||||||
AuthSetErrors,
|
AuthSetErrors,
|
||||||
AuthSetResponses,
|
AuthSetResponses,
|
||||||
CommandListResponses,
|
CommandListResponses,
|
||||||
|
CommitMessageGenerateErrors,
|
||||||
|
CommitMessageGenerateResponses,
|
||||||
Config as Config3,
|
Config as Config3,
|
||||||
ConfigGetResponses,
|
ConfigGetResponses,
|
||||||
ConfigProvidersResponses,
|
ConfigProvidersResponses,
|
||||||
@@ -2217,6 +2219,51 @@ export class Telemetry extends HeyApiClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class CommitMessage extends HeyApiClient {
|
||||||
|
/**
|
||||||
|
* Generate commit message
|
||||||
|
*
|
||||||
|
* Generate a commit message using AI based on the current git diff.
|
||||||
|
*/
|
||||||
|
public generate<ThrowOnError extends boolean = false>(
|
||||||
|
parameters?: {
|
||||||
|
directory?: string
|
||||||
|
path?: string
|
||||||
|
selectedFiles?: Array<string>
|
||||||
|
previousMessage?: string
|
||||||
|
},
|
||||||
|
options?: Options<never, ThrowOnError>,
|
||||||
|
) {
|
||||||
|
const params = buildClientParams(
|
||||||
|
[parameters],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
args: [
|
||||||
|
{ in: "query", key: "directory" },
|
||||||
|
{ in: "body", key: "path" },
|
||||||
|
{ in: "body", key: "selectedFiles" },
|
||||||
|
{ in: "body", key: "previousMessage" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return (options?.client ?? this.client).post<
|
||||||
|
CommitMessageGenerateResponses,
|
||||||
|
CommitMessageGenerateErrors,
|
||||||
|
ThrowOnError
|
||||||
|
>({
|
||||||
|
url: "/commit-message",
|
||||||
|
...options,
|
||||||
|
...params,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...options?.headers,
|
||||||
|
...params.headers,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export class Organization extends HeyApiClient {
|
export class Organization extends HeyApiClient {
|
||||||
/**
|
/**
|
||||||
* Update Kilo Gateway organization
|
* Update Kilo Gateway organization
|
||||||
@@ -3439,6 +3486,11 @@ export class OpencodeClient extends HeyApiClient {
|
|||||||
return (this._telemetry ??= new Telemetry({ client: this.client }))
|
return (this._telemetry ??= new Telemetry({ client: this.client }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _commitMessage?: CommitMessage
|
||||||
|
get commitMessage(): CommitMessage {
|
||||||
|
return (this._commitMessage ??= new CommitMessage({ client: this.client }))
|
||||||
|
}
|
||||||
|
|
||||||
private _kilo?: Kilo
|
private _kilo?: Kilo
|
||||||
get kilo(): Kilo {
|
get kilo(): Kilo {
|
||||||
return (this._kilo ??= new Kilo({ client: this.client }))
|
return (this._kilo ??= new Kilo({ client: this.client }))
|
||||||
|
|||||||
@@ -1564,6 +1564,8 @@ export type ProviderConfig = {
|
|||||||
input: Array<"text" | "audio" | "image" | "video" | "pdf">
|
input: Array<"text" | "audio" | "image" | "video" | "pdf">
|
||||||
output: Array<"text" | "audio" | "image" | "video" | "pdf">
|
output: Array<"text" | "audio" | "image" | "video" | "pdf">
|
||||||
}
|
}
|
||||||
|
recommended?: boolean
|
||||||
|
recommendedIndex?: number
|
||||||
experimental?: boolean
|
experimental?: boolean
|
||||||
status?: "alpha" | "beta" | "deprecated"
|
status?: "alpha" | "beta" | "deprecated"
|
||||||
options?: {
|
options?: {
|
||||||
@@ -2017,6 +2019,8 @@ export type Model = {
|
|||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
recommended?: boolean
|
||||||
|
recommendedIndex?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export type Provider = {
|
export type Provider = {
|
||||||
@@ -4087,6 +4091,8 @@ export type ProviderListResponses = {
|
|||||||
input: Array<"text" | "audio" | "image" | "video" | "pdf">
|
input: Array<"text" | "audio" | "image" | "video" | "pdf">
|
||||||
output: Array<"text" | "audio" | "image" | "video" | "pdf">
|
output: Array<"text" | "audio" | "image" | "video" | "pdf">
|
||||||
}
|
}
|
||||||
|
recommended?: boolean
|
||||||
|
recommendedIndex?: number
|
||||||
experimental?: boolean
|
experimental?: boolean
|
||||||
status?: "alpha" | "beta" | "deprecated"
|
status?: "alpha" | "beta" | "deprecated"
|
||||||
options: {
|
options: {
|
||||||
@@ -4252,6 +4258,48 @@ export type TelemetryCaptureResponses = {
|
|||||||
|
|
||||||
export type TelemetryCaptureResponse = TelemetryCaptureResponses[keyof TelemetryCaptureResponses]
|
export type TelemetryCaptureResponse = TelemetryCaptureResponses[keyof TelemetryCaptureResponses]
|
||||||
|
|
||||||
|
export type CommitMessageGenerateData = {
|
||||||
|
body?: {
|
||||||
|
/**
|
||||||
|
* Workspace/repo path
|
||||||
|
*/
|
||||||
|
path: string
|
||||||
|
/**
|
||||||
|
* Optional subset of files to include
|
||||||
|
*/
|
||||||
|
selectedFiles?: Array<string>
|
||||||
|
/**
|
||||||
|
* Previously generated message — triggers regeneration with a different result
|
||||||
|
*/
|
||||||
|
previousMessage?: string
|
||||||
|
}
|
||||||
|
path?: never
|
||||||
|
query?: {
|
||||||
|
directory?: string
|
||||||
|
}
|
||||||
|
url: "/commit-message"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommitMessageGenerateErrors = {
|
||||||
|
/**
|
||||||
|
* Bad request
|
||||||
|
*/
|
||||||
|
400: BadRequestError
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommitMessageGenerateError = CommitMessageGenerateErrors[keyof CommitMessageGenerateErrors]
|
||||||
|
|
||||||
|
export type CommitMessageGenerateResponses = {
|
||||||
|
/**
|
||||||
|
* Generated commit message
|
||||||
|
*/
|
||||||
|
200: {
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CommitMessageGenerateResponse = CommitMessageGenerateResponses[keyof CommitMessageGenerateResponses]
|
||||||
|
|
||||||
export type KiloProfileData = {
|
export type KiloProfileData = {
|
||||||
body?: never
|
body?: never
|
||||||
path?: never
|
path?: never
|
||||||
|
|||||||
@@ -3768,6 +3768,12 @@
|
|||||||
},
|
},
|
||||||
"required": ["input", "output"]
|
"required": ["input", "output"]
|
||||||
},
|
},
|
||||||
|
"recommended": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"recommendedIndex": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
"experimental": {
|
"experimental": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -4116,6 +4122,83 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/commit-message": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "commitMessage.generate",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "query",
|
||||||
|
"name": "directory",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Generate commit message",
|
||||||
|
"description": "Generate a commit message using AI based on the current git diff.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Generated commit message",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["message"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad request",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/BadRequestError"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"requestBody": {
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"path": {
|
||||||
|
"description": "Workspace/repo path",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"selectedFiles": {
|
||||||
|
"description": "Optional subset of files to include",
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"previousMessage": {
|
||||||
|
"description": "Previously generated message — triggers regeneration with a different result",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["path"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"x-codeSamples": [
|
||||||
|
{
|
||||||
|
"lang": "js",
|
||||||
|
"source": "import { createOpencodeClient } from \"@kilocode/sdk\n\nconst client = createOpencodeClient()\nawait client.commitMessage.generate({\n ...\n})"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/kilo/profile": {
|
"/kilo/profile": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "kilo.profile",
|
"operationId": "kilo.profile",
|
||||||
@@ -9812,6 +9895,12 @@
|
|||||||
},
|
},
|
||||||
"required": ["input", "output"]
|
"required": ["input", "output"]
|
||||||
},
|
},
|
||||||
|
"recommended": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"recommendedIndex": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
"experimental": {
|
"experimental": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -10795,6 +10884,12 @@
|
|||||||
},
|
},
|
||||||
"additionalProperties": {}
|
"additionalProperties": {}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"recommended": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"recommendedIndex": {
|
||||||
|
"type": "number"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/ui",
|
"name": "@opencode-ai/ui",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"exports": {
|
"exports": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@opencode-ai/util",
|
"name": "@opencode-ai/util",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@kilocode/upstream-merge",
|
"name": "@kilocode/upstream-merge",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "Scripts for automating upstream opencode merges into Kilo",
|
"description": "Scripts for automating upstream opencode merges into Kilo",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "opencode",
|
"name": "opencode",
|
||||||
"displayName": "opencode",
|
"displayName": "opencode",
|
||||||
"description": "opencode for VS Code",
|
"description": "opencode for VS Code",
|
||||||
"version": "1.0.23",
|
"version": "1.0.24",
|
||||||
"publisher": "sst-dev",
|
"publisher": "sst-dev",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
Reference in New Issue
Block a user