Merge branch 'dev' into fix-406

This commit is contained in:
Catriel Müller
2026-02-20 09:34:46 -03:00
committed by GitHub
59 changed files with 2718 additions and 259 deletions
+1
View File
@@ -6,6 +6,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
- The default branch in this repo is `dev`.
- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs.
- Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility.
- You may be running in a git worktree. All changes must be made in your current working directory — never modify files in the main repo checkout.
## Build and Dev
+30 -14
View File
@@ -23,7 +23,7 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@kilocode/kilo-i18n": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
@@ -75,7 +75,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@opencode-ai/app": "workspace:*",
"@opencode-ai/ui": "workspace:*",
@@ -108,7 +108,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "1.0.0",
"version": "1.0.24",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -136,7 +136,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@clack/prompts": "1.0.0-alpha.1",
"@kilocode/plugin": "workspace:*",
@@ -168,7 +168,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "1.0.23",
"version": "1.0.24",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -178,7 +178,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"@opentelemetry/api": "1.9.0",
@@ -198,7 +198,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@kobalte/core": "0.13.11",
},
@@ -221,13 +221,14 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.0.23",
"version": "7.0.24",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-i18n": "workspace:*",
"@kilocode/kilo-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "^7.0.0",
"dotenv": "^16.4.7",
"eventsource": "^2.0.2",
@@ -257,13 +258,14 @@
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"qrcode": "^1.5.4",
"ts-morph": "27.0.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0",
},
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "1.0.23",
"version": "1.0.24",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilocode",
@@ -373,7 +375,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"zod": "catalog:",
@@ -387,14 +389,14 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "1.0.23",
"version": "1.0.24",
"devDependencies": {
"@types/bun": "catalog:",
},
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "1.0.23",
"version": "1.0.24",
"devDependencies": {
"@hey-api/openapi-ts": "0.90.10",
"@tsconfig/node22": "catalog:",
@@ -405,7 +407,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -447,7 +449,7 @@
},
"packages/util": {
"name": "@opencode-ai/util",
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {
"zod": "catalog:",
},
@@ -1582,6 +1584,8 @@
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
"@ts-morph/common": ["@ts-morph/common@0.28.1", "", { "dependencies": { "minimatch": "^10.0.1", "path-browserify": "^1.0.1", "tinyglobby": "^0.2.14" } }, "sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g=="],
"@tsconfig/bun": ["@tsconfig/bun@1.0.9", "", {}, "sha512-4M0/Ivfwcpz325z6CwSifOBZYji3DFOEpY6zEUt0+Xi2qRhzwvmqQN9XAHJh3OVvRJuAqVTLU2abdCplvp6mwQ=="],
"@tsconfig/node22": ["@tsconfig/node22@22.0.2", "", {}, "sha512-Kmwj4u8sDRDrMYRoN9FDEcXD8UpBSaPQQ24Gz+Gamqfm7xxn+GBR7ge/Z7pK8OXNGyUzbSwJj+TH6B+DS/epyA=="],
@@ -1914,6 +1918,8 @@
"cliui": ["cliui@9.0.1", "", { "dependencies": { "string-width": "^7.2.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w=="],
"code-block-writer": ["code-block-writer@13.0.3", "", {}, "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
@@ -2634,6 +2640,8 @@
"partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
@@ -3018,6 +3026,8 @@
"ts-dedent": ["ts-dedent@2.2.0", "", {}, "sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ=="],
"ts-morph": ["ts-morph@27.0.2", "", { "dependencies": { "@ts-morph/common": "~0.28.1", "code-block-writer": "^13.0.3" } }, "sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tunnel": ["tunnel@0.0.6", "", {}, "sha512-1h/Lnq9yajKY2PEbBadPXj3VxsDDu844OnaAo52UVmIzIvwwtBPIuNvkjuzBlTWpfJyUbG3ez0KSBibQkj4ojg=="],
@@ -3418,6 +3428,8 @@
"@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="],
"@ts-morph/common/minimatch": ["minimatch@10.2.1", "", { "dependencies": { "brace-expansion": "^5.0.2" } }, "sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"@vscode/test-cli/chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="],
@@ -3772,6 +3784,8 @@
"@tailwindcss/vite/@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"@ts-morph/common/minimatch/brace-expansion": ["brace-expansion@5.0.2", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw=="],
"@vscode/test-cli/chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="],
"@vscode/test-cli/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
@@ -4078,6 +4092,8 @@
"@tailwindcss/vite/@tailwindcss/oxide/@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@ts-morph/common/minimatch/brace-expansion/balanced-match": ["balanced-match@4.0.3", "", {}, "sha512-1pHv8LX9CpKut1Zp4EXey7Z8OfH11ONNH6Dhi2WDUt31VVZFXZzKwXcysBgqSumFCmR+0dqjMK5v5JiFHzi0+g=="],
"@vscode/test-cli/chokidar/readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="],
"@vscode/test-cli/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-BEhWiPWkXCUblpkXVAIDmWndNbuulgfaDRO80dvssPY=",
"aarch64-linux": "sha256-8ttezrn/CuIJDoAS1SZvq0laoQR6kBcrvv615uCxCJg=",
"aarch64-darwin": "sha256-krlzO1o/VVOC15hneG9W1dgp3Od96QFqZ0wKnjt89c4=",
"x86_64-darwin": "sha256-WjIaEu3jIQTqdWLQ29nacIraxsxZp5YMvzYC0j+pBMY="
"x86_64-linux": "sha256-V1SML9naH7HShbdovoQIUc46rH6YNgFF9MSTw95k0fU=",
"aarch64-linux": "sha256-wddgxfbK7v7O/zsdbx0e/TNYAw0KzzxyQbuUdwudf/Y=",
"aarch64-darwin": "sha256-y7bMXWMSiHTHfKKiJ+c/2WRJmc7mCw2o601Ny8W5Zzs=",
"x86_64-darwin": "sha256-hXdKlmVFWq2C0kJWgO1+g82B+/fvn9CG/JQyR31QtzY="
}
}
+1 -1
View File
@@ -102,6 +102,6 @@
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
},
"version": "1.0.23",
"version": "1.0.24",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.0.23",
"version": "1.0.24",
"description": "",
"type": "module",
"exports": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"scripts": {
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "1.0.23"
version = "1.0.24"
schema_version = 1
authors = ["Anomaly"]
repository = "https://github.com/Kilo-Org/kilo"
@@ -11,26 +11,26 @@ name = "Kilo"
icon = "./icons/opencode.svg"
[agent_servers.opencode.targets.darwin-aarch64]
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-darwin-arm64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.darwin-x86_64]
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-linux-arm64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-x86_64]
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-linux-x64.tar.gz"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.windows-x86_64]
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "1.0.0",
"version": "1.0.24",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -49,8 +49,8 @@ Before enabling Code Reviews:
Once configured, the Review Agent runs automatically on PR/MR events. For platform-specific setup, see:
- [GitHub Code Reviews](./github.md)
- [GitLab Code Reviews](./gitlab.md)
- [GitHub Code Reviews](./github)
- [GitLab Code Reviews](./gitlab)
## Local Code Reviews
+1 -1
View File
@@ -30,7 +30,7 @@ const kilo = createOpenAI({
})
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "Why is the sky blue?",
})
```
@@ -13,7 +13,7 @@ Models are identified using the format `provider/model-name`. Pass this as the `
```typescript
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "Hello!",
})
```
@@ -53,7 +53,7 @@ const kilo = createOpenAI({
async function main() {
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "Invent a new holiday and describe its traditions.",
})
@@ -29,7 +29,7 @@ const kilo = createOpenAI({
})
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "Write a haiku about programming.",
})
@@ -51,7 +51,7 @@ const kilo = createOpenAI({
})
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "What is the weather in San Francisco?",
tools: {
getWeather: tool({
@@ -86,7 +86,7 @@ export async function POST(request: Request) {
const { messages } = await request.json()
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
messages,
})
@@ -37,7 +37,7 @@ const kilo = createOpenAI({
})
const result = streamText({
model: kilo("anthropic/claude-sonnet-4.5"),
model: kilo.chat("anthropic/claude-sonnet-4.5"),
prompt: "Write a short story about a robot.",
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-ui",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"exports": {
@@ -26,12 +26,12 @@ None of these exist yet in the rebuild.
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) |
| 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.
@@ -41,11 +41,11 @@ All commands use the VS Code editor API to capture the active editor's file path
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 |
| 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.
@@ -57,12 +57,12 @@ Terminal context capture uses `vscode.window.activeTerminal` and the terminal se
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` |
| 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`).
@@ -70,12 +70,12 @@ 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 |
| 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 |
---
@@ -83,17 +83,17 @@ Controlled by the `enableCodeActions` extension setting (default: `true`).
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) |
| 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) |
---
@@ -127,6 +127,7 @@ Commands like `addToContext` and `terminalAddToContext` follow a different flow:
### `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
@@ -157,12 +158,14 @@ Register in `contributes`:
### 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
@@ -290,7 +293,7 @@ Please provide:
3. Expected output and behavior
```
### TERMINAL_GENERATE *(Kilo-specific addition)*
### TERMINAL*GENERATE *(Kilo-specific addition)\_
**Variables:** `userInput`, `operatingSystem`, `currentDirectory`, `shell`
@@ -364,7 +367,7 @@ Example summary structure:
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))*
### COMMIT*MESSAGE *(Kilo-specific addition, tracked separately in [git-commit-message-generation.md](git-commit-message-generation.md))\_
**Variables:** `customInstructions`, `gitContext`
+76 -1
View File
@@ -2,7 +2,7 @@
"name": "kilo-code",
"displayName": "Kilo Code",
"description": "Kilo Code AI Agent & Autocomplete",
"version": "7.0.23",
"version": "7.0.24",
"publisher": "kilocode",
"repository": {
"type": "git",
@@ -101,6 +101,38 @@
"title": "Agent Manager: Next Tab",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.showTerminal",
"title": "Agent Manager: Focus Terminal",
"category": "Kilo Code",
"icon": "$(terminal)"
},
{
"command": "kilo-code.new.agentManager.focusPanel",
"title": "Agent Manager: Focus Panel",
"category": "Kilo Code",
"icon": "$(circuit-board)"
},
{
"command": "kilo-code.new.agentManager.newTab",
"title": "Agent Manager: New Tab",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.closeTab",
"title": "Agent Manager: Close Tab",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.newWorktree",
"title": "Agent Manager: New Worktree",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.agentManager.closeWorktree",
"title": "Agent Manager: Close Worktree",
"category": "Kilo Code"
},
{
"command": "kilo-code.new.generateCommitMessage",
"title": "Generate Commit Message",
@@ -259,6 +291,11 @@
"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",
@@ -288,6 +325,42 @@
"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": {
@@ -452,6 +525,7 @@
"eslint-config-prettier": "^10.1.8",
"prettier": "^3.8.1",
"qrcode": "^1.5.4",
"ts-morph": "27.0.2",
"typescript": "^5.9.3",
"typescript-eslint": "^8.54.0"
},
@@ -461,6 +535,7 @@
"@kilocode/kilo-ui": "workspace:*",
"@kilocode/sdk": "workspace:*",
"@opencode-ai/ui": "workspace:*",
"@thisbeyond/solid-dnd": "0.7.5",
"diff": "^7.0.0",
"dotenv": "^16.4.7",
"eventsource": "^2.0.2",
@@ -4,6 +4,10 @@ import { KiloProvider } from "../KiloProvider"
import { buildWebviewHtml } from "../utils"
import { WorktreeManager, type CreateWorktreeResult } from "./WorktreeManager"
import { WorktreeStateManager } from "./WorktreeStateManager"
import { SetupScriptService } from "./SetupScriptService"
import { SetupScriptRunner } from "./SetupScriptRunner"
import { SessionTerminalManager } from "./SessionTerminalManager"
import { formatKeybinding } from "./format-keybinding"
/**
* AgentManagerProvider opens the Agent Manager panel.
@@ -21,12 +25,17 @@ export class AgentManagerProvider implements vscode.Disposable {
private outputChannel: vscode.OutputChannel
private worktrees: WorktreeManager | undefined
private state: WorktreeStateManager | undefined
private setupScript: SetupScriptService | undefined
private terminalManager: SessionTerminalManager
constructor(
private readonly extensionUri: vscode.Uri,
private readonly connectionService: KiloConnectionService,
) {
this.outputChannel = vscode.window.createOutputChannel("Kilo Agent Manager")
this.terminalManager = new SessionTerminalManager((msg) =>
this.outputChannel.appendLine(`[SessionTerminal] ${msg}`),
)
}
private log(...args: unknown[]) {
@@ -67,6 +76,7 @@ export class AgentManagerProvider implements vscode.Disposable {
void this.initializeState()
void this.sendRepoInfo()
this.sendKeybindings()
this.panel.onDidDispose(() => {
this.log("Panel disposed")
@@ -124,10 +134,27 @@ export class AgentManagerProvider implements vscode.Disposable {
return this.onAddSessionToWorktree(msg.worktreeId)
if (type === "agentManager.closeSession" && typeof msg.sessionId === "string")
return this.onCloseSession(msg.sessionId)
if (type === "agentManager.configureSetupScript") {
void this.configureSetupScript()
return null
}
if (type === "agentManager.showTerminal" && typeof msg.sessionId === "string") {
this.terminalManager.showTerminal(msg.sessionId, this.state)
return null
}
if (type === "agentManager.requestRepoInfo") {
void this.sendRepoInfo()
return null
}
if (type === "agentManager.setTabOrder" && typeof msg.key === "string" && Array.isArray(msg.order)) {
this.state?.setTabOrder(msg.key as string, msg.order as string[])
return null
}
// When switching sessions, show existing terminal if one is open
if (type === "loadMessages" && typeof msg.sessionID === "string") {
this.terminalManager.showExisting(msg.sessionID)
}
// After clearSession, re-register worktree sessions so SSE events keep flowing
if (type === "clearSession") {
@@ -154,7 +181,11 @@ export class AgentManagerProvider implements vscode.Disposable {
const manager = this.getWorktreeManager()
const state = this.getStateManager()
if (!manager || !state) {
this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", message: "No workspace folder open" })
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "error",
message: "Open a folder that contains a git repository to use worktrees",
})
return null
}
@@ -164,11 +195,11 @@ export class AgentManagerProvider implements vscode.Disposable {
try {
result = await manager.createWorktree({ prompt: "kilo" })
} catch (error) {
const err = error instanceof Error ? error.message : String(error)
const msg = error instanceof Error ? error.message : String(error)
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "error",
message: `Failed to create worktree: ${err}`,
message: msg,
})
return null
}
@@ -240,6 +271,9 @@ export class AgentManagerProvider implements vscode.Disposable {
const created = await this.createWorktreeOnDisk()
if (!created) return null
// Run setup script for new worktree (blocks until complete, shows in overlay)
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
const session = await this.createSessionInWorktree(created.result.path, created.result.branch)
if (!session) {
const state = this.getStateManager()
@@ -289,6 +323,9 @@ export class AgentManagerProvider implements vscode.Disposable {
const created = await this.createWorktreeOnDisk()
if (!created) return null
// Run setup script for new worktree (blocks until complete, shows in overlay)
await this.runSetupScriptForWorktree(created.result.path, created.result.branch)
const state = this.getStateManager()!
if (!state.getSession(sessionId)) {
state.addSession(sessionId, created.worktree.id)
@@ -308,11 +345,7 @@ export class AgentManagerProvider implements vscode.Disposable {
try {
client = this.connectionService.getHttpClient()
} catch {
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "error",
message: "Not connected to CLI backend",
})
this.postToWebview({ type: "error", message: "Not connected to CLI backend" })
return null
}
@@ -338,11 +371,9 @@ export class AgentManagerProvider implements vscode.Disposable {
this.registerWorktreeSession(session.id, worktree.path)
this.pushState()
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "ready",
message: "Session created",
type: "agentManager.sessionAdded",
sessionId: session.id,
branch: worktree.branch,
worktreeId,
})
if (this.provider) {
@@ -364,6 +395,64 @@ export class AgentManagerProvider implements vscode.Disposable {
return null
}
// ---------------------------------------------------------------------------
// Keybindings
// ---------------------------------------------------------------------------
private sendKeybindings(): void {
const ext = vscode.extensions.getExtension("kilocode.kilo-code")
const keybindings: Array<{ command: string; key?: string; mac?: string }> =
ext?.packageJSON?.contributes?.keybindings ?? []
const mac = process.platform === "darwin"
const prefix = "kilo-code.new.agentManager."
const bindings: Record<string, string> = {}
for (const kb of keybindings) {
if (!kb.command.startsWith(prefix)) continue
const action = kb.command.slice(prefix.length)
const raw = mac ? (kb.mac ?? kb.key) : kb.key
if (raw) bindings[action] = formatKeybinding(raw, mac)
}
this.postToWebview({ type: "agentManager.keybindings", bindings })
}
// ---------------------------------------------------------------------------
// Setup script
// ---------------------------------------------------------------------------
/** Open the worktree setup script in the editor for user configuration. */
private async configureSetupScript(): Promise<void> {
const service = this.getSetupScriptService()
if (!service) return
try {
await service.openInEditor()
} catch (error) {
this.log(`Failed to open setup script: ${error}`)
}
}
/** Run the worktree setup script if configured. Blocks until complete. Shows progress in overlay. */
private async runSetupScriptForWorktree(worktreePath: string, branch?: string): Promise<void> {
const root = this.getWorkspaceRoot()
if (!root) return
try {
const service = this.getSetupScriptService()
if (!service || !service.hasScript()) return
this.postToWebview({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Running setup script...",
branch,
})
const runner = new SetupScriptRunner(this.outputChannel, service)
await runner.runIfConfigured({ worktreePath, repoPath: root })
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
this.outputChannel.appendLine(`[AgentManager] Setup script error: ${msg}`)
}
}
// ---------------------------------------------------------------------------
// Repo info
// ---------------------------------------------------------------------------
@@ -396,6 +485,7 @@ export class AgentManagerProvider implements vscode.Disposable {
type: "agentManager.state",
worktrees: state.getWorktrees(),
sessions: state.getSessions(),
tabOrder: state.getTabOrder(),
})
}
@@ -431,6 +521,17 @@ export class AgentManagerProvider implements vscode.Disposable {
return this.state
}
private getSetupScriptService(): SetupScriptService | undefined {
if (this.setupScript) return this.setupScript
const root = this.getWorkspaceRoot()
if (!root) {
this.log("getSetupScriptService: no workspace folder available")
return undefined
}
this.setupScript = new SetupScriptService(root)
return this.setupScript
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -449,11 +550,29 @@ export class AgentManagerProvider implements vscode.Disposable {
})
}
/**
* Show terminal for the currently active session (triggered by keyboard shortcut).
* Posts an action to the webview which will respond with the session ID.
*/
public showTerminalForCurrentSession(): void {
this.postToWebview({ type: "action", action: "showTerminal" })
}
/**
* Reveal the Agent Manager panel and focus the prompt input.
* Used for the keyboard shortcut to switch back from terminal.
*/
public focusPanel(): void {
if (!this.panel) return
this.panel.reveal(vscode.ViewColumn.One, false)
}
public postMessage(message: unknown): void {
this.panel?.webview.postMessage(message)
}
public dispose(): void {
this.terminalManager.dispose()
this.provider?.dispose()
this.panel?.dispose()
this.outputChannel.dispose()
@@ -0,0 +1,135 @@
import * as vscode from "vscode"
import type { WorktreeStateManager } from "./WorktreeStateManager"
/**
* Manages VS Code terminals for agent manager sessions.
* Each session can have an associated terminal that opens in the session's worktree directory,
* or the main workspace folder for local sessions.
*/
export class SessionTerminalManager {
private terminals = new Map<string, { terminal: vscode.Terminal; cwd: string }>()
private disposables: vscode.Disposable[] = []
constructor(private log: (msg: string) => void) {
this.disposables.push(
vscode.window.onDidCloseTerminal((terminal) => {
for (const [sessionId, entry] of this.terminals) {
if (entry.terminal !== terminal) continue
this.terminals.delete(sessionId)
this.log(`Removed terminal mapping for session ${sessionId} (terminal closed)`)
break
}
this.updateContextKey()
}),
vscode.window.onDidChangeActiveTerminal((terminal) => {
const managed = terminal ? this.isManaged(terminal) : false
vscode.commands.executeCommand("setContext", "kilo-code.agentTerminalFocus", managed)
}),
)
}
/**
* Show (or create) a terminal for the given session.
* Resolves CWD from the worktree state, falling back to workspace root.
*/
showTerminal(sessionId: string, state: WorktreeStateManager | undefined): void {
// If terminal already exists, just focus it
if (this.showExisting(sessionId, false)) return
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath
const worktreePath = state?.directoryFor(sessionId)
const cwd = worktreePath ?? workspacePath
if (!cwd) {
this.log(`showTerminal: no cwd resolved for session ${sessionId}`)
vscode.window.showWarningMessage("Open a folder that contains a git repository to use worktrees")
return
}
const session = state?.getSession(sessionId)
const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined
const name = worktree ? `Agent: ${worktree.branch}` : "Agent: local"
this.showOrCreate(sessionId, cwd, name)
}
/**
* Show the terminal for a session if it already exists (used when switching sessions).
* Returns true if the terminal was shown, false if no terminal exists for the session.
* Pass preserveFocus=true to keep focus on the current editor (default for session switching).
*/
showExisting(sessionId: string, preserveFocus = true): boolean {
const entry = this.terminals.get(sessionId)
if (!entry) return false
if (entry.terminal.exitStatus !== undefined) {
this.terminals.delete(sessionId)
this.log(`showExisting: terminal exited for session ${sessionId}, clearing`)
return false
}
entry.terminal.show(preserveFocus)
this.log(`showExisting: revealed terminal for session ${sessionId}`)
return true
}
/**
* Check if a session has an active terminal.
*/
hasTerminal(sessionId: string): boolean {
const entry = this.terminals.get(sessionId)
return entry !== undefined && entry.terminal.exitStatus === undefined
}
dispose(): void {
vscode.commands.executeCommand("setContext", "kilo-code.agentTerminalFocus", false)
for (const entry of this.terminals.values()) entry.terminal.dispose()
this.terminals.clear()
for (const d of this.disposables) d.dispose()
}
private isManaged(terminal: vscode.Terminal): boolean {
for (const entry of this.terminals.values()) {
if (entry.terminal === terminal) return true
}
return false
}
private updateContextKey(): void {
const active = vscode.window.activeTerminal
const managed = active ? this.isManaged(active) : false
vscode.commands.executeCommand("setContext", "kilo-code.agentTerminalFocus", managed)
}
private showOrCreate(sessionId: string, cwd: string, name: string): void {
let entry = this.terminals.get(sessionId)
// Clean up exited terminals
if (entry && entry.terminal.exitStatus !== undefined) {
this.terminals.delete(sessionId)
entry = undefined
}
// Recreate if CWD changed
if (entry && entry.cwd !== cwd) {
entry.terminal.dispose()
this.terminals.delete(sessionId)
entry = undefined
this.log(`showTerminal: cwd changed for session ${sessionId}, recreating`)
}
if (!entry) {
const terminal = vscode.window.createTerminal({
cwd,
name,
iconPath: new vscode.ThemeIcon("terminal"),
})
entry = { terminal, cwd }
this.terminals.set(sessionId, entry)
this.log(`showTerminal: created terminal for session ${sessionId} (cwd=${cwd})`)
}
entry.terminal.show(false)
this.updateContextKey()
}
}
@@ -0,0 +1,152 @@
/**
* SetupScriptRunner - Executes worktree setup scripts
*
* Runs setup scripts in VS Code integrated terminal before agent starts.
* Uses VS Code shell integration to track execution and exit code.
* Falls back to sendText + onDidCloseTerminal if shell integration is unavailable.
* Cross-platform: Unix uses sh, Windows uses cmd.exe.
*/
import * as vscode from "vscode"
import { SetupScriptService } from "./SetupScriptService"
import { buildSetupCommand } from "./setup-script-command"
export interface SetupScriptEnvironment {
/** Absolute path to the worktree directory */
worktreePath: string
/** Absolute path to the main repository */
repoPath: string
}
export class SetupScriptRunner {
constructor(
private readonly output: vscode.OutputChannel,
private readonly service: SetupScriptService,
) {}
/**
* Execute setup script in a worktree if script exists.
* Waits for the script to finish before resolving.
*
* @returns true if script was executed, false if skipped (no script configured)
*/
async runIfConfigured(env: SetupScriptEnvironment): Promise<boolean> {
if (!this.service.hasScript()) {
this.log("No setup script configured, skipping")
return false
}
const script = this.service.getScriptPath()
this.log(`Running setup script: ${script}`)
try {
await this.executeInTerminal(script, env)
this.log("Setup script completed")
return true
} catch (error) {
const msg = error instanceof Error ? error.message : String(error)
this.log(`Setup script execution failed: ${msg}`)
return true // Script was attempted
}
}
/** Execute the setup script in a VS Code terminal and wait for it to finish. */
private async executeInTerminal(script: string, env: SetupScriptEnvironment): Promise<void> {
const terminal = vscode.window.createTerminal({
name: "Worktree Setup",
cwd: env.worktreePath,
env: {
WORKTREE_PATH: env.worktreePath,
REPO_PATH: env.repoPath,
},
iconPath: new vscode.ThemeIcon("gear"),
})
terminal.show(true)
// Try shell integration first — gives us proper exit code tracking
const integration = await this.waitForShellIntegration(terminal, 5000)
if (integration) {
this.log("Using shell integration for setup script execution")
await this.runViaShellIntegration(terminal, integration, script, env)
} else {
this.log("Shell integration unavailable, falling back to sendText")
await this.runViaSendText(terminal, script, env)
}
}
/** Wait for shell integration to become available on a terminal, with timeout. */
private waitForShellIntegration(
terminal: vscode.Terminal,
timeout: number,
): Promise<vscode.TerminalShellIntegration | undefined> {
if (terminal.shellIntegration) return Promise.resolve(terminal.shellIntegration)
return new Promise((resolve) => {
const timer = setTimeout(() => {
listener.dispose()
resolve(undefined)
}, timeout)
const listener = vscode.window.onDidChangeTerminalShellIntegration((e) => {
if (e.terminal !== terminal) return
clearTimeout(timer)
listener.dispose()
resolve(e.shellIntegration)
})
})
}
/** Run script via shell integration — tracks execution and exit code properly. */
private runViaShellIntegration(
terminal: vscode.Terminal,
integration: vscode.TerminalShellIntegration,
script: string,
env: SetupScriptEnvironment,
): Promise<void> {
return new Promise((resolve) => {
const command = buildSetupCommand(script, env)
const execution = integration.executeCommand(command)
const cleanup = () => {
execListener.dispose()
closeListener.dispose()
}
// Primary: shell integration reports execution finished with exit code
const execListener = vscode.window.onDidEndTerminalShellExecution((e) => {
if (e.execution !== execution) return
cleanup()
this.log(`Setup script exited with code ${e.exitCode ?? "unknown"}`)
resolve()
})
// Fallback: terminal was closed externally (user, VS Code restart, etc.)
const closeListener = vscode.window.onDidCloseTerminal((closed) => {
if (closed !== terminal) return
cleanup()
this.log("Setup script terminal closed before execution event fired")
resolve()
})
})
}
/** Fallback: run via sendText and wait for terminal to close. */
private runViaSendText(terminal: vscode.Terminal, script: string, env: SetupScriptEnvironment): Promise<void> {
return new Promise((resolve) => {
const listener = vscode.window.onDidCloseTerminal((closed) => {
if (closed !== terminal) return
listener.dispose()
resolve()
})
const command = buildSetupCommand(script, env) + (process.platform === "win32" ? "& exit" : "; exit")
terminal.sendText(command)
this.log("Setup script started in terminal, waiting for completion...")
})
}
private log(message: string): void {
this.output.appendLine(`[SetupScriptRunner] ${message}`)
}
}
@@ -0,0 +1,68 @@
/**
* SetupScriptService - Manages worktree setup scripts
*
* Handles reading, creating, and checking for setup scripts stored in .kilocode/setup-script.
* Setup scripts run before an agent starts in a worktree (new sessions only).
*/
import * as vscode from "vscode"
import * as fs from "node:fs"
import * as path from "node:path"
import { SETUP_SCRIPT_TEMPLATE } from "./setup-script-template"
const SETUP_SCRIPT_FILENAME = "setup-script"
const KILOCODE_DIR = ".kilocode"
export class SetupScriptService {
private readonly root: string
private readonly script: string
constructor(root: string) {
this.root = root
this.script = path.join(root, KILOCODE_DIR, SETUP_SCRIPT_FILENAME)
}
/** Get the path to the setup script */
getScriptPath(): string {
return this.script
}
/** Check if a setup script exists */
hasScript(): boolean {
return fs.existsSync(this.script)
}
/** Read the setup script content. Returns null if not found or read fails. */
async getScript(): Promise<string | null> {
if (!this.hasScript()) return null
try {
return await fs.promises.readFile(this.script, "utf-8")
} catch (error) {
this.log(`Failed to read setup script: ${error}`)
return null
}
}
/** Create a default setup script with helpful comments */
async createDefaultScript(): Promise<void> {
const dir = path.join(this.root, KILOCODE_DIR)
if (!fs.existsSync(dir)) {
await fs.promises.mkdir(dir, { recursive: true })
}
await fs.promises.writeFile(this.script, SETUP_SCRIPT_TEMPLATE, "utf-8")
}
/** Open the setup script in VS Code editor. Creates the default script if it doesn't exist. */
async openInEditor(): Promise<void> {
if (!this.hasScript()) {
await this.createDefaultScript()
}
const document = await vscode.workspace.openTextDocument(this.script)
await vscode.window.showTextDocument(document)
}
private log(message: string): void {
// Log to console since we don't have an OutputChannel here
console.log(`[SetupScriptService] ${message}`)
}
}
@@ -46,7 +46,10 @@ export class WorktreeManager {
async createWorktree(params: { prompt?: string; existingBranch?: string }): Promise<CreateWorktreeResult> {
const repo = await this.git.checkIsRepo()
if (!repo) throw new Error("Workspace is not a git repository")
if (!repo)
throw new Error(
"This folder is not a git repository. Initialize a repository or open a git project to use worktrees.",
)
await this.ensureDir()
await this.ensureGitExclude()
@@ -182,6 +185,7 @@ export class WorktreeManager {
const excludePath = path.join(gitDir, "info", "exclude")
await this.addExcludeEntry(excludePath, ".kilocode/worktrees/", "Kilo Code agent worktrees")
await this.addExcludeEntry(excludePath, ".kilocode/agent-manager.json", "Kilo Agent Manager state")
await this.addExcludeEntry(excludePath, ".kilocode/setup-script", "Kilo Code worktree setup script")
}
private async ensureWorktreeExclude(worktreePath: string): Promise<void> {
@@ -29,6 +29,7 @@ export interface ManagedSession {
interface StateFile {
worktrees: Record<string, Omit<Worktree, "id">>
sessions: Record<string, Omit<ManagedSession, "id">>
tabOrder?: Record<string, string[]>
}
const STATE_FILE = "agent-manager.json"
@@ -44,6 +45,7 @@ export class WorktreeStateManager {
private readonly file: string
private worktrees = new Map<string, Worktree>()
private sessions = new Map<string, ManagedSession>()
private tabOrder: Record<string, string[]> = {}
private readonly log: (msg: string) => void
private saving: Promise<void> | undefined
private pendingSave = false
@@ -125,6 +127,9 @@ export class WorktreeStateManager {
}
}
// Clean up tab order for this worktree
delete this.tabOrder[id]
this.log(`Removed worktree ${id}, orphaned ${orphaned.length} sessions`)
void this.save()
return orphaned
@@ -149,6 +154,34 @@ export class WorktreeStateManager {
removeSession(id: string): void {
this.sessions.delete(id)
// Remove this session from any tab order arrays
for (const [key, order] of Object.entries(this.tabOrder)) {
const idx = order.indexOf(id)
if (idx !== -1) {
order.splice(idx, 1)
if (order.length === 0) delete this.tabOrder[key]
}
}
void this.save()
}
// ---------------------------------------------------------------------------
// Tab order
// ---------------------------------------------------------------------------
getTabOrder(): Record<string, string[]> {
return this.tabOrder
}
setTabOrder(key: string, order: string[]): void {
this.tabOrder[key] = order
void this.save()
}
removeTabOrder(key: string): void {
delete this.tabOrder[key]
void this.save()
}
@@ -162,6 +195,7 @@ export class WorktreeStateManager {
const data = JSON.parse(content) as StateFile
this.worktrees.clear()
this.sessions.clear()
this.tabOrder = {}
for (const [id, wt] of Object.entries(data.worktrees ?? {})) {
this.worktrees.set(id, { id, ...wt })
@@ -169,6 +203,9 @@ export class WorktreeStateManager {
for (const [id, s] of Object.entries(data.sessions ?? {})) {
this.sessions.set(id, { id, ...s })
}
if (data.tabOrder) {
this.tabOrder = data.tabOrder
}
this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`)
} catch (error) {
const code = (error as NodeJS.ErrnoException).code
@@ -229,6 +266,9 @@ export class WorktreeStateManager {
const { id: _, ...rest } = s
data.sessions[id] = rest
}
if (Object.keys(this.tabOrder).length > 0) {
data.tabOrder = this.tabOrder
}
const dir = path.dirname(this.file)
if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true })
@@ -0,0 +1,85 @@
import { describe, it, expect } from "vitest"
import { buildSetupCommand } from "../setup-script-command"
const env = {
worktreePath: "/repos/project/.kilocode/worktrees/wt-1",
repoPath: "/repos/project",
}
const script = "/repos/project/.kilocode/setup-script"
describe("buildSetupCommand", () => {
it("builds unix command with inline env vars and sh", () => {
const result = buildSetupCommand(script, env, "darwin")
expect(result).toBe(
`WORKTREE_PATH="/repos/project/.kilocode/worktrees/wt-1" REPO_PATH="/repos/project" sh "/repos/project/.kilocode/setup-script"`,
)
})
it("builds linux command same as darwin", () => {
const result = buildSetupCommand(script, env, "linux")
expect(result).toContain("sh ")
expect(result).not.toContain("set ")
expect(result).not.toContain("call ")
})
it("builds windows command with set and call", () => {
const result = buildSetupCommand(script, env, "win32")
expect(result).toBe(
`set "WORKTREE_PATH=/repos/project/.kilocode/worktrees/wt-1" && set "REPO_PATH=/repos/project" && call "/repos/project/.kilocode/setup-script"`,
)
})
it("includes both env vars in unix command", () => {
const result = buildSetupCommand(script, env, "darwin")
expect(result).toContain(`WORKTREE_PATH="${env.worktreePath}"`)
expect(result).toContain(`REPO_PATH="${env.repoPath}"`)
})
it("includes both env vars in windows command", () => {
const result = buildSetupCommand(script, env, "win32")
expect(result).toContain(`set "WORKTREE_PATH=${env.worktreePath}"`)
expect(result).toContain(`set "REPO_PATH=${env.repoPath}"`)
})
it("handles paths with spaces", () => {
const spaced = {
worktreePath: "/Users/dev/my project/.kilocode/worktrees/wt-1",
repoPath: "/Users/dev/my project",
}
const spacedScript = "/Users/dev/my project/.kilocode/setup-script"
const unix = buildSetupCommand(spacedScript, spaced, "darwin")
expect(unix).toContain(`sh "/Users/dev/my project/.kilocode/setup-script"`)
const win = buildSetupCommand(spacedScript, spaced, "win32")
expect(win).toContain(`call "/Users/dev/my project/.kilocode/setup-script"`)
})
it("escapes double quotes in unix paths", () => {
const dangerous = {
worktreePath: '/repos/proj"ect',
repoPath: "/repos/safe",
}
const result = buildSetupCommand(script, dangerous, "darwin")
expect(result).toContain(`WORKTREE_PATH="/repos/proj\\"ect"`)
})
it("escapes dollar signs in unix paths", () => {
const dangerous = {
worktreePath: "/repos/$HOME/project",
repoPath: "/repos/safe",
}
const result = buildSetupCommand(script, dangerous, "darwin")
expect(result).toContain(`WORKTREE_PATH="/repos/\\$HOME/project"`)
})
it("escapes backticks in unix paths", () => {
const dangerous = {
worktreePath: "/repos/`whoami`/project",
repoPath: "/repos/safe",
}
const result = buildSetupCommand(script, dangerous, "darwin")
expect(result).toContain('WORKTREE_PATH="/repos/\\`whoami\\`/project"')
})
})
@@ -0,0 +1,34 @@
const KEY_SYMBOLS: Record<string, { mac: string; other: string }> = {
ctrl: { mac: "⌃", other: "Ctrl" },
cmd: { mac: "⌘", other: "Ctrl" },
shift: { mac: "⇧", other: "Shift" },
alt: { mac: "⌥", other: "Alt" },
}
const SPECIAL_KEYS: Record<string, string> = {
left: "←",
right: "→",
up: "↑",
down: "↓",
backspace: "⌫",
delete: "Del",
enter: "↵",
escape: "Esc",
}
/**
* Format a VS Code keybinding string (e.g. "cmd+shift+w") into
* a display string using platform-appropriate symbols.
* Mac: "⌘⇧W" Windows/Linux: "Ctrl+Shift+W"
*/
export function formatKeybinding(raw: string, mac: boolean): string {
const symbols = raw
.split("+")
.map((p) => p.trim().toLowerCase())
.map((part) => {
const mod = KEY_SYMBOLS[part]
if (mod) return mac ? mod.mac : mod.other
return SPECIAL_KEYS[part] ?? part.toUpperCase()
})
return mac ? symbols.join("") : symbols.join("+")
}
@@ -0,0 +1,21 @@
/** Escape characters that are special inside double-quoted shell strings. */
function escapeShell(value: string): string {
return value.replace(/["$`\\]/g, "\\$&")
}
/** Build the platform-appropriate command string for running a setup script. */
export function buildSetupCommand(
script: string,
env: { worktreePath: string; repoPath: string },
platform: NodeJS.Platform = process.platform,
): string {
if (platform === "win32") {
// Windows cmd.exe: double quotes in set values don't need escaping the same way,
// but we escape for the call argument
return `set "WORKTREE_PATH=${env.worktreePath}" && set "REPO_PATH=${env.repoPath}" && call "${script}"`
}
const wt = escapeShell(env.worktreePath)
const repo = escapeShell(env.repoPath)
const path = escapeShell(script)
return `WORKTREE_PATH="${wt}" REPO_PATH="${repo}" sh "${path}"`
}
@@ -0,0 +1,41 @@
/** Default template for worktree setup scripts. */
export const SETUP_SCRIPT_TEMPLATE = `#!/bin/bash
# Kilo Code Worktree Setup Script
# This script runs before the agent starts in a worktree (new sessions only).
#
# Available environment variables:
# WORKTREE_PATH - Absolute path to the worktree directory
# REPO_PATH - Absolute path to the main repository
#
# Example tasks:
# - Copy .env files from main repo
# - Install dependencies
# - Run database migrations
# - Set up local configuration
set -e # Exit on error
echo "Setting up worktree: $WORKTREE_PATH"
# Uncomment and modify as needed:
# Copy environment files
# if [ -f "$REPO_PATH/.env" ]; then
# cp "$REPO_PATH/.env" "$WORKTREE_PATH/.env"
# echo "Copied .env"
# fi
# Install dependencies (Node.js)
# if [ -f "$WORKTREE_PATH/package.json" ]; then
# cd "$WORKTREE_PATH"
# npm install
# fi
# Install dependencies (Python)
# if [ -f "$WORKTREE_PATH/requirements.txt" ]; then
# cd "$WORKTREE_PATH"
# pip install -r requirements.txt
# fi
echo "Setup complete!"
`
+18
View File
@@ -82,6 +82,24 @@ export function activate(context: vscode.ExtensionContext) {
vscode.commands.registerCommand("kilo-code.new.agentManager.nextTab", () => {
agentManagerProvider.postMessage({ type: "action", action: "tabNext" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.showTerminal", () => {
agentManagerProvider.showTerminalForCurrentSession()
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.focusPanel", () => {
agentManagerProvider.focusPanel()
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.newTab", () => {
agentManagerProvider.postMessage({ type: "action", action: "newTab" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.closeTab", () => {
agentManagerProvider.postMessage({ type: "action", action: "closeTab" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.newWorktree", () => {
agentManagerProvider.postMessage({ type: "action", action: "newWorktree" })
}),
vscode.commands.registerCommand("kilo-code.new.agentManager.closeWorktree", () => {
agentManagerProvider.postMessage({ type: "action", action: "closeWorktree" })
}),
)
// Register autocomplete provider
@@ -19,10 +19,6 @@ export function getEditorContext(): EditorContext | undefined {
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
),
diagnostics: vscode.languages.getDiagnostics(doc.uri).filter((d) => d.range.intersection(selection) !== undefined),
}
}
@@ -3,16 +3,27 @@
*
* The agent manager runs in the same webview context as other UI.
* All its CSS classes must be prefixed with "am-" to avoid conflicts.
* These tests also verify consistency between CSS definitions and TSX usage.
* These tests also verify consistency between CSS definitions and TSX usage,
* and that the provider sends correct message types for each action.
*/
import { describe, it, expect } from "bun:test"
import fs from "node:fs"
import path from "node:path"
import { Project, SyntaxKind } from "ts-morph"
const ROOT = path.resolve(import.meta.dir, "../..")
const CSS_FILE = path.join(ROOT, "webview-ui/agent-manager/agent-manager.css")
const TSX_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx")
const TSX_FILES = [
path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"),
path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"),
]
const TSX_FILE = TSX_FILES[0]
const PROVIDER_FILE = path.join(ROOT, "src/agent-manager/AgentManagerProvider.ts")
function readAllTsx(): string {
return TSX_FILES.map((f) => fs.readFileSync(f, "utf-8")).join("\n")
}
describe("Agent Manager CSS Prefix", () => {
it("all class selectors should use am- prefix", () => {
@@ -51,7 +62,7 @@ describe("Agent Manager CSS Prefix", () => {
describe("Agent Manager CSS/TSX Consistency", () => {
it("all classes used in TSX should be defined in CSS", () => {
const css = fs.readFileSync(CSS_FILE, "utf-8")
const tsx = fs.readFileSync(TSX_FILE, "utf-8")
const tsx = readAllTsx()
// Extract am- classes defined in CSS
const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)]
@@ -68,7 +79,7 @@ describe("Agent Manager CSS/TSX Consistency", () => {
it("all am- classes defined in CSS should be used in TSX", () => {
const css = fs.readFileSync(CSS_FILE, "utf-8")
const tsx = fs.readFileSync(TSX_FILE, "utf-8")
const tsx = readAllTsx()
// Extract am- classes defined in CSS
const cssMatches = [...css.matchAll(/\.([a-z][a-z0-9-]*)/gi)]
@@ -79,3 +90,29 @@ describe("Agent Manager CSS/TSX Consistency", () => {
expect(unused, `Classes defined in CSS but not used in TSX: ${unused.join(", ")}`).toEqual([])
})
})
describe("Agent Manager Provider Messages", () => {
function getMethodBody(name: string): string {
const project = new Project({ compilerOptions: { allowJs: true } })
const source = project.addSourceFileAtPath(PROVIDER_FILE)
const cls = source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration)
const method = cls?.getMethod(name)
expect(method, `method ${name} not found in AgentManagerProvider`).toBeTruthy()
return method!.getText()
}
/**
* Regression: onAddSessionToWorktree must NOT send agentManager.worktreeSetup
* because that triggers a full-screen overlay with a spinner. Adding a session
* to an existing worktree should use agentManager.sessionAdded instead.
*/
it("onAddSessionToWorktree should not send worktreeSetup messages", () => {
const body = getMethodBody("onAddSessionToWorktree")
expect(body).not.toContain("agentManager.worktreeSetup")
})
it("onAddSessionToWorktree should send sessionAdded message", () => {
const body = getMethodBody("onAddSessionToWorktree")
expect(body).toContain("agentManager.sessionAdded")
})
})
@@ -0,0 +1,72 @@
import { describe, it, expect } from "bun:test"
import { formatKeybinding } from "../../src/agent-manager/format-keybinding"
describe("formatKeybinding", () => {
describe("mac", () => {
it("formats cmd as ⌘", () => {
expect(formatKeybinding("cmd+w", true)).toBe("⌘W")
})
it("formats cmd+shift as ⌘⇧", () => {
expect(formatKeybinding("cmd+shift+w", true)).toBe("⌘⇧W")
})
it("formats ctrl as ⌃", () => {
expect(formatKeybinding("ctrl+c", true)).toBe("⌃C")
})
it("formats alt as ⌥", () => {
expect(formatKeybinding("alt+f", true)).toBe("⌥F")
})
it("formats arrow keys as symbols", () => {
expect(formatKeybinding("cmd+left", true)).toBe("⌘←")
expect(formatKeybinding("cmd+right", true)).toBe("⌘→")
expect(formatKeybinding("cmd+up", true)).toBe("⌘↑")
expect(formatKeybinding("cmd+down", true)).toBe("⌘↓")
})
it("formats special keys", () => {
expect(formatKeybinding("cmd+backspace", true)).toBe("⌘⌫")
expect(formatKeybinding("cmd+enter", true)).toBe("⌘↵")
expect(formatKeybinding("escape", true)).toBe("Esc")
})
it("joins without separator on mac", () => {
expect(formatKeybinding("cmd+shift+alt+t", true)).toBe("⌘⇧⌥T")
})
it("formats plain key", () => {
expect(formatKeybinding("cmd+/", true)).toBe("⌘/")
})
})
describe("windows/linux", () => {
it("formats cmd as Ctrl", () => {
expect(formatKeybinding("cmd+w", false)).toBe("Ctrl+W")
})
it("formats ctrl as Ctrl", () => {
expect(formatKeybinding("ctrl+w", false)).toBe("Ctrl+W")
})
it("formats ctrl+shift", () => {
expect(formatKeybinding("ctrl+shift+w", false)).toBe("Ctrl+Shift+W")
})
it("formats alt as Alt", () => {
expect(formatKeybinding("alt+f", false)).toBe("Alt+F")
})
it("formats arrow keys as symbols", () => {
expect(formatKeybinding("ctrl+left", false)).toBe("Ctrl+←")
expect(formatKeybinding("ctrl+right", false)).toBe("Ctrl+→")
expect(formatKeybinding("ctrl+up", false)).toBe("Ctrl+↑")
expect(formatKeybinding("ctrl+down", false)).toBe("Ctrl+↓")
})
it("joins with + separator on non-mac", () => {
expect(formatKeybinding("ctrl+shift+alt+t", false)).toBe("Ctrl+Shift+Alt+T")
})
})
})
@@ -1,5 +1,5 @@
import { describe, it, expect } from "bun:test"
import { resolveNavigation, validateLocalSession } from "../../webview-ui/agent-manager/navigate"
import { resolveNavigation, validateLocalSession, adjacentHint, LOCAL } from "../../webview-ui/agent-manager/navigate"
const ids = ["a", "b", "c", "d"]
@@ -24,7 +24,7 @@ describe("resolveNavigation", () => {
describe("from first session", () => {
it("up → local", () => {
expect(resolveNavigation("up", "a", ids)).toEqual({ action: "local" })
expect(resolveNavigation("up", "a", ids)).toEqual({ action: LOCAL })
})
it("down → selects second session", () => {
@@ -68,7 +68,7 @@ describe("resolveNavigation", () => {
})
it("up from only session → local", () => {
expect(resolveNavigation("up", "x", ["x"])).toEqual({ action: "local" })
expect(resolveNavigation("up", "x", ["x"])).toEqual({ action: LOCAL })
})
it("down from only session → none", () => {
@@ -95,20 +95,20 @@ describe("resolveNavigation", () => {
expect(trail).toEqual(["s1", "s2", "s3"])
// Navigate back up through all sessions to local
const upTrail: (string | "local")[] = []
const upTrail: (string | typeof LOCAL)[] = []
for (let i = 0; i < 4; i++) {
const result = resolveNavigation("up", current, sessions)
if (result.action === "select") {
current = result.id
upTrail.push(current)
} else if (result.action === "local") {
} else if (result.action === LOCAL) {
current = undefined
upTrail.push("local")
upTrail.push(LOCAL)
} else {
break
}
}
expect(upTrail).toEqual(["s2", "s1", "local"])
expect(upTrail).toEqual(["s2", "s1", LOCAL])
})
})
})
@@ -134,3 +134,49 @@ describe("validateLocalSession", () => {
expect(validateLocalSession(undefined, [])).toBeUndefined()
})
})
describe("adjacentHint", () => {
const flat = [LOCAL, "wt1", "wt2", "wt3", "s1"]
it("returns prev hint when item is directly above active", () => {
expect(adjacentHint("wt1", "wt2", flat, "⌘↑", "⌘↓")).toBe("⌘↑")
})
it("returns next hint when item is directly below active", () => {
expect(adjacentHint("wt3", "wt2", flat, "⌘↑", "⌘↓")).toBe("⌘↓")
})
it("returns empty string for the active item itself", () => {
expect(adjacentHint("wt2", "wt2", flat, "⌘↑", "⌘↓")).toBe("")
})
it("returns empty string for non-adjacent items", () => {
expect(adjacentHint("wt1", "wt3", flat, "⌘↑", "⌘↓")).toBe("")
expect(adjacentHint("s1", "wt1", flat, "⌘↑", "⌘↓")).toBe("")
})
it("returns empty string when active is undefined", () => {
expect(adjacentHint("wt1", undefined, flat, "⌘↑", "⌘↓")).toBe("")
})
it("returns empty string when active is not in list", () => {
expect(adjacentHint("wt1", "unknown", flat, "⌘↑", "⌘↓")).toBe("")
})
it("returns empty string when item is not in list", () => {
expect(adjacentHint("unknown", "wt2", flat, "⌘↑", "⌘↓")).toBe("")
})
it("works at boundaries — first item with LOCAL active", () => {
expect(adjacentHint("wt1", LOCAL, flat, "⌘↑", "⌘↓")).toBe("⌘↓")
})
it("works at boundaries — LOCAL with first item active", () => {
expect(adjacentHint(LOCAL, "wt1", flat, "⌘↑", "⌘↓")).toBe("⌘↑")
})
it("works with single-item list", () => {
expect(adjacentHint("a", "b", ["a", "b"], "prev", "next")).toBe("prev")
expect(adjacentHint("b", "a", ["a", "b"], "prev", "next")).toBe("next")
})
})
@@ -0,0 +1,36 @@
import { describe, it, expect } from "bun:test"
import { nextSelectionAfterDelete, LOCAL } from "../../webview-ui/agent-manager/navigate"
describe("nextSelectionAfterDelete", () => {
it("selects the worktree below when deleting from the middle", () => {
expect(nextSelectionAfterDelete("b", ["a", "b", "c"])).toBe("c")
})
it("selects the worktree above when deleting the last item", () => {
expect(nextSelectionAfterDelete("c", ["a", "b", "c"])).toBe("b")
})
it("selects the worktree below when deleting the first item", () => {
expect(nextSelectionAfterDelete("a", ["a", "b", "c"])).toBe("b")
})
it("falls back to LOCAL when deleting the only worktree", () => {
expect(nextSelectionAfterDelete("a", ["a"])).toBe(LOCAL)
})
it("falls back to LOCAL when ID is not found", () => {
expect(nextSelectionAfterDelete("x", ["a", "b"])).toBe(LOCAL)
})
it("falls back to LOCAL when list is empty", () => {
expect(nextSelectionAfterDelete("a", [])).toBe(LOCAL)
})
it("handles two-item list deleting first", () => {
expect(nextSelectionAfterDelete("a", ["a", "b"])).toBe("b")
})
it("handles two-item list deleting second", () => {
expect(nextSelectionAfterDelete("b", ["a", "b"])).toBe("a")
})
})
@@ -0,0 +1,53 @@
import { describe, expect, it } from "bun:test"
import { removeSessionPermissions, upsertPermission } from "../../webview-ui/src/context/permission-queue"
import type { PermissionRequest } from "../../webview-ui/src/types/messages"
const permission = (input: Partial<PermissionRequest> = {}): PermissionRequest => ({
id: input.id ?? "perm-1",
sessionID: input.sessionID ?? "session-1",
toolName: input.toolName ?? "read",
patterns: input.patterns ?? ["/tmp/*"],
args: input.args ?? {},
message: input.message,
tool: input.tool,
})
describe("permission queue", () => {
it("appends a new permission id", () => {
const result = upsertPermission([], permission({ id: "perm-1" }))
expect(result).toHaveLength(1)
expect(result[0].id).toBe("perm-1")
})
it("updates an existing permission id instead of duplicating", () => {
const existing = permission({ id: "perm-1", toolName: "read", patterns: ["a"] })
const incoming = permission({ id: "perm-1", toolName: "write", patterns: ["b"] })
const result = upsertPermission([existing], incoming)
expect(result).toHaveLength(1)
expect(result[0]).toEqual(incoming)
})
it("keeps other permission entries when updating one id", () => {
const first = permission({ id: "perm-1", sessionID: "session-1" })
const second = permission({ id: "perm-2", sessionID: "session-2" })
const incoming = permission({ id: "perm-1", toolName: "edit" })
const result = upsertPermission([first, second], incoming)
expect(result).toHaveLength(2)
expect(result.find((item) => item.id === "perm-1")).toEqual(incoming)
expect(result.find((item) => item.id === "perm-2")).toEqual(second)
})
it("removes only permissions from the deleted session", () => {
const first = permission({ id: "perm-1", sessionID: "session-1" })
const second = permission({ id: "perm-2", sessionID: "session-2" })
const third = permission({ id: "perm-3", sessionID: "session-1" })
const result = removeSessionPermissions([first, second, third], "session-1")
expect(result).toEqual([second])
})
})
@@ -0,0 +1,200 @@
import { describe, it, expect } from "bun:test"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "../../webview-ui/agent-manager/tab-order"
describe("reorderTabs", () => {
const tabs = ["a", "b", "c", "d"]
it("moves an item forward", () => {
expect(reorderTabs(tabs, "a", "c")).toEqual(["b", "c", "a", "d"])
})
it("moves an item backward", () => {
expect(reorderTabs(tabs, "c", "a")).toEqual(["c", "a", "b", "d"])
})
it("swaps adjacent items forward", () => {
expect(reorderTabs(tabs, "a", "b")).toEqual(["b", "a", "c", "d"])
})
it("swaps adjacent items backward", () => {
expect(reorderTabs(tabs, "b", "a")).toEqual(["b", "a", "c", "d"])
})
it("moves first to last", () => {
expect(reorderTabs(tabs, "a", "d")).toEqual(["b", "c", "d", "a"])
})
it("moves last to first", () => {
expect(reorderTabs(tabs, "d", "a")).toEqual(["d", "a", "b", "c"])
})
it("returns undefined when from equals to", () => {
expect(reorderTabs(tabs, "a", "a")).toBeUndefined()
})
it("returns undefined when from is not found", () => {
expect(reorderTabs(tabs, "x", "a")).toBeUndefined()
})
it("returns undefined when to is not found", () => {
expect(reorderTabs(tabs, "a", "x")).toBeUndefined()
})
it("returns undefined when both are missing", () => {
expect(reorderTabs(tabs, "x", "y")).toBeUndefined()
})
it("handles a two-item list", () => {
expect(reorderTabs(["a", "b"], "a", "b")).toEqual(["b", "a"])
expect(reorderTabs(["a", "b"], "b", "a")).toEqual(["b", "a"])
})
it("handles a single-item list (from === to)", () => {
expect(reorderTabs(["a"], "a", "a")).toBeUndefined()
})
it("handles empty list", () => {
expect(reorderTabs([], "a", "b")).toBeUndefined()
})
it("does not mutate the original array", () => {
const original = ["a", "b", "c"]
reorderTabs(original, "a", "c")
expect(original).toEqual(["a", "b", "c"])
})
it("preserves unrelated items", () => {
const result = reorderTabs(["a", "b", "c", "d", "e"], "b", "d")!
expect(result).toEqual(["a", "c", "d", "b", "e"])
expect(result.sort()).toEqual(["a", "b", "c", "d", "e"])
})
it("round-trip: moving forward then back restores original order", () => {
const moved = reorderTabs(tabs, "a", "c")!
const restored = reorderTabs(moved, "a", "b")!
expect(restored).toEqual(["a", "b", "c", "d"])
})
})
describe("applyTabOrder", () => {
const items = [
{ id: "a", name: "Alice" },
{ id: "b", name: "Bob" },
{ id: "c", name: "Carol" },
]
it("reorders items according to custom order", () => {
const result = applyTabOrder(items, ["c", "a", "b"])
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"])
})
it("appends items not in the order", () => {
const result = applyTabOrder(items, ["b"])
expect(result.map((i) => i.id)).toEqual(["b", "a", "c"])
})
it("skips order IDs that are not in items", () => {
const result = applyTabOrder(items, ["x", "c", "y", "a"])
expect(result.map((i) => i.id)).toEqual(["c", "a", "b"])
})
it("returns original array when order is undefined", () => {
const result = applyTabOrder(items, undefined)
expect(result).toBe(items)
})
it("returns original array when order is empty", () => {
const result = applyTabOrder(items, [])
expect(result).toBe(items)
})
it("handles empty items", () => {
expect(applyTabOrder([], ["a", "b"])).toEqual([])
})
it("preserves item properties", () => {
const result = applyTabOrder(items, ["b", "a", "c"])
expect(result[0]).toEqual({ id: "b", name: "Bob" })
})
})
describe("firstOrderedTitle", () => {
const items = [{ id: "a", title: "Alpha" }, { id: "b", title: "Beta" }, { id: "c", title: "" }, { id: "d" }]
it("returns first titled item from custom order", () => {
expect(firstOrderedTitle(items, ["b", "a"], "fallback")).toBe("Beta")
})
it("skips items without titles in order", () => {
expect(firstOrderedTitle(items, ["d", "c", "b"], "fallback")).toBe("Beta")
})
it("falls back to first titled item when order has no matches", () => {
expect(firstOrderedTitle(items, ["x", "y"], "fallback")).toBe("Alpha")
})
it("falls back to first titled item when order is undefined", () => {
expect(firstOrderedTitle(items, undefined, "fallback")).toBe("Alpha")
})
it("returns fallback when no items have titles", () => {
expect(firstOrderedTitle([{ id: "a" }, { id: "b", title: "" }], ["a", "b"], "fallback")).toBe("fallback")
})
it("returns fallback for empty items", () => {
expect(firstOrderedTitle([], ["a"], "fallback")).toBe("fallback")
})
})
// Helper: simulate reconciliation the same way handleDragOver does
function reconcile(current: string[], stored: string[]): string[] {
return applyTabOrder(
current.map((id) => ({ id })),
stored,
).map((item) => item.id)
}
describe("applyTabOrder as reconciliation (string IDs)", () => {
it("returns stored order unchanged when it matches current IDs", () => {
expect(reconcile(["a", "b", "c"], ["a", "b", "c"])).toEqual(["a", "b", "c"])
})
it("appends new IDs not in stored order", () => {
expect(reconcile(["a", "b", "c"], ["a", "b"])).toEqual(["a", "b", "c"])
})
it("removes stale IDs no longer in current", () => {
expect(reconcile(["a", "c"], ["a", "b", "c"])).toEqual(["a", "c"])
})
it("preserves custom ordering while adding new tabs", () => {
expect(reconcile(["a", "b", "c"], ["b", "a"])).toEqual(["b", "a", "c"])
})
it("returns current IDs when stored order is undefined", () => {
expect(applyTabOrder([{ id: "a" }, { id: "b" }], undefined).map((i) => i.id)).toEqual(["a", "b"])
})
describe("regression: reorder a newly added tab immediately", () => {
it("new tab should be reorderable after reconcile via applyTabOrder", () => {
// Stored order from a previous drag: [s2, s1]
// A third session s3 was just added to the worktree
const stored = ["s2", "s1"]
const current = ["s2", "s1", "s3"]
const reconciled = reconcile(current, stored)
expect(reconciled).toEqual(["s2", "s1", "s3"])
// Now the user drags s3 to position of s2 — this must succeed
const reordered = reorderTabs(reconciled, "s3", "s2")
expect(reordered).toEqual(["s3", "s2", "s1"])
expect(reordered).not.toBeUndefined()
})
it("without reconcile, reorderTabs fails on the new tab", () => {
const stored = ["s2", "s1"]
const reordered = reorderTabs(stored, "s3", "s2")
expect(reordered).toBeUndefined()
})
})
})
@@ -174,6 +174,81 @@ describe("WorktreeStateManager", () => {
})
})
describe("tab order", () => {
it("sets and gets tab order for a key", () => {
manager.setTabOrder("wt-1", ["s1", "s2", "s3"])
expect(manager.getTabOrder()["wt-1"]).toEqual(["s1", "s2", "s3"])
})
it("overwrites existing tab order", () => {
manager.setTabOrder("wt-1", ["s1", "s2"])
manager.setTabOrder("wt-1", ["s2", "s1"])
expect(manager.getTabOrder()["wt-1"]).toEqual(["s2", "s1"])
})
it("removes tab order for a key", () => {
manager.setTabOrder("wt-1", ["s1"])
manager.removeTabOrder("wt-1")
expect(manager.getTabOrder()["wt-1"]).toBeUndefined()
})
it("removeTabOrder is a no-op for missing key", () => {
manager.removeTabOrder("nonexistent")
expect(Object.keys(manager.getTabOrder())).toHaveLength(0)
})
it("cleans up tab order when worktree is removed", () => {
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
manager.addSession("s1", wt.id)
manager.setTabOrder(wt.id, ["s1"])
manager.removeWorktree(wt.id)
expect(manager.getTabOrder()[wt.id]).toBeUndefined()
})
it("removes session from tab order arrays when session is removed", () => {
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
manager.addSession("s1", wt.id)
manager.addSession("s2", wt.id)
manager.setTabOrder(wt.id, ["s1", "s2"])
manager.removeSession("s1")
expect(manager.getTabOrder()[wt.id]).toEqual(["s2"])
})
it("removes tab order entry when last session in order is removed", () => {
manager.addSession("s1", null)
manager.setTabOrder("local", ["s1"])
manager.removeSession("s1")
expect(manager.getTabOrder()["local"]).toBeUndefined()
})
it("persists and loads tab order", async () => {
const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
manager.setTabOrder(wt.id, ["s2", "s1"])
manager.setTabOrder("local", ["s3", "s4"])
await manager.flush()
await manager.save()
const loaded = new WorktreeStateManager(root, () => {})
await loaded.load()
expect(loaded.getTabOrder()[wt.id]).toEqual(["s2", "s1"])
expect(loaded.getTabOrder()["local"]).toEqual(["s3", "s4"])
})
it("does not persist empty tab order", async () => {
manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" })
await manager.flush()
await manager.save()
const content = fs.readFileSync(path.join(root, ".kilocode", "agent-manager.json"), "utf-8")
const data = JSON.parse(content)
expect(data.tabOrder).toBeUndefined()
})
})
describe("validate", () => {
it("removes worktrees whose directories do not exist", async () => {
const existing = path.join(root, "wt-exists")
@@ -1,28 +1,45 @@
// Agent Manager root component
import { Component, For, Show, createSignal, createMemo, createEffect, onMount, onCleanup } from "solid-js"
import {
Component,
For,
Show,
createSignal,
createMemo,
createEffect,
onMount,
onCleanup,
type Accessor,
} from "solid-js"
import type {
ExtensionMessage,
AgentManagerRepoInfoMessage,
AgentManagerWorktreeSetupMessage,
AgentManagerStateMessage,
AgentManagerKeybindingsMessage,
WorktreeState,
ManagedSessionState,
SessionInfo,
} from "../src/types/messages"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
import { DialogProvider } from "@kilocode/kilo-ui/context/dialog"
import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog"
import { Dialog } from "@kilocode/kilo-ui/dialog"
import { MarkedProvider } from "@kilocode/kilo-ui/context/marked"
import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code"
import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff"
import { Code } from "@kilocode/kilo-ui/code"
import { Diff } from "@kilocode/kilo-ui/diff"
import { Toast } from "@kilocode/kilo-ui/toast"
import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { HoverCard } from "@kilocode/kilo-ui/hover-card"
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
import { VSCodeProvider, useVSCode } from "../src/context/vscode"
import { ServerProvider } from "../src/context/server"
import { ProviderProvider } from "../src/context/provider"
@@ -32,7 +49,9 @@ import { WorktreeModeProvider } from "../src/context/worktree-mode"
import { ChatView } from "../src/components/chat"
import { LanguageBridge, DataBridge } from "../src/App"
import { formatRelativeDate } from "../src/utils/date"
import { validateLocalSession } from "./navigate"
import { validateLocalSession, nextSelectionAfterDelete, adjacentHint, LOCAL } from "./navigate"
import { reorderTabs, applyTabOrder, firstOrderedTitle } from "./tab-order"
import { ConstrainDragYAxis, SortableTab } from "./sortable-tab"
import "./agent-manager.css"
interface SetupState {
@@ -42,30 +61,127 @@ interface SetupState {
error?: boolean
}
/** Sidebar selection: "local" for workspace, worktree ID for a worktree, or null for an unassigned session. */
type SidebarSelection = "local" | string | null
/** Sidebar selection: LOCAL for workspace, worktree ID for a worktree, or null for an unassigned session. */
type SidebarSelection = typeof LOCAL | string | null
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.userAgent)
// Fallback keybindings before extension sends resolved ones
const defaultBindings: Record<string, string> = {
previousSession: isMac ? "⌘↑" : "Ctrl+↑",
nextSession: isMac ? "⌘↓" : "Ctrl+↓",
previousTab: isMac ? "⌘←" : "Ctrl+←",
nextTab: isMac ? "⌘→" : "Ctrl+→",
showTerminal: isMac ? "⌘/" : "Ctrl+/",
newTab: isMac ? "⌘T" : "Ctrl+T",
closeTab: isMac ? "⌘W" : "Ctrl+W",
newWorktree: isMac ? "⌘N" : "Ctrl+N",
closeWorktree: isMac ? "⌘⇧W" : "Ctrl+Shift+W",
}
/** Manages horizontal scroll for the tab list: hides the scrollbar, converts
* vertical wheel events to horizontal scroll, tracks overflow to show/hide
* fade indicators, and auto-scrolls the active tab into view. */
function useTabScroll(activeTabs: Accessor<SessionInfo[]>, activeId: Accessor<string | undefined>) {
const [ref, setRef] = createSignal<HTMLDivElement | undefined>()
const [showLeft, setShowLeft] = createSignal(false)
const [showRight, setShowRight] = createSignal(false)
const update = () => {
const el = ref()
if (!el) return
setShowLeft(el.scrollLeft > 2)
setShowRight(el.scrollLeft + el.clientWidth < el.scrollWidth - 2)
}
// Wheel → horizontal scroll conversion
const onWheel = (e: WheelEvent) => {
const el = ref()
if (!el) return
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return
e.preventDefault()
el.scrollLeft += e.deltaY > 0 ? 60 : -60
}
// Recalculate on scroll, resize, or tab changes
createEffect(() => {
const el = ref()
if (!el) return
el.addEventListener("scroll", update, { passive: true })
el.addEventListener("wheel", onWheel, { passive: false })
const ro = new ResizeObserver(update)
ro.observe(el)
const mo = new MutationObserver(update)
mo.observe(el, { childList: true, subtree: true })
onCleanup(() => {
el.removeEventListener("scroll", update)
el.removeEventListener("wheel", onWheel)
ro.disconnect()
mo.disconnect()
})
})
// Auto-scroll active tab into view
createEffect(() => {
const id = activeId()
const el = ref()
// depend on tabs length to trigger on tab add/remove
activeTabs()
if (!id || !el) return
requestAnimationFrame(() => {
const tab = el.querySelector(`[data-tab-id="${id}"]`) as HTMLElement | null
if (!tab) return
const left = tab.offsetLeft
const right = left + tab.offsetWidth
if (left < el.scrollLeft) {
el.scrollTo({ left: left - 8, behavior: "smooth" })
} else if (right > el.scrollLeft + el.clientWidth) {
el.scrollTo({ left: right - el.clientWidth + 8, behavior: "smooth" })
}
})
})
return { setRef, showLeft, showRight }
}
const AgentManagerContent: Component = () => {
const session = useSession()
const vscode = useVSCode()
const dialog = useDialog()
const [kb, setKb] = createSignal<Record<string, string>>(defaultBindings)
const [setup, setSetup] = createSignal<SetupState>({ active: false, message: "" })
const [worktrees, setWorktrees] = createSignal<WorktreeState[]>([])
const [managedSessions, setManagedSessions] = createSignal<ManagedSessionState[]>([])
const [selection, setSelection] = createSignal<SidebarSelection>("local")
const [selection, setSelection] = createSignal<SidebarSelection>(LOCAL)
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
const [deletingWorktrees, setDeletingWorktrees] = createSignal<Set<string>>(new Set())
const DEFAULT_SIDEBAR_WIDTH = 260
const MIN_SIDEBAR_WIDTH = 200
const MAX_SIDEBAR_WIDTH_RATIO = 0.4
// Recover persisted local session IDs from webview state
const persisted = vscode.getState<{ localSessionIDs?: string[] }>()
const persisted = vscode.getState<{ localSessionIDs?: string[]; sidebarWidth?: number }>()
const [localSessionIDs, setLocalSessionIDs] = createSignal<string[]>(persisted?.localSessionIDs ?? [])
const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH)
// Pending local tab counter for generating unique IDs
let pendingCounter = 0
const PENDING_PREFIX = "pending:"
const [activePendingId, setActivePendingId] = createSignal<string | undefined>()
// Per-context tab memory: maps sidebar selection key -> last active session/pending ID
const [tabMemory, setTabMemory] = createSignal<Record<string, string>>({})
const isPending = (id: string) => id.startsWith(PENDING_PREFIX)
// Drag-and-drop state for tab reordering
const [draggingTab, setDraggingTab] = createSignal<string | undefined>()
// Tab ordering: context key → ordered session ID array (recovered from extension state)
const [worktreeTabOrder, setWorktreeTabOrder] = createSignal<Record<string, string[]>>({})
const addPendingTab = () => {
const id = `${PENDING_PREFIX}${++pendingCounter}`
setLocalSessionIDs((prev) => [...prev, id])
@@ -74,11 +190,25 @@ const AgentManagerContent: Component = () => {
return id
}
// Persist local session IDs to webview state for recovery (exclude pending tabs)
// Persist local session IDs and sidebar width to webview state for recovery (exclude pending tabs)
createEffect(() => {
vscode.setState({ localSessionIDs: localSessionIDs().filter((id) => !isPending(id)) })
vscode.setState({
localSessionIDs: localSessionIDs().filter((id) => !isPending(id)),
sidebarWidth: sidebarWidth(),
})
})
// Save the currently active tab for the current sidebar context before switching away
const saveTabMemory = () => {
const sel = selection()
if (sel === null) return
const key = sel === LOCAL ? LOCAL : sel
const active = session.currentSessionID() ?? activePendingId()
if (active) {
setTabMemory((prev) => (prev[key] === active ? prev : { ...prev, [key]: active }))
}
}
// Invalidate local session IDs if they no longer exist (preserve pending tabs)
createEffect(() => {
const all = session.sessions()
@@ -126,22 +256,23 @@ const AgentManagerContent: Component = () => {
return result
})
// Sessions for the currently selected worktree (tab bar), sorted by creation date
// Sessions for the currently selected worktree (tab bar), respecting custom order if set
const activeWorktreeSessions = createMemo((): SessionInfo[] => {
const sel = selection()
if (!sel || sel === "local") return []
if (!sel || sel === LOCAL) return []
const managed = managedSessions().filter((ms) => ms.worktreeId === sel)
const ids = new Set(managed.map((ms) => ms.id))
return session
const sessions = session
.sessions()
.filter((s) => ids.has(s.id))
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
return applyTabOrder(sessions, worktreeTabOrder()[sel])
})
// Active tab sessions: local sessions when on "local", worktree sessions otherwise
const activeTabs = createMemo((): SessionInfo[] => {
const sel = selection()
if (sel === "local") return localSessions()
if (sel === LOCAL) return localSessions()
if (sel) return activeWorktreeSessions()
return []
})
@@ -149,7 +280,7 @@ const AgentManagerContent: Component = () => {
// Whether the selected context has zero sessions
const contextEmpty = createMemo(() => {
const sel = selection()
if (sel === "local") return localSessionIDs().length === 0
if (sel === LOCAL) return localSessionIDs().length === 0
if (sel) return activeWorktreeSessions().length === 0
return false
})
@@ -157,12 +288,16 @@ const AgentManagerContent: Component = () => {
// Read-only mode: viewing an unassigned session (not in a worktree or local)
const readOnly = createMemo(() => selection() === null && !!session.currentSessionID())
// Display name for worktree
// Tab scroll: hidden scrollbar with fade overflow indicators
const visibleTabId = createMemo(() => session.currentSessionID() ?? activePendingId())
const tabScroll = useTabScroll(activeTabs, visibleTabId)
// Display name for worktree — uses first tab in custom order when available
const worktreeLabel = (wt: WorktreeState): string => {
const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id)
const ids = new Set(managed.map((ms) => ms.id))
const first = session.sessions().find((s) => ids.has(s.id))
return first?.title || wt.branch
const sessions = session.sessions().filter((s) => ids.has(s.id))
return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch)
}
const scrollIntoView = (el: HTMLElement) => {
@@ -171,8 +306,8 @@ const AgentManagerContent: Component = () => {
// Navigate sidebar items with arrow keys
const navigate = (direction: "up" | "down") => {
const flat: { type: "local" | "wt" | "session"; id: string }[] = [
{ type: "local", id: "local" },
const flat: { type: typeof LOCAL | "wt" | "session"; id: string }[] = [
{ type: LOCAL, id: LOCAL },
...worktrees().map((wt) => ({ type: "wt" as const, id: wt.id })),
...unassignedSessions().map((s) => ({ type: "session" as const, id: s.id })),
]
@@ -184,11 +319,12 @@ const AgentManagerContent: Component = () => {
if (next < 0 || next >= flat.length) return
const item = flat[next]!
if (item.type === "local") {
if (item.type === LOCAL) {
selectLocal()
} else if (item.type === "wt") {
selectWorktree(item.id)
} else {
saveTabMemory()
setSelection(null)
session.selectSession(item.id)
}
@@ -217,15 +353,18 @@ const AgentManagerContent: Component = () => {
}
const selectLocal = () => {
setSelection("local")
saveTabMemory()
setSelection(LOCAL)
vscode.postMessage({ type: "agentManager.requestRepoInfo" })
const locals = localSessions()
const first = locals[0]
if (first && !isPending(first.id)) {
const remembered = tabMemory()[LOCAL]
const target = remembered ? locals.find((s) => s.id === remembered) : undefined
const fallback = target ?? locals[0]
if (fallback && !isPending(fallback.id)) {
setActivePendingId(undefined)
session.selectSession(first.id)
} else if (first && isPending(first.id)) {
setActivePendingId(first.id)
session.selectSession(fallback.id)
} else if (fallback && isPending(fallback.id)) {
setActivePendingId(fallback.id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
@@ -234,12 +373,16 @@ const AgentManagerContent: Component = () => {
}
const selectWorktree = (worktreeId: string) => {
saveTabMemory()
setSelection(worktreeId)
const managed = managedSessions().filter((ms) => ms.worktreeId === worktreeId)
const ids = new Set(managed.map((ms) => ms.id))
const first = session.sessions().find((s) => ids.has(s.id))
if (first) {
session.selectSession(first.id)
const sessions = session.sessions().filter((s) => ids.has(s.id))
const remembered = tabMemory()[worktreeId]
const target = remembered ? sessions.find((s) => s.id === remembered) : undefined
const fallback = target ?? sessions[0]
if (fallback) {
session.selectSession(fallback.id)
} else {
session.setCurrentSessionID(undefined)
}
@@ -253,17 +396,32 @@ const AgentManagerContent: Component = () => {
else if (msg.action === "sessionNext") navigate("down")
else if (msg.action === "tabPrevious") navigateTab("left")
else if (msg.action === "tabNext") navigateTab("right")
else if (msg.action === "showTerminal") {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
} else if (msg.action === "newTab") handleNewTabForCurrentSelection()
else if (msg.action === "closeTab") closeActiveTab()
else if (msg.action === "newWorktree") handleNewWorktreeOrPromote()
else if (msg.action === "closeWorktree") closeSelectedWorktree()
}
window.addEventListener("message", handler)
// Prevent Cmd+Up/Down/Left/Right from triggering native scroll
const preventScroll = (e: KeyboardEvent) => {
// Prevent Cmd+Arrow/T/W/N from triggering native browser actions
const preventDefaults = (e: KeyboardEvent) => {
if (!(e.metaKey || e.ctrlKey)) return
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
e.preventDefault()
}
// Prevent browser defaults for our shortcuts (new tab, close tab, new window)
if (["t", "w", "n"].includes(e.key.toLowerCase()) && !e.shiftKey) {
e.preventDefault()
}
// Prevent defaults for shift variants (close worktree)
if (e.key.toLowerCase() === "w" && e.shiftKey) {
e.preventDefault()
}
}
window.addEventListener("keydown", preventScroll)
window.addEventListener("keydown", preventDefaults)
// When the panel regains focus (e.g. returning from terminal), focus the prompt
const onWindowFocus = () => window.dispatchEvent(new Event("focusPrompt"))
@@ -272,7 +430,7 @@ const AgentManagerContent: Component = () => {
// When a session is created while on local, replace the current pending tab with the real session.
// Guard against duplicate sessionCreated events (HTTP response + SSE can both fire).
const unsubCreate = vscode.onMessage((msg) => {
if (msg.type === "sessionCreated" && selection() === "local") {
if (msg.type === "sessionCreated" && selection() === LOCAL) {
const created = msg as { type: string; session: { id: string } }
if (localSessionIDs().includes(created.session.id)) return
const pending = activePendingId()
@@ -299,27 +457,56 @@ const AgentManagerContent: Component = () => {
globalThis.setTimeout(() => setSetup({ active: false, message: "" }), error ? 3000 : 500)
if (!error && ev.sessionId) {
session.selectSession(ev.sessionId)
// Auto-switch sidebar to the worktree containing this session
const ms = managedSessions().find((s) => s.id === ev.sessionId)
if (ms?.worktreeId) setSelection(ms.worktreeId)
}
} else {
setSetup({ active: true, message: ev.message, branch: ev.branch })
}
}
if (msg.type === "agentManager.sessionAdded") {
const ev = msg as { type: string; sessionId: string; worktreeId: string }
session.selectSession(ev.sessionId)
}
if (msg.type === "agentManager.keybindings") {
const ev = msg as AgentManagerKeybindingsMessage
setKb(ev.bindings)
}
if (msg.type === "agentManager.state") {
const state = msg as AgentManagerStateMessage
setWorktrees(state.worktrees)
setManagedSessions(state.sessions)
if (state.tabOrder) setWorktreeTabOrder(state.tabOrder)
const current = session.currentSessionID()
if (current) {
const ms = state.sessions.find((s) => s.id === current)
if (ms?.worktreeId) setSelection(ms.worktreeId)
}
// Recover local tab order from persisted state
const localOrder = state.tabOrder?.[LOCAL]
if (localOrder && localSessionIDs().length > 0) {
const reordered = applyTabOrder(
localSessionIDs().map((id) => ({ id })),
localOrder,
).map((item) => item.id)
setLocalSessionIDs(reordered)
}
// Clear deleting state for worktrees that have been removed
const ids = new Set(state.worktrees.map((wt) => wt.id))
setDeletingWorktrees((prev) => {
const next = new Set([...prev].filter((id) => ids.has(id)))
return next.size === prev.size ? prev : next
})
}
})
onCleanup(() => {
window.removeEventListener("message", handler)
window.removeEventListener("keydown", preventScroll)
window.removeEventListener("keydown", preventDefaults)
window.removeEventListener("focus", onWindowFocus)
unsubCreate()
unsub()
@@ -335,14 +522,62 @@ const AgentManagerContent: Component = () => {
}
})
const handleConfigureSetupScript = () => {
vscode.postMessage({ type: "agentManager.configureSetupScript" })
}
const handleCreateWorktree = () => {
vscode.postMessage({ type: "agentManager.createWorktree" })
}
const confirmDeleteWorktree = (worktreeId: string) => {
const wt = worktrees().find((w) => w.id === worktreeId)
if (!wt) return
const doDelete = () => {
setDeletingWorktrees((prev) => new Set([...prev, wt.id]))
vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId: wt.id })
if (selection() === wt.id) {
const next = nextSelectionAfterDelete(
wt.id,
worktrees().map((w) => w.id),
)
if (next === LOCAL) selectLocal()
else selectWorktree(next)
}
dialog.close()
}
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
doDelete()
}
}
dialog.show(() => (
<Dialog title="Delete Worktree" fit>
<div class="am-confirm" onKeyDown={onKeyDown}>
<div class="am-confirm-message">
<Icon name="trash" size="small" />
<span>
Delete worktree <code class="am-confirm-branch">{wt.branch}</code>? This removes the worktree from disk
and dissociates all sessions.
</span>
</div>
<div class="am-confirm-actions">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
Cancel
</Button>
<Button variant="primary" size="large" class="am-confirm-delete" onClick={doDelete} autofocus>
Delete
</Button>
</div>
</div>
</Dialog>
))
}
const handleDeleteWorktree = (worktreeId: string, e: MouseEvent) => {
e.stopPropagation()
vscode.postMessage({ type: "agentManager.deleteWorktree", worktreeId })
if (selection() === worktreeId) selectLocal()
confirmDeleteWorktree(worktreeId)
}
const handlePromote = (sessionId: string, e: MouseEvent) => {
@@ -352,7 +587,7 @@ const AgentManagerContent: Component = () => {
const handleAddSession = () => {
const sel = selection()
if (sel === "local") {
if (sel === LOCAL) {
addPendingTab()
} else if (sel) {
vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel })
@@ -394,12 +629,115 @@ const AgentManagerContent: Component = () => {
}
}
// Drag-and-drop handlers for tab reordering
const tabIds = createMemo(() => activeTabs().map((s) => s.id))
const handleDragStart = (event: DragEvent) => {
const id = event.draggable?.id
if (typeof id === "string") setDraggingTab(id)
}
const handleDragOver = (event: DragEvent) => {
const from = event.draggable?.id
const to = event.droppable?.id
if (typeof from !== "string" || typeof to !== "string") return
const sel = selection()
if (sel === LOCAL) {
setLocalSessionIDs((prev) => reorderTabs(prev, from, to) ?? prev)
} else if (sel) {
setWorktreeTabOrder((prev) => {
const ids = applyTabOrder(
tabIds().map((id) => ({ id })),
prev[sel],
).map((item) => item.id)
const reordered = reorderTabs(ids, from, to)
if (!reordered) return prev
return { ...prev, [sel]: reordered }
})
}
}
const handleDragEnd = () => {
setDraggingTab(undefined)
// Persist the new tab order to the extension
const sel = selection()
if (sel === LOCAL) {
const order = localSessionIDs().filter((id) => !isPending(id))
if (order.length > 0) vscode.postMessage({ type: "agentManager.setTabOrder", key: LOCAL, order })
} else if (sel) {
const order = worktreeTabOrder()[sel]
if (order) vscode.postMessage({ type: "agentManager.setTabOrder", key: sel, order })
}
}
const draggedTab = createMemo(() => {
const id = draggingTab()
if (!id) return undefined
return activeTabs().find((s) => s.id === id)
})
// Close the currently active tab via keyboard shortcut.
// If no tabs remain, fall through to close the selected worktree.
const closeActiveTab = () => {
const tabs = activeTabs()
if (tabs.length === 0) {
closeSelectedWorktree()
return
}
const current = session.currentSessionID()
const pending = activePendingId()
const target = current
? tabs.find((s) => s.id === current)
: pending
? tabs.find((s) => s.id === pending)
: undefined
if (!target) return
const synthetic = new MouseEvent("click")
handleCloseTab(target.id, synthetic)
}
// Cmd+T: add a new tab strictly to the current selection (no side effects)
const handleNewTabForCurrentSelection = () => {
const sel = selection()
if (sel === LOCAL) {
addPendingTab()
} else if (sel) {
// Pass the captured worktree ID directly to avoid race conditions
vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel })
}
}
// Cmd+N: if an unassigned session is selected, promote it; otherwise create a new worktree
const handleNewWorktreeOrPromote = () => {
const sel = selection()
const sid = session.currentSessionID()
if (sel === null && sid && !worktreeSessionIds().has(sid)) {
vscode.postMessage({ type: "agentManager.promoteSession", sessionId: sid })
return
}
handleCreateWorktree()
}
// Close the currently selected worktree with a confirmation dialog
const closeSelectedWorktree = () => {
const sel = selection()
if (!sel || sel === LOCAL) return
confirmDeleteWorktree(sel)
}
return (
<div class="am-layout">
<div class="am-sidebar">
<div class="am-sidebar" style={{ width: `${sidebarWidth()}px` }}>
<ResizeHandle
direction="horizontal"
size={sidebarWidth()}
min={MIN_SIDEBAR_WIDTH}
max={9999}
onResize={(width) => setSidebarWidth(Math.min(width, window.innerWidth * MAX_SIDEBAR_WIDTH_RATIO))}
/>
{/* Local workspace item */}
<button
class={`am-local-item ${selection() === "local" ? "am-local-item-active" : ""}`}
class={`am-local-item ${selection() === LOCAL ? "am-local-item-active" : ""}`}
data-sidebar-id="local"
onClick={() => selectLocal()}
>
@@ -420,31 +758,124 @@ const AgentManagerContent: Component = () => {
<div class="am-section">
<div class="am-section-header">
<span class="am-section-label">WORKTREES</span>
<IconButton icon="plus" size="small" variant="ghost" label="New Worktree" onClick={handleCreateWorktree} />
<div class="am-section-actions">
<DropdownMenu>
<DropdownMenu.Trigger
as={IconButton}
icon="settings-gear"
size="small"
variant="ghost"
label="Worktree settings"
/>
<DropdownMenu.Portal>
<DropdownMenu.Content>
<DropdownMenu.Item onSelect={handleConfigureSetupScript}>
<DropdownMenu.ItemLabel>Worktree Setup Script</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<TooltipKeybind title="New worktree" keybind={kb().newWorktree ?? ""} placement="bottom">
<IconButton
icon="plus"
size="small"
variant="ghost"
label="New worktree"
onClick={handleCreateWorktree}
/>
</TooltipKeybind>
</div>
</div>
<div class="am-worktree-list">
<For each={worktrees()}>
{(wt) => (
<div
class={`am-worktree-item ${selection() === wt.id ? "am-worktree-item-active" : ""}`}
data-sidebar-id={wt.id}
onClick={() => selectWorktree(wt.id)}
>
<Icon name="branch" size="small" />
<span class="am-worktree-branch" title={wt.branch}>
{worktreeLabel(wt)}
</span>
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Close worktree"
class="am-worktree-close"
onClick={(e: MouseEvent) => handleDeleteWorktree(wt.id, e)}
/>
</div>
)}
</For>
{(() => {
const [hoveredWt, setHoveredWt] = createSignal<string | null>(null)
const [overClose, setOverClose] = createSignal(false)
return (
<For each={worktrees()}>
{(wt, wtIdx) => {
const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id))
const navHint = () => {
const flat = [
LOCAL as string,
...worktrees().map((w) => w.id),
...unassignedSessions().map((s) => s.id),
]
const active = selection() ?? session.currentSessionID() ?? ""
return adjacentHint(wt.id, active, flat, kb().previousSession ?? "", kb().nextSession ?? "")
}
return (
<HoverCard
openDelay={100}
closeDelay={100}
placement="right-start"
gutter={8}
open={hoveredWt() === wt.id && !overClose()}
onOpenChange={(open) => setHoveredWt(open ? wt.id : null)}
trigger={
<div
class={`am-worktree-item ${selection() === wt.id ? "am-worktree-item-active" : ""}`}
data-sidebar-id={wt.id}
onClick={() => selectWorktree(wt.id)}
>
<Icon name="branch" size="small" />
<span class="am-worktree-branch">{worktreeLabel(wt)}</span>
<Show
when={!deletingWorktrees().has(wt.id)}
fallback={<Spinner class="am-worktree-spinner" />}
>
<div
class="am-worktree-close"
onMouseEnter={() => setOverClose(true)}
onMouseLeave={() => setOverClose(false)}
>
<TooltipKeybind
title="Delete worktree"
keybind={kb().closeWorktree ?? ""}
placement="top"
>
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Delete worktree"
onClick={(e: MouseEvent) => handleDeleteWorktree(wt.id, e)}
/>
</TooltipKeybind>
</div>
</Show>
</div>
}
>
<div class="am-hover-card">
<div class="am-hover-card-header">
<div>
<div class="am-hover-card-label">BRANCH</div>
<div class="am-hover-card-branch">{wt.branch}</div>
<div class="am-hover-card-meta">{formatRelativeDate(wt.createdAt)}</div>
</div>
<Show when={navHint()}>
<span class="am-hover-card-keybind">{navHint()}</span>
</Show>
</div>
<Show when={wt.parentBranch}>
<div class="am-hover-card-divider" />
<div class="am-hover-card-row">
<span class="am-hover-card-row-label">Base</span>
<span class="am-hover-card-row-value">{wt.parentBranch}</span>
</div>
</Show>
<div class="am-hover-card-divider" />
<div class="am-hover-card-row">
<span class="am-hover-card-row-label">Sessions</span>
<span class="am-hover-card-row-value">{sessions().length}</span>
</div>
</div>
</HoverCard>
)
}}
</For>
)
})()}
<Show when={worktrees().length === 0}>
<button class="am-worktree-create" onClick={handleCreateWorktree}>
<Icon name="plus" size="small" />
@@ -466,20 +897,24 @@ const AgentManagerContent: Component = () => {
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>
<IconButton
icon="branch"
size="small"
variant="ghost"
label="Open in worktree"
class="am-item-promote"
onClick={(e: MouseEvent) => handlePromote(s.id, e)}
/>
<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>
@@ -490,54 +925,92 @@ const AgentManagerContent: Component = () => {
<div class="am-detail">
{/* Tab bar — visible when a section is selected and has tabs or a pending new session */}
<Show when={selection() !== null && !contextEmpty()}>
<div class="am-tab-bar">
<div class="am-tab-list">
<For each={activeTabs()}>
{(s) => {
const pending = isPending(s.id)
const active = () =>
pending
? s.id === activePendingId() && !session.currentSessionID()
: s.id === session.currentSessionID()
return (
<Tooltip value={s.title || "Untitled"} placement="bottom">
<div
class={`am-tab ${active() ? "am-tab-active" : ""}`}
onClick={() => {
if (pending) {
setActivePendingId(s.id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
session.selectSession(s.id)
}
}}
onMouseDown={(e: MouseEvent) => handleTabMouseDown(s.id, e)}
>
<span class="am-tab-label">{s.title || "Untitled"}</span>
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Close tab"
class="am-tab-close"
onClick={(e: MouseEvent) => handleCloseTab(s.id, e)}
/>
</div>
</Tooltip>
)
}}
</For>
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
collisionDetector={closestCenter}
>
<DragDropSensors />
<ConstrainDragYAxis />
<div class="am-tab-bar">
<div class="am-tab-scroll-area">
<div class={`am-tab-fade am-tab-fade-left ${tabScroll.showLeft() ? "am-tab-fade-visible" : ""}`} />
<div class="am-tab-list" ref={tabScroll.setRef}>
<SortableProvider ids={tabIds()}>
<For each={activeTabs()}>
{(s) => {
const pending = isPending(s.id)
const active = () =>
pending
? s.id === activePendingId() && !session.currentSessionID()
: s.id === session.currentSessionID()
const tabDirection = () => {
if (active()) return ""
const ids = activeTabs().map((t) => t.id)
const activeId = session.currentSessionID() ?? activePendingId() ?? ""
return adjacentHint(s.id, activeId, ids, kb().previousTab ?? "", kb().nextTab ?? "")
}
return (
<SortableTab
tab={s}
active={active()}
keybind={tabDirection()}
closeKeybind={kb().closeTab ?? ""}
onSelect={() => {
if (pending) {
setActivePendingId(s.id)
session.clearCurrentSession()
} else {
setActivePendingId(undefined)
session.selectSession(s.id)
}
}}
onMiddleClick={(e: MouseEvent) => handleTabMouseDown(s.id, e)}
onClose={(e: MouseEvent) => handleCloseTab(s.id, e)}
/>
)
}}
</For>
</SortableProvider>
</div>
<div class={`am-tab-fade am-tab-fade-right ${tabScroll.showRight() ? "am-tab-fade-visible" : ""}`} />
</div>
<TooltipKeybind title="New session" keybind={kb().newTab ?? ""} placement="bottom">
<IconButton
icon="plus"
size="small"
variant="ghost"
label="New session"
class="am-tab-add"
onClick={handleAddSession}
/>
</TooltipKeybind>
<div class="am-tab-terminal">
<TooltipKeybind title="Terminal" keybind={kb().showTerminal ?? ""} placement="bottom">
<IconButton
icon="console"
size="small"
variant="ghost"
label="Open Terminal"
onClick={() => {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
}}
/>
</TooltipKeybind>
</div>
</div>
<IconButton
icon="plus"
size="small"
variant="ghost"
label="New session"
class="am-tab-add"
onClick={handleAddSession}
/>
</div>
<DragOverlay>
<Show when={draggedTab()}>
{(tab) => (
<div class="am-tab am-tab-overlay">
<span class="am-tab-label">{tab().title || "Untitled"}</span>
</div>
)}
</Show>
</DragOverlay>
</DragDropProvider>
</Show>
{/* Empty worktree state */}
@@ -549,6 +1022,7 @@ const AgentManagerContent: Component = () => {
<div class="am-empty-state-text">No sessions open</div>
<Button variant="primary" size="small" onClick={handleAddSession}>
New session
<span class="am-shortcut-hint">{kb().newTab ?? ""}</span>
</Button>
</div>
</Show>
@@ -557,7 +1031,7 @@ const AgentManagerContent: Component = () => {
<div class="am-setup-overlay">
<div class="am-setup-card">
<Icon name="branch" size="large" />
<div class="am-setup-title">Setting up workspace</div>
<div class="am-setup-title">{setup().error ? "Workspace setup failed" : "Setting up workspace"}</div>
<Show when={setup().branch}>
<div class="am-setup-branch">{setup().branch}</div>
</Show>
@@ -7,7 +7,7 @@
}
.am-sidebar {
width: 260px;
position: relative;
min-width: 200px;
border-right: 1px solid var(--border-weak-base);
display: flex;
@@ -18,6 +18,10 @@
gap: 4px;
}
.am-sidebar > [data-component="resize-handle"]::after {
background: var(--surface-interactive-base);
}
/* Fixed local workspace item */
.am-local-item {
@@ -105,6 +109,12 @@
color: var(--text-weak);
}
.am-section-actions {
display: flex;
align-items: center;
gap: 2px;
}
/* Worktree list */
.am-worktree-list {
@@ -112,12 +122,18 @@
flex-direction: column;
gap: 2px;
overflow-y: auto;
overflow-x: hidden;
max-height: 50vh;
}
/* Worktree item — larger card style */
.am-worktree-list [data-slot="hover-card-trigger"] {
min-width: 0;
}
.am-worktree-item {
position: relative;
display: flex;
align-items: center;
gap: 8px;
@@ -126,6 +142,8 @@
cursor: pointer;
font-size: var(--font-size-base);
color: var(--text-base);
min-width: 0;
width: 100%;
}
.am-worktree-item:hover {
@@ -151,6 +169,24 @@
}
.am-worktree-close {
position: absolute;
right: 4px;
flex-shrink: 0;
opacity: 0;
}
.am-worktree-item:hover .am-worktree-branch {
mask-image: linear-gradient(to right, black calc(100% - 48px), transparent calc(100% - 16px));
-webkit-mask-image: linear-gradient(to right, black calc(100% - 48px), transparent calc(100% - 16px));
}
.am-worktree-item:hover .am-worktree-close {
opacity: 1;
}
.am-worktree-spinner {
width: 16px;
height: 16px;
flex-shrink: 0;
}
@@ -168,6 +204,7 @@
/* Session items */
.am-item {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
@@ -219,10 +256,16 @@
/* Promote button on session rows */
.am-item-promote {
position: absolute;
right: 4px;
flex-shrink: 0;
opacity: 0;
}
.am-item:hover .am-item-time {
opacity: 0;
}
.am-item:hover .am-item-promote {
opacity: 1;
}
@@ -282,6 +325,51 @@
min-width: 0;
overflow-x: auto;
height: 100%;
/* Hide scrollbar while keeping scroll functionality */
scrollbar-width: none;
-ms-overflow-style: none;
}
.am-tab-list::-webkit-scrollbar {
display: none;
}
/* Wrapper that positions overflow fade indicators */
.am-tab-scroll-area {
position: relative;
display: flex;
min-width: 0;
flex-shrink: 1;
align-items: stretch;
height: 100%;
overflow: hidden;
}
/* Fade indicators for overflow */
.am-tab-fade {
position: absolute;
top: 0;
bottom: 0;
width: 32px;
pointer-events: none;
z-index: 1;
opacity: 0;
transition: opacity 0.15s ease;
}
.am-tab-fade-visible {
opacity: 1;
}
.am-tab-fade-left {
left: 0;
background: linear-gradient(to right, var(--surface-base) 0%, transparent 100%);
}
.am-tab-fade-right {
right: 0;
background: linear-gradient(to left, var(--surface-base) 0%, transparent 100%);
}
.am-tab {
@@ -336,11 +424,41 @@
opacity: 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 {
flex-shrink: 0;
align-self: center;
}
.am-tab-terminal {
flex-shrink: 0;
align-self: center;
margin-left: auto;
padding-right: 4px;
}
/* Empty worktree state */
.am-empty-state {
@@ -422,7 +540,7 @@
flex-direction: column;
align-items: center;
gap: 12px;
max-width: 300px;
max-width: 400px;
text-align: center;
}
@@ -446,12 +564,14 @@
.am-setup-status {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 16px;
border-radius: var(--radius-sm);
background: var(--surface-inset-base);
color: var(--text-base);
font-size: var(--font-size-base);
text-align: center;
}
.am-setup-spinner {
@@ -459,3 +579,143 @@
height: 16px;
flex-shrink: 0;
}
/* Confirmation dialog */
.am-confirm {
display: flex;
flex-direction: column;
gap: 16px;
padding: 0 24px 16px 24px;
}
.am-confirm-message {
display: flex;
align-items: flex-start;
gap: 10px;
font-size: var(--font-size-base);
color: var(--text-base);
line-height: 1.5;
}
.am-confirm-message > [data-component="icon"] {
flex-shrink: 0;
color: var(--icon-critical-base);
margin-top: 2px;
}
.am-confirm-branch {
font-family: var(--font-family-mono, monospace);
font-size: var(--font-size-small);
padding: 1px 6px;
border-radius: var(--radius-sm);
background: var(--surface-inset-base);
white-space: nowrap;
}
.am-confirm-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.am-confirm-delete {
background: var(--surface-critical-strong) !important;
color: var(--text-on-brand-base) !important;
}
.am-confirm-delete:hover {
opacity: 0.9;
}
/* Keyboard shortcut hint inside buttons */
.am-shortcut-hint {
font-size: var(--font-size-small);
opacity: 0.6;
margin-left: 6px;
}
/* HoverCard popover for worktree items */
.am-hover-card {
padding: 10px 12px;
min-width: 160px;
max-width: 240px;
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.am-hover-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.am-hover-card-label {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-weaker);
line-height: 1.4;
}
.am-hover-card-branch {
font-size: 13px;
font-weight: 600;
color: var(--text-strong);
line-height: 1.4;
word-break: break-all;
}
.am-hover-card-meta {
font-size: 12px;
color: var(--text-weaker);
line-height: 1.4;
}
.am-hover-card-keybind {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
height: 20px;
padding: 0 6px;
border-radius: 3px;
background: var(--surface-inset-base);
border: 1px solid var(--border-weak-base);
font-family: var(--font-family-sans);
font-size: 11px;
font-weight: 500;
line-height: 1;
color: var(--text-weak);
white-space: nowrap;
}
.am-hover-card-divider {
height: 1px;
background: var(--border-weak-base);
margin: 6px 0;
}
.am-hover-card-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.am-hover-card-row-label {
font-size: 12px;
color: var(--text-weaker);
}
.am-hover-card-row-value {
font-size: 12px;
font-weight: 500;
color: var(--text-base);
}
@@ -7,7 +7,10 @@
* Returns the action to take: select a session by ID, go to local, or do nothing.
*/
export type NavResult = { action: "select"; id: string } | { action: "local" } | { action: "none" }
/** Sentinel value for the local workspace selection. */
export const LOCAL = "local" as const
export type NavResult = { action: "select"; id: string } | { action: typeof LOCAL } | { action: "none" }
export function resolveNavigation(direction: "up" | "down", current: string | undefined, ids: string[]): NavResult {
// Determine current position: -1 = local, 0..N-1 = session index
@@ -25,7 +28,7 @@ export function resolveNavigation(direction: "up" | "down", current: string | un
const next = direction === "up" ? idx - 1 : idx + 1
// Moving up past the first session → go to local
if (next === -1) return { action: "local" }
if (next === -1) return { action: LOCAL }
// At the bottom boundary
if (next >= ids.length) return { action: "none" }
@@ -42,3 +45,44 @@ export function validateLocalSession(persisted: string | undefined, ids: string[
if (ids.indexOf(persisted) === -1) return undefined
return persisted
}
/**
* Return the keybinding hint for an item adjacent to the active item.
* Only returns a hint when the item is exactly one step away in the flat list.
* Returns empty string for non-adjacent items or the active item itself.
*
* @param itemId - The item being hovered
* @param activeId - The currently selected/active item (or undefined for LOCAL)
* @param flatIds - The full ordered sidebar list (LOCAL first, then worktrees, then sessions)
* @param prev - Display string for "go up" (e.g. "⌘↑" or keybinding)
* @param next - Display string for "go down" (e.g. "⌘↓" or keybinding)
*/
export function adjacentHint(
itemId: string,
activeId: string | undefined,
flatIds: string[],
prev: string,
next: string,
): string {
if (!activeId || itemId === activeId) return ""
const activeIdx = flatIds.indexOf(activeId)
const itemIdx = flatIds.indexOf(itemId)
if (activeIdx === -1 || itemIdx === -1) return ""
const diff = itemIdx - activeIdx
if (diff === -1) return prev
if (diff === 1) return next
return ""
}
/**
* After removing a worktree, pick the nearest remaining sidebar neighbor.
* Order: the worktree just below → the one above → LOCAL.
*/
export function nextSelectionAfterDelete(deletedId: string, worktreeIds: string[]): typeof LOCAL | string {
const idx = worktreeIds.indexOf(deletedId)
if (idx === -1) return LOCAL
const remaining = worktreeIds.filter((id) => id !== deletedId)
if (remaining.length === 0) return LOCAL
// Prefer the item that was below (same index in the shortened list), else the one above
return remaining[Math.min(idx, remaining.length - 1)]!
}
@@ -0,0 +1,78 @@
/**
* Drag-and-drop sortable tab components for the agent manager tab bar.
*/
import { Component, onCleanup } from "solid-js"
import { createSortable, useDragDropContext } from "@thisbeyond/solid-dnd"
import type { Transformer } from "@thisbeyond/solid-dnd"
import { createRoot } from "solid-js"
import type { SessionInfo } from "../src/types/messages"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
/** Lock drag movement to the X axis (horizontal-only tab dragging). */
export const ConstrainDragYAxis: Component = () => {
const context = useDragDropContext()
if (!context) return null
const [, { onDragStart, onDragEnd, addTransformer, removeTransformer }] = context
const transformer: Transformer = { id: "constrain-y-axis", order: 100, callback: (t) => ({ ...t, y: 0 }) }
const dispose = createRoot((dispose) => {
onDragStart(({ draggable }) => {
if (draggable) addTransformer("draggables", draggable.id as string, transformer)
})
onDragEnd(({ draggable }) => {
if (draggable) removeTransformer("draggables", draggable.id as string, transformer.id)
})
return dispose
})
onCleanup(dispose)
return null
}
/** Individual sortable tab wrapper using the `use:sortable` directive. */
export const SortableTab: Component<{
tab: SessionInfo
active: boolean
keybind?: string
closeKeybind?: string
onSelect: () => void
onMiddleClick: (e: MouseEvent) => void
onClose: (e: MouseEvent) => void
}> = (props) => {
const sortable = createSortable(props.tab.id)
// Prevent tree-shaking of the directive reference used by `use:sortable`
void sortable
return (
// @ts-ignore - use:sortable is a SolidJS directive compiled by esbuild-plugin-solid
<div
use:sortable
class={`am-tab-sortable ${sortable.isActiveDraggable ? "am-tab-dragging" : ""}`}
data-tab-id={props.tab.id}
>
<TooltipKeybind
title={props.tab.title || "Untitled"}
keybind={props.keybind ?? ""}
placement="bottom"
inactive={props.active}
>
<div
class={`am-tab ${props.active ? "am-tab-active" : ""}`}
onClick={props.onSelect}
onMouseDown={props.onMiddleClick}
>
<span class="am-tab-label">{props.tab.title || "Untitled"}</span>
<TooltipKeybind title="Close" keybind={props.closeKeybind ?? ""} placement="bottom">
<IconButton
icon="close-small"
size="small"
variant="ghost"
label="Close tab"
class="am-tab-close"
onClick={props.onClose}
/>
</TooltipKeybind>
</div>
</TooltipKeybind>
</div>
)
}
@@ -0,0 +1,62 @@
/**
* Pure tab-ordering logic for the agent manager.
*/
/**
* Reorder an array by moving the item at `from` to the position of `to`.
* Returns a new array, or undefined if either ID is not found or they are equal.
*/
export function reorderTabs(tabs: readonly string[], from: string, to: string): string[] | undefined {
if (from === to) return undefined
const fi = tabs.indexOf(from)
const ti = tabs.indexOf(to)
if (fi === -1 || ti === -1) return undefined
const result = [...tabs]
result.splice(fi, 1)
result.splice(ti, 0, from)
return result
}
/**
* Apply a custom ordering to a list of items.
*
* Items are returned in `order` sequence (skipping IDs not in `items`),
* followed by any items not present in `order`.
* Returns the original array unchanged if `order` is undefined or empty.
*/
export function applyTabOrder<T extends { id: string }>(items: T[], order: string[] | undefined): T[] {
if (!order || order.length === 0) return items
const lookup = new Map(items.map((item) => [item.id, item]))
const ordered: T[] = []
for (const id of order) {
const item = lookup.get(id)
if (item) {
ordered.push(item)
lookup.delete(id)
}
}
for (const item of lookup.values()) ordered.push(item)
return ordered
}
/**
* Find the title of the first item according to a custom order.
*
* Falls back to the first titled item in `items` if the order
* doesn't produce a match, then to `fallback`.
*/
export function firstOrderedTitle(
items: { id: string; title?: string }[],
order: string[] | undefined,
fallback: string,
): string {
if (order) {
const lookup = new Map(items.map((item) => [item.id, item]))
for (const id of order) {
const item = lookup.get(id)
if (item?.title) return item.title
}
}
const first = items.find((item) => item.title)
return first?.title || fallback
}
+11 -5
View File
@@ -50,15 +50,21 @@ export const DataBridge: Component<{ children: any }> = (props) => {
const data = createMemo(() => {
const id = session.currentSessionID()
const msgs = session.allMessages()
const parts = session.allParts()
const perms = id ? session.permissions().filter((p) => p.sessionID === id) : []
return {
session: session.sessions().map((s) => ({ ...s, id: s.id, role: "user" as const })) as unknown as any[],
session_status: {} as Record<string, any>,
session_diff: {} as Record<string, any[]>,
message: msgs as unknown as Record<string, SDKMessage[]>,
part: parts as unknown as Record<string, SDKPart[]>,
permission: id ? { [id]: session.permissions() as unknown as any[] } : {},
message: id ? { [id]: session.messages() as unknown as SDKMessage[] } : {},
part: id
? Object.fromEntries(
session
.messages()
.map((msg) => [msg.id, session.getParts(msg.id) as unknown as SDKPart[]])
.filter(([, parts]) => (parts as SDKPart[]).length > 0),
)
: {},
permission: id ? { [id]: perms as unknown as any[] } : {},
}
})
@@ -27,7 +27,7 @@ export const ChatView: Component<ChatViewProps> = (props) => {
const sessionPermissions = () => session.permissions().filter((p) => p.sessionID === id())
const questionRequest = () => sessionQuestions()[0]
const permissionRequest = () => sessionPermissions()[0]
const permissionRequest = () => sessionPermissions().find((p) => !p.tool)
const blocked = () => sessionPermissions().length > 0 || sessionQuestions().length > 0
const [responding, setResponding] = createSignal(false)
@@ -0,0 +1,13 @@
import type { PermissionRequest } from "../types/messages"
export function upsertPermission(list: PermissionRequest[], permission: PermissionRequest) {
const idx = list.findIndex((item) => item.id === permission.id)
if (idx === -1) return [...list, permission]
const next = list.slice()
next[idx] = permission
return next
}
export function removeSessionPermissions(list: PermissionRequest[], sessionID: string) {
return list.filter((item) => item.sessionID !== sessionID)
}
@@ -37,6 +37,7 @@ import type {
ExtensionMessage,
FileAttachment,
} from "../types/messages"
import { removeSessionPermissions, upsertPermission } from "./permission-queue"
// Derive human-readable status from the last streaming part
function computeStatus(
@@ -508,7 +509,7 @@ export const SessionProvider: ParentComponent = (props) => {
}
function handlePermissionRequest(permission: PermissionRequest) {
setPermissions((prev) => [...prev, permission])
setPermissions((prev) => upsertPermission(prev, permission))
}
function handleQuestionRequest(question: QuestionRequest) {
@@ -603,6 +604,7 @@ export const SessionProvider: ParentComponent = (props) => {
return next
})
}
setPermissions((prev) => removeSessionPermissions(prev, sessionID))
setStatusMap(
produce((map) => {
delete map[sessionID]
@@ -551,11 +551,25 @@ export interface ManagedSessionState {
createdAt: string
}
// Agent Manager session added to an existing worktree (no setup overlay needed)
export interface AgentManagerSessionAddedMessage {
type: "agentManager.sessionAdded"
sessionId: string
worktreeId: string
}
// Full state push from extension to webview
export interface AgentManagerStateMessage {
type: "agentManager.state"
worktrees: WorktreeState[]
sessions: ManagedSessionState[]
tabOrder?: Record<string, string[]>
}
// Resolved keybindings for agent manager actions
export interface AgentManagerKeybindingsMessage {
type: "agentManager.keybindings"
bindings: Record<string, string>
}
export type ExtensionMessage =
@@ -594,7 +608,9 @@ export type ExtensionMessage =
| AgentManagerSessionMetaMessage
| AgentManagerRepoInfoMessage
| AgentManagerWorktreeSetupMessage
| AgentManagerSessionAddedMessage
| AgentManagerStateMessage
| AgentManagerKeybindingsMessage
| SetChatBoxMessage
| TriggerTaskMessage
@@ -825,6 +841,24 @@ export interface RequestRepoInfoMessage {
type: "agentManager.requestRepoInfo"
}
// Configure worktree setup script
export interface ConfigureSetupScriptRequest {
type: "agentManager.configureSetupScript"
}
// Show terminal for a session
export interface ShowTerminalRequest {
type: "agentManager.showTerminal"
sessionId: string
}
// Persist tab order for a context (worktree ID or "local")
export interface SetTabOrderRequest {
type: "agentManager.setTabOrder"
key: string
order: string[]
}
export type WebviewMessage =
| SendMessageRequest
| AbortRequest
@@ -868,6 +902,9 @@ export type WebviewMessage =
| CloseSessionRequest
| TelemetryRequest
| RequestRepoInfoMessage
| ConfigureSetupScriptRequest
| ShowTerminalRequest
| SetTabOrderRequest
// ============================================
// VS Code API type
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.0.23",
"version": "1.0.24",
"name": "@kilocode/cli",
"type": "module",
"license": "MIT",
@@ -1,6 +1,7 @@
import { describe, expect, test, beforeEach } from "bun:test"
import { describe, expect, test, beforeEach, mock } from "bun:test"
// Mock Bun.spawnSync before importing the module under 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) {
@@ -13,17 +14,171 @@ function clearGitOutputs() {
}
}
// Replace global Bun.spawnSync — the git() helper in git-context.ts calls
// result.stdout.toString().trim(), so we return a Buffer and let git() trim.
Bun.spawnSync = ((cmd: string[], _opts?: any) => {
const args = cmd.slice(1).join(" ")
const output = spawnSyncResults[args] ?? ""
return {
stdout: Buffer.from(output),
stderr: Buffer.from(""),
exitCode: 0,
// 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] ?? ""
}
}) as typeof Bun.spawnSync
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"
+3 -2
View File
@@ -26,7 +26,7 @@ import { DEFAULT_HEADERS } from "@/kilocode/const" // kilocode_change
import { Telemetry } from "@kilocode/kilo-telemetry" // kilocode_change
// kilocode_change start
import { getKiloProjectId } from "@/kilocode/project-id"
import { HEADER_PROJECTID, HEADER_MACHINEID } from "@kilocode/kilo-gateway"
import { HEADER_PROJECTID, HEADER_MACHINEID, HEADER_TASKID } from "@kilocode/kilo-gateway"
import { Identity } from "@kilocode/kilo-telemetry"
// kilocode_change end
@@ -239,9 +239,10 @@ export namespace LLM {
? DEFAULT_HEADERS // kilocode_change
: undefined),
...(isKilo && input.agent.name ? { "x-kilocode-mode": input.agent.name.toLowerCase() } : {}),
// kilocode_change start - add project ID and machine ID headers for kilo provider
// kilocode_change start - add project ID, machine ID, and task ID headers for kilo provider
...(isKilo && kiloProjectId ? { [HEADER_PROJECTID]: kiloProjectId } : {}),
...(isKilo && machineId ? { [HEADER_MACHINEID]: machineId } : {}),
...(isKilo ? { [HEADER_TASKID]: input.sessionID } : {}),
// kilocode_change end
...input.model.headers,
...headers,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/plugin",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -8,7 +8,7 @@
"exports": {
".": "./src/index.ts"
},
"version": "1.0.23",
"version": "1.0.24",
"dependencies": {},
"peerDependencies": {}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/sdk",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"repository": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/ui",
"version": "1.0.23",
"version": "1.0.24",
"type": "module",
"license": "MIT",
"exports": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/util",
"version": "1.0.23",
"version": "1.0.24",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/upstream-merge",
"version": "1.0.23",
"version": "1.0.24",
"private": true,
"type": "module",
"description": "Scripts for automating upstream opencode merges into Kilo",
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "opencode",
"displayName": "opencode",
"description": "opencode for VS Code",
"version": "1.0.23",
"version": "1.0.24",
"publisher": "sst-dev",
"repository": {
"type": "git",