mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-09-01 15:32:11 +08:00
Merge branch 'dev' into fix-406
This commit is contained in:
@@ -6,6 +6,7 @@ Kilo CLI is an open source AI coding agent that generates code from natural lang
|
||||
- The default branch in this repo is `dev`.
|
||||
- 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
|
||||
|
||||
|
||||
@@ -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
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-BEhWiPWkXCUblpkXVAIDmWndNbuulgfaDRO80dvssPY=",
|
||||
"aarch64-linux": "sha256-8ttezrn/CuIJDoAS1SZvq0laoQR6kBcrvv615uCxCJg=",
|
||||
"aarch64-darwin": "sha256-krlzO1o/VVOC15hneG9W1dgp3Od96QFqZ0wKnjt89c4=",
|
||||
"x86_64-darwin": "sha256-WjIaEu3jIQTqdWLQ29nacIraxsxZp5YMvzYC0j+pBMY="
|
||||
"x86_64-linux": "sha256-V1SML9naH7HShbdovoQIUc46rH6YNgFF9MSTw95k0fU=",
|
||||
"aarch64-linux": "sha256-wddgxfbK7v7O/zsdbx0e/TNYAw0KzzxyQbuUdwudf/Y=",
|
||||
"aarch64-darwin": "sha256-y7bMXWMSiHTHfKKiJ+c/2WRJmc7mCw2o601Ny8W5Zzs=",
|
||||
"x86_64-darwin": "sha256-hXdKlmVFWq2C0kJWgO1+g82B+/fvn9CG/JQyR31QtzY="
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -102,6 +102,6 @@
|
||||
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
|
||||
"ghostty-web@0.3.0": "patches/ghostty-web@0.3.0.patch"
|
||||
},
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/app",
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@opencode-ai/desktop",
|
||||
"private": true,
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
id = "kilo"
|
||||
name = "Kilo"
|
||||
description = "The open source coding agent."
|
||||
version = "1.0.23"
|
||||
version = "1.0.24"
|
||||
schema_version = 1
|
||||
authors = ["Anomaly"]
|
||||
repository = "https://github.com/Kilo-Org/kilo"
|
||||
@@ -11,26 +11,26 @@ name = "Kilo"
|
||||
icon = "./icons/opencode.svg"
|
||||
|
||||
[agent_servers.opencode.targets.darwin-aarch64]
|
||||
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-darwin-arm64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-darwin-arm64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.darwin-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-darwin-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-darwin-x64.zip"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-aarch64]
|
||||
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-linux-arm64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-linux-arm64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.linux-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-linux-x64.tar.gz"
|
||||
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-linux-x64.tar.gz"
|
||||
cmd = "./opencode"
|
||||
args = ["acp"]
|
||||
|
||||
[agent_servers.opencode.targets.windows-x86_64]
|
||||
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.23/opencode-windows-x64.zip"
|
||||
archive = "https://github.com/Kilo-Org/kilo/releases/download/v1.0.24/opencode-windows-x64.zip"
|
||||
cmd = "./opencode.exe"
|
||||
args = ["acp"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-docs",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.24",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --webpack --port 3002",
|
||||
|
||||
@@ -49,8 +49,8 @@ Before enabling Code Reviews:
|
||||
|
||||
Once configured, the Review Agent runs automatically on PR/MR events. For platform-specific setup, see:
|
||||
|
||||
- [GitHub Code Reviews](./github.md)
|
||||
- [GitLab Code Reviews](./gitlab.md)
|
||||
- [GitHub Code Reviews](./github)
|
||||
- [GitLab Code Reviews](./gitlab)
|
||||
|
||||
## Local Code Reviews
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ const kilo = createOpenAI({
|
||||
})
|
||||
|
||||
const result = streamText({
|
||||
model: kilo("anthropic/claude-sonnet-4.5"),
|
||||
model: kilo.chat("anthropic/claude-sonnet-4.5"),
|
||||
prompt: "Why is the sky blue?",
|
||||
})
|
||||
```
|
||||
|
||||
@@ -13,7 +13,7 @@ Models are identified using the format `provider/model-name`. Pass this as the `
|
||||
|
||||
```typescript
|
||||
const result = streamText({
|
||||
model: kilo("anthropic/claude-sonnet-4.5"),
|
||||
model: kilo.chat("anthropic/claude-sonnet-4.5"),
|
||||
prompt: "Hello!",
|
||||
})
|
||||
```
|
||||
|
||||
@@ -53,7 +53,7 @@ const kilo = createOpenAI({
|
||||
|
||||
async function main() {
|
||||
const result = streamText({
|
||||
model: kilo("anthropic/claude-sonnet-4.5"),
|
||||
model: kilo.chat("anthropic/claude-sonnet-4.5"),
|
||||
prompt: "Invent a new holiday and describe its traditions.",
|
||||
})
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const kilo = createOpenAI({
|
||||
})
|
||||
|
||||
const result = streamText({
|
||||
model: kilo("anthropic/claude-sonnet-4.5"),
|
||||
model: kilo.chat("anthropic/claude-sonnet-4.5"),
|
||||
prompt: "Write a haiku about programming.",
|
||||
})
|
||||
|
||||
@@ -51,7 +51,7 @@ const kilo = createOpenAI({
|
||||
})
|
||||
|
||||
const result = streamText({
|
||||
model: kilo("anthropic/claude-sonnet-4.5"),
|
||||
model: kilo.chat("anthropic/claude-sonnet-4.5"),
|
||||
prompt: "What is the weather in San Francisco?",
|
||||
tools: {
|
||||
getWeather: tool({
|
||||
@@ -86,7 +86,7 @@ export async function POST(request: Request) {
|
||||
const { messages } = await request.json()
|
||||
|
||||
const result = streamText({
|
||||
model: kilo("anthropic/claude-sonnet-4.5"),
|
||||
model: kilo.chat("anthropic/claude-sonnet-4.5"),
|
||||
messages,
|
||||
})
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ const kilo = createOpenAI({
|
||||
})
|
||||
|
||||
const result = streamText({
|
||||
model: kilo("anthropic/claude-sonnet-4.5"),
|
||||
model: kilo.chat("anthropic/claude-sonnet-4.5"),
|
||||
prompt: "Write a short story about a robot.",
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-gateway",
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-i18n",
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Kilo-specific i18n translations and overrides",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/package.json",
|
||||
"name": "@kilocode/kilo-telemetry",
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
+39
-36
@@ -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`
|
||||
|
||||
|
||||
@@ -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!"
|
||||
`
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,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"
|
||||
|
||||
|
||||
@@ -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,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": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"dependencies": {},
|
||||
"peerDependencies": {}
|
||||
}
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/ui",
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@opencode-ai/util",
|
||||
"version": "1.0.23",
|
||||
"version": "1.0.24",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user