diff --git a/AGENTS.md b/AGENTS.md index ca29c22cdb..3cc0c8411d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/bun.lock b/bun.lock index 9fb62d4cd3..247c12614c 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/nix/hashes.json b/nix/hashes.json index 690bd8d5d4..d679b783ee 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -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=" } } diff --git a/package.json b/package.json index 627198d2a5..db88a45a70 100644 --- a/package.json +++ b/package.json @@ -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": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 37a51119d1..4266ce8d7e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.0.23", + "version": "1.0.24", "description": "", "type": "module", "exports": { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 2e4913f28e..63ceb10b28 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.0.23", + "version": "1.0.24", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 7e0d594572..67e645d0ac 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -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"] diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 8e7c12b9d2..810c630435 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -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", diff --git a/packages/kilo-docs/pages/automate/code-reviews/overview.md b/packages/kilo-docs/pages/automate/code-reviews/overview.md index b968e88169..25d4bdfa27 100644 --- a/packages/kilo-docs/pages/automate/code-reviews/overview.md +++ b/packages/kilo-docs/pages/automate/code-reviews/overview.md @@ -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 diff --git a/packages/kilo-docs/pages/gateway/index.md b/packages/kilo-docs/pages/gateway/index.md index 42a28bb9b2..0ff8abf2da 100644 --- a/packages/kilo-docs/pages/gateway/index.md +++ b/packages/kilo-docs/pages/gateway/index.md @@ -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?", }) ``` diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 1faa8aa989..f0084e959a 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -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!", }) ``` diff --git a/packages/kilo-docs/pages/gateway/quickstart.md b/packages/kilo-docs/pages/gateway/quickstart.md index 268b754193..66ddc6e5b9 100644 --- a/packages/kilo-docs/pages/gateway/quickstart.md +++ b/packages/kilo-docs/pages/gateway/quickstart.md @@ -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.", }) diff --git a/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md b/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md index 2d633d420f..e470215132 100644 --- a/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md +++ b/packages/kilo-docs/pages/gateway/sdks-and-frameworks.md @@ -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, }) diff --git a/packages/kilo-docs/pages/gateway/streaming.md b/packages/kilo-docs/pages/gateway/streaming.md index e6fa6c6f2e..ece396c19a 100644 --- a/packages/kilo-docs/pages/gateway/streaming.md +++ b/packages/kilo-docs/pages/gateway/streaming.md @@ -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.", }) diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 0cba7fd706..dc55a1a037 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -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", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 867d6f81dc..f81a8c91fd 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -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", diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index 1684c9e6c6..d8df487d5d 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -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", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index be3e00bad7..8a32e7cf09 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "1.0.23", + "version": "1.0.24", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-vscode/docs/non-agent-features/editor-context-menus-and-code-actions.md b/packages/kilo-vscode/docs/non-agent-features/editor-context-menus-and-code-actions.md index 5ded1f54c8..b8271bec63 100644 --- a/packages/kilo-vscode/docs/non-agent-features/editor-context-menus-and-code-actions.md +++ b/packages/kilo-vscode/docs/non-agent-features/editor-context-menus-and-code-actions.md @@ -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` diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index a99b5ff572..265c7213b5 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -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", diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 152582bc10..349205d9a6 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -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 = {} + 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 { + 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 { + 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() diff --git a/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts new file mode 100644 index 0000000000..029d04b7b0 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/SessionTerminalManager.ts @@ -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() + 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() + } +} diff --git a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts new file mode 100644 index 0000000000..3703c4bb6c --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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}`) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts b/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts new file mode 100644 index 0000000000..26f08eec22 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptService.ts @@ -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 { + 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 { + 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 { + 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}`) + } +} diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index 826da03b55..28df15009d 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -46,7 +46,10 @@ export class WorktreeManager { async createWorktree(params: { prompt?: string; existingBranch?: string }): Promise { 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 { diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index b36b79d7d7..cb722f2fa1 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -29,6 +29,7 @@ export interface ManagedSession { interface StateFile { worktrees: Record> sessions: Record> + tabOrder?: Record } const STATE_FILE = "agent-manager.json" @@ -44,6 +45,7 @@ export class WorktreeStateManager { private readonly file: string private worktrees = new Map() private sessions = new Map() + private tabOrder: Record = {} private readonly log: (msg: string) => void private saving: Promise | 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 { + 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 }) diff --git a/packages/kilo-vscode/src/agent-manager/__tests__/buildSetupCommand.spec.ts b/packages/kilo-vscode/src/agent-manager/__tests__/buildSetupCommand.spec.ts new file mode 100644 index 0000000000..ed69b1c37b --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/__tests__/buildSetupCommand.spec.ts @@ -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"') + }) +}) diff --git a/packages/kilo-vscode/src/agent-manager/format-keybinding.ts b/packages/kilo-vscode/src/agent-manager/format-keybinding.ts new file mode 100644 index 0000000000..e305ad2aae --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/format-keybinding.ts @@ -0,0 +1,34 @@ +const KEY_SYMBOLS: Record = { + ctrl: { mac: "⌃", other: "Ctrl" }, + cmd: { mac: "⌘", other: "Ctrl" }, + shift: { mac: "⇧", other: "Shift" }, + alt: { mac: "⌥", other: "Alt" }, +} + +const SPECIAL_KEYS: Record = { + 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("+") +} diff --git a/packages/kilo-vscode/src/agent-manager/setup-script-command.ts b/packages/kilo-vscode/src/agent-manager/setup-script-command.ts new file mode 100644 index 0000000000..3d3dd14718 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/setup-script-command.ts @@ -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}"` +} diff --git a/packages/kilo-vscode/src/agent-manager/setup-script-template.ts b/packages/kilo-vscode/src/agent-manager/setup-script-template.ts new file mode 100644 index 0000000000..8c59a63b6f --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/setup-script-template.ts @@ -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!" +` diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index be001db535..abe16739a4 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -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 diff --git a/packages/kilo-vscode/src/services/code-actions/editor-utils.ts b/packages/kilo-vscode/src/services/code-actions/editor-utils.ts index 12c6a4dfff..a2017cb111 100644 --- a/packages/kilo-vscode/src/services/code-actions/editor-utils.ts +++ b/packages/kilo-vscode/src/services/code-actions/editor-utils.ts @@ -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), } } - - diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index c0c1935da2..7a6e2887f4 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -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") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/format-keybinding.test.ts b/packages/kilo-vscode/tests/unit/format-keybinding.test.ts new file mode 100644 index 0000000000..564b8a9ef3 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/format-keybinding.test.ts @@ -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") + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/navigate.test.ts b/packages/kilo-vscode/tests/unit/navigate.test.ts index b63bb7b888..219fb495b5 100644 --- a/packages/kilo-vscode/tests/unit/navigate.test.ts +++ b/packages/kilo-vscode/tests/unit/navigate.test.ts @@ -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") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/next-selection-after-delete.test.ts b/packages/kilo-vscode/tests/unit/next-selection-after-delete.test.ts new file mode 100644 index 0000000000..19b0368640 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/next-selection-after-delete.test.ts @@ -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") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/permission-queue.test.ts b/packages/kilo-vscode/tests/unit/permission-queue.test.ts new file mode 100644 index 0000000000..e1fc360586 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/permission-queue.test.ts @@ -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 => ({ + 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]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/tab-order.test.ts b/packages/kilo-vscode/tests/unit/tab-order.test.ts new file mode 100644 index 0000000000..999bb9b267 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/tab-order.test.ts @@ -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() + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts index decdcc9e8c..8b4f69a170 100644 --- a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts @@ -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") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 214d286bf2..77b1fa731b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -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 = { + 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, activeId: Accessor) { + const [ref, setRef] = createSignal() + 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>(defaultBindings) const [setup, setSetup] = createSignal({ active: false, message: "" }) const [worktrees, setWorktrees] = createSignal([]) const [managedSessions, setManagedSessions] = createSignal([]) - const [selection, setSelection] = createSignal("local") + const [selection, setSelection] = createSignal(LOCAL) const [repoBranch, setRepoBranch] = createSignal() + const [deletingWorktrees, setDeletingWorktrees] = createSignal>(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(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() + // Per-context tab memory: maps sidebar selection key -> last active session/pending ID + const [tabMemory, setTabMemory] = createSignal>({}) + const isPending = (id: string) => id.startsWith(PENDING_PREFIX) + // Drag-and-drop state for tab reordering + const [draggingTab, setDraggingTab] = createSignal() + // Tab ordering: context key → ordered session ID array (recovered from extension state) + const [worktreeTabOrder, setWorktreeTabOrder] = createSignal>({}) + 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(() => ( + +
+
+ + + Delete worktree {wt.branch}? This removes the worktree from disk + and dissociates all sessions. + +
+
+ + +
+
+
+ )) + } + 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 (
-
+
+ setSidebarWidth(Math.min(width, window.innerWidth * MAX_SIDEBAR_WIDTH_RATIO))} + /> {/* Local workspace item */} )} @@ -490,54 +925,92 @@ const AgentManagerContent: Component = () => {
{/* Tab bar — visible when a section is selected and has tabs or a pending new session */} -
-
- - {(s) => { - const pending = isPending(s.id) - const active = () => - pending - ? s.id === activePendingId() && !session.currentSessionID() - : s.id === session.currentSessionID() - return ( - -
{ - if (pending) { - setActivePendingId(s.id) - session.clearCurrentSession() - } else { - setActivePendingId(undefined) - session.selectSession(s.id) - } - }} - onMouseDown={(e: MouseEvent) => handleTabMouseDown(s.id, e)} - > - {s.title || "Untitled"} - handleCloseTab(s.id, e)} - /> -
-
- ) - }} -
+ + + +
+
+
+
+ + + {(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 ( + { + 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)} + /> + ) + }} + + +
+
+
+ + + +
+ + { + const id = session.currentSessionID() + if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id }) + }} + /> + +
- -
+ + + {(tab) => ( +
+ {tab().title || "Untitled"} +
+ )} +
+
+ {/* Empty worktree state */} @@ -549,6 +1022,7 @@ const AgentManagerContent: Component = () => {
No sessions open
@@ -557,7 +1031,7 @@ const AgentManagerContent: Component = () => {
-
Setting up workspace
+
{setup().error ? "Workspace setup failed" : "Setting up workspace"}
{setup().branch}
diff --git a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css index 218c000fd1..0f217669f0 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css +++ b/packages/kilo-vscode/webview-ui/agent-manager/agent-manager.css @@ -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); +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts index bbcab8d336..615a07d8b6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts @@ -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)]! +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx new file mode 100644 index 0000000000..2b0948fb4e --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/sortable-tab.tsx @@ -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 +
+ +
+ {props.tab.title || "Untitled"} + + + +
+
+
+ ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/tab-order.ts b/packages/kilo-vscode/webview-ui/agent-manager/tab-order.ts new file mode 100644 index 0000000000..f20663cbb0 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/tab-order.ts @@ -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(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 +} diff --git a/packages/kilo-vscode/webview-ui/src/App.tsx b/packages/kilo-vscode/webview-ui/src/App.tsx index f940c36e46..3578ebb36b 100644 --- a/packages/kilo-vscode/webview-ui/src/App.tsx +++ b/packages/kilo-vscode/webview-ui/src/App.tsx @@ -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, session_diff: {} as Record, - message: msgs as unknown as Record, - part: parts as unknown as Record, - 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[] } : {}, } }) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 56db2a1eb5..f8e12ebaea 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -27,7 +27,7 @@ export const ChatView: Component = (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) diff --git a/packages/kilo-vscode/webview-ui/src/context/permission-queue.ts b/packages/kilo-vscode/webview-ui/src/context/permission-queue.ts new file mode 100644 index 0000000000..da977468a6 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/permission-queue.ts @@ -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) +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 3718b66063..00a454de29 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -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] diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 70169e5f98..2be7c991e8 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -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 +} + +// Resolved keybindings for agent manager actions +export interface AgentManagerKeybindingsMessage { + type: "agentManager.keybindings" + bindings: Record } 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 diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ba8f3a07c3..17b3163098 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -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", diff --git a/packages/opencode/src/commit-message/__tests__/git-context.test.ts b/packages/opencode/src/commit-message/__tests__/git-context.test.ts index 86f182f093..83754b7130 100644 --- a/packages/opencode/src/commit-message/__tests__/git-context.test.ts +++ b/packages/opencode/src/commit-message/__tests__/git-context.test.ts @@ -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 = {} 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" diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 1b41f18f52..59a20e017a 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -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, diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 104fddb251..2e26b8796c 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -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": { diff --git a/packages/script/package.json b/packages/script/package.json index ad7654bee6..2ebb9bf029 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -8,7 +8,7 @@ "exports": { ".": "./src/index.ts" }, - "version": "1.0.23", + "version": "1.0.24", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index c5f14f8ae1..12c9e9ae35 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -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": { diff --git a/packages/ui/package.json b/packages/ui/package.json index 0088587ea9..a32083710b 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.0.23", + "version": "1.0.24", "type": "module", "license": "MIT", "exports": { diff --git a/packages/util/package.json b/packages/util/package.json index ecde7ab01e..ef4ef23a8a 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/util", - "version": "1.0.23", + "version": "1.0.24", "private": true, "type": "module", "license": "MIT", diff --git a/script/upstream/package.json b/script/upstream/package.json index 13f42bca37..7b42e05a99 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -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", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 3ed809ece6..f92e07029d 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -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",