diff --git a/.github/workflows/test-vscode.yml b/.github/workflows/test-vscode.yml new file mode 100644 index 000000000..899ef3217 --- /dev/null +++ b/.github/workflows/test-vscode.yml @@ -0,0 +1,32 @@ +name: test-vscode + +on: + push: + branches: + - dev + paths: + - "packages/kilo-vscode/**" + pull_request: + paths: + - "packages/kilo-vscode/**" + workflow_dispatch: + +jobs: + unit: + name: unit tests + runs-on: blacksmith-4vcpu-ubuntu-2404 + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Run unit tests + working-directory: packages/kilo-vscode + run: bun run test:unit diff --git a/AGENTS.md b/AGENTS.md index ca29c22cd..3cc0c8411 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/README.md b/README.md index 85d94682b..a8aa8b1e8 100644 --- a/README.md +++ b/README.md @@ -1,18 +1,53 @@

- - Kilo CLI logo - -

-

The open source AI coding agent.

-

- Discord - X - Reddit + VS Code Marketplace + X (Twitter) + Substack Blog + Discord + Reddit

---- +# 🚀 Kilo -### Installation +> Kilo is the all-in-one agentic engineering platform. Build, ship, and iterate faster with the most popular open source coding agent. +> #1 on OpenRouter. 1.5M+ Kilo Coders. 25T+ tokens processed + +- ✨ Generate code from natural language +- ✅ Checks its own work +- 🧪 Run terminal commands +- 🌐 Automate the browser +- ⚡ Inline autocomplete suggestions +- 🤖 Latest AI models +- 🎁 API keys optional +- 💡 **Get $20 in bonus credits when you top-up for the first time** Credits can be used with 500+ models like Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 + +

+ +

+ +## Quick Links + +- [VS Code Marketplace](https://kilo.ai/vscode-marketplace?utm_source=Readme) (download) +- Install CLI: `npm install -g @kilocode/cli` +- [Official Kilo.ai Home page](https://kilo.ai) (learn more) + +## Key Features + +- **Code Generation:** Kilo can generate code using natural language. +- **Inline Autocomplete:** Get intelligent code completions as you type, powered by AI. +- **Task Automation:** Kilo can automate repetitive coding tasks to save time. +- **Automated Refactoring:** Kilo can refactor and improve existing code efficiently. +- **MCP Server Marketplace**: Kilo can easily find, and use MCP servers to extend the agent capabilities. +- **Multi Mode**: Plan with Architect, Code with Coder, and Debug with Debugger, and make your own custom modes. + +## Get Started in Visual Studio Code + +1. Install the Kilo Code extension from the [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=kilocode.Kilo-Code). +2. Create your account to access 500+ cutting-edge AI models including Gemini 3 Pro, Claude 4.5 Sonnet & Opus, and GPT-5 – with transparent pricing that matches provider rates exactly. +3. Start coding with AI that adapts to your workflow. Watch our quick-start guide to see Kilo in action: + +[![Watch the video](https://img.youtube.com/vi/pqGfYXgrhig/maxresdefault.jpg)](https://youtu.be/pqGfYXgrhig) + +## Get Started with the CLI ```bash # npm @@ -24,19 +59,6 @@ npx @kilocode/cli Then run `kilo` in any project directory to start. -### Agents - -Kilo CLI includes two built-in agents you can switch between using the `Tab` key: - -- **build** - Default, full access agent for development work -- **plan** - Read-only agent for analysis and code exploration - - Denies file edits by default - - Asks permission before running bash commands - - Ideal for exploring unfamiliar codebases or planning changes - -Also included is a **general** subagent for complex searches and multi-step tasks. -This is used internally and can be invoked using `@general` in messages. - ### Autonomous Mode (CI/CD) Use the `--auto` flag with `kilo run` to enable fully autonomous operation without user interaction. This is ideal for CI/CD pipelines and automated workflows: @@ -47,44 +69,20 @@ kilo run --auto "run tests and fix any failures" **Important:** The `--auto` flag disables all permission prompts and allows the agent to execute any action without confirmation. Only use this in trusted environments like CI/CD pipelines. -### Migrating from Kilo Code Extension +## Contributing -If you're coming from the Kilo Code VS Code extension, your configurations are automatically migrated: +We welcome contributions from developers, writers, and enthusiasts! +To get started, please read our [Contributing Guide](/CONTRIBUTING.md). It includes details on setting up your environment, coding standards, types of contribution and how to submit pull requests. -| Kilo Code Feature | Kilo CLI Equivalent | -| -------------------------------------------- | -------------------------------------------- | -| Custom modes | Converted to agents | -| Rules (`.kilocoderules`, `.kilocode/rules/`) | Added to `instructions` array | -| Skills (`.kilocode/skills/`) | Auto-discovered alongside `.opencode/skill/` | -| Workflows (`.kilocode/workflows/`) | Converted to commands | -| MCP servers | Migrated to `mcp` config | +## Code of Conduct -MCP servers are configured in **`~/.config/kilo/opencode.json`** (or `opencode.jsonc`; on Windows the config directory may be under `%USERPROFILE%` depending on your environment). Use a top-level `"mcp"` object: each key is a server name, value is `type: "local"` and `command: ["executable", "arg1", ...]`. Optional per-server: `environment`, `enabled`, `timeout`. Restart the CLI after editing for changes to take effect. (Path from [`packages/opencode/src/global/index.ts`](packages/opencode/src/global/index.ts); schema in [`config.ts`](packages/opencode/src/config/config.ts) `McpLocal`.) +Our community is built on respect, inclusivity, and collaboration. Please review our [Code of Conduct](/CODE_OF_CONDUCT.md) to understand the expectations for all contributors and community members. -**Default mode mappings:** +## License -- `code` → `build` agent -- `architect` → `plan` agent +This project is licensed under the MIT License. +You’re free to use, modify, and distribute this code, including for commercial purposes as long as you include proper attribution and license notices. See [License](/LICENSE). -For detailed migration information, see: - -- [Migration Overview](packages/opencode/src/kilocode/docs/migration.md) -- [Rules Migration](packages/opencode/src/kilocode/docs/rules-migration.md) - -### Documentation - -For more info on how to configure Kilo CLI, [**head over to our docs**](https://kilo.ai/docs). - -### Contributing - -If you're interested in contributing, please read our [contributing docs](./CONTRIBUTING.md) before submitting a pull request. - -### FAQ - -#### Where did Kilo CLI come from? +### Where did Kilo CLI come from? Kilo CLI is a fork of [OpenCode](https://github.com/anomalyco/opencode), enhanced to work within the Kilo agentic engineering platform. - ---- - -**Join our community** [Discord](https://kilo.ai/discord) | [X.com](https://x.com/kilocode) | [Reddit](https://reddit.com/r/kilocode) diff --git a/bun.lock b/bun.lock index 9fb62d4cd..247c12614 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/flake.lock b/flake.lock index 10fa973cf..18fa20d60 100644 --- a/flake.lock +++ b/flake.lock @@ -2,11 +2,11 @@ "nodes": { "nixpkgs": { "locked": { - "lastModified": 1770073757, - "narHash": "sha256-Vy+G+F+3E/Tl+GMNgiHl9Pah2DgShmIUBJXmbiQPHbI=", + "lastModified": 1771207753, + "narHash": "sha256-b9uG8yN50DRQ6A7JdZBfzq718ryYrlmGgqkRm9OOwCE=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "47472570b1e607482890801aeaf29bfb749884f6", + "rev": "d1c15b7d5806069da59e819999d70e1cec0760bf", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index f6da887f5..73ed40aea 100644 --- a/flake.nix +++ b/flake.nix @@ -162,6 +162,7 @@ unzip gnutar gzip + ripgrep kilo-dev kilo-install-bin kilo-bin diff --git a/kilo.gif b/kilo.gif new file mode 100644 index 000000000..800d05f52 Binary files /dev/null and b/kilo.gif differ diff --git a/nix/hashes.json b/nix/hashes.json index 690bd8d5d..d679b783e 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 627198d2a..db88a45a7 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/e2e/models/models-visibility.spec.ts b/packages/app/e2e/models/models-visibility.spec.ts index c69911179..66173d54c 100644 --- a/packages/app/e2e/models/models-visibility.spec.ts +++ b/packages/app/e2e/models/models-visibility.spec.ts @@ -2,7 +2,9 @@ import { test, expect } from "../fixtures" import { promptSelector } from "../selectors" import { closeDialog, openSettings, clickListItem } from "../actions" -test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => { +// kilocode_change start +test.skip("hiding a model removes it from the model picker", async ({ page, gotoSession }) => { + // kilocode_change end await gotoSession() await page.locator(promptSelector).click() diff --git a/packages/app/e2e/settings/settings-models.spec.ts b/packages/app/e2e/settings/settings-models.spec.ts index f7397abe8..88dba6e5b 100644 --- a/packages/app/e2e/settings/settings-models.spec.ts +++ b/packages/app/e2e/settings/settings-models.spec.ts @@ -2,7 +2,9 @@ import { test, expect } from "../fixtures" import { promptSelector } from "../selectors" import { closeDialog, openSettings } from "../actions" -test("hiding a model removes it from the model picker", async ({ page, gotoSession }) => { +// kilocode_change start +test.skip("hiding a model removes it from the model picker", async ({ page, gotoSession }) => { + // kilocode_change end await gotoSession() await page.locator(promptSelector).click() @@ -60,7 +62,9 @@ test("hiding a model removes it from the model picker", async ({ page, gotoSessi await expect(pickerAgain).toHaveCount(0) }) -test("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => { +// kilocode_change start +test.skip("showing a hidden model restores it to the model picker", async ({ page, gotoSession }) => { + // kilocode_change end await gotoSession() await page.locator(promptSelector).click() diff --git a/packages/app/package.json b/packages/app/package.json index 37a51119d..4266ce8d7 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 2e4913f28..63ceb10b2 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 7e0d59457..67e645d0a 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 8e7c12b9d..810c63043 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 b968e8816..25d4bdfa2 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/automate/kiloclaw.md b/packages/kilo-docs/pages/automate/kiloclaw.md index a6150c3d1..0ef093760 100644 --- a/packages/kilo-docs/pages/automate/kiloclaw.md +++ b/packages/kilo-docs/pages/automate/kiloclaw.md @@ -58,6 +58,22 @@ Once created, you can control your instance from the dashboard. | **Settings** | Model configuration and instance parameters | | **Actions** | Quick actions and connected platform management | +### Changelog + +Your instance page includes a changelog with recent KiloClaw platform updates. + +Each changelog entry is labeled by update type: + +- **Feature** — New capability or enhancement +- **Bug** — Fix for incorrect or broken behavior + +Some entries also include a redeploy label: + +- **Redeploy required** — You must redeploy your instance to fully take advantage of the change +- **Redeploy suggested** — Redeploy is optional and only needed if you want to use the new behavior + +For example, if you manually configured a channel such as Telegram, and would prefer to have KiloClaw manage the channel for you, you would need to redeploy. + ## Accessing Your Agent To connect to your agent's web interface: diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md index aa15f950a..678371f69 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cli.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cli.md @@ -220,7 +220,7 @@ For most permissions, you can use an object to apply different actions based on ```json { - "$schema": "https://opencode.ai/config.json", + "$schema": "https://app.kilo.ai/config.json", "permission": { "bash": { "*": "ask", @@ -305,7 +305,7 @@ Project-level configuration takes precedence over global settings. ```json { - "$schema": "https://opencode.ai/config.json", + "$schema": "https://app.kilo.ai/config.json", "model": "anthropic/claude-sonnet-4-20250514", "provider": { "anthropic": { diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md b/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md index 708e51e81..967ec9816 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md @@ -85,6 +85,15 @@ You can customize each Cloud Agent session by also defining env vars and startup - Bootstrapping tooling - Running setup scripts +### Setup Commands vs `.kilocode/setup-script` + +- Cloud Agent executes **Setup Commands** configured in the Cloud UI/profile. +- Cloud Agent does **not** automatically discover or run `.kilocode/setup-script`. +- If you want to use `.kilocode/setup-script` in Cloud Agent, call it explicitly from Setup Commands, for example: `bash .kilocode/setup-script`. +- If both are present, execution order is: + 1. Setup Commands (in the order you define them) + 2. Anything those commands invoke (such as `.kilocode/setup-script`) + ## Skills Cloud Agents support project-level [skills](/docs/code-with-ai/platforms/cli#skills) stored in your repository. When your repo is cloned, any skills in `.kilocode/skills/` are automatically available. diff --git a/packages/kilo-docs/pages/customize/custom-modes.md b/packages/kilo-docs/pages/customize/custom-modes.md index a7fdda9fd..86bcc17a2 100644 --- a/packages/kilo-docs/pages/customize/custom-modes.md +++ b/packages/kilo-docs/pages/customize/custom-modes.md @@ -74,7 +74,7 @@ Modes are managed from the Modes area in Kilo Code. Depending on your UI layout, 1. Open the Modes area from the mode selector in the chat panel (or via the icon if shown) 2. Click the Import Mode button (upload icon) -3. Select the mode's YAML file +3. Select the mode's YAML file (`.yaml`) 4. Choose the import level: - **Project:** Available only in current workspace (saved to `.kilocodemodes` file) - **Global:** Available in all projects (saved to global settings) @@ -121,11 +121,16 @@ The interface provides fields for Name, Slug, Description, Save Location, Role D You can directly edit the configuration files to create or modify custom modes. This method offers the most control over all properties. Kilo Code now supports both YAML (preferred) and JSON formats. -- **Global Modes:** Edit the `custom_modes.yaml` (preferred) or `custom_modes.json` file. Open the Modes area, click next to Global Modes, then choose "Edit Global Modes" -- **Project Modes:** Edit the `.kilocodemodes` file (which can be YAML or JSON) in your project root. Open the Modes area, click next to Project Modes, then choose "Edit Project Modes" +- **Global Modes:** Edit `custom_modes.yaml` (primary). `custom_modes.json` is a legacy fallback and may still exist in older setups. +- **Project Modes:** Edit `.kilocodemodes` in your project root (YAML preferred; JSON still supported for compatibility). +- **Open from UI:** Open the Modes area, click next to Global or Project Modes, then choose **Edit Global Modes** or **Edit Project Modes**. These files define an array/list of custom modes. +{% callout type="info" title="Why JSON Files May Still Exist" %} +If you see both YAML and JSON mode files, this is usually from legacy configuration. Kilo Code reads YAML first and does not keep both files synchronized line-by-line. In practice, edit YAML unless you have a specific reason to stay on JSON. +{% /callout %} + ## YAML Configuration Format (Preferred) YAML is now the preferred format for defining custom modes due to better readability, comment support, and cleaner multi-line strings. diff --git a/packages/kilo-docs/pages/gateway/index.md b/packages/kilo-docs/pages/gateway/index.md index 42a28bb9b..0ff8abf2d 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 1faa8aa98..f0084e959 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 268b75419..66ddc6e5b 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 2d633d420..e47021513 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 e6fa6c6f2..ece396c19 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 0cba7fd70..dc55a1a03 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-gateway/src/api/models.ts b/packages/kilo-gateway/src/api/models.ts index b9a4a73aa..1a8976965 100644 --- a/packages/kilo-gateway/src/api/models.ts +++ b/packages/kilo-gateway/src/api/models.ts @@ -29,6 +29,7 @@ const openRouterModelSchema = z.object({ architecture: openRouterArchitectureSchema.optional(), top_provider: z.object({ max_completion_tokens: z.number().nullish() }).optional(), supported_parameters: z.array(z.string()).optional(), + preferredIndex: z.number().optional(), }) const openRouterModelsResponseSchema = z.object({ @@ -167,6 +168,10 @@ function transformToModelDevFormat(model: OpenRouterModel): any { output: mapModalities(outputModalities), }, }), + ...(model.preferredIndex !== undefined && { + recommended: true, + recommendedIndex: model.preferredIndex, + }), options: { ...(model.description && { description: model.description }), }, diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 867d6f81d..f81a8c91f 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 1684c9e6c..d8df487d5 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 be3e00bad..8a32e7cf0 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-ui/src/components/markdown.css b/packages/kilo-ui/src/components/markdown.css index 52fdf3854..0cf0b2dd4 100644 --- a/packages/kilo-ui/src/components/markdown.css +++ b/packages/kilo-ui/src/components/markdown.css @@ -2,4 +2,32 @@ .shiki { margin: 0rem; } + + pre { + scrollbar-width: thin; + scrollbar-color: var(--border-weak-base) transparent; + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-corner { + background: transparent; + } + + &::-webkit-scrollbar-button { + display: none; + width: 0; + height: 0; + } + + &::-webkit-scrollbar-thumb { + background: var(--border-weak-base); + border-radius: 4px; + + &:hover { + background: var(--border-strong-base); + } + } + } } diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 7aa79677b..5b9bfbfde 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -5,4 +5,8 @@ [data-slot="text-part-body"] { margin-top: 0; } + + [data-slot="text-part-copy-wrapper"] { + display: none; + } } diff --git a/packages/kilo-ui/src/styles/globals.css b/packages/kilo-ui/src/styles/globals.css index e6fac23f6..63bd138e3 100644 --- a/packages/kilo-ui/src/styles/globals.css +++ b/packages/kilo-ui/src/styles/globals.css @@ -49,7 +49,8 @@ } ::-webkit-scrollbar-button { - display: block; + display: none; + width: 0; height: 0; } @@ -63,7 +64,8 @@ html[data-theme="kilo-vscode"] { font-weight: var(--vscode-font-weight); ::-webkit-scrollbar-button { - display: block; + display: none; + width: 0; height: 0; } diff --git a/packages/kilo-vscode/.gitignore b/packages/kilo-vscode/.gitignore index 24d653c6d..07a0b88ee 100644 --- a/packages/kilo-vscode/.gitignore +++ b/packages/kilo-vscode/.gitignore @@ -1,2 +1,3 @@ bin/ -out/ \ No newline at end of file +out/ +.vscode-test/ \ No newline at end of file diff --git a/packages/kilo-vscode/AGENTS.md b/packages/kilo-vscode/AGENTS.md index 2cc80f70f..cbe2cc712 100644 --- a/packages/kilo-vscode/AGENTS.md +++ b/packages/kilo-vscode/AGENTS.md @@ -85,6 +85,7 @@ New webview features must use **`@kilocode/kilo-ui`** components instead of raw - New CSS for components not yet in kilo-ui goes into `chat.css` grouped by comment-delimited sections (`/* Component Name */`). Once a kilo-ui equivalent exists, remove the section. - **Check the desktop app first**: [`packages/app/src/`](../../packages/app/src/) is the reference implementation for how kilo-ui components are composed together. Always check how the app uses a component before implementing it in the webview — don't just look at the component API in isolation. - **`data-component` and `data-slot` attributes carry CSS styling** — kilo-ui uses `[data-component]` and `[data-slot]` attribute selectors, not class names. When the app uses e.g. `data-component="permission-prompt"` and `data-slot="permission-actions"`, these get kilo-ui styling for free. +- **Prefer kilo-ui styles**: Always reuse existing kilo-ui CSS variables, tokens, and component styles instead of writing custom CSS. If a style doesn't exist in kilo-ui yet, add it there and reuse it rather than inlining or duplicating styles in the webview. - **Icons**: kilo-ui has 75+ custom SVG icons in [`packages/ui/src/components/icon.tsx`](../../packages/ui/src/components/icon.tsx). To list all available icon names: `node -e "const c=require('fs').readFileSync('../../packages/ui/src/components/icon.tsx','utf8');[...c.matchAll(/^\\s{2}[\"']?([\\w-]+)[\"']?:\\s*\x60/gm)].map(m=>m[1]).sort().forEach(n=>console.log(n))"`. Icon names use both hyphenated (`arrow-left`) and bare-word (`brain`, `console`, `providers`) keys. ## Debugging diff --git a/packages/kilo-vscode/docs/chat-ui-features/context-menus-tooltips.md b/packages/kilo-vscode/docs/chat-ui-features/context-menus-tooltips.md index 2893fc5b6..a13d794c4 100644 --- a/packages/kilo-vscode/docs/chat-ui-features/context-menus-tooltips.md +++ b/packages/kilo-vscode/docs/chat-ui-features/context-menus-tooltips.md @@ -1,20 +1,29 @@ -# Context Menus & Tooltips +# Context Menus & Tooltips (Webview) -Right-click/context actions and tooltip affordances throughout the chat UI. +Right-click/context actions and tooltip affordances within the chat webview UI. + +> **Note:** For VS Code-native context menus (editor right-click, terminal right-click, code action lightbulb), see [Editor Context Menus & Code Actions](../non-agent-features/editor-context-menus-and-code-actions.md). + +## Scope + +This document covers **webview-internal** context menus and tooltips — i.e., right-click menus and hover tooltips rendered inside the Kilo Code chat panel. + +## Current State + +- kilo-ui provides `Tooltip` and `Popover` components, already in use throughout the webview (TaskHeader, ModelSelector, etc.) +- No webview-internal right-click context menu exists yet + +## Remaining Work + +- Add hover tooltips with explanatory text for all interactive buttons in the chat UI +- Implement right-click context menus on chat messages (copy, retry, edit, delete) +- Consider context menus on code blocks (copy code, insert at cursor, apply diff) ## Location - [`webview-ui/src/components/common/ContextMenu.tsx`](../../webview-ui/src/components/common/ContextMenu.tsx:1) -- Various `StandardTooltip` usage +- kilo-ui `Tooltip` component usage throughout webview -## Interactions +## Implementation Notes -- Hover tooltips with explanatory text for all interactive buttons -- Context actions via right-click or dedicated menu buttons - -## Suggested migration - -**Reimplement?** No (UI-only). - -- These are presentation-layer affordances; backend migration does not affect them. -- Kilo CLI has similar tooltip infrastructure in its UI package ([`packages/ui/src/components/tooltip.tsx`](https://github.com/Kilo-Org/kilo/blob/main/packages/ui/src/components/tooltip.tsx:1)), but Kilo can keep its current tooltip + context menu components. +These are presentation-layer affordances; the CLI backend is not involved. kilo-ui already provides the tooltip infrastructure needed. diff --git a/packages/kilo-vscode/docs/non-agent-features/code-actions.md b/packages/kilo-vscode/docs/non-agent-features/code-actions.md deleted file mode 100644 index 939745d64..000000000 --- a/packages/kilo-vscode/docs/non-agent-features/code-actions.md +++ /dev/null @@ -1,24 +0,0 @@ -# Code Actions - -- **What it is**: Integrates into VS Code Code Actions UI (lightbulb/context menu) to trigger AI actions like explain/fix/improve, or add selection to context. - -## Notable characteristics - -- Can run inside current task or spawn a new task. -- Prompt templates configurable. - -## Docs references - -- [`apps/kilocode-docs/pages/code-with-ai/features/code-actions.md`](../../apps/kilocode-docs/pages/code-with-ai/features/code-actions.md) - -## Suggested migration - -- **Kilo CLI availability**: Not present. -- **Migration recommendation**: - - Keep code actions in the VS Code extension host (VS Code APIs, diagnostics, and editor-specific UX). - - Reimplement any backing logic that currently depends on the core agent loop, but keep action registration and application IDE-side. -- **Reimplementation required?**: Yes. - -## Primary implementation anchors (partial) - -- [`src/services/ghost/GhostCodeActionProvider.ts`](../../src/services/ghost/GhostCodeActionProvider.ts) 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 new file mode 100644 index 000000000..b8271bec6 --- /dev/null +++ b/packages/kilo-vscode/docs/non-agent-features/editor-context-menus-and-code-actions.md @@ -0,0 +1,461 @@ +# Editor Context Menus & Code Actions + +Full spec of the VS Code-native menu, command, code action, keyboard shortcut, and prompt template system from the old Kilo Code extension. All of these need to be rebuilt in the new extension. + +> **Note:** For webview-internal context menus (right-click inside the chat panel), see [Context Menus & Tooltips (Webview)](../chat-ui-features/context-menus-tooltips.md). +> For SCM commit message generation, see [Git Commit Message Generation](git-commit-message-generation.md). +> This document supersedes the old stub at [`code-actions.md`](code-actions.md). + +--- + +## Overview + +The old extension registered a comprehensive set of VS Code contributions: + +- A **"Kilo Code" submenu** in the editor right-click context menu +- A **"Kilo Code" submenu** in the terminal right-click context menu +- A **CodeActionProvider** for lightbulb quick fixes +- **Keyboard shortcuts** for common actions +- **Prompt templates** (user-customizable) that turn captured context into agent task instructions + +None of these exist yet in the rebuild. + +--- + +## Editor Context Menus + +Right-clicking in the editor shows a "Kilo Code" submenu with these commands: + +| Command ID | Label | Captured Context | Behavior | +| -------------- | -------------- | ------------------------------------------------- | ------------------------------------------------------------------------ | +| `explainCode` | Explain Code | File path, selected text, line range | Starts an agent task with the EXPLAIN prompt | +| `fixCode` | Fix Code | File path, selected text, line range, diagnostics | Starts an agent task with the FIX prompt | +| `improveCode` | Improve Code | File path, selected text, line range | Starts an agent task with the IMPROVE prompt | +| `addToContext` | Add to Context | File path, selected text, line range | Injects formatted code block into chat input (does **not** start a task) | + +All commands use the VS Code editor API to capture the active editor's file path (`document.uri`), current selection (`editor.selection`), and the selected text. `fixCode` additionally captures diagnostics from `vscode.languages.getDiagnostics()` for the selection range. + +--- + +## Terminal Context Menus + +Right-clicking in the terminal shows a "Kilo Code" submenu: + +| Command ID | Label | Captured Context | Behavior | +| ------------------------ | --------------- | ----------------------------------- | ----------------------------------------------------- | +| `terminalAddToContext` | Add to Context | Terminal selection or recent buffer | Injects terminal output into chat input | +| `terminalFixCommand` | Fix Command | Last executed command + output | Starts an agent task with the TERMINAL_FIX prompt | +| `terminalExplainCommand` | Explain Command | Last executed command + output | Starts an agent task with the TERMINAL_EXPLAIN prompt | + +Terminal context capture uses `vscode.window.activeTerminal` and the terminal selection API. + +> See also [Terminal / Shell Integration](terminal-shell-integration.md) for the underlying terminal command execution and PTY management that these context menu actions depend on. + +--- + +## Code Action Provider (Lightbulb Quick Fixes) + +A `CodeActionProvider` is registered for all languages. When the user clicks the lightbulb or presses the quick fix shortcut: + +| Condition | Actions shown | +| ------------------------------ | --------------------------------------------------- | +| Always | **Add to Kilo Code** → triggers `addToContext` | +| Diagnostics in selection range | **Fix with Kilo Code** → triggers `fixCode` | +| No diagnostics | **Explain with Kilo Code** → triggers `explainCode` | +| No diagnostics | **Improve with Kilo Code** → triggers `improveCode` | + +Controlled by the `enableCodeActions` extension setting (default: `true`). + +--- + +## Keyboard Shortcuts + +| Shortcut (Mac / Win+Linux) | Command | Description | +| ------------------------------- | ------------------------- | ----------------------------- | +| `Cmd+Shift+A` / `Ctrl+Shift+A` | Focus chat input | Opens/focuses the chat panel | +| `Cmd+K Cmd+A` / `Ctrl+K Ctrl+A` | Add selection to context | Runs `addToContext` | +| `Cmd+Shift+G` / `Ctrl+Shift+G` | Generate terminal command | Starts TERMINAL_GENERATE task | +| `Cmd+Alt+A` / `Ctrl+Alt+A` | Toggle auto-approve | Toggles auto-approval setting | + +--- + +## Prompt Templates + +The old extension defined prompt templates in `support-prompt.ts` that format captured context into agent task instructions. Each template is user-customizable via extension settings. + +| Template | Used by | Purpose | +| ------------------------- | ------------------------- | ------------------------------------------------------- | +| `EXPLAIN` | `explainCode` | Ask the agent to explain the selected code | +| `FIX` | `fixCode` | Ask the agent to fix code, including diagnostic details | +| `IMPROVE` | `improveCode` | Ask the agent to improve/refactor selected code | +| `ADD_TO_CONTEXT` | `addToContext` | Format a code block for injection into chat input | +| `TERMINAL_ADD_TO_CONTEXT` | `terminalAddToContext` | Format terminal output for injection into chat input | +| `TERMINAL_FIX` | `terminalFixCommand` | Ask the agent to fix a failed terminal command | +| `TERMINAL_EXPLAIN` | `terminalExplainCommand` | Ask the agent to explain a terminal command/output | +| `TERMINAL_GENERATE` | Generate terminal command | Ask the agent to generate a terminal command | +| `COMMIT_MESSAGE` | SCM integration | Generate a commit message (tracked separately) | + +--- + +## Data Flow + +There are two distinct patterns: + +### 1. Action → Agent Task + +Commands like `explainCode`, `fixCode`, `improveCode`, `terminalFixCommand`, and `terminalExplainCommand` follow this flow: + +1. User triggers command (context menu, code action, or keybinding) +2. Extension captures context from VS Code APIs (editor selection, diagnostics, terminal buffer) +3. Extension fills in the prompt template with captured context +4. Extension sends the formatted prompt as a new message to the CLI session (creating a new task) + +### 2. Action → Context Injection + +Commands like `addToContext` and `terminalAddToContext` follow a different flow: + +1. User triggers command +2. Extension captures context from VS Code APIs +3. Extension formats the context as a code block using the template +4. Extension posts a message to the webview to **set the chat input text** (not submit it) +5. User can review and edit before sending + +--- + +## Implementation Notes for Rebuild + +### `package.json` Contributions + +Register in `contributes`: + +- `submenus`: Define "Kilo Code" submenus for editor and terminal contexts +- `menus`: Register commands under `editor/context` and `terminal/context` menu groups +- `commands`: Register all command IDs with titles and icons +- `keybindings`: Register keyboard shortcuts with `key`, `mac`, and `when` clauses + +> **Note:** [`package.json`](../../package.json) already has `contributes.commands` (8 commands) and `contributes.menus` (`view/title` + `editor/title` menus) registered. New commands, submenus, and keybindings should be **added alongside** the existing entries, not replace them. + +### CodeActionProvider + +- Register a `CodeActionProvider` for `*` (all languages) +- Check `vscode.languages.getDiagnostics()` to decide which actions to show +- Gate behind the `enableCodeActions` setting +- Return `CodeAction` instances with `command` set to the appropriate command ID + +### Editor Context Capture + +- `vscode.window.activeTextEditor` for file path, selection, document +- `editor.document.getText(selection)` for selected text +- `selection.start.line` / `selection.end.line` for line range +- `vscode.languages.getDiagnostics(document.uri)` filtered to selection range for `fixCode` + +### Terminal Context Capture + +- `vscode.window.activeTerminal` for the active terminal +- Terminal selection API for selected text in terminal +- Shell integration API for last command and its output (where available) + +### Prompt Templates + +Need an equivalent template system in the extension. Options: + +- Hardcode templates with settings overrides (like the old extension) +- Delegate prompt construction to the CLI (if it supports parameterized task creation) + +### "Add to Context" Pattern + +The webview needs to handle an incoming message that **sets the chat input text** without submitting it. This requires: + +- A new message type (e.g., `SetChatInput`) in the extension→webview protocol +- The `PromptInput` component to accept externally-set text + +--- + +## Prompt Templates (Full Text) + +Below are the exact prompt templates from the old extension's `src/shared/support-prompt.ts`. Each template uses `${variable}` interpolation. These are the defaults; users can customize any template via the `customSupportPrompts` extension setting. + +> **Note:** The `diagnosticText` variable used in the FIX template is auto-generated from VS Code diagnostics using the format `- [source] message (code)`. + +### EXPLAIN + +**Variables:** `filePath`, `startLine`, `endLine`, `userInput`, `selectedText` + +``` +Explain the following code from file path ${filePath}:${startLine}-${endLine} +${userInput} + +\`\`\` +${selectedText} +\`\`\` + +Please provide a clear and concise explanation of what this code does, including: +1. The purpose and functionality +2. Key components and their interactions +3. Important patterns or techniques used +``` + +### FIX + +**Variables:** `filePath`, `startLine`, `endLine`, `diagnosticText`, `userInput`, `selectedText` + +``` +Fix any issues in the following code from file path ${filePath}:${startLine}-${endLine} +${diagnosticText} +${userInput} + +\`\`\` +${selectedText} +\`\`\` + +Please: +1. Address all detected problems listed above (if any) +2. Identify any other potential bugs or issues +3. Provide corrected code +4. Explain what was fixed and why +``` + +### IMPROVE + +**Variables:** `filePath`, `startLine`, `endLine`, `userInput`, `selectedText` + +``` +Improve the following code from file path ${filePath}:${startLine}-${endLine} +${userInput} + +\`\`\` +${selectedText} +\`\`\` + +Please suggest improvements for: +1. Code readability and maintainability +2. Performance optimization +3. Best practices and patterns +4. Error handling and edge cases + +Provide the improved code along with explanations for each enhancement. +``` + +### ADD_TO_CONTEXT + +**Variables:** `filePath`, `startLine`, `endLine`, `selectedText` + +``` +${filePath}:${startLine}-${endLine} +\`\`\` +${selectedText} +\`\`\` +``` + +### TERMINAL_ADD_TO_CONTEXT + +**Variables:** `userInput`, `terminalContent` + +``` +${userInput} +Terminal output: +\`\`\` +${terminalContent} +\`\`\` +``` + +### TERMINAL_FIX + +**Variables:** `userInput`, `terminalContent` + +``` +${userInput} +Fix this terminal command: +\`\`\` +${terminalContent} +\`\`\` + +Please: +1. Identify any issues in the command +2. Provide the corrected command +3. Explain what was fixed and why +``` + +### TERMINAL_EXPLAIN + +**Variables:** `userInput`, `terminalContent` + +``` +${userInput} +Explain this terminal command: +\`\`\` +${terminalContent} +\`\`\` + +Please provide: +1. What the command does +2. Explanation of each part/flag +3. Expected output and behavior +``` + +### TERMINAL*GENERATE *(Kilo-specific addition)\_ + +**Variables:** `userInput`, `operatingSystem`, `currentDirectory`, `shell` + +``` +Generate a terminal command based on this description: "${userInput}" + +Context: +- Operating System: ${operatingSystem} +- Current Directory: ${currentDirectory} +- Shell: ${shell} + +Requirements: +1. Generate ONLY the command, no explanations or formatting +2. Ensure the command is safe and appropriate +3. Use common command-line tools and best practices +4. Consider the current working directory context +5. Return only the raw command that can be executed directly +``` + +### ENHANCE + +**Variables:** `userInput` + +``` +Generate an enhanced version of this prompt (reply with only the enhanced prompt - no conversation, explanations, lead-in, bullet points, placeholders, or surrounding quotes): + +${userInput} +``` + +### CONDENSE + +**Variables:** none (system prompt for conversation summary) + +``` +Your task is to create a detailed summary of the conversation so far, paying close attention to the user's explicit requests and your previous actions. +This summary should be thorough in capturing technical details, code patterns, and architectural decisions that would be essential for continuing with the conversation and supporting any continuing tasks. + +Your summary should be structured as follows: +Context: The context to continue the conversation with. If applicable based on the current task, this should include: + 1. Previous Conversation: High level details about what was discussed throughout the entire conversation with the user. This should be written to allow someone to be able to follow the general overarching conversation flow. + 2. Current Work: Describe in detail what was being worked on prior to this request to summarize the conversation. Pay special attention to the more recent messages in the conversation. + 3. Key Technical Concepts: List all important technical concepts, technologies, coding conventions, and frameworks discussed, which might be relevant for continuing with this work. + 4. Relevant Files and Code: If applicable, enumerate specific files and code sections examined, modified, or created for the task continuation. Pay special attention to the most recent messages and changes. + 5. Problem Solving: Document problems solved thus far and any ongoing troubleshooting efforts. + 6. Pending Tasks and Next Steps: Outline all pending tasks that you have explicitly been asked to work on, as well as list the next steps you will take for all outstanding work, if applicable. Include code snippets where they add clarity. For any next steps, include direct quotes from the most recent conversation showing exactly what task you were working on and where you left off. This should be verbatim to ensure there's no information loss in context between tasks. + +Example summary structure: +1. Previous Conversation: + [Detailed description] +2. Current Work: + [Detailed description] +3. Key Technical Concepts: + - [Concept 1] + - [Concept 2] + - [...] +4. Relevant Files and Code: + - [File Name 1] + - [Summary of why this file is important] + - [Summary of the changes made to this file, if any] + - [Important Code Snippet] + - [File Name 2] + - [Important Code Snippet] + - [...] +5. Problem Solving: + [Detailed description] +6. Pending Tasks and Next Steps: + - [Task 1 details & next steps] + - [Task 2 details & next steps] + - [...] + +Output only the summary of the conversation so far, without any additional commentary or explanation. +``` + +### COMMIT*MESSAGE *(Kilo-specific addition, tracked separately in [git-commit-message-generation.md](git-commit-message-generation.md))\_ + +**Variables:** `customInstructions`, `gitContext` + +```` +# Conventional Commit Message Generator +## System Instructions +You are an expert Git commit message generator that creates conventional commit messages based on staged changes. Analyze the provided git diff output and generate appropriate conventional commit messages following the specification. + +${customInstructions} + +## CRITICAL: Commit Message Output Rules +- DO NOT include any internal status indicators or bracketed metadata (e.g. "[Status: Active]", "[Context: Missing]") +- DO NOT include any task-specific formatting or artifacts from other rules +- ONLY Generate a clean conventional commit message as specified below + +${gitContext} + +## Conventional Commits Format +Generate commit messages following this exact structure: +``` +[optional scope]: +[optional body] +[optional footer(s)] +``` + +### Core Types (Required) +- **feat**: New feature or functionality (MINOR version bump) +- **fix**: Bug fix or error correction (PATCH version bump) + +### Additional Types (Extended) +- **docs**: Documentation changes only +- **style**: Code style changes (whitespace, formatting, semicolons, etc.) +- **refactor**: Code refactoring without feature changes or bug fixes +- **perf**: Performance improvements +- **test**: Adding or fixing tests +- **build**: Build system or external dependency changes +- **ci**: CI/CD configuration changes +- **chore**: Maintenance tasks, tooling changes +- **revert**: Reverting previous commits + +### Scope Guidelines +- Use parentheses: `feat(api):`, `fix(ui):` +- Common scopes: `api`, `ui`, `auth`, `db`, `config`, `deps`, `docs` +- For monorepos: package or module names +- Keep scope concise and lowercase + +### Description Rules +- Use imperative mood ("add" not "added" or "adds") +- Start with lowercase letter +- No period at the end +- Maximum 50 characters +- Be concise but descriptive + +### Body Guidelines (Optional) +- Start one blank line after description +- Explain the "what" and "why", not the "how" +- Wrap at 72 characters per line +- Use for complex changes requiring explanation + +### Footer Guidelines (Optional) +- Start one blank line after body +- **Breaking Changes**: `BREAKING CHANGE: description` + +## Analysis Instructions +When analyzing staged changes: +1. Determine Primary Type based on the nature of changes +2. Identify Scope from modified directories or modules +3. Craft Description focusing on the most significant change +4. Determine if there are Breaking Changes +5. For complex changes, include a detailed body explaining what and why +6. Add appropriate footers for issue references or breaking changes + +For significant changes, include a detailed body explaining the changes. + +Return ONLY the commit message in the conventional format, nothing else. +```` + +### NEW_TASK + +**Variables:** `userInput` + +``` +${userInput} +``` + +--- + +### Already Done / Tracked Elsewhere + +- **View title bar buttons**: ✅ Done ([#181](https://github.com/Kilo-Org/kilo/issues/181)) +- **SCM commit message generation**: Tracked in [git-commit-message-generation.md](git-commit-message-generation.md) diff --git a/packages/kilo-vscode/docs/opencode-migration-plan.md b/packages/kilo-vscode/docs/opencode-migration-plan.md index bd018b217..6ed20972d 100644 --- a/packages/kilo-vscode/docs/opencode-migration-plan.md +++ b/packages/kilo-vscode/docs/opencode-migration-plan.md @@ -72,41 +72,41 @@ The rebuild has a working foundation: ## Non-Agent Feature Parity -| Feature | Status | Details | Backend | Priority | -| ------------------------------------------------------------------------------------------------------- | -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------- | -| Agent Manager | ✅ Done | `AgentManagerProvider` opens editor panel, creates git worktrees for parallel sessions, full `createWorktreeSession` lifecycle (worktree → session → first message). `WorktreeManager` handles `git worktree add`, metadata, and discovery. `WorktreeSelector` in PromptInput toggles local/worktree mode. [#178](https://github.com/Kilo-Org/kilo/issues/178) | Extension orchestrates multiple CLI sessions | P1 | -| [Authentication & Enterprise](non-agent-features/authentication-organization-enterprise-enforcement.md) | 🔨 Partial | Device auth flow works, organization switching implemented. Missing: org feature flags, MDM policy enforcement. [#160](https://github.com/Kilo-Org/kilo/issues/160) | CLI handles its auth; extension handles org/MDM | P1 | -| [Auto-Purge](non-agent-features/auto-purge.md) | ❌ Not started | No scheduled cleanup of old session/task storage. | Extension-side (storage ownership TBD) | P3 | -| Autocomplete / Ghost | ✅ Done | `AutocompleteInlineCompletionProvider` implements `vscode.InlineCompletionItemProvider` with FIM completions via Kilo Gateway. Includes debouncing, LRU cache, tree-sitter context, bracket matching, and stream-based completion. `AutocompleteServiceManager` registers/unregisters the provider. [#164](https://github.com/Kilo-Org/kilo/issues/164) | Extension-side (VS Code InlineCompletionProvider) | P1 | -| [Browser Automation & URL Ingestion](non-agent-features/browser-automation-url-ingestion.md) | 🔨 Partial | Playwright MCP integration implemented: settings toggle, BrowserAutomationService, CLI MCP hub registration, BrowserTab settings UI. Missing: URL-to-markdown ingestion, screenshot viewing in chat. | CLI MCP hub (POST /mcp); extension manages lifecycle | P3 | -| [Checkpoints](non-agent-features/checkpoints.md) | ❌ Not started | No shadow git repo, per-task snapshots, restore UI, or diff viewing. Settings tab is a stub. | CLI (partial: session undo/redo); extension for git snapshots | P1 | -| [Cloud Task Support](non-agent-features/cloud-task-support.md) | ❌ Not started | No cloud sync for tasks across devices. [#168](https://github.com/Kilo-Org/kilo/issues/168) | Kilo cloud API + CLI; extension provides UI | P2 | -| [Code Actions](non-agent-features/code-actions.md) | ❌ Not started | No VS Code lightbulb/context menu integrations (explain, fix, improve). | Extension-side (VS Code CodeActionProvider) | P2 | -| [Code Reviews](non-agent-features/code-reviews.md) | ❌ Not started | No local review mode or automated AI review of uncommitted/branch changes. | CLI (partial); extension for VS Code review UX | P2 | -| [Codebase Indexing & Semantic Search](non-agent-features/codebase-indexing-semantic-search.md) | ❌ Not started | No vector indexing, semantic search, or embeddings infrastructure. | CLI has grep/glob endpoints; semantic indexing is extension or cloud | P2 | -| [Contribution Tracking](non-agent-features/contribution-tracking.md) | ❌ Not started | No AI attribution tracking, line fingerprinting, or reporting. | Extension-side | P3 | -| [Custom Commands](non-agent-features/custom-command-system.md) | ❌ Not started | No slash commands, project-level command discovery, or YAML frontmatter support. | CLI has custom commands; extension provides UI entry points | P2 | -| [Deploy & Secure Surfaces](non-agent-features/deploy-and-secure-surfaces.md) | ❌ Not started | No deploy workflows, managed indexing UI, or security review surfaces. | Extension-side | P3 | -| [Fast Edits](non-agent-features/fast-edits.md) | ❌ Not started | No fast edit mode for quick inline code changes. | CLI fast-edit runtime; extension provides UI | P2 | -| [Git Commit Message Generation](non-agent-features/git-commit-message-generation.md) | ❌ Not started | No AI commit message generation or VS Code Source Control integration. [#165](https://github.com/Kilo-Org/kilo/issues/165) | Extension-side (VS Code Git API) | P2 | -| [Integrations](non-agent-features/integrations.md) | ❌ Not started | No external system integrations (GitHub, etc.) beyond basic auth. | CLI plugin system (partial); extension for IDE hooks | P3 | -| Kilo Gateway | ✅ Done | Kilo Gateway (`KILO_GATEWAY_ID = "kilo"`) is the default provider, listed first in ModelSelector via `PROVIDER_ORDER`. Login/logout/org switching fully implemented in `KiloProvider.ts` and `ProfileView.tsx`. Default model selection in ProvidersTab. [#176](https://github.com/Kilo-Org/kilo/issues/176) | CLI handles gateway connection; extension provides config UI | P0 | -| Localization | ✅ Done | Full i18n system with 16 locales in [`i18n/`](../webview-ui/src/i18n/), three-layer dict merging, locale auto-detection, LanguageTab settings UI with locale selector. | Extension + webview; CLI locale mapping needed | P3 | -| [Marketplace](non-agent-features/marketplace.md) | 🔨 Partial | Placeholder view exists but is non-functional. No catalog, install, or update capabilities. [#169](https://github.com/Kilo-Org/kilo/issues/169) | Extension-side | P2 | -| [MCP & MCP Hub](non-agent-features/mcp-and-mcp-hub.md) | 🔨 Partial | MCP types and HTTP client methods added (getMcpStatus, addMcpServer, connectMcpServer, disconnectMcpServer). Used by BrowserAutomationService. Missing: general MCP configuration UI, server management, tool allowlisting, connection status display. | CLI owns MCP lifecycle; extension provides config UI | P1 | -| Mode Switcher | ✅ Done | ModeSwitcher.tsx implements popover-based agent/mode selector with descriptions, persisted via session.selectAgent(). Integrated into PromptInput. [#162](https://github.com/Kilo-Org/kilo/issues/162) | CLI manages modes; extension provides switcher UI | P2 | -| Model Switcher | ✅ Done | ModelSelector.tsx implements popover-based selector with search/filter, keyboard navigation, provider grouping, free model tags. Integrated into PromptInput. [#163](https://github.com/Kilo-Org/kilo/issues/163) | CLI provides model list; extension provides switcher UI | P1 | -| Provider Configuration | ✅ Done | `ProvidersTab` implements default/small model selection (backed by `ConfigProvider` + `PATCH /global/config`), disabled-providers list, and enabled-providers allowlist. Provider context wired into ModelSelector. [#175](https://github.com/Kilo-Org/kilo/issues/175) | CLI manages providers; extension provides config UI | P1 | -| [Repository Initialization](non-agent-features/repository-initialization.md) | ❌ Not started | No /init command support for setting up agentic engineering. [#174](https://github.com/Kilo-Org/kilo/issues/174) | CLI /init endpoint; extension provides UI trigger | P3 | -| [Rules & Workflows](non-agent-features/rules-and-workflows.md) | ❌ Not started | No rules or workflow management UI. [#173](https://github.com/Kilo-Org/kilo/issues/173) | CLI owns rules runtime; extension provides management UI | P3 | -| [Search & Repo Scanning](non-agent-features/search-and-repo-scanning-infrastructure.md) | ❌ Not started | No search infrastructure beyond CLI grep/glob. | CLI has grep/glob; extension may add UI | P2 | -| [Settings Sync](non-agent-features/settings-sync-integration.md) | ❌ Not started | No VS Code Settings Sync allowlist registration. Settings tabs are all stubs. | Extension-side (VS Code API) | P3 | -| [Settings UI](non-agent-features/settings-ui.md) | 🔨 Partial | 14-tab settings implemented: Providers (model selection + allowlists), AgentBehaviour (MCP read-only, rules, skills), AutoApprove (per-tool dropdowns), Browser, Autocomplete, Display, Notifications, Context, Terminal, Prompts, Experimental, Language, AboutKiloCode. Missing: Workflows subtab (placeholder). [#170](https://github.com/Kilo-Org/kilo/issues/170) | CLI exposes config; extension provides settings forms | P1 | -| [Skills System](non-agent-features/skills-system.md) | ❌ Not started | No skill discovery, management, or hot-reload in extension. | CLI has skills runtime; extension provides packaging/UI | P2 | -| [Speech-to-Text](non-agent-features/speech-to-text.md) | ❌ Not started | No voice input or streaming STT. | Webview (mic capture); CLI-compatible STT optional | P3 | -| Task History | ✅ Done | `SessionList` uses kilo-ui `List` with search (filterKeys=["title"]), date grouping (Today/Yesterday/This Week/This Month/Older), relative timestamps, inline rename, and delete with confirmation. [#167](https://github.com/Kilo-Org/kilo/issues/167) | CLI session storage; extension provides history UI | P1 | -| Telemetry | ✅ Done | `TelemetryProxy` singleton POSTs events to `/telemetry/capture` respecting `vscode.env.isTelemetryEnabled`. Webview `captureTelemetryEvent()` posts to extension which forwards to `TelemetryProxy`. Both sides wired end-to-end. | Extension-side (PostHog + kilo-telemetry) | P1 | -| [Terminal / Shell Integration](non-agent-features/terminal-shell-integration.md) | ❌ Not started | No VS Code terminal integration for command execution display, exit code tracking, or working directory changes. | CLI executes commands; extension provides terminal UX | P1 | +| Feature | Status | Details | Backend | Priority | +| ------------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | -------- | +| Agent Manager | ✅ Done | `AgentManagerProvider` opens editor panel, creates git worktrees for parallel sessions, full `createWorktreeSession` lifecycle (worktree → session → first message). `WorktreeManager` handles `git worktree add`, metadata, and discovery. `WorktreeSelector` in PromptInput toggles local/worktree mode. [#178](https://github.com/Kilo-Org/kilo/issues/178) | Extension orchestrates multiple CLI sessions | P1 | +| [Authentication & Enterprise](non-agent-features/authentication-organization-enterprise-enforcement.md) | 🔨 Partial | Device auth flow works, organization switching implemented. Missing: org feature flags, MDM policy enforcement. [#160](https://github.com/Kilo-Org/kilo/issues/160) | CLI handles its auth; extension handles org/MDM | P1 | +| [Auto-Purge](non-agent-features/auto-purge.md) | ❌ Not started | No scheduled cleanup of old session/task storage. | Extension-side (storage ownership TBD) | P3 | +| Autocomplete / Ghost | ✅ Done | `AutocompleteInlineCompletionProvider` implements `vscode.InlineCompletionItemProvider` with FIM completions via Kilo Gateway. Includes debouncing, LRU cache, tree-sitter context, bracket matching, and stream-based completion. `AutocompleteServiceManager` registers/unregisters the provider. [#164](https://github.com/Kilo-Org/kilo/issues/164) | Extension-side (VS Code InlineCompletionProvider) | P1 | +| [Browser Automation & URL Ingestion](non-agent-features/browser-automation-url-ingestion.md) | 🔨 Partial | Playwright MCP integration implemented: settings toggle, BrowserAutomationService, CLI MCP hub registration, BrowserTab settings UI. Missing: URL-to-markdown ingestion, screenshot viewing in chat. | CLI MCP hub (POST /mcp); extension manages lifecycle | P3 | +| [Checkpoints](non-agent-features/checkpoints.md) | ❌ Not started | No shadow git repo, per-task snapshots, restore UI, or diff viewing. Settings tab is a stub. | CLI (partial: session undo/redo); extension for git snapshots | P1 | +| [Cloud Task Support](non-agent-features/cloud-task-support.md) | ❌ Not started | No cloud sync for tasks across devices. [#168](https://github.com/Kilo-Org/kilo/issues/168) | Kilo cloud API + CLI; extension provides UI | P2 | +| [Code Actions & Editor Menus](non-agent-features/editor-context-menus-and-code-actions.md) | 🔨 Partial | Editor/terminal right-click submenus, CodeActionProvider lightbulb, keyboard shortcuts, and prompt templates implemented. Terminal content capture is a stub (needs shell integration API). Missing: custom prompt overrides via settings. | Extension-side (VS Code CodeActionProvider + menus + keybindings) | P1 | +| [Code Reviews](non-agent-features/code-reviews.md) | ❌ Not started | No local review mode or automated AI review of uncommitted/branch changes. | CLI (partial); extension for VS Code review UX | P2 | +| [Codebase Indexing & Semantic Search](non-agent-features/codebase-indexing-semantic-search.md) | ❌ Not started | No vector indexing, semantic search, or embeddings infrastructure. | CLI has grep/glob endpoints; semantic indexing is extension or cloud | P2 | +| [Contribution Tracking](non-agent-features/contribution-tracking.md) | ❌ Not started | No AI attribution tracking, line fingerprinting, or reporting. | Extension-side | P3 | +| [Custom Commands](non-agent-features/custom-command-system.md) | ❌ Not started | No slash commands, project-level command discovery, or YAML frontmatter support. | CLI has custom commands; extension provides UI entry points | P2 | +| [Deploy & Secure Surfaces](non-agent-features/deploy-and-secure-surfaces.md) | ❌ Not started | No deploy workflows, managed indexing UI, or security review surfaces. | Extension-side | P3 | +| [Fast Edits](non-agent-features/fast-edits.md) | ❌ Not started | No fast edit mode for quick inline code changes. | CLI fast-edit runtime; extension provides UI | P2 | +| [Git Commit Message Generation](non-agent-features/git-commit-message-generation.md) | ❌ Not started | No AI commit message generation or VS Code Source Control integration. [#165](https://github.com/Kilo-Org/kilo/issues/165) | Extension-side (VS Code Git API) | P2 | +| [Integrations](non-agent-features/integrations.md) | ❌ Not started | No external system integrations (GitHub, etc.) beyond basic auth. | CLI plugin system (partial); extension for IDE hooks | P3 | +| Kilo Gateway | ✅ Done | Kilo Gateway (`KILO_GATEWAY_ID = "kilo"`) is the default provider, listed first in ModelSelector via `PROVIDER_ORDER`. Login/logout/org switching fully implemented in `KiloProvider.ts` and `ProfileView.tsx`. Default model selection in ProvidersTab. [#176](https://github.com/Kilo-Org/kilo/issues/176) | CLI handles gateway connection; extension provides config UI | P0 | +| Localization | ✅ Done | Full i18n system with 16 locales in [`i18n/`](../webview-ui/src/i18n/), three-layer dict merging, locale auto-detection, LanguageTab settings UI with locale selector. | Extension + webview; CLI locale mapping needed | P3 | +| [Marketplace](non-agent-features/marketplace.md) | 🔨 Partial | Placeholder view exists but is non-functional. No catalog, install, or update capabilities. [#169](https://github.com/Kilo-Org/kilo/issues/169) | Extension-side | P2 | +| [MCP & MCP Hub](non-agent-features/mcp-and-mcp-hub.md) | 🔨 Partial | MCP types and HTTP client methods added (getMcpStatus, addMcpServer, connectMcpServer, disconnectMcpServer). Used by BrowserAutomationService. Missing: general MCP configuration UI, server management, tool allowlisting, connection status display. | CLI owns MCP lifecycle; extension provides config UI | P1 | +| [Mode Switcher](non-agent-features/mode-switcher.md) | ✅ Done | ModeSwitcher.tsx implements popover-based agent/mode selector with descriptions, persisted via session.selectAgent(). Integrated into PromptInput. [#162](https://github.com/Kilo-Org/kilo/issues/162) | CLI manages modes; extension provides switcher UI | P2 | +| [Model Switcher](non-agent-features/model-switcher.md) | ✅ Done | ModelSelector.tsx implements popover-based selector with search/filter, keyboard navigation, provider grouping, free model tags. Integrated into PromptInput. [#163](https://github.com/Kilo-Org/kilo/issues/163) | CLI provides model list; extension provides switcher UI | P1 | +| [Provider Configuration](non-agent-features/provider-configuration.md) | 🔨 Partial | Provider context fetches and exposes provider data, connected providers, defaults, model lists — wired into ModelSelector. ProvidersTab UI still a stub. [#175](https://github.com/Kilo-Org/kilo/issues/175) | CLI manages providers; extension provides config UI | P1 | +| [Repository Initialization](non-agent-features/repository-initialization.md) | ❌ Not started | No /init command support for setting up agentic engineering. [#174](https://github.com/Kilo-Org/kilo/issues/174) | CLI /init endpoint; extension provides UI trigger | P3 | +| [Rules & Workflows](non-agent-features/rules-and-workflows.md) | ❌ Not started | No rules or workflow management UI. [#173](https://github.com/Kilo-Org/kilo/issues/173) | CLI owns rules runtime; extension provides management UI | P3 | +| [Search & Repo Scanning](non-agent-features/search-and-repo-scanning-infrastructure.md) | ❌ Not started | No search infrastructure beyond CLI grep/glob. | CLI has grep/glob; extension may add UI | P2 | +| [Settings Sync](non-agent-features/settings-sync-integration.md) | ❌ Not started | No VS Code Settings Sync allowlist registration. Settings tabs are all stubs. | Extension-side (VS Code API) | P3 | +| [Settings UI](non-agent-features/settings-ui.md) | 🔨 Partial | 15-tab settings shell exists. BrowserTab has real settings controls (enable/disable, system Chrome, headless toggles). LanguageTab has working locale selector. Remaining 13 tabs are stubs. [#170](https://github.com/Kilo-Org/kilo/issues/170) | CLI exposes config; extension provides settings forms | P1 | +| [Skills System](non-agent-features/skills-system.md) | ❌ Not started | No skill discovery, management, or hot-reload in extension. | CLI has skills runtime; extension provides packaging/UI | P2 | +| [Speech-to-Text](non-agent-features/speech-to-text.md) | ❌ Not started | No voice input or streaming STT. | Webview (mic capture); CLI-compatible STT optional | P3 | +| [Task History](non-agent-features/task-history.md) | 🔨 Partial | Session list exists but lacks search, metadata, and full persistence. [#167](https://github.com/Kilo-Org/kilo/issues/167) | CLI session storage; extension provides history UI | P1 | +| [Telemetry](non-agent-features/telemetry.md) | ❌ Not started | Dual-layer telemetry (PostHog Node server-side + PostHog JS client-side) for extension usage, errors, LLM completions, and AI interactions. Includes privacy controls, typed events, and structured error tracking. See detailed plan. | Extension-side (PostHog + kilo-telemetry) | P1 | +| [Terminal / Shell Integration](non-agent-features/terminal-shell-integration.md) | ❌ Not started | No VS Code terminal integration for command execution display, exit code tracking, or working directory changes. | CLI executes commands; extension provides terminal UX | P1 | --- diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 2ac3129b5..3b90ed282 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", @@ -100,32 +100,97 @@ "command": "kilo-code.new.agentManager.nextTab", "title": "Agent Manager: Next Tab", "category": "Kilo Code" + }, + { + "command": "kilo-code.new.agentManager.showTerminal", + "title": "Agent Manager: Focus Terminal", + "category": "Kilo Code", + "icon": "$(terminal)" + }, + { + "command": "kilo-code.new.agentManager.focusPanel", + "title": "Agent Manager: Focus Panel", + "category": "Kilo Code", + "icon": "$(circuit-board)" + }, + { + "command": "kilo-code.new.agentManager.newTab", + "title": "Agent Manager: New Tab", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.agentManager.closeTab", + "title": "Agent Manager: Close Tab", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.agentManager.newWorktree", + "title": "Agent Manager: New Worktree", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.agentManager.closeWorktree", + "title": "Agent Manager: Close Worktree", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.generateCommitMessage", + "title": "Generate Commit Message", + "category": "Kilo Code (NEW)", + "icon": { + "light": "assets/icons/kilo-light.svg", + "dark": "assets/icons/kilo-dark.svg" + } + }, + { + "command": "kilo-code.new.explainCode", + "title": "Explain Code", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.fixCode", + "title": "Fix Code", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.improveCode", + "title": "Improve Code", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.addToContext", + "title": "Add to Context", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.terminalAddToContext", + "title": "Add Terminal Content to Context", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.terminalFixCommand", + "title": "Fix This Command", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.terminalExplainCommand", + "title": "Explain This Command", + "category": "Kilo Code" + }, + { + "command": "kilo-code.new.focusChatInput", + "title": "Focus Chat Input", + "category": "Kilo Code" } ], - "keybindings": [ + "submenus": [ { - "command": "kilo-code.new.agentManager.previousSession", - "key": "ctrl+up", - "mac": "cmd+up", - "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + "id": "kilo-code.new.editorContextMenu", + "label": "Kilo Code" }, { - "command": "kilo-code.new.agentManager.nextSession", - "key": "ctrl+down", - "mac": "cmd+down", - "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" - }, - { - "command": "kilo-code.new.agentManager.previousTab", - "key": "ctrl+left", - "mac": "cmd+left", - "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" - }, - { - "command": "kilo-code.new.agentManager.nextTab", - "key": "ctrl+right", - "mac": "cmd+right", - "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + "id": "kilo-code.new.terminalContextMenu", + "label": "Kilo Code" } ], "menus": { @@ -161,14 +226,143 @@ "when": "view == kilo-code.new.sidebarView" } ], + "scm/title": [ + { + "command": "kilo-code.new.generateCommitMessage", + "group": "navigation", + "when": "scmProvider == git" + } + ], "editor/title": [ { "command": "kilo-code.new.openInTab", "group": "navigation", "when": "true" } + ], + "editor/context": [ + { + "submenu": "kilo-code.new.editorContextMenu", + "group": "1_kilocode" + } + ], + "kilo-code.new.editorContextMenu": [ + { + "command": "kilo-code.new.explainCode", + "group": "1_actions@1" + }, + { + "command": "kilo-code.new.fixCode", + "group": "1_actions@2" + }, + { + "command": "kilo-code.new.improveCode", + "group": "1_actions@3" + }, + { + "command": "kilo-code.new.addToContext", + "group": "1_actions@4" + } + ], + "terminal/context": [ + { + "submenu": "kilo-code.new.terminalContextMenu", + "group": "2_kilocode" + } + ], + "kilo-code.new.terminalContextMenu": [ + { + "command": "kilo-code.new.terminalAddToContext", + "group": "1_actions@1" + }, + { + "command": "kilo-code.new.terminalFixCommand", + "group": "1_actions@2" + }, + { + "command": "kilo-code.new.terminalExplainCommand", + "group": "1_actions@3" + } ] }, + "keybindings": [ + { + "command": "kilo-code.new.focusChatInput", + "key": "ctrl+shift+a", + "mac": "cmd+shift+a" + }, + { + "command": "kilo-code.new.agentManagerOpen", + "key": "ctrl+shift+m", + "mac": "cmd+shift+m" + }, + { + "command": "kilo-code.new.addToContext", + "key": "ctrl+k ctrl+a", + "mac": "cmd+k cmd+a", + "when": "editorTextFocus && editorHasSelection" + }, + { + "command": "kilo-code.new.agentManager.previousSession", + "key": "ctrl+up", + "mac": "cmd+up", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.nextSession", + "key": "ctrl+down", + "mac": "cmd+down", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.previousTab", + "key": "ctrl+left", + "mac": "cmd+left", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.nextTab", + "key": "ctrl+right", + "mac": "cmd+right", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.showTerminal", + "key": "ctrl+/", + "mac": "cmd+/", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.focusPanel", + "key": "ctrl+.", + "mac": "cmd+.", + "when": "terminalFocus && kilo-code.agentTerminalFocus" + }, + { + "command": "kilo-code.new.agentManager.newTab", + "key": "ctrl+t", + "mac": "cmd+t", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.closeTab", + "key": "ctrl+w", + "mac": "cmd+w", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.newWorktree", + "key": "ctrl+n", + "mac": "cmd+n", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + }, + { + "command": "kilo-code.new.agentManager.closeWorktree", + "key": "ctrl+shift+w", + "mac": "cmd+shift+w", + "when": "activeWebviewPanelId == 'kilo-code.new.AgentManagerPanel'" + } + ], "configuration": { "title": "Kilo Code", "properties": { @@ -315,7 +509,8 @@ "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint src", - "test": "vscode-test" + "test": "vscode-test", + "test:unit": "bun test tests/unit/" }, "devDependencies": { "@types/diff": "^6.0.0", @@ -331,6 +526,7 @@ "eslint-config-prettier": "^10.1.8", "prettier": "^3.8.1", "qrcode": "^1.5.4", + "ts-morph": "27.0.2", "typescript": "^5.9.3", "typescript-eslint": "^8.54.0" }, @@ -340,6 +536,7 @@ "@kilocode/kilo-ui": "workspace:*", "@kilocode/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", + "@thisbeyond/solid-dnd": "0.7.5", "diff": "^7.0.0", "dotenv": "^16.4.7", "eventsource": "^2.0.2", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index e9c6a09bd..5c882e7f0 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -1,10 +1,23 @@ import * as vscode from "vscode" import { z } from "zod" -import { type HttpClient, type SessionInfo, type SSEEvent, type KiloConnectionService } from "./services/cli-backend" +import { + type HttpClient, + type SessionInfo, + type SSEEvent, + type KiloConnectionService, + type KilocodeNotification, +} from "./services/cli-backend" import { handleChatCompletionRequest } from "./services/autocomplete/chat-autocomplete/handleChatCompletionRequest" import { handleChatCompletionAccepted } from "./services/autocomplete/chat-autocomplete/handleChatCompletionAccepted" import { buildWebviewHtml } from "./utils" import { TelemetryProxy, type TelemetryPropertiesProvider } from "./services/telemetry" +import { + sessionToWebview, + normalizeProviders, + filterVisibleAgents, + buildSettingPath, + mapSSEEventToWebviewMessage, +} from "./kilo-provider-utils" export class KiloProvider implements vscode.WebviewViewProvider, TelemetryPropertiesProvider { public static readonly viewType = "kilo-code.new.sidebarView" @@ -22,12 +35,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private cachedAgentsMessage: unknown = null /** Cached configLoaded payload so requestConfig can be served before httpClient is ready */ private cachedConfigMessage: unknown = null + /** Cached notificationsLoaded payload */ + private cachedNotificationsMessage: unknown = null private trackedSessionIds: Set = new Set() /** Per-session directory overrides (e.g., worktree paths registered by AgentManagerProvider). */ private sessionDirectories = new Map() + /** Abort controller for the current loadMessages request; aborted when a new session is selected. */ + private loadMessagesAbort: AbortController | null = null private unsubscribeEvent: (() => void) | null = null private unsubscribeState: (() => void) | null = null + private unsubscribeNotificationDismiss: (() => void) | null = null private webviewMessageDisposable: vscode.Disposable | null = null /** Optional interceptor called before the standard message handler. @@ -37,6 +55,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper constructor( private readonly extensionUri: vscode.Uri, private readonly connectionService: KiloConnectionService, + private readonly extensionContext?: vscode.ExtensionContext, ) { TelemetryProxy.getInstance().setProvider(this) } @@ -267,6 +286,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper message.providerID, message.modelID, message.agent, + message.variant, files, ) break @@ -285,7 +305,9 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.trackedSessionIds.clear() break case "loadMessages": - await this.handleLoadMessages(message.sessionID) + // Don't await: allow parallel loads so rapid session switching + // isn't blocked by slow responses for earlier sessions. + void this.handleLoadMessages(message.sessionID) break case "syncSession": await this.handleSyncSession(message.sessionID) @@ -316,6 +338,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper vscode.env.openExternal(vscode.Uri.parse(message.url)) } break + case "openFile": + if (message.filePath) { + this.handleOpenFile(message.filePath, message.line, message.column) + } + break case "requestProviders": await this.fetchAndSendProviders() break @@ -402,12 +429,29 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "requestNotificationSettings": this.sendNotificationSettings() break + case "requestNotifications": + await this.fetchAndSendNotifications() + break + case "dismissNotification": + await this.handleDismissNotification(message.notificationId) + break case "resetAllSettings": await this.handleResetAllSettings() break case "telemetry": TelemetryProxy.capture(message.event, message.properties) break + case "persistVariant": { + const stored = this.extensionContext?.globalState.get>("variantSelections") ?? {} + stored[message.key] = message.value + await this.extensionContext?.globalState.update("variantSelections", stored) + break + } + case "requestVariants": { + const variants = this.extensionContext?.globalState.get>("variantSelections") ?? {} + this.postMessage({ type: "variantsLoaded", variants }) + break + } } }) } @@ -422,6 +466,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Clean up any existing subscriptions (e.g., sidebar re-shown) this.unsubscribeEvent?.() this.unsubscribeState?.() + this.unsubscribeNotificationDismiss?.() try { const workspaceDir = this.getWorkspaceDirectory() @@ -469,6 +514,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } }) + // Subscribe to notification dismiss broadcast from other KiloProvider instances + this.unsubscribeNotificationDismiss = this.connectionService.onNotificationDismissed(() => { + this.fetchAndSendNotifications() + }) + // Get current state and push to webview const serverInfo = this.connectionService.getServerInfo() this.connectionState = this.connectionService.getConnectionState() @@ -491,6 +541,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper await this.fetchAndSendProviders() await this.fetchAndSendAgents() await this.fetchAndSendConfig() + await this.fetchAndSendNotifications() this.sendNotificationSettings() console.log("[Kilo New] KiloProvider: ✅ initializeConnection completed successfully") @@ -505,16 +556,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - /** - * Convert SessionInfo to webview format. - */ private sessionToWebview(session: SessionInfo) { - return { - id: session.id, - title: session.title, - createdAt: new Date(session.time.created).toISOString(), - updatedAt: new Date(session.time.updated).toISOString(), - } + return sessionToWebview(session) } /** @@ -560,25 +603,53 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.postMessage({ type: "error", message: "Not connected to CLI backend", + sessionID, }) return } + // Abort any previous in-flight loadMessages request so the backend + // isn't overwhelmed when the user switches sessions rapidly. + this.loadMessagesAbort?.abort() + const abort = new AbortController() + this.loadMessagesAbort = abort + try { const workspaceDir = this.getWorkspaceDirectory(sessionID) - const messagesData = await this.httpClient.getMessages(sessionID, workspaceDir) + const messagesData = await this.httpClient.getMessages(sessionID, workspaceDir, abort.signal) + + // If this request was aborted while awaiting, skip posting stale results + if (abort.signal.aborted) return // Update currentSession so fallback logic in handleSendMessage/handleAbort // references the correct session after switching to a historical session. // Non-blocking: don't let a failure here prevent messages from loading. + // 404s are expected for cross-worktree sessions — use silent to suppress HTTP error logs. this.httpClient - .getSession(sessionID, workspaceDir) + .getSession(sessionID, workspaceDir, true) .then((session) => { if (!this.currentSession || this.currentSession.id === sessionID) { this.currentSession = session } }) - .catch((err) => console.error("[Kilo New] KiloProvider: Failed to fetch session for tracking:", err)) + .catch((err) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", err)) + + // Fetch current session status so the webview has the correct busy/idle + // state after switching tabs (SSE events may have been missed). + this.httpClient + .getSessionStatuses(workspaceDir) + .then((statuses) => { + for (const [sid, info] of Object.entries(statuses)) { + if (!this.trackedSessionIds.has(sid)) continue + this.postMessage({ + type: "sessionStatus", + sessionID: sid, + status: info.type, + ...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}), + }) + } + }) + .catch((err) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", err)) // Convert to webview format, including cost/tokens for assistant messages const messages = messagesData.map((m) => ({ @@ -601,10 +672,13 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper messages, }) } catch (error) { + // Silently ignore aborted requests — the user switched to a different session + if (abort.signal.aborted) return console.error("[Kilo New] KiloProvider: Failed to load messages:", error) this.postMessage({ type: "error", message: error instanceof Error ? error.message : "Failed to load messages", + sessionID, }) } } @@ -769,11 +843,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const workspaceDir = this.getWorkspaceDirectory() const response = await this.httpClient.listProviders(workspaceDir) - // Re-key providers from numeric indices to provider.id - const normalized: typeof response.all = {} - for (const provider of Object.values(response.all)) { - normalized[provider.id] = provider - } + const normalized = normalizeProviders(response.all) const config = vscode.workspace.getConfiguration("kilo-code.new.model") const providerID = config.get("providerID", "kilo") @@ -808,11 +878,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const workspaceDir = this.getWorkspaceDirectory() const agents = await this.httpClient.listAgents(workspaceDir) - // Filter to only visible primary/all modes (not subagents, not hidden) - const visible = agents.filter((a) => a.mode !== "subagent" && !a.hidden) - - // Find default agent: first one in list (CLI sorts default first) - const defaultAgent = visible.length > 0 ? visible[0].name : "code" + const { visible, defaultAgent } = filterVisibleAgents(agents) const message = { type: "agentsLoaded", @@ -858,6 +924,47 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + /** + * Fetch Kilo news/notifications and send to webview. + * Uses the cached message pattern so the webview gets data immediately on refresh. + */ + private async fetchAndSendNotifications(): Promise { + if (!this.httpClient) { + if (this.cachedNotificationsMessage) { + this.postMessage(this.cachedNotificationsMessage) + } + return + } + + try { + const notifications = await this.httpClient.getNotifications() + const existing = this.extensionContext?.globalState.get("kilo.dismissedNotificationIds", []) ?? [] + const active = new Set(notifications.map((n) => n.id)) + const dismissedIds = existing.filter((id) => active.has(id)) + if (dismissedIds.length !== existing.length) { + await this.extensionContext?.globalState.update("kilo.dismissedNotificationIds", dismissedIds) + } + const message = { type: "notificationsLoaded", notifications, dismissedIds } + this.cachedNotificationsMessage = message + this.postMessage(message) + } catch (error) { + console.error("[Kilo New] KiloProvider: Failed to fetch notifications:", error) + } + } + + /** + * Persist a dismissed notification ID in globalState and push updated lists to webview. + */ + private async handleDismissNotification(notificationId: string): Promise { + if (!this.extensionContext) return + const existing = this.extensionContext.globalState.get("kilo.dismissedNotificationIds", []) + if (!existing.includes(notificationId)) { + await this.extensionContext.globalState.update("kilo.dismissedNotificationIds", [...existing, notificationId]) + } + await this.fetchAndSendNotifications() + this.connectionService.notifyNotificationDismissed(notificationId) + } + /** * Read notification/sound settings from VS Code config and push to webview. */ @@ -915,6 +1022,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper providerID?: string, modelID?: string, agent?: string, + variant?: string, files?: Array<{ mime: string; url: string }>, ): Promise { if (!this.httpClient) { @@ -970,6 +1078,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper providerID, modelID, agent, + variant, }) } catch (error) { console.error("[Kilo New] KiloProvider: Failed to send message:", error) @@ -1201,6 +1310,28 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + /** + * Handle openFile request from the webview — open a file in the VS Code editor. + */ + private handleOpenFile(filePath: string, line?: number, column?: number): void { + const absolute = /^(?:\/|[a-zA-Z]:[\\/])/.test(filePath) + const uri = absolute + ? vscode.Uri.file(filePath) + : vscode.Uri.joinPath(vscode.Uri.file(this.getWorkspaceDirectory()), filePath) + vscode.workspace.openTextDocument(uri).then( + (doc) => { + const options: vscode.TextDocumentShowOptions = { preview: true } + if (line !== undefined && line > 0) { + const col = column !== undefined && column > 0 ? column - 1 : 0 + const pos = new vscode.Position(line - 1, col) + options.selection = new vscode.Range(pos, pos) + } + vscode.window.showTextDocument(doc, options) + }, + (err) => console.error("[Kilo New] KiloProvider: Failed to open file:", uri.fsPath, err), + ) + } + /** * Handle logout request from the webview. */ @@ -1239,9 +1370,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper * The key uses dot notation relative to `kilo-code.new` (e.g. "browserAutomation.enabled"). */ private async handleUpdateSetting(key: string, value: unknown): Promise { - const parts = key.split(".") - const section = parts.slice(0, -1).join(".") - const leaf = parts[parts.length - 1] + const { section, leaf } = buildSettingPath(key) const config = vscode.workspace.getConfiguration(`kilo-code.new${section ? `.${section}` : ""}`) await config.update(leaf, value, vscode.ConfigurationTarget.Global) } @@ -1325,121 +1454,18 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } // Forward relevant events to webview - switch (event.type) { - case "message.part.updated": { - // The part contains the full part data including messageID, delta is optional text delta - const part = event.properties.part as { messageID?: string; sessionID?: string } - const messageID = part.messageID || "" + // Side effects that must happen before the webview message is sent + if (event.type === "session.created" && !this.currentSession) { + this.currentSession = event.properties.info + this.trackedSessionIds.add(event.properties.info.id) + } + if (event.type === "session.updated" && this.currentSession?.id === event.properties.info.id) { + this.currentSession = event.properties.info + } - const resolvedSessionID = sessionID - if (!resolvedSessionID) { - return - } - this.postMessage({ - type: "partUpdated", - sessionID: resolvedSessionID, - messageID, - part: event.properties.part, - delta: event.properties.delta ? { type: "text-delta", textDelta: event.properties.delta } : undefined, - }) - break - } - - case "message.updated": - // Message info updated — forward cost/tokens for assistant messages - this.postMessage({ - type: "messageCreated", - message: { - id: event.properties.info.id, - sessionID: event.properties.info.sessionID, - role: event.properties.info.role, - createdAt: new Date(event.properties.info.time.created).toISOString(), - cost: event.properties.info.cost, - tokens: event.properties.info.tokens, - }, - }) - break - - case "session.status": - this.postMessage({ - type: "sessionStatus", - sessionID: event.properties.sessionID, - status: event.properties.status.type, - }) - break - - case "permission.asked": - this.postMessage({ - type: "permissionRequest", - permission: { - id: event.properties.id, - sessionID: event.properties.sessionID, - toolName: event.properties.permission, - patterns: event.properties.patterns ?? [], - args: event.properties.metadata, - message: `Permission required: ${event.properties.permission}`, - tool: event.properties.tool, - }, - }) - break - - case "todo.updated": - this.postMessage({ - type: "todoUpdated", - sessionID: event.properties.sessionID, - items: event.properties.items, - }) - break - - case "question.asked": - this.postMessage({ - type: "questionRequest", - question: { - id: event.properties.id, - sessionID: event.properties.sessionID, - questions: event.properties.questions, - tool: event.properties.tool, - }, - }) - break - - case "question.replied": - this.postMessage({ - type: "questionResolved", - requestID: event.properties.requestID, - }) - break - - case "question.rejected": - this.postMessage({ - type: "questionResolved", - requestID: event.properties.requestID, - }) - break - - case "session.created": - // Store session if we don't have one yet - if (!this.currentSession) { - this.currentSession = event.properties.info - this.trackedSessionIds.add(event.properties.info.id) - } - // Notify webview - this.postMessage({ - type: "sessionCreated", - session: this.sessionToWebview(event.properties.info), - }) - break - - case "session.updated": - // Keep local state in sync (e.g. title generation) - if (this.currentSession?.id === event.properties.info.id) { - this.currentSession = event.properties.info - } - this.postMessage({ - type: "sessionUpdated", - session: this.sessionToWebview(event.properties.info), - }) - break + const msg = mapSSEEventToWebviewMessage(event, sessionID) + if (msg) { + this.postMessage(msg) } } @@ -1514,6 +1540,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper dispose(): void { this.unsubscribeEvent?.() this.unsubscribeState?.() + this.unsubscribeNotificationDismiss?.() this.webviewMessageDisposable?.dispose() this.trackedSessionIds.clear() this.sessionDirectories.clear() diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 152582bc1..d210eaa18 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,18 @@ export class AgentManagerProvider implements vscode.Disposable { private outputChannel: vscode.OutputChannel private worktrees: WorktreeManager | undefined private state: WorktreeStateManager | undefined + private setupScript: SetupScriptService | undefined + private terminalManager: SessionTerminalManager + private stateReady: Promise | undefined constructor( private readonly extensionUri: vscode.Uri, private readonly connectionService: KiloConnectionService, ) { this.outputChannel = vscode.window.createOutputChannel("Kilo Agent Manager") + this.terminalManager = new SessionTerminalManager((msg) => + this.outputChannel.appendLine(`[SessionTerminal] ${msg}`), + ) } private log(...args: unknown[]) { @@ -65,8 +75,9 @@ export class AgentManagerProvider implements vscode.Disposable { onBeforeMessage: (msg) => this.onMessage(msg), }) - void this.initializeState() + this.stateReady = this.initializeState() void this.sendRepoInfo() + this.sendKeybindings() this.panel.onDidDispose(() => { this.log("Panel disposed") @@ -83,7 +94,10 @@ export class AgentManagerProvider implements vscode.Disposable { private async initializeState(): Promise { const manager = this.getWorktreeManager() const state = this.getStateManager() - if (!manager || !state) return + if (!manager || !state) { + this.pushEmptyState() + return + } await state.load() @@ -124,10 +138,54 @@ export class AgentManagerProvider implements vscode.Disposable { return this.onAddSessionToWorktree(msg.worktreeId) if (type === "agentManager.closeSession" && typeof msg.sessionId === "string") return this.onCloseSession(msg.sessionId) + if (type === "agentManager.configureSetupScript") { + void this.configureSetupScript() + return null + } + if (type === "agentManager.showTerminal" && typeof msg.sessionId === "string") { + this.terminalManager.showTerminal(msg.sessionId, this.state) + return null + } if (type === "agentManager.requestRepoInfo") { void this.sendRepoInfo() return null } + if (type === "agentManager.createMultiVersion") { + void this.onCreateMultiVersion(msg) + return null + } + if (type === "agentManager.requestState") { + void this.stateReady + ?.then(() => { + this.pushState() + // Refresh sessions after pushState so the webview's sessionsLoaded + // handler is guaranteed to be registered (requestState fires from + // onMount). Without this, the initial refreshSessions() in + // initializeState() can race ahead of webview mount, causing + // sessionsLoaded to never flip to true. + if (this.state && this.state.getSessions().length > 0) { + this.provider?.refreshSessions() + } + }) + .catch((err) => { + this.log("initializeState failed, pushing partial state:", err) + this.pushState() + }) + return null + } + if (type === "agentManager.setTabOrder" && typeof msg.key === "string" && Array.isArray(msg.order)) { + this.state?.setTabOrder(msg.key as string, msg.order as string[]) + return null + } + if (type === "agentManager.setSessionsCollapsed" && typeof msg.collapsed === "boolean") { + this.state?.setSessionsCollapsed(msg.collapsed as boolean) + return null + } + + // When switching sessions, show existing terminal if one is open + if (type === "loadMessages" && typeof msg.sessionID === "string") { + this.terminalManager.showExisting(msg.sessionID) + } // After clearSession, re-register worktree sessions so SSE events keep flowing if (type === "clearSession") { @@ -147,14 +205,18 @@ export class AgentManagerProvider implements vscode.Disposable { // --------------------------------------------------------------------------- /** Create a git worktree on disk and register it in state. Returns null on failure. */ - private async createWorktreeOnDisk(): Promise<{ + private async createWorktreeOnDisk(groupId?: string): Promise<{ worktree: ReturnType result: CreateWorktreeResult } | null> { const manager = this.getWorktreeManager() const state = this.getStateManager() if (!manager || !state) { - this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", message: "No workspace folder open" }) + this.postToWebview({ + type: "agentManager.worktreeSetup", + status: "error", + message: "Open a folder that contains a git repository to use worktrees", + }) return null } @@ -164,21 +226,41 @@ export class AgentManagerProvider implements vscode.Disposable { try { result = await manager.createWorktree({ prompt: "kilo" }) } catch (error) { - const err = error instanceof Error ? error.message : String(error) + const msg = error instanceof Error ? error.message : String(error) this.postToWebview({ type: "agentManager.worktreeSetup", status: "error", - message: `Failed to create worktree: ${err}`, + message: msg, }) return null } - const worktree = state.addWorktree({ branch: result.branch, path: result.path, parentBranch: result.parentBranch }) + const worktree = state.addWorktree({ + branch: result.branch, + path: result.path, + parentBranch: result.parentBranch, + groupId, + }) + + // Push state immediately so the sidebar shows the new worktree with a loading indicator + this.pushState() + this.postToWebview({ + type: "agentManager.worktreeSetup", + status: "creating", + message: "Setting up workspace...", + branch: result.branch, + worktreeId: worktree.id, + }) + return { worktree, result } } /** Create a CLI session in a worktree directory. Returns null on failure. */ - private async createSessionInWorktree(worktreePath: string, branch: string): Promise { + private async createSessionInWorktree( + worktreePath: string, + branch: string, + worktreeId?: string, + ): Promise { let client: HttpClient try { client = this.connectionService.getHttpClient() @@ -187,6 +269,7 @@ export class AgentManagerProvider implements vscode.Disposable { type: "agentManager.worktreeSetup", status: "error", message: "Not connected to CLI backend", + worktreeId, }) return null } @@ -196,6 +279,7 @@ export class AgentManagerProvider implements vscode.Disposable { status: "starting", message: "Starting session...", branch, + worktreeId, }) try { @@ -206,13 +290,14 @@ export class AgentManagerProvider implements vscode.Disposable { type: "agentManager.worktreeSetup", status: "error", message: `Failed to create session: ${err}`, + worktreeId, }) return null } } /** Send worktreeSetup.ready + sessionMeta + pushState after worktree creation. */ - private notifyWorktreeReady(sessionId: string, result: CreateWorktreeResult): void { + private notifyWorktreeReady(sessionId: string, result: CreateWorktreeResult, worktreeId?: string): void { this.pushState() this.postToWebview({ type: "agentManager.worktreeSetup", @@ -220,6 +305,7 @@ export class AgentManagerProvider implements vscode.Disposable { message: "Worktree ready", sessionId, branch: result.branch, + worktreeId, }) this.postToWebview({ type: "agentManager.sessionMeta", @@ -240,19 +326,23 @@ export class AgentManagerProvider implements vscode.Disposable { const created = await this.createWorktreeOnDisk() if (!created) return null - const session = await this.createSessionInWorktree(created.result.path, created.result.branch) + // Run setup script for new worktree (blocks until complete, shows in overlay) + await this.runSetupScriptForWorktree(created.result.path, created.result.branch, created.worktree.id) + + const session = await this.createSessionInWorktree(created.result.path, created.result.branch, created.worktree.id) if (!session) { const state = this.getStateManager() const manager = this.getWorktreeManager() state?.removeWorktree(created.worktree.id) await manager?.removeWorktree(created.result.path) + this.pushState() return null } const state = this.getStateManager()! state.addSession(session.id, created.worktree.id) this.registerWorktreeSession(session.id, created.result.path) - this.notifyWorktreeReady(session.id, created.result) + this.notifyWorktreeReady(session.id, created.result, created.worktree.id) this.log(`Created worktree ${created.worktree.id} with session ${session.id}`) return null } @@ -289,6 +379,9 @@ export class AgentManagerProvider implements vscode.Disposable { const created = await this.createWorktreeOnDisk() if (!created) return null + // Run setup script for new worktree (blocks until complete, shows in overlay) + await this.runSetupScriptForWorktree(created.result.path, created.result.branch, created.worktree.id) + const state = this.getStateManager()! if (!state.getSession(sessionId)) { state.addSession(sessionId, created.worktree.id) @@ -297,7 +390,7 @@ export class AgentManagerProvider implements vscode.Disposable { } this.registerWorktreeSession(sessionId, created.result.path) - this.notifyWorktreeReady(sessionId, created.result) + this.notifyWorktreeReady(sessionId, created.result, created.worktree.id) this.log(`Promoted session ${sessionId} to worktree ${created.worktree.id}`) return null } @@ -308,11 +401,7 @@ export class AgentManagerProvider implements vscode.Disposable { try { client = this.connectionService.getHttpClient() } catch { - this.postToWebview({ - type: "agentManager.worktreeSetup", - status: "error", - message: "Not connected to CLI backend", - }) + this.postToWebview({ type: "error", message: "Not connected to CLI backend" }) return null } @@ -338,11 +427,9 @@ export class AgentManagerProvider implements vscode.Disposable { this.registerWorktreeSession(session.id, worktree.path) this.pushState() this.postToWebview({ - type: "agentManager.worktreeSetup", - status: "ready", - message: "Session created", + type: "agentManager.sessionAdded", sessionId: session.id, - branch: worktree.branch, + worktreeId, }) if (this.provider) { @@ -364,6 +451,210 @@ export class AgentManagerProvider implements vscode.Disposable { return null } + // --------------------------------------------------------------------------- + // Multi-version worktree creation + // --------------------------------------------------------------------------- + + /** Create N worktree sessions for the same prompt (multi-version mode). */ + private async onCreateMultiVersion(msg: Record): Promise { + const text = msg.text as string + if (!text) return null + + const versions = Math.min(Math.max(Number(msg.versions) || 1, 1), 4) + const providerID = msg.providerID as string | undefined + const modelID = msg.modelID as string | undefined + const agent = msg.agent as string | undefined + const files = msg.files as Array<{ mime: string; url: string }> | undefined + + // Generate a shared group ID for multi-version worktrees + const groupId = versions > 1 ? `grp-${Date.now()}` : undefined + + this.log( + `Creating ${versions} multi-version worktrees for: ${text.slice(0, 60)}${groupId ? ` (group=${groupId})` : ""}`, + ) + + // Notify webview that multi-version creation has started + this.postToWebview({ + type: "agentManager.multiVersionProgress", + status: "creating", + total: versions, + completed: 0, + groupId, + }) + + // Phase 1: Create all worktrees + sessions first + const created: Array<{ + worktreeId: string + sessionId: string + path: string + branch: string + parentBranch: string + }> = [] + + for (let i = 0; i < versions; i++) { + this.log(`Creating worktree ${i + 1}/${versions}`) + + const wt = await this.createWorktreeOnDisk(groupId) + if (!wt) { + this.log(`Failed to create worktree for version ${i + 1}`) + continue + } + + await this.runSetupScriptForWorktree(wt.result.path, wt.result.branch) + + const session = await this.createSessionInWorktree(wt.result.path, wt.result.branch) + if (!session) { + const state = this.getStateManager() + const manager = this.getWorktreeManager() + state?.removeWorktree(wt.worktree.id) + await manager?.removeWorktree(wt.result.path) + this.log(`Failed to create session for version ${i + 1}`) + continue + } + + const state = this.getStateManager()! + state.addSession(session.id, wt.worktree.id) + this.registerWorktreeSession(session.id, wt.result.path) + this.notifyWorktreeReady(session.id, wt.result) + + created.push({ + worktreeId: wt.worktree.id, + sessionId: session.id, + path: wt.result.path, + branch: wt.result.branch, + parentBranch: wt.result.parentBranch, + }) + + this.log(`Version ${i + 1} worktree ready: session=${session.id}`) + + // Update progress + this.postToWebview({ + type: "agentManager.multiVersionProgress", + status: "creating", + total: versions, + completed: created.length, + groupId, + }) + } + + // Phase 2: Send the initial prompt to all sessions via the KiloProvider's + // message handling (same path as typing in the chat). This ensures SSE + // subscriptions and session tracking are properly set up before the message + // is sent. We route each message through the webview→KiloProvider pipeline. + for (let i = 0; i < created.length; i++) { + const entry = created[i]! + this.log(`Sending initial message to version ${i + 1} (session=${entry.sessionId})`) + + // Tell the webview to send the message through the normal session flow + this.postToWebview({ + type: "agentManager.sendInitialMessage", + sessionId: entry.sessionId, + worktreeId: entry.worktreeId, + text, + providerID, + modelID, + agent, + files, + }) + + // Small delay between sends to avoid overwhelming the backend + if (i < created.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 300)) + } + } + + // Notify completion + this.postToWebview({ + type: "agentManager.multiVersionProgress", + status: "done", + total: versions, + completed: created.length, + groupId, + }) + + if (created.length === 0) { + vscode.window.showErrorMessage(`Failed to create any of the ${versions} multi-version worktrees.`) + } + + this.log(`Multi-version creation complete: ${created.length}/${versions} versions`) + return null + } + + // --------------------------------------------------------------------------- + // Keybindings + // --------------------------------------------------------------------------- + + private sendKeybindings(): void { + const ext = vscode.extensions.getExtension("kilocode.kilo-code") + const keybindings: Array<{ command: string; key?: string; mac?: string }> = + ext?.packageJSON?.contributes?.keybindings ?? [] + + const mac = process.platform === "darwin" + const prefix = "kilo-code.new.agentManager." + const bindings: Record = {} + + // Global keybindings exposed to the shortcuts dialog + const globals: Record = { + "kilo-code.new.agentManagerOpen": "agentManagerOpen", + } + + for (const kb of keybindings) { + const raw = mac ? (kb.mac ?? kb.key) : kb.key + if (!raw) continue + + if (kb.command.startsWith(prefix)) { + bindings[kb.command.slice(prefix.length)] = formatKeybinding(raw, mac) + } else if (globals[kb.command]) { + bindings[globals[kb.command]] = formatKeybinding(raw, mac) + } + } + + this.postToWebview({ type: "agentManager.keybindings", bindings }) + } + + // --------------------------------------------------------------------------- + // Setup script + // --------------------------------------------------------------------------- + + /** Open the worktree setup script in the editor for user configuration. */ + private async configureSetupScript(): Promise { + const service = this.getSetupScriptService() + if (!service) return + try { + await service.openInEditor() + } catch (error) { + this.log(`Failed to open setup script: ${error}`) + } + } + + /** Run the worktree setup script if configured. Blocks until complete. Shows progress in overlay. */ + private async runSetupScriptForWorktree(worktreePath: string, branch?: string, worktreeId?: string): Promise { + const root = this.getWorkspaceRoot() + if (!root) return + try { + const service = this.getSetupScriptService() + if (!service || !service.hasScript()) return + this.postToWebview({ + type: "agentManager.worktreeSetup", + status: "creating", + message: "Running setup script...", + branch, + worktreeId, + }) + const runner = new SetupScriptRunner(this.outputChannel, service) + await runner.runIfConfigured({ worktreePath, repoPath: root }) + } catch (error) { + const msg = error instanceof Error ? error.message : String(error) + this.outputChannel.appendLine(`[AgentManager] Setup script error: ${msg}`) + this.postToWebview({ + type: "agentManager.worktreeSetup", + status: "error", + message: `Setup script failed: ${msg}`, + branch, + }) + } + } + // --------------------------------------------------------------------------- // Repo info // --------------------------------------------------------------------------- @@ -396,6 +687,19 @@ export class AgentManagerProvider implements vscode.Disposable { type: "agentManager.state", worktrees: state.getWorktrees(), sessions: state.getSessions(), + tabOrder: state.getTabOrder(), + sessionsCollapsed: state.getSessionsCollapsed(), + isGitRepo: true, + }) + } + + /** Push empty state when the workspace is not a git repo or has no workspace folder. */ + private pushEmptyState(): void { + this.postToWebview({ + type: "agentManager.state", + worktrees: [], + sessions: [], + isGitRepo: false, }) } @@ -431,6 +735,17 @@ export class AgentManagerProvider implements vscode.Disposable { return this.state } + private getSetupScriptService(): SetupScriptService | undefined { + if (this.setupScript) return this.setupScript + const root = this.getWorkspaceRoot() + if (!root) { + this.log("getSetupScriptService: no workspace folder available") + return undefined + } + this.setupScript = new SetupScriptService(root) + return this.setupScript + } + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- @@ -449,11 +764,33 @@ export class AgentManagerProvider implements vscode.Disposable { }) } + /** + * Show terminal for the currently active session (triggered by keyboard shortcut). + * Posts an action to the webview which will respond with the session ID. + */ + public showTerminalForCurrentSession(): void { + this.postToWebview({ type: "action", action: "showTerminal" }) + } + + /** + * Reveal the Agent Manager panel and focus the prompt input. + * Used for the keyboard shortcut to switch back from terminal. + */ + public focusPanel(): void { + if (!this.panel) return + this.panel.reveal(vscode.ViewColumn.One, false) + } + + public isActive(): boolean { + return this.panel?.active === true + } + public postMessage(message: unknown): void { this.panel?.webview.postMessage(message) } public dispose(): void { + this.terminalManager.dispose() this.provider?.dispose() this.panel?.dispose() this.outputChannel.dispose() 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 000000000..029d04b7b --- /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 000000000..3703c4bb6 --- /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 000000000..26f08eec2 --- /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 826da03b5..28df15009 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 b36b79d7d..5e3fb3331 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -18,6 +18,8 @@ export interface Worktree { path: string parentBranch: string createdAt: string + /** Shared identifier for worktrees created together via multi-version mode. */ + groupId?: string } export interface ManagedSession { @@ -29,6 +31,8 @@ export interface ManagedSession { interface StateFile { worktrees: Record> sessions: Record> + tabOrder?: Record + sessionsCollapsed?: boolean } const STATE_FILE = "agent-manager.json" @@ -44,6 +48,8 @@ export class WorktreeStateManager { private readonly file: string private worktrees = new Map() private sessions = new Map() + private tabOrder: Record = {} + private collapsed = false private readonly log: (msg: string) => void private saving: Promise | undefined private pendingSave = false @@ -103,11 +109,18 @@ export class WorktreeStateManager { // Mutations // --------------------------------------------------------------------------- - addWorktree(params: { branch: string; path: string; parentBranch: string }): Worktree { + addWorktree(params: { branch: string; path: string; parentBranch: string; groupId?: string }): Worktree { const id = generateId("wt") - const wt: Worktree = { id, ...params, createdAt: new Date().toISOString() } + const wt: Worktree = { + id, + branch: params.branch, + path: params.path, + parentBranch: params.parentBranch, + createdAt: new Date().toISOString(), + } + if (params.groupId) wt.groupId = params.groupId this.worktrees.set(id, wt) - this.log(`Added worktree ${id}: ${params.branch}`) + this.log(`Added worktree ${id}: ${params.branch}${params.groupId ? ` (group=${params.groupId})` : ""}`) void this.save() return wt } @@ -125,6 +138,9 @@ export class WorktreeStateManager { } } + // Clean up tab order for this worktree + delete this.tabOrder[id] + this.log(`Removed worktree ${id}, orphaned ${orphaned.length} sessions`) void this.save() return orphaned @@ -149,6 +165,47 @@ export class WorktreeStateManager { removeSession(id: string): void { this.sessions.delete(id) + + // Remove this session from any tab order arrays + for (const [key, order] of Object.entries(this.tabOrder)) { + const idx = order.indexOf(id) + if (idx !== -1) { + order.splice(idx, 1) + if (order.length === 0) delete this.tabOrder[key] + } + } + + void this.save() + } + + // --------------------------------------------------------------------------- + // Tab order + // --------------------------------------------------------------------------- + + getTabOrder(): Record { + return this.tabOrder + } + + setTabOrder(key: string, order: string[]): void { + this.tabOrder[key] = order + void this.save() + } + + removeTabOrder(key: string): void { + delete this.tabOrder[key] + void this.save() + } + + // --------------------------------------------------------------------------- + // Sessions collapsed + // --------------------------------------------------------------------------- + + getSessionsCollapsed(): boolean { + return this.collapsed + } + + setSessionsCollapsed(value: boolean): void { + this.collapsed = value void this.save() } @@ -162,6 +219,7 @@ export class WorktreeStateManager { const data = JSON.parse(content) as StateFile this.worktrees.clear() this.sessions.clear() + this.tabOrder = {} for (const [id, wt] of Object.entries(data.worktrees ?? {})) { this.worktrees.set(id, { id, ...wt }) @@ -169,6 +227,10 @@ export class WorktreeStateManager { for (const [id, s] of Object.entries(data.sessions ?? {})) { this.sessions.set(id, { id, ...s }) } + if (data.tabOrder) { + this.tabOrder = data.tabOrder + } + this.collapsed = data.sessionsCollapsed ?? false this.log(`Loaded state: ${this.worktrees.size} worktrees, ${this.sessions.size} sessions`) } catch (error) { const code = (error as NodeJS.ErrnoException).code @@ -229,6 +291,12 @@ export class WorktreeStateManager { const { id: _, ...rest } = s data.sessions[id] = rest } + if (Object.keys(this.tabOrder).length > 0) { + data.tabOrder = this.tabOrder + } + if (this.collapsed) { + data.sessionsCollapsed = true + } const dir = path.dirname(this.file) if (!fs.existsSync(dir)) await fs.promises.mkdir(dir, { recursive: true }) 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 000000000..ed69b1c37 --- /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 000000000..e305ad2aa --- /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 000000000..3d3dd1471 --- /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 000000000..8c59a63b6 --- /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 1c99d0aa8..904de6fe2 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -6,6 +6,8 @@ import { KiloConnectionService } from "./services/cli-backend" import { registerAutocompleteProvider } from "./services/autocomplete" import { BrowserAutomationService } from "./services/browser-automation" import { TelemetryProxy } from "./services/telemetry" +import { registerCommitMessageService } from "./services/commit-message" +import { registerCodeActions, registerTerminalActions, KiloCodeActionProvider } from "./services/code-actions" export function activate(context: vscode.ExtensionContext) { console.log("Kilo Code extension is now active") @@ -31,7 +33,7 @@ export function activate(context: vscode.ExtensionContext) { }) // Create the provider with shared service - const provider = new KiloProvider(context.extensionUri, connectionService) + const provider = new KiloProvider(context.extensionUri, connectionService, context) // Register the webview view provider for the sidebar. // retainContextWhenHidden keeps the webview alive when switching to other sidebar panels. @@ -80,11 +82,45 @@ export function activate(context: vscode.ExtensionContext) { vscode.commands.registerCommand("kilo-code.new.agentManager.nextTab", () => { agentManagerProvider.postMessage({ type: "action", action: "tabNext" }) }), + vscode.commands.registerCommand("kilo-code.new.agentManager.showTerminal", () => { + agentManagerProvider.showTerminalForCurrentSession() + }), + vscode.commands.registerCommand("kilo-code.new.agentManager.focusPanel", () => { + agentManagerProvider.focusPanel() + }), + vscode.commands.registerCommand("kilo-code.new.agentManager.newTab", () => { + agentManagerProvider.postMessage({ type: "action", action: "newTab" }) + }), + vscode.commands.registerCommand("kilo-code.new.agentManager.closeTab", () => { + agentManagerProvider.postMessage({ type: "action", action: "closeTab" }) + }), + vscode.commands.registerCommand("kilo-code.new.agentManager.newWorktree", () => { + agentManagerProvider.postMessage({ type: "action", action: "newWorktree" }) + }), + vscode.commands.registerCommand("kilo-code.new.agentManager.closeWorktree", () => { + agentManagerProvider.postMessage({ type: "action", action: "closeWorktree" }) + }), ) // Register autocomplete provider registerAutocompleteProvider(context, connectionService) + // Register commit message generation + registerCommitMessageService(context, connectionService) + + // Register code actions (editor context menus, terminal context menus, keyboard shortcuts) + registerCodeActions(context, provider, agentManagerProvider) + registerTerminalActions(context, provider) + + // Register CodeActionProvider (lightbulb quick fixes) + context.subscriptions.push( + vscode.languages.registerCodeActionsProvider( + { scheme: "file" }, + new KiloCodeActionProvider(), + KiloCodeActionProvider.metadata, + ), + ) + // Dispose services when extension deactivates (kills the server) context.subscriptions.push({ dispose: () => { @@ -121,7 +157,7 @@ async function openKiloInNewTab(context: vscode.ExtensionContext, connectionServ dark: vscode.Uri.joinPath(context.extensionUri, "assets", "icons", "kilo-dark.svg"), } - const tabProvider = new KiloProvider(context.extensionUri, connectionService) + const tabProvider = new KiloProvider(context.extensionUri, connectionService, context) tabProvider.resolveWebviewPanel(panel) // Wait for the new panel to become active before locking the editor group. diff --git a/packages/kilo-vscode/src/kilo-provider-utils.ts b/packages/kilo-vscode/src/kilo-provider-utils.ts new file mode 100644 index 000000000..9abd47f50 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider-utils.ts @@ -0,0 +1,147 @@ +import type { SessionInfo, AgentInfo, Provider, SSEEvent } from "./services/cli-backend/types" + +export function sessionToWebview(session: SessionInfo) { + return { + id: session.id, + title: session.title, + createdAt: new Date(session.time.created).toISOString(), + updatedAt: new Date(session.time.updated).toISOString(), + } +} + +export function normalizeProviders(all: Record): Record { + const normalized: Record = {} + for (const provider of Object.values(all)) { + normalized[provider.id] = provider + } + return normalized +} + +export function filterVisibleAgents(agents: AgentInfo[]): { visible: AgentInfo[]; defaultAgent: string } { + const visible = agents.filter((a) => a.mode !== "subagent" && !a.hidden) + const defaultAgent = visible.length > 0 ? visible[0]!.name : "code" + return { visible, defaultAgent } +} + +export function buildSettingPath(key: string): { section: string; leaf: string } { + const parts = key.split(".") + const section = parts.slice(0, -1).join(".") + const leaf = parts[parts.length - 1]! + return { section, leaf } +} + +export type WebviewMessage = + | { + type: "partUpdated" + sessionID: string + messageID: string + part: unknown + delta?: { type: "text-delta"; textDelta: string } + } + | { + type: "messageCreated" + message: { id: string; sessionID: string; role: string; createdAt: string; cost?: number; tokens?: unknown } + } + | { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number } + | { + type: "permissionRequest" + permission: { + id: string + sessionID: string + toolName: string + patterns: string[] + args: Record + message: string + tool?: { messageID: string; callID: string } + } + } + | { type: "todoUpdated"; sessionID: string; items: unknown[] } + | { type: "questionRequest"; question: { id: string; sessionID: string; questions: unknown[]; tool?: unknown } } + | { type: "questionResolved"; requestID: string } + | { type: "sessionCreated"; session: ReturnType } + | { type: "sessionUpdated"; session: ReturnType } + | null + +export function mapSSEEventToWebviewMessage(event: SSEEvent, sessionID: string | undefined): WebviewMessage { + switch (event.type) { + case "message.part.updated": { + const part = event.properties.part as { messageID?: string; sessionID?: string } + if (!sessionID) return null + return { + type: "partUpdated", + sessionID, + messageID: part.messageID || "", + part: event.properties.part, + delta: event.properties.delta ? { type: "text-delta", textDelta: event.properties.delta } : undefined, + } + } + case "message.updated": + return { + type: "messageCreated", + message: { + id: event.properties.info.id, + sessionID: event.properties.info.sessionID, + role: event.properties.info.role, + createdAt: new Date(event.properties.info.time.created).toISOString(), + cost: event.properties.info.cost, + tokens: event.properties.info.tokens, + }, + } + case "session.status": { + const info = event.properties.status + return { + type: "sessionStatus", + sessionID: event.properties.sessionID, + status: info.type, + ...(info.type === "retry" ? { attempt: info.attempt, message: info.message, next: info.next } : {}), + } + } + case "permission.asked": + return { + type: "permissionRequest", + permission: { + id: event.properties.id, + sessionID: event.properties.sessionID, + toolName: event.properties.permission, + patterns: event.properties.patterns ?? [], + args: event.properties.metadata, + message: `Permission required: ${event.properties.permission}`, + tool: event.properties.tool, + }, + } + case "todo.updated": + return { + type: "todoUpdated", + sessionID: event.properties.sessionID, + items: event.properties.items, + } + case "question.asked": + return { + type: "questionRequest", + question: { + id: event.properties.id, + sessionID: event.properties.sessionID, + questions: event.properties.questions, + tool: event.properties.tool, + }, + } + case "question.replied": + case "question.rejected": + return { + type: "questionResolved", + requestID: event.properties.requestID, + } + case "session.created": + return { + type: "sessionCreated", + session: sessionToWebview(event.properties.info), + } + case "session.updated": + return { + type: "sessionUpdated", + session: sessionToWebview(event.properties.info), + } + default: + return null + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/AutocompleteStatusBar.ts b/packages/kilo-vscode/src/services/autocomplete/AutocompleteStatusBar.ts index b371f0b0c..06eaf6b1a 100644 --- a/packages/kilo-vscode/src/services/autocomplete/AutocompleteStatusBar.ts +++ b/packages/kilo-vscode/src/services/autocomplete/AutocompleteStatusBar.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import { t } from "./shims/i18n" import type { AutocompleteStatusBarStateProps } from "./types" +import { humanFormatSessionCost, formatTime } from "./statusbar-utils" const SUPPORTED_PROVIDER_DISPLAY_NAME = "Kilo Gateway" @@ -40,14 +41,7 @@ export class AutocompleteStatusBar { } private humanFormatSessionCost(): string { - const cost = this.props.totalSessionCost - if (cost === 0) { - return t("kilocode:autocomplete.statusBar.cost.zero") - } - if (cost > 0 && cost < 0.01) { - return t("kilocode:autocomplete.statusBar.cost.lessThanCent") - } - return `$${cost.toFixed(2)}` + return humanFormatSessionCost(this.props.totalSessionCost) } public update(params: Partial) { @@ -60,8 +54,7 @@ export class AutocompleteStatusBar { } private formatTime(timestamp: number): string { - const date = new Date(timestamp) - return date.toLocaleTimeString() + return formatTime(timestamp) } private renderDefault() { diff --git a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts index 7f86c55ef..113247c89 100644 --- a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts +++ b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/ChatTextAreaAutocomplete.ts @@ -4,6 +4,7 @@ import { removePrefixOverlap } from "../continuedev/core/autocomplete/postproces import { AutocompleteTelemetry } from "../classic-auto-complete/AutocompleteTelemetry" import { postprocessAutocompleteSuggestion } from "../classic-auto-complete/uselessSuggestionFilter" import type { KiloConnectionService } from "../../cli-backend" +import { finalizeChatSuggestion, buildChatPrefix } from "./chat-autocomplete-utils" export class ChatTextAreaAutocomplete { private model: AutocompleteModel @@ -134,52 +135,17 @@ TASK: Complete the user's message naturally. } private async buildPrefix(userText: string, visibleCodeContext?: VisibleCodeContext): Promise { - const contextParts: string[] = [] - - // Add visible code context (replaces cursor-based prefix/suffix) - if (visibleCodeContext && visibleCodeContext.editors.length > 0) { - contextParts.push("// Code visible in editor:") - - for (const editor of visibleCodeContext.editors) { - const fileName = editor.filePath.split("/").pop() || editor.filePath - contextParts.push(`\n// File: ${fileName} (${editor.languageId})`) - - for (const range of editor.visibleRanges) { - contextParts.push(range.content) - } - } - } - - contextParts.push("\n// User's message:") - contextParts.push(userText) - - return contextParts.join("\n") + return buildChatPrefix(userText, visibleCodeContext?.editors) } public cleanSuggestion(suggestion: string, userText: string): string { - let cleaned = postprocessAutocompleteSuggestion({ + const cleaned = postprocessAutocompleteSuggestion({ suggestion: removePrefixOverlap(suggestion, userText), prefix: userText, - suffix: "", // Chat textarea has no suffix + suffix: "", model: this.model.getModelName() ?? "unknown", }) - - if (cleaned === undefined) { - return "" - } - - // Filter suggestions that look like code rather than natural language - if (cleaned.match(/^(\/\/|\/\*|\*|#)/)) { - return "" - } - - // Chat-specific: truncate at first newline for single-line suggestions - const firstNewline = cleaned.indexOf("\n") - if (firstNewline !== -1) { - cleaned = cleaned.substring(0, firstNewline) - } - cleaned = cleaned.trimEnd() - - return cleaned + if (cleaned === undefined) return "" + return finalizeChatSuggestion(cleaned) } } diff --git a/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/chat-autocomplete-utils.ts b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/chat-autocomplete-utils.ts new file mode 100644 index 000000000..110efbb38 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/chat-autocomplete/chat-autocomplete-utils.ts @@ -0,0 +1,45 @@ +/** + * Apply chat-specific post-processing to a suggestion: + * - Filter suggestions that look like code comments + * - Truncate at first newline (chat is single-line) + * - Trim trailing whitespace + * Returns empty string when the suggestion should be discarded. + */ +export function finalizeChatSuggestion(cleaned: string): string { + if (!cleaned) return "" + + if (cleaned.match(/^(\/\/|\/\*|\*|#)/)) { + return "" + } + + const firstNewline = cleaned.indexOf("\n") + const truncated = firstNewline !== -1 ? cleaned.substring(0, firstNewline) : cleaned + return truncated.trimEnd() +} + +/** + * Build the prefix string for a chat completion request from user text and visible code context. + */ +export function buildChatPrefix( + userText: string, + editors?: Array<{ + filePath: string + languageId: string + visibleRanges: Array<{ content: string }> + }>, +): string { + const parts: string[] = [] + if (editors && editors.length > 0) { + parts.push("// Code visible in editor:") + for (const editor of editors) { + const fileName = editor.filePath.split("/").pop() || editor.filePath + parts.push(`\n// File: ${fileName} (${editor.languageId})`) + for (const range of editor.visibleRanges) { + parts.push(range.content) + } + } + } + parts.push("\n// User's message:") + parts.push(userText) + return parts.join("\n") +} diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts index bd39c826d..a7aac27ce 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteInlineCompletionProvider.ts @@ -13,6 +13,15 @@ import { AutocompleteContext, LastSuggestionInfo, } from "../types" +import { + findMatchingSuggestion as _findMatchingSuggestion, + applyFirstLineOnly as _applyFirstLineOnly, + countLines as _countLines, + shouldShowOnlyFirstLine as _shouldShowOnlyFirstLine, + getFirstLine as _getFirstLine, + calcDebounceDelay, + MatchingSuggestionWithFillIn as _MatchingSuggestionWithFillIn, +} from "./inline-utils" import { HoleFiller } from "./HoleFiller" import { FimPromptBuilder } from "./FillInTheMiddle" import { AutocompleteModel } from "../AutocompleteModel" @@ -59,101 +68,21 @@ const LATENCY_SAMPLE_SIZE = 10 export type { CostTrackingCallback, AutocompletePrompt, MatchingSuggestionResult, LLMRetrievalResult } -/** - * Result from findMatchingSuggestion including the original suggestion for telemetry tracking - */ -export interface MatchingSuggestionWithFillIn extends MatchingSuggestionResult { - /** The original FillInAtCursorSuggestion for telemetry tracking */ - fillInAtCursor: FillInAtCursorSuggestion -} +export type MatchingSuggestionWithFillIn = _MatchingSuggestionWithFillIn -/** - * Find a matching suggestion from the history based on current prefix and suffix. - * - * @param prefix - The text before the cursor position - * @param suffix - The text after the cursor position - * @param suggestionsHistory - Array of previous suggestions (most recent last) - * @returns The matching suggestion with match type and the original FillInAtCursorSuggestion, or null if no match found - */ export function findMatchingSuggestion( prefix: string, suffix: string, suggestionsHistory: FillInAtCursorSuggestion[], ): MatchingSuggestionWithFillIn | null { - // Search from most recent to least recent - for (let i = suggestionsHistory.length - 1; i >= 0; i--) { - const fillInAtCursor = suggestionsHistory[i] - - // First, try exact prefix/suffix match - if (prefix === fillInAtCursor.prefix && suffix === fillInAtCursor.suffix) { - return { - text: fillInAtCursor.text, - matchType: "exact", - fillInAtCursor, - } - } - - // If no exact match, but suggestion is available, check for partial typing - // The user may have started typing the suggested text - if (fillInAtCursor.text !== "" && prefix.startsWith(fillInAtCursor.prefix) && suffix === fillInAtCursor.suffix) { - // Extract what the user has typed between the original prefix and current position - const typedContent = prefix.substring(fillInAtCursor.prefix.length) - - // Check if the typed content matches the beginning of the suggestion - if (fillInAtCursor.text.startsWith(typedContent)) { - // Return the remaining part of the suggestion (with already-typed portion removed) - return { - text: fillInAtCursor.text.substring(typedContent.length), - matchType: "partial_typing", - fillInAtCursor, - } - } - } - - // Check for backward deletion: user deleted characters from the end of the prefix - // The stored prefix should start with the current prefix (current is shorter) - // Only use this logic if the original suggestion is non-empty - if (fillInAtCursor.text !== "" && fillInAtCursor.prefix.startsWith(prefix) && suffix === fillInAtCursor.suffix) { - // Extract the deleted portion of the prefix - const deletedContent = fillInAtCursor.prefix.substring(prefix.length) - - // Return the deleted portion plus the original suggestion text - return { - text: deletedContent + fillInAtCursor.text, - matchType: "backward_deletion", - fillInAtCursor, - } - } - } - - return null + return _findMatchingSuggestion(prefix, suffix, suggestionsHistory) } -/** - * Transforms a matching suggestion result by applying first-line-only logic if needed. - * Use this at call sites where you want to show only the first line of multi-line completions - * when the cursor is in the middle of a line. - * - * @param result - The result from findMatchingSuggestion - * @param prefix - The text before the cursor position - * @returns A new result with potentially truncated text, or null if input was null - */ export function applyFirstLineOnly( result: MatchingSuggestionWithFillIn | null, prefix: string, ): MatchingSuggestionWithFillIn | null { - if (result === null || result.text === "") { - return result - } - if (shouldShowOnlyFirstLine(prefix, result.text)) { - const firstLineText = getFirstLine(result.text) - return { - text: firstLineText, - matchType: result.matchType, - fillInAtCursor: result.fillInAtCursor, - } - } - return result + return _applyFirstLineOnly(result, prefix) } /** @@ -162,76 +91,16 @@ export function applyFirstLineOnly( */ export const INLINE_COMPLETION_ACCEPTED_COMMAND = "kilocode.autocomplete.inline-completion.accepted" -/** - * Counts the number of lines in a text string. - * - * Notes: - * - Returns 0 for an empty string - * - A single trailing newline (or CRLF) does not count as an additional line - * - * @param text - The text to count lines in - * @returns The number of lines - */ export function countLines(text: string): number { - if (text === "") { - return 0 - } - - // Count line breaks and add 1 for the first line. - // If the text ends with a line break, don't count the implicit trailing empty line. - const lineBreakCount = (text.match(/\r?\n/g) || []).length - const endsWithLineBreak = text.endsWith("\n") - - return lineBreakCount + 1 - (endsWithLineBreak ? 1 : 0) + return _countLines(text) } -/** - * Determines if only the first line of a completion should be shown. - * - * The logic is: - * - If the suggestion starts with a newline → show the whole block - * - If the prefix's last line has non-whitespace text → show only the first line - * - If at start of line and suggestion is 3+ lines → show only the first line - * - Otherwise → show the whole block - * - * @param prefix - The text before the cursor position - * @param suggestion - The completion text being suggested - * @returns true if only the first line should be shown - */ export function shouldShowOnlyFirstLine(prefix: string, suggestion: string): boolean { - // If the suggestion starts with a newline, show the whole block - if (suggestion.startsWith("\n") || suggestion.startsWith("\r\n")) { - return false - } - - // Check if the current line (before cursor) has non-whitespace text - const lastNewlineIndex = prefix.lastIndexOf("\n") - const currentLinePrefix = prefix.slice(lastNewlineIndex + 1) - - // if the first line contains no word characters, show the whole block - if (!currentLinePrefix.match(/\w/)) { - return false - } - - // If the current line prefix contains non-whitespace, only show the first line - if (currentLinePrefix.trim().length > 0) { - return true - } - - // At start of line (only whitespace before cursor on this line) - // Show only first line if suggestion is 3 or more lines - const lineCount = countLines(suggestion) - return lineCount >= 3 + return _shouldShowOnlyFirstLine(prefix, suggestion) } -/** - * Extracts the first line from a completion text. - * - * @param text - The full completion text - * @returns The first line of the completion (without the newline) - */ export function getFirstLine(text: string): string { - return text.split(/\r?\n/, 1)[0] + return _getFirstLine(text) } export function stringToInlineCompletions(text: string, position: vscode.Position): vscode.InlineCompletionItem[] { @@ -398,19 +267,10 @@ export class AutocompleteInlineCompletionProvider implements vscode.InlineComple * @param latencyMs - The latency of the most recent request in milliseconds */ public recordLatency(latencyMs: number): void { - // Add the new latency to the history this.latencyHistory.push(latencyMs) - - // Remove oldest if we exceed the sample size if (this.latencyHistory.length > LATENCY_SAMPLE_SIZE) { this.latencyHistory.shift() - - // Once we have enough samples, update the debounce delay to the average - const sum = this.latencyHistory.reduce((acc, val) => acc + val, 0) - const averageLatency = Math.round(sum / this.latencyHistory.length) - - // Clamp the debounce delay between MIN and MAX - this.debounceDelayMs = Math.max(MIN_DEBOUNCE_DELAY_MS, Math.min(averageLatency, MAX_DEBOUNCE_DELAY_MS)) + this.debounceDelayMs = calcDebounceDelay(this.latencyHistory) } } diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteTelemetry.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteTelemetry.ts index 7dc1fb5cc..8c7328a56 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteTelemetry.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/AutocompleteTelemetry.ts @@ -1,14 +1,11 @@ import { TelemetryProxy, TelemetryEventName } from "../../telemetry" import type { AutocompleteContext, CacheMatchType, FillInAtCursorSuggestion } from "../types" +import { getSuggestionKey as _getSuggestionKey, insertWithLRUEviction } from "./telemetry-utils" export type { AutocompleteContext, CacheMatchType, FillInAtCursorSuggestion } -/** - * Generate a unique key for a suggestion based on its content and context. - * This key is used to track whether the same suggestion is still being displayed. - */ export function getSuggestionKey(suggestion: FillInAtCursorSuggestion): string { - return `${suggestion.prefix}|${suggestion.suffix}|${suggestion.text}` + return _getSuggestionKey(suggestion) } /** @@ -64,14 +61,7 @@ export class AutocompleteTelemetry { private firedUniqueTelemetryKeys: Map = new Map() private markSuggestionKeyAsFired(suggestionKey: string): void { - this.firedUniqueTelemetryKeys.set(suggestionKey, true) - - if (this.firedUniqueTelemetryKeys.size > MAX_FIRED_UNIQUE_TELEMETRY_KEYS) { - const oldestKey = this.firedUniqueTelemetryKeys.keys().next().value as string | undefined - if (oldestKey) { - this.firedUniqueTelemetryKeys.delete(oldestKey) - } - } + insertWithLRUEviction(this.firedUniqueTelemetryKeys, suggestionKey, MAX_FIRED_UNIQUE_TELEMETRY_KEYS) } /** diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts index 1e67e83e9..9d73504f8 100644 --- a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/HoleFiller.ts @@ -5,6 +5,7 @@ import { FillInAtCursorSuggestion, ChatCompletionResult, } from "../types" +import { parseAutocompleteResponse as _parseAutocompleteResponse } from "./hole-filler-utils" import { getProcessedSnippets } from "./getProcessedSnippets" import { formatSnippets } from "../continuedev/core/autocomplete/templating/formatting" import { AutocompleteModel, ApiStreamChunk } from "../AutocompleteModel" @@ -20,24 +21,7 @@ export function parseAutocompleteResponse( prefix: string, suffix: string, ): FillInAtCursorSuggestion { - let fimText: string = "" - - // Match content strictly between and tags - const completionMatch = fullResponse.match(/([\s\S]*?)<\/COMPLETION>/i) - - if (completionMatch) { - // Extract the captured group (content between tags) - fimText = completionMatch[1] || "" - } - // Remove any accidentally captured tag remnants - fimText = fimText.replace(/<\/?COMPLETION>/gi, "") - - // Return FillInAtCursorSuggestion with the text (empty string if nothing found) - return { - text: fimText, - prefix, - suffix, - } + return _parseAutocompleteResponse(fullResponse, prefix, suffix) } export class HoleFiller { @@ -200,8 +184,7 @@ Return the COMPLETION tags` const usageInfo = await model.generateResponse(systemPrompt, userPrompt, onChunk) // Extract just the text from the response - prefix/suffix are handled by the caller - const completionMatch = response.match(/([\s\S]*?)<\/COMPLETION>/i) - const suggestionText = completionMatch ? (completionMatch[1] || "").replace(/<\/?COMPLETION>/gi, "") : "" + const { text: suggestionText } = _parseAutocompleteResponse(response, "", "") const fillInAtCursorSuggestion = processSuggestion(suggestionText) diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/hole-filler-utils.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/hole-filler-utils.ts new file mode 100644 index 000000000..7db068bc6 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/hole-filler-utils.ts @@ -0,0 +1,19 @@ +import type { FillInAtCursorSuggestion } from "../types" + +/** + * Parse a chat completion response and extract the text between tags. + * Returns a FillInAtCursorSuggestion with the extracted text, or empty string if not found. + */ +export function parseAutocompleteResponse( + fullResponse: string, + prefix: string, + suffix: string, +): FillInAtCursorSuggestion { + let fimText = "" + const completionMatch = fullResponse.match(/([\s\S]*?)<\/COMPLETION>/i) + if (completionMatch) { + fimText = completionMatch[1] || "" + } + fimText = fimText.replace(/<\/?COMPLETION>/gi, "") + return { text: fimText, prefix, suffix } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/inline-utils.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/inline-utils.ts new file mode 100644 index 000000000..b570605e7 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/inline-utils.ts @@ -0,0 +1,96 @@ +import type { FillInAtCursorSuggestion, MatchingSuggestionResult } from "../types" + +export interface MatchingSuggestionWithFillIn extends MatchingSuggestionResult { + fillInAtCursor: FillInAtCursorSuggestion +} + +const MIN_DEBOUNCE_DELAY_MS = 150 +const MAX_DEBOUNCE_DELAY_MS = 1000 + +/** + * Find a matching suggestion from history based on current prefix and suffix. + * Searches from most recent to least recent. + */ +export function findMatchingSuggestion( + prefix: string, + suffix: string, + suggestionsHistory: FillInAtCursorSuggestion[], +): MatchingSuggestionWithFillIn | null { + for (let i = suggestionsHistory.length - 1; i >= 0; i--) { + const fillInAtCursor = suggestionsHistory[i]! + + if (prefix === fillInAtCursor.prefix && suffix === fillInAtCursor.suffix) { + return { text: fillInAtCursor.text, matchType: "exact", fillInAtCursor } + } + + if (fillInAtCursor.text !== "" && prefix.startsWith(fillInAtCursor.prefix) && suffix === fillInAtCursor.suffix) { + const typedContent = prefix.substring(fillInAtCursor.prefix.length) + if (fillInAtCursor.text.startsWith(typedContent)) { + return { + text: fillInAtCursor.text.substring(typedContent.length), + matchType: "partial_typing", + fillInAtCursor, + } + } + } + + if (fillInAtCursor.text !== "" && fillInAtCursor.prefix.startsWith(prefix) && suffix === fillInAtCursor.suffix) { + const deletedContent = fillInAtCursor.prefix.substring(prefix.length) + return { text: deletedContent + fillInAtCursor.text, matchType: "backward_deletion", fillInAtCursor } + } + } + return null +} + +/** + * Counts the number of lines in a text string. + * A single trailing newline does not count as an additional line. + */ +export function countLines(text: string): number { + if (text === "") return 0 + const lineBreakCount = (text.match(/\r?\n/g) || []).length + const endsWithLineBreak = text.endsWith("\n") + return lineBreakCount + 1 - (endsWithLineBreak ? 1 : 0) +} + +/** + * Returns true if only the first line of a completion should be shown. + */ +export function shouldShowOnlyFirstLine(prefix: string, suggestion: string): boolean { + if (suggestion.startsWith("\n") || suggestion.startsWith("\r\n")) return false + const lastNewlineIndex = prefix.lastIndexOf("\n") + const currentLinePrefix = prefix.slice(lastNewlineIndex + 1) + if (!currentLinePrefix.match(/\w/)) return false + if (currentLinePrefix.trim().length > 0) return true + return countLines(suggestion) >= 3 +} + +/** Extracts the first line from a completion text. */ +export function getFirstLine(text: string): string { + return text.split(/\r?\n/, 1)[0]! +} + +/** + * Apply first-line-only logic to a matching suggestion result. + */ +export function applyFirstLineOnly( + result: MatchingSuggestionWithFillIn | null, + prefix: string, +): MatchingSuggestionWithFillIn | null { + if (result === null || result.text === "") return result + if (shouldShowOnlyFirstLine(prefix, result.text)) { + return { text: getFirstLine(result.text), matchType: result.matchType, fillInAtCursor: result.fillInAtCursor } + } + return result +} + +/** + * Calculate adaptive debounce delay from a latency history. + * Clamps result between MIN_DEBOUNCE_DELAY_MS and MAX_DEBOUNCE_DELAY_MS. + */ +export function calcDebounceDelay(latencyHistory: number[]): number { + if (latencyHistory.length === 0) return MIN_DEBOUNCE_DELAY_MS + const sum = latencyHistory.reduce((acc, v) => acc + v, 0) + const avg = Math.round(sum / latencyHistory.length) + return Math.max(MIN_DEBOUNCE_DELAY_MS, Math.min(avg, MAX_DEBOUNCE_DELAY_MS)) +} diff --git a/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/telemetry-utils.ts b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/telemetry-utils.ts new file mode 100644 index 000000000..1bf8805f5 --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/classic-auto-complete/telemetry-utils.ts @@ -0,0 +1,24 @@ +import type { FillInAtCursorSuggestion } from "../types" + +/** + * Generate a unique key for a suggestion based on its content and context. + * Used to deduplicate telemetry for the same suggestion shown multiple times. + */ +export function getSuggestionKey(suggestion: FillInAtCursorSuggestion): string { + return `${suggestion.prefix}|${suggestion.suffix}|${suggestion.text}` +} + +/** + * Insert a key into a Map used as a bounded LRU set. + * Evicts the oldest entry when the map exceeds `maxSize`. + * Returns the (possibly evicted) updated map. + */ +export function insertWithLRUEviction(map: Map, key: string, maxSize: number): void { + map.set(key, true) + if (map.size > maxSize) { + const oldest = map.keys().next().value as string | undefined + if (oldest !== undefined) { + map.delete(oldest) + } + } +} diff --git a/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts b/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts index 7003b04ed..dfe2b6a8f 100644 --- a/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts +++ b/packages/kilo-vscode/src/services/autocomplete/context/VisibleCodeTracker.ts @@ -18,6 +18,7 @@ function toRelativePath(absolutePath: string, workspacePath: string): string { } import { VisibleCodeContext, VisibleEditorInfo, VisibleRange, DiffInfo } from "../types" +import { extractDiffInfo as _extractDiffInfo } from "./visible-code-utils" // Git-related URI schemes that should be captured for diff support const GIT_SCHEMES = ["git", "gitfs", "file", "vscode-remote"] @@ -120,38 +121,6 @@ export class VisibleCodeTracker { * Git URIs typically look like: git:/path/to/file.ts?ref=HEAD~1 */ private extractDiffInfo(uri: vscode.Uri): DiffInfo | undefined { - const scheme = uri.scheme - - // Only extract diff info for git-related schemes - if (scheme === "git" || scheme === "gitfs") { - // Parse query parameters for git reference - const query = uri.query - let gitRef: string | undefined - - if (query) { - // Common patterns: ref=HEAD, ref=abc123 - const refMatch = query.match(/ref=([^&]+)/) - if (refMatch) { - gitRef = refMatch[1] - } - } - - return { - scheme, - side: "old", // Git scheme documents are typically the "old" side - gitRef, - originalPath: uri.fsPath, - } - } - - // File scheme in a diff view is the "new" side - // We can't always tell if it's in a diff, so we mark it as new when there's a paired git doc - if (scheme === "file") { - // This will be marked as diffInfo only if we detect it's paired with a git document - // For now, we don't set diffInfo for regular file scheme documents - return undefined - } - - return undefined + return _extractDiffInfo(uri.scheme, uri.query, uri.fsPath) } } diff --git a/packages/kilo-vscode/src/services/autocomplete/context/visible-code-utils.ts b/packages/kilo-vscode/src/services/autocomplete/context/visible-code-utils.ts new file mode 100644 index 000000000..53f5c02da --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/context/visible-code-utils.ts @@ -0,0 +1,19 @@ +import type { DiffInfo } from "../types" + +/** + * Extract git diff metadata from a URI. + * Returns DiffInfo for git/gitfs scheme URIs, undefined for regular file URIs. + */ +export function extractDiffInfo(scheme: string, query: string, fsPath: string): DiffInfo | undefined { + if (scheme === "git" || scheme === "gitfs") { + let gitRef: string | undefined + if (query) { + const refMatch = query.match(/ref=([^&]+)/) + if (refMatch) { + gitRef = refMatch[1] + } + } + return { scheme, side: "old", gitRef, originalPath: fsPath } + } + return undefined +} diff --git a/packages/kilo-vscode/src/services/autocomplete/statusbar-utils.ts b/packages/kilo-vscode/src/services/autocomplete/statusbar-utils.ts new file mode 100644 index 000000000..6fb98fe9b --- /dev/null +++ b/packages/kilo-vscode/src/services/autocomplete/statusbar-utils.ts @@ -0,0 +1,24 @@ +import { t } from "./shims/i18n" + +/** + * Format a session cost value to a human-readable string. + * - $0 → translated zero string + * - $0.001 → translated "less than a cent" + * - $0.12 → "$0.12" + */ +export function humanFormatSessionCost(cost: number): string { + if (cost === 0) { + return t("kilocode:autocomplete.statusBar.cost.zero") + } + if (cost > 0 && cost < 0.01) { + return t("kilocode:autocomplete.statusBar.cost.lessThanCent") + } + return `$${cost.toFixed(2)}` +} + +/** + * Format a Unix timestamp (ms) as a locale time string. + */ +export function formatTime(timestamp: number): string { + return new Date(timestamp).toLocaleTimeString() +} diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index 004585bd7..2012950b6 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -3,11 +3,13 @@ import { ServerManager } from "./server-manager" import { HttpClient } from "./http-client" import { SSEClient } from "./sse-client" import type { ServerConfig, SSEEvent } from "./types" +import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" type SSEEventListener = (event: SSEEvent) => void type StateListener = (state: ConnectionState) => void type SSEEventFilter = (event: SSEEvent) => boolean +type NotificationDismissListener = (notificationId: string) => void /** * Shared connection service that owns the single ServerManager, HttpClient, and SSEClient. @@ -24,6 +26,7 @@ export class KiloConnectionService { private readonly eventListeners: Set = new Set() private readonly stateListeners: Set = new Set() + private readonly notificationDismissListeners: Set = new Set() /** * Shared mapping used to resolve session scope for events that don't reliably include a sessionID. @@ -131,35 +134,29 @@ export class KiloConnectionService { * Returns undefined for global events. */ resolveEventSessionId(event: SSEEvent): string | undefined { - switch (event.type) { - case "session.created": - case "session.updated": - return event.properties.info.id - case "session.status": - case "session.idle": - case "todo.updated": - return event.properties.sessionID - case "message.updated": - this.recordMessageSessionId(event.properties.info.id, event.properties.info.sessionID) - return event.properties.info.sessionID - case "message.part.updated": { - const part = event.properties.part as { messageID?: string; sessionID?: string } - if (part.sessionID) { - return part.sessionID - } - if (!part.messageID) { - return undefined - } - return this.messageSessionIdsByMessageId.get(part.messageID) - } - case "permission.asked": - case "permission.replied": - case "question.asked": - case "question.replied": - case "question.rejected": - return event.properties.sessionID - default: - return undefined + return resolveEventSessionIdPure( + event, + (messageId) => this.messageSessionIdsByMessageId.get(messageId), + (messageId, sessionId) => this.recordMessageSessionId(messageId, sessionId), + ) + } + + /** + * Subscribe to notification dismiss events broadcast from any KiloProvider. Returns unsubscribe function. + */ + onNotificationDismissed(listener: NotificationDismissListener): () => void { + this.notificationDismissListeners.add(listener) + return () => { + this.notificationDismissListeners.delete(listener) + } + } + + /** + * Broadcast a notification dismiss event to all subscribed KiloProvider instances. + */ + notifyNotificationDismissed(notificationId: string): void { + for (const listener of this.notificationDismissListeners) { + listener(notificationId) } } @@ -181,6 +178,7 @@ export class KiloConnectionService { this.serverManager.dispose() this.eventListeners.clear() this.stateListeners.clear() + this.notificationDismissListeners.clear() this.messageSessionIdsByMessageId.clear() this.client = null this.sseClient = null diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts new file mode 100644 index 000000000..b6cc01d84 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -0,0 +1,44 @@ +import type { SSEEvent } from "./types" + +/** + * Pure session ID resolution for SSE events. + * The lookupMessageSessionId callback is used for message.part.updated fallback lookup, + * and onMessageUpdated is called when message.updated is encountered so the caller can + * record the messageID -> sessionID mapping. + */ +export function resolveEventSessionId( + event: SSEEvent, + lookupMessageSessionId: (messageId: string) => string | undefined, + onMessageUpdated?: (messageId: string, sessionId: string) => void, +): string | undefined { + switch (event.type) { + case "session.created": + case "session.updated": + return event.properties.info.id + case "session.status": + case "session.idle": + case "todo.updated": + return event.properties.sessionID + case "message.updated": + onMessageUpdated?.(event.properties.info.id, event.properties.info.sessionID) + return event.properties.info.sessionID + case "message.part.updated": { + const part = event.properties.part as { messageID?: string; sessionID?: string } + if (part.sessionID) { + return part.sessionID + } + if (!part.messageID) { + return undefined + } + return lookupMessageSessionId(part.messageID) + } + case "permission.asked": + case "permission.replied": + case "question.asked": + case "question.replied": + case "question.rejected": + return event.properties.sessionID + default: + return undefined + } +} diff --git a/packages/kilo-vscode/src/services/cli-backend/http-client.ts b/packages/kilo-vscode/src/services/cli-backend/http-client.ts index 5d12b2f50..8892525c4 100644 --- a/packages/kilo-vscode/src/services/cli-backend/http-client.ts +++ b/packages/kilo-vscode/src/services/cli-backend/http-client.ts @@ -1,6 +1,7 @@ import type { ServerConfig, SessionInfo, + SessionStatusInfo, MessageInfo, MessagePart, AgentInfo, @@ -10,7 +11,9 @@ import type { McpStatus, McpConfig, Config, + KilocodeNotification, } from "./types" +import { extractHttpErrorMessage, parseSSEDataLine } from "./http-utils" /** * HTTP Client for communicating with the CLI backend server. @@ -23,8 +26,8 @@ export class HttpClient { constructor(config: ServerConfig) { this.baseUrl = config.baseUrl - // Auth header format: Basic base64("opencode:password") - // NOTE: The CLI server expects a non-empty username ("opencode"). Using an empty username + // Auth header format: Basic base64("kilo:password") + // NOTE: The CLI server expects a non-empty username ("kilo"). Using an empty username // (":password") results in 401 for both REST and SSE endpoints. this.authHeader = `Basic ${Buffer.from(`${this.authUsername}:${config.password}`).toString("base64")}` @@ -42,7 +45,7 @@ export class HttpClient { method: string, path: string, body?: unknown, - options?: { directory?: string; allowEmpty?: boolean }, + options?: { directory?: string; allowEmpty?: boolean; silent?: boolean; signal?: AbortSignal }, ): Promise { const url = `${this.baseUrl}${path}` @@ -59,6 +62,7 @@ export class HttpClient { method, headers, body: body !== undefined ? JSON.stringify(body) : undefined, + signal: options?.signal, }) // Read the raw response first so we can produce useful errors when JSON is empty/truncated. @@ -66,22 +70,16 @@ export class HttpClient { // Non-2xx: try to extract an error message from JSON, otherwise fall back to raw text. if (!response.ok) { - let errorMessage = response.statusText - if (rawText.trim().length > 0) { - try { - const errorJson = JSON.parse(rawText) as { error?: string; message?: string } - errorMessage = errorJson.error || errorJson.message || errorMessage - } catch { - errorMessage = rawText - } - } + const errorMessage = extractHttpErrorMessage(response.statusText, rawText) - console.error("[Kilo New] HTTP: ❌ Request failed", { - method, - path, - status: response.status, - errorMessage, - }) + if (!options?.silent) { + console.error("[Kilo New] HTTP: ❌ Request failed", { + method, + path, + status: response.status, + errorMessage, + }) + } throw new Error(`HTTP ${response.status}: ${errorMessage}`) } @@ -127,9 +125,10 @@ export class HttpClient { /** * Get information about an existing session. + * Set silent to suppress error logging (e.g. for expected 404s on cross-worktree sessions). */ - async getSession(sessionId: string, directory: string): Promise { - return this.request("GET", `/session/${sessionId}`, undefined, { directory }) + async getSession(sessionId: string, directory: string, silent?: boolean): Promise { + return this.request("GET", `/session/${sessionId}`, undefined, { directory, silent }) } /** @@ -139,6 +138,14 @@ export class HttpClient { return this.request("GET", "/session", undefined, { directory }) } + /** + * Get the status of all sessions. + * Returns a map of sessionID → SessionStatusInfo. + */ + async getSessionStatuses(directory: string): Promise> { + return this.request>("GET", "/session/status", undefined, { directory }) + } + /** * Delete a session permanently. */ @@ -207,7 +214,7 @@ export class HttpClient { sessionId: string, parts: Array<{ type: "text"; text: string } | { type: "file"; mime: string; url: string }>, directory: string, - options?: { providerID?: string; modelID?: string; agent?: string }, + options?: { providerID?: string; modelID?: string; agent?: string; variant?: string }, ): Promise { const body: Record = { parts } if (options?.providerID && options?.modelID) { @@ -217,6 +224,9 @@ export class HttpClient { if (options?.agent) { body.agent = options.agent } + if (options?.variant) { + body.variant = options.variant + } await this.request("POST", `/session/${sessionId}/message`, body, { directory, allowEmpty: true }) } @@ -224,12 +234,16 @@ export class HttpClient { /** * Get all messages for a session. */ - async getMessages(sessionId: string, directory: string): Promise> { + async getMessages( + sessionId: string, + directory: string, + signal?: AbortSignal, + ): Promise> { return this.request>( "GET", `/session/${sessionId}/message`, undefined, - { directory }, + { directory, signal }, ) } @@ -313,6 +327,19 @@ export class HttpClient { } } + /** + * Fetch Kilo notifications for the current user from the kilo-gateway. + * Returns an empty array if not logged in or if the request fails. + */ + async getNotifications(): Promise { + try { + return await this.request("GET", "/kilo/notifications") + } catch (err) { + console.warn("[Kilo] Failed to fetch notifications:", err) + return [] + } + } + /** * Switch the active organization. * Pass null to switch back to personal account. @@ -389,38 +416,12 @@ export class HttpClient { buffer = lines.pop() ?? "" // Keep incomplete line in buffer for (const line of lines) { - if (!line.startsWith("data: ")) { - continue - } - - const data = line.slice(6).trim() - if (data === "[DONE]") { - continue - } - - try { - const parsed = JSON.parse(data) as { - choices?: Array<{ delta?: { content?: string } }> - usage?: { prompt_tokens?: number; completion_tokens?: number } - cost?: number - } - - const content = parsed.choices?.[0]?.delta?.content - if (content) { - onChunk(content) - } - - if (parsed.usage) { - inputTokens = parsed.usage.prompt_tokens ?? 0 - outputTokens = parsed.usage.completion_tokens ?? 0 - } - - if (parsed.cost !== undefined) { - cost = parsed.cost - } - } catch { - // Skip malformed JSON lines - } + const chunk = parseSSEDataLine(line) + if (!chunk) continue + if (chunk.content) onChunk(chunk.content) + if (chunk.inputTokens !== undefined) inputTokens = chunk.inputTokens + if (chunk.outputTokens !== undefined) outputTokens = chunk.outputTokens + if (chunk.cost !== undefined) cost = chunk.cost } } @@ -469,6 +470,22 @@ export class HttpClient { return this.request("GET", `/find/file?${params.toString()}`, undefined, { directory }) } + // ============================================ + // Commit Message Methods + // ============================================ + + /** + * Generate a commit message for the current diff in the given directory. + */ + async generateCommitMessage(path: string, selectedFiles?: string[], previousMessage?: string): Promise { + const result = await this.request<{ message: string }>("POST", "/commit-message", { + path, + selectedFiles, + previousMessage, + }) + return result.message + } + // ============================================ // MCP Methods // ============================================ diff --git a/packages/kilo-vscode/src/services/cli-backend/http-utils.ts b/packages/kilo-vscode/src/services/cli-backend/http-utils.ts new file mode 100644 index 000000000..30a25fbbe --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/http-utils.ts @@ -0,0 +1,59 @@ +/** + * Extract a human-readable error message from an HTTP error response. + * Tries to parse JSON and look for `error` or `message` fields; falls back to raw text. + */ +export function extractHttpErrorMessage(statusText: string, rawText: string): string { + if (rawText.trim().length === 0) { + return statusText + } + try { + const errorJson = JSON.parse(rawText) as { error?: string; message?: string } + return errorJson.error || errorJson.message || statusText + } catch { + return rawText + } +} + +export type SSEChunkResult = { + content?: string + inputTokens?: number + outputTokens?: number + cost?: number +} + +/** + * Parse a single SSE data line (starting with "data: ") into its structured parts. + * Returns null for non-data lines and the [DONE] sentinel. + */ +export function parseSSEDataLine(line: string): SSEChunkResult | null { + if (!line.startsWith("data: ")) { + return null + } + const data = line.slice(6).trim() + if (data === "[DONE]") { + return null + } + try { + const parsed = JSON.parse(data) as { + choices?: Array<{ delta?: { content?: string } }> + usage?: { prompt_tokens?: number; completion_tokens?: number } + cost?: number + } + const result: SSEChunkResult = {} + const content = parsed.choices?.[0]?.delta?.content + if (content) { + result.content = content + } + if (parsed.usage) { + result.inputTokens = parsed.usage.prompt_tokens ?? 0 + result.outputTokens = parsed.usage.completion_tokens ?? 0 + } + if (parsed.cost !== undefined) { + result.cost = parsed.cost + } + return result + } catch (err) { + console.warn("[Kilo New] Failed to parse SSE data line", { err, line }) + return null + } +} diff --git a/packages/kilo-vscode/src/services/cli-backend/index.ts b/packages/kilo-vscode/src/services/cli-backend/index.ts index 8ceccad45..10e9ac968 100644 --- a/packages/kilo-vscode/src/services/cli-backend/index.ts +++ b/packages/kilo-vscode/src/services/cli-backend/index.ts @@ -26,6 +26,8 @@ export type { McpRemoteConfig, McpConfig, Config, + KilocodeNotification, + KilocodeNotificationAction, } from "./types" export { ServerManager } from "./server-manager" diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 679df2045..f2dd0fa7c 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -3,6 +3,7 @@ import * as crypto from "crypto" import * as fs from "fs" import * as path from "path" import * as vscode from "vscode" +import { parseServerPort } from "./server-utils" export interface ServerInstance { port: number @@ -85,11 +86,9 @@ export class ServerManager { const output = data.toString() console.log("[Kilo New] ServerManager: 📥 CLI Server stdout:", output) - // Parse: "kilo server listening on http://127.0.0.1:12345" - const match = output.match(/listening on http:\/\/[\w.]+:(\d+)/) - if (match && !resolved) { + const port = parseServerPort(output) + if (port !== null && !resolved) { resolved = true - const port = parseInt(match[1], 10) console.log("[Kilo New] ServerManager: 🎯 Port detected:", port) resolve({ port, password, process: serverProcess }) } diff --git a/packages/kilo-vscode/src/services/cli-backend/server-utils.ts b/packages/kilo-vscode/src/services/cli-backend/server-utils.ts new file mode 100644 index 000000000..0daf711d0 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/server-utils.ts @@ -0,0 +1,10 @@ +/** + * Parse the port number from CLI server startup output. + * Matches lines like: "kilo server listening on http://127.0.0.1:12345" + * Returns the port number or null if not found. + */ +export function parseServerPort(output: string): number | null { + const match = output.match(/listening on http:\/\/[\w.]+:(\d+)/) + if (!match) return null + return parseInt(match[1]!, 10) +} diff --git a/packages/kilo-vscode/src/services/cli-backend/sse-client.ts b/packages/kilo-vscode/src/services/cli-backend/sse-client.ts index 69d0a4561..ed80b4c59 100644 --- a/packages/kilo-vscode/src/services/cli-backend/sse-client.ts +++ b/packages/kilo-vscode/src/services/cli-backend/sse-client.ts @@ -1,5 +1,6 @@ import EventSource from "eventsource" import type { ServerConfig, SSEEvent } from "./types" +import { unwrapSSEPayload } from "./sse-utils" // Type definitions for handlers export type SSEEventHandler = (event: SSEEvent) => void @@ -67,9 +68,8 @@ export class SSEClient { console.log("[Kilo New] SSE: 📨 Received message event:", messageEvent.data) try { const raw = JSON.parse(messageEvent.data) - // Global endpoint wraps events as { directory, payload: { type, properties } } - const event = (raw.payload ?? raw) as SSEEvent - if (!event.type) { + const event = unwrapSSEPayload(raw) + if (!event) { console.warn("[Kilo New] SSE: ⚠️ Received event without type:", raw) return } diff --git a/packages/kilo-vscode/src/services/cli-backend/sse-utils.ts b/packages/kilo-vscode/src/services/cli-backend/sse-utils.ts new file mode 100644 index 000000000..0d77a41f9 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/sse-utils.ts @@ -0,0 +1,16 @@ +import type { SSEEvent } from "./types" + +/** + * Unwrap an SSE message payload. + * The global /global/event endpoint wraps events as { directory, payload: SSEEvent }. + * Direct event endpoints return the SSEEvent directly. + * Returns null if the parsed data has no `type` field (malformed or unknown event). + */ +export function unwrapSSEPayload(raw: unknown): SSEEvent | null { + if (!raw || typeof raw !== "object") return null + const event = ((raw as { payload?: SSEEvent }).payload ?? raw) as SSEEvent + if (!event || typeof event !== "object" || !("type" in event)) { + return null + } + return event +} diff --git a/packages/kilo-vscode/src/services/cli-backend/types.ts b/packages/kilo-vscode/src/services/cli-backend/types.ts index 06c3a9075..bf143c714 100644 --- a/packages/kilo-vscode/src/services/cli-backend/types.ts +++ b/packages/kilo-vscode/src/services/cli-backend/types.ts @@ -139,6 +139,8 @@ export interface ProviderModel { latest?: boolean // Actual shape returned by the server (Provider.Model) limit?: { context: number; input?: number; output: number } + variants?: Record> + capabilities?: { reasoning: boolean } } // Provider definition @@ -174,6 +176,20 @@ export interface ProviderAuthAuthorization { instructions: string } +// Kilo notification from kilo-gateway +export interface KilocodeNotificationAction { + actionText: string + actionURL: string +} + +export interface KilocodeNotification { + id: string + title: string + message: string + action?: KilocodeNotificationAction + showIn?: string[] +} + // Profile types from kilo-gateway export interface KilocodeOrganization { id: string diff --git a/packages/kilo-vscode/src/services/code-actions/code-action-provider.ts b/packages/kilo-vscode/src/services/code-actions/code-action-provider.ts new file mode 100644 index 000000000..ddbaf025e --- /dev/null +++ b/packages/kilo-vscode/src/services/code-actions/code-action-provider.ts @@ -0,0 +1,42 @@ +import * as vscode from "vscode" + +export class KiloCodeActionProvider implements vscode.CodeActionProvider { + static readonly metadata: vscode.CodeActionProviderMetadata = { + providedCodeActionKinds: [vscode.CodeActionKind.QuickFix, vscode.CodeActionKind.RefactorRewrite], + } + + provideCodeActions( + document: vscode.TextDocument, + range: vscode.Range | vscode.Selection, + context: vscode.CodeActionContext, + ): vscode.CodeAction[] { + if (range.isEmpty) return [] + + const actions: vscode.CodeAction[] = [] + + const add = new vscode.CodeAction("Add to Kilo Code", vscode.CodeActionKind.RefactorRewrite) + add.command = { command: "kilo-code.new.addToContext", title: "Add to Kilo Code" } + actions.push(add) + + const hasDiagnostics = context.diagnostics.length > 0 + + if (hasDiagnostics) { + const fix = new vscode.CodeAction("Fix with Kilo Code", vscode.CodeActionKind.QuickFix) + fix.command = { command: "kilo-code.new.fixCode", title: "Fix with Kilo Code" } + fix.isPreferred = true + actions.push(fix) + } + + if (!hasDiagnostics) { + const explain = new vscode.CodeAction("Explain with Kilo Code", vscode.CodeActionKind.RefactorRewrite) + explain.command = { command: "kilo-code.new.explainCode", title: "Explain with Kilo Code" } + actions.push(explain) + + const improve = new vscode.CodeAction("Improve with Kilo Code", vscode.CodeActionKind.RefactorRewrite) + improve.command = { command: "kilo-code.new.improveCode", title: "Improve with Kilo Code" } + actions.push(improve) + } + + return actions + } +} diff --git a/packages/kilo-vscode/src/services/code-actions/editor-utils.ts b/packages/kilo-vscode/src/services/code-actions/editor-utils.ts new file mode 100644 index 000000000..a2017cb11 --- /dev/null +++ b/packages/kilo-vscode/src/services/code-actions/editor-utils.ts @@ -0,0 +1,24 @@ +import * as vscode from "vscode" + +export interface EditorContext { + filePath: string + selectedText: string + startLine: number + endLine: number + diagnostics: vscode.Diagnostic[] +} + +export function getEditorContext(): EditorContext | undefined { + const editor = vscode.window.activeTextEditor + if (!editor) return undefined + const selection = editor.selection + if (selection.isEmpty) return undefined + const doc = editor.document + return { + filePath: vscode.workspace.asRelativePath(doc.uri), + selectedText: doc.getText(selection), + startLine: selection.start.line + 1, + endLine: selection.end.line + 1, + diagnostics: vscode.languages.getDiagnostics(doc.uri).filter((d) => d.range.intersection(selection) !== undefined), + } +} diff --git a/packages/kilo-vscode/src/services/code-actions/index.ts b/packages/kilo-vscode/src/services/code-actions/index.ts new file mode 100644 index 000000000..442a332e9 --- /dev/null +++ b/packages/kilo-vscode/src/services/code-actions/index.ts @@ -0,0 +1,3 @@ +export { registerCodeActions } from "./register-code-actions" +export { registerTerminalActions } from "./register-terminal-actions" +export { KiloCodeActionProvider } from "./code-action-provider" diff --git a/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts new file mode 100644 index 000000000..ef5df137d --- /dev/null +++ b/packages/kilo-vscode/src/services/code-actions/register-code-actions.ts @@ -0,0 +1,72 @@ +import * as vscode from "vscode" +import type { KiloProvider } from "../../KiloProvider" +import type { AgentManagerProvider } from "../../agent-manager/AgentManagerProvider" +import { getEditorContext } from "./editor-utils" +import { createPrompt } from "./support-prompt" + +export function registerCodeActions( + context: vscode.ExtensionContext, + provider: KiloProvider, + agentManager?: AgentManagerProvider, +): void { + const target = () => (agentManager?.isActive() ? agentManager : provider) + + context.subscriptions.push( + vscode.commands.registerCommand("kilo-code.new.explainCode", () => { + const ctx = getEditorContext() + if (!ctx) return + const prompt = createPrompt("EXPLAIN", { + filePath: ctx.filePath, + startLine: String(ctx.startLine), + endLine: String(ctx.endLine), + selectedText: ctx.selectedText, + userInput: "", + }) + provider.postMessage({ type: "triggerTask", text: prompt }) + }), + + vscode.commands.registerCommand("kilo-code.new.fixCode", () => { + const ctx = getEditorContext() + if (!ctx) return + const prompt = createPrompt("FIX", { + filePath: ctx.filePath, + startLine: String(ctx.startLine), + endLine: String(ctx.endLine), + selectedText: ctx.selectedText, + diagnostics: ctx.diagnostics, + userInput: "", + }) + provider.postMessage({ type: "triggerTask", text: prompt }) + }), + + vscode.commands.registerCommand("kilo-code.new.improveCode", () => { + const ctx = getEditorContext() + if (!ctx) return + const prompt = createPrompt("IMPROVE", { + filePath: ctx.filePath, + startLine: String(ctx.startLine), + endLine: String(ctx.endLine), + selectedText: ctx.selectedText, + userInput: "", + }) + provider.postMessage({ type: "triggerTask", text: prompt }) + }), + + vscode.commands.registerCommand("kilo-code.new.addToContext", () => { + const ctx = getEditorContext() + if (!ctx) return + const prompt = createPrompt("ADD_TO_CONTEXT", { + filePath: ctx.filePath, + startLine: String(ctx.startLine), + endLine: String(ctx.endLine), + selectedText: ctx.selectedText, + }) + target().postMessage({ type: "setChatBoxMessage", text: prompt }) + target().postMessage({ type: "action", action: "focusInput" }) + }), + + vscode.commands.registerCommand("kilo-code.new.focusChatInput", () => { + target().postMessage({ type: "action", action: "focusInput" }) + }), + ) +} diff --git a/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts b/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts new file mode 100644 index 000000000..1633db946 --- /dev/null +++ b/packages/kilo-vscode/src/services/code-actions/register-terminal-actions.ts @@ -0,0 +1,57 @@ +import * as vscode from "vscode" +import type { KiloProvider } from "../../KiloProvider" +import { createPrompt } from "./support-prompt" + +function getTerminalSelection(): string { + const terminal = vscode.window.activeTerminal + if (!terminal) return "" + // VS Code terminal API doesn't expose buffer contents directly. + // Terminal selection is available via clipboard in some cases. + // For now, this is a placeholder — full implementation requires + // VS Code shell integration API (terminal.shellIntegration). + return "" +} + +export function registerTerminalActions(context: vscode.ExtensionContext, provider: KiloProvider): void { + context.subscriptions.push( + vscode.commands.registerCommand("kilo-code.new.terminalAddToContext", () => { + const content = getTerminalSelection() + if (!content) { + vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.") + return + } + const prompt = createPrompt("TERMINAL_ADD_TO_CONTEXT", { + terminalContent: content, + userInput: "", + }) + provider.postMessage({ type: "setChatBoxMessage", text: prompt }) + provider.postMessage({ type: "action", action: "focusInput" }) + }), + + vscode.commands.registerCommand("kilo-code.new.terminalFixCommand", () => { + const content = getTerminalSelection() + if (!content) { + vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.") + return + } + const prompt = createPrompt("TERMINAL_FIX", { + terminalContent: content, + userInput: "", + }) + provider.postMessage({ type: "triggerTask", text: prompt }) + }), + + vscode.commands.registerCommand("kilo-code.new.terminalExplainCommand", () => { + const content = getTerminalSelection() + if (!content) { + vscode.window.showInformationMessage("No terminal content available. Select text in the terminal first.") + return + } + const prompt = createPrompt("TERMINAL_EXPLAIN", { + terminalContent: content, + userInput: "", + }) + provider.postMessage({ type: "triggerTask", text: prompt }) + }), + ) +} diff --git a/packages/kilo-vscode/src/services/code-actions/support-prompt.ts b/packages/kilo-vscode/src/services/code-actions/support-prompt.ts new file mode 100644 index 000000000..3a23b392e --- /dev/null +++ b/packages/kilo-vscode/src/services/code-actions/support-prompt.ts @@ -0,0 +1,107 @@ +type Params = Record + +function diagnosticText(diagnostics?: any[]) { + if (!diagnostics?.length) return "" + return `\nCurrent problems detected:\n${diagnostics + .map((d) => `- [${d.source || "Error"}] ${d.message}${d.code ? ` (${d.code})` : ""}`) + .join("\n")}` +} + +function fill(template: string, params: Params): string { + return template.replace(/\${(.*?)}/g, (_, key) => { + if (key === "diagnosticText") return diagnosticText(params["diagnostics"] as any[]) + if (key in params) return String(params[key] ?? "") + return "" + }) +} + +type PromptType = + | "EXPLAIN" + | "FIX" + | "IMPROVE" + | "ADD_TO_CONTEXT" + | "TERMINAL_ADD_TO_CONTEXT" + | "TERMINAL_FIX" + | "TERMINAL_EXPLAIN" + +const templates: Record = { + EXPLAIN: `Explain the following code from file path \${filePath}:\${startLine}-\${endLine} +\${userInput} + +\`\`\` +\${selectedText} +\`\`\` + +Please provide a clear and concise explanation of what this code does, including: +1. The purpose and functionality +2. Key components and their interactions +3. Important patterns or techniques used`, + + FIX: `Fix any issues in the following code from file path \${filePath}:\${startLine}-\${endLine} +\${diagnosticText} +\${userInput} + +\`\`\` +\${selectedText} +\`\`\` + +Please: +1. Address all detected problems listed above (if any) +2. Identify any other potential bugs or issues +3. Provide corrected code +4. Explain what was fixed and why`, + + IMPROVE: `Improve the following code from file path \${filePath}:\${startLine}-\${endLine} +\${userInput} + +\`\`\` +\${selectedText} +\`\`\` + +Please suggest improvements for: +1. Code readability and maintainability +2. Performance optimization +3. Best practices and patterns +4. Error handling and edge cases + +Provide the improved code along with explanations for each enhancement.`, + + ADD_TO_CONTEXT: `\${filePath}:\${startLine}-\${endLine} +\`\`\` +\${selectedText} +\`\`\``, + + TERMINAL_ADD_TO_CONTEXT: `\${userInput} +Terminal output: +\`\`\` +\${terminalContent} +\`\`\``, + + TERMINAL_FIX: `\${userInput} +Fix this terminal command: +\`\`\` +\${terminalContent} +\`\`\` + +Please: +1. Identify any issues in the command +2. Provide the corrected command +3. Explain what was fixed and why`, + + TERMINAL_EXPLAIN: `\${userInput} +Explain this terminal command: +\`\`\` +\${terminalContent} +\`\`\` + +Please provide: +1. What the command does +2. Explanation of each part/flag +3. Expected output and behavior`, +} + +export function createPrompt(type: PromptType, params: Params): string { + return fill(templates[type], params) +} + +export type { PromptType } diff --git a/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts b/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts new file mode 100644 index 000000000..6afa163c8 --- /dev/null +++ b/packages/kilo-vscode/src/services/commit-message/__tests__/index.spec.ts @@ -0,0 +1,207 @@ +import { describe, it, expect, vi, beforeEach } from "vitest" + +// Mock vscode following the pattern from AutocompleteServiceManager.spec.ts +vi.mock("vscode", () => { + const disposable = { dispose: vi.fn() } + + return { + commands: { + registerCommand: vi.fn((_command: string, _callback: (...args: any[]) => any) => disposable), + }, + window: { + showErrorMessage: vi.fn(), + withProgress: vi.fn(), + }, + workspace: { + workspaceFolders: [ + { + uri: { fsPath: "/test/workspace" }, + }, + ], + }, + extensions: { + getExtension: vi.fn(), + }, + ProgressLocation: { + SourceControl: 1, + }, + Uri: { + parse: (s: string) => ({ fsPath: s }), + }, + } +}) + +import * as vscode from "vscode" +import { registerCommitMessageService } from "../index" +import type { KiloConnectionService } from "../../cli-backend/connection-service" + +describe("commit-message service", () => { + let mockContext: vscode.ExtensionContext + let mockConnectionService: KiloConnectionService + let mockHttpClient: { generateCommitMessage: ReturnType } + + beforeEach(() => { + vi.clearAllMocks() + + mockContext = { + subscriptions: [], + } as any + + mockHttpClient = { + generateCommitMessage: vi.fn().mockResolvedValue("feat: add new feature"), + } + + mockConnectionService = { + getHttpClient: vi.fn().mockReturnValue(mockHttpClient), + } as any + }) + + describe("registerCommitMessageService", () => { + it("returns an array of disposables", () => { + const disposables = registerCommitMessageService(mockContext, mockConnectionService) + + expect(Array.isArray(disposables)).toBe(true) + expect(disposables.length).toBeGreaterThan(0) + }) + + it("registers the kilo-code.new.generateCommitMessage command", () => { + registerCommitMessageService(mockContext, mockConnectionService) + + expect(vscode.commands.registerCommand).toHaveBeenCalledWith( + "kilo-code.new.generateCommitMessage", + expect.any(Function), + ) + }) + + it("pushes the command disposable to context.subscriptions", () => { + registerCommitMessageService(mockContext, mockConnectionService) + + expect(mockContext.subscriptions.length).toBe(1) + }) + }) + + describe("command execution", () => { + let commandCallback: (...args: any[]) => Promise + + beforeEach(() => { + registerCommitMessageService(mockContext, mockConnectionService) + + // Extract the registered command callback + const registerCall = vi.mocked(vscode.commands.registerCommand).mock.calls[0]! + commandCallback = registerCall[1] as (...args: any[]) => Promise + }) + + it("shows error when git extension is not found", async () => { + vi.mocked(vscode.extensions.getExtension).mockReturnValue(undefined) + + await commandCallback() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Git extension not found") + }) + + it("shows error when no git repository is found", async () => { + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), + exports: { + getAPI: () => ({ repositories: [] }), + }, + } as any) + + await commandCallback() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No Git repository found") + }) + + it("shows error when backend is not connected", async () => { + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), + exports: { + getAPI: () => ({ + repositories: [{ inputBox: { value: "" }, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + vi.mocked(mockConnectionService.getHttpClient as any).mockImplementation(() => { + throw new Error("Not connected") + }) + + await commandCallback() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Kilo backend is not connected. Please wait for the connection to establish.", + ) + }) + + it("calls generateCommitMessage on the HTTP client with repository root path", async () => { + const mockInputBox = { value: "" } + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), + exports: { + getAPI: () => ({ + repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + + // Make withProgress execute its callback + vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => { + await task({} as any, {} as any) + }) + + await commandCallback() + + expect(mockHttpClient.generateCommitMessage).toHaveBeenCalledWith("/repo", undefined, undefined) + }) + + it("sets the generated message on the repository inputBox", async () => { + const mockInputBox = { value: "" } + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), + exports: { + getAPI: () => ({ + repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + + vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => { + await task({} as any, {} as any) + }) + + await commandCallback() + + expect(mockInputBox.value).toBe("feat: add new feature") + }) + + it("shows progress in SourceControl location", async () => { + const mockInputBox = { value: "" } + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + isActive: true, + activate: vi.fn().mockResolvedValue(undefined), + exports: { + getAPI: () => ({ + repositories: [{ inputBox: mockInputBox, rootUri: { fsPath: "/repo" } }], + }), + }, + } as any) + + vi.mocked(vscode.window.withProgress).mockImplementation(async (_options, task) => { + await task({} as any, {} as any) + }) + + await commandCallback() + + expect(vscode.window.withProgress).toHaveBeenCalledWith( + expect.objectContaining({ + location: vscode.ProgressLocation.SourceControl, + title: "Generating commit message...", + }), + expect.any(Function), + ) + }) + }) +}) diff --git a/packages/kilo-vscode/src/services/commit-message/index.ts b/packages/kilo-vscode/src/services/commit-message/index.ts new file mode 100644 index 000000000..825f36cee --- /dev/null +++ b/packages/kilo-vscode/src/services/commit-message/index.ts @@ -0,0 +1,79 @@ +import * as vscode from "vscode" +import type { KiloConnectionService } from "../cli-backend/connection-service" +import type { HttpClient } from "../cli-backend/http-client" + +let lastGeneratedMessage: string | undefined +let lastWorkspacePath: string | undefined + +interface GitRepository { + inputBox: { value: string } + rootUri: vscode.Uri +} + +interface GitAPI { + repositories: GitRepository[] +} + +interface GitExtensionExports { + getAPI(version: number): GitAPI +} + +export function registerCommitMessageService( + context: vscode.ExtensionContext, + connectionService: KiloConnectionService, +): vscode.Disposable[] { + const command = vscode.commands.registerCommand("kilo-code.new.generateCommitMessage", async () => { + const extension = vscode.extensions.getExtension("vscode.git") + if (!extension) { + vscode.window.showErrorMessage("Git extension not found") + return + } + + if (!extension.isActive) { + await extension.activate() + } + + const git = extension.exports?.getAPI(1) + const repository = git?.repositories[0] + if (!repository) { + vscode.window.showErrorMessage("No Git repository found") + return + } + + let client: HttpClient | undefined + try { + client = connectionService.getHttpClient() + } catch { + vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.") + return + } + if (!client) { + vscode.window.showErrorMessage("Kilo backend is not connected. Please wait for the connection to establish.") + return + } + + const path = repository.rootUri.fsPath + + const previousMessage = lastWorkspacePath === path ? lastGeneratedMessage : undefined + + await vscode.window + .withProgress( + { location: vscode.ProgressLocation.SourceControl, title: "Generating commit message..." }, + async () => { + const message = await client.generateCommitMessage(path, undefined, previousMessage) + repository.inputBox.value = message + lastGeneratedMessage = message + lastWorkspacePath = path + console.log("[Kilo New] Commit message generated successfully") + }, + ) + .then(undefined, (error: unknown) => { + const msg = error instanceof Error ? error.message : String(error) + console.error("[Kilo New] Failed to generate commit message:", msg) + vscode.window.showErrorMessage(`Failed to generate commit message: ${msg}`) + }) + }) + + context.subscriptions.push(command) + return [command] +} diff --git a/packages/kilo-vscode/src/services/telemetry/telemetry-proxy-utils.ts b/packages/kilo-vscode/src/services/telemetry/telemetry-proxy-utils.ts new file mode 100644 index 000000000..a20348a16 --- /dev/null +++ b/packages/kilo-vscode/src/services/telemetry/telemetry-proxy-utils.ts @@ -0,0 +1,21 @@ +/** + * Build the merged properties object for a telemetry event. + * Provider properties are included first so event-specific properties can override them. + */ +export function buildTelemetryPayload( + event: string, + properties: Record | undefined, + providerProperties: Record | undefined, +): { event: string; properties: Record } { + return { + event, + properties: { ...providerProperties, ...properties }, + } +} + +/** + * Build the Authorization header value for the telemetry endpoint. + */ +export function buildTelemetryAuthHeader(password: string): string { + return `Basic ${Buffer.from(`kilo:${password}`).toString("base64")}` +} diff --git a/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts b/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts index 2ebe2e378..4e5b97e4a 100644 --- a/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts +++ b/packages/kilo-vscode/src/services/telemetry/telemetry-proxy.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode" import { TelemetryEventName, type TelemetryPropertiesProvider } from "./types" +import { buildTelemetryPayload, buildTelemetryAuthHeader } from "./telemetry-proxy-utils" /** * Singleton proxy that captures telemetry events and forwards them to the CLI @@ -45,13 +46,9 @@ export class TelemetryProxy { if (!this.isVSCodeTelemetryEnabled()) return if (!this.url || !this.password) return - const merged = { - ...this.provider?.getTelemetryProperties(), - ...properties, - } - - const payload = JSON.stringify({ event, properties: merged }) - const auth = `Basic ${Buffer.from(`kilo:${this.password}`).toString("base64")}` + const built = buildTelemetryPayload(event, properties, this.provider?.getTelemetryProperties()) + const payload = JSON.stringify(built) + const auth = buildTelemetryAuthHeader(this.password) fetch(`${this.url}/telemetry/capture`, { method: "POST", diff --git a/packages/kilo-vscode/src/utils.ts b/packages/kilo-vscode/src/utils.ts index 72beab466..948c20e2e 100644 --- a/packages/kilo-vscode/src/utils.ts +++ b/packages/kilo-vscode/src/utils.ts @@ -1,5 +1,6 @@ import * as crypto from "crypto" import * as vscode from "vscode" +import { buildCspString } from "./webview-html-utils" export function getNonce(): string { return crypto.randomBytes(16).toString("hex") @@ -17,18 +18,7 @@ export function buildWebviewHtml( }, ): string { const nonce = getNonce() - const connectSrc = opts.port - ? `http://127.0.0.1:${opts.port} http://localhost:${opts.port} ws://127.0.0.1:${opts.port} ws://localhost:${opts.port}` - : "http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*" - - const csp = [ - "default-src 'none'", - `style-src 'unsafe-inline' ${webview.cspSource}`, - `script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`, - `font-src ${webview.cspSource}`, - `connect-src ${connectSrc}`, - `img-src ${webview.cspSource} data: https:`, - ].join("; ") + const csp = buildCspString(webview.cspSource, nonce, opts.port) return ` diff --git a/packages/kilo-vscode/src/webview-html-utils.ts b/packages/kilo-vscode/src/webview-html-utils.ts new file mode 100644 index 000000000..629320dfa --- /dev/null +++ b/packages/kilo-vscode/src/webview-html-utils.ts @@ -0,0 +1,34 @@ +/** + * Build the Content-Security-Policy connect-src directive value. + * If a port is specified, restricts connections to that port. + * Otherwise, allows any localhost/127.0.0.1 port. + */ +export function buildConnectSrc(port?: number): string { + if (port) { + return `http://127.0.0.1:${port} http://localhost:${port} ws://127.0.0.1:${port} ws://localhost:${port}` + } + return "http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:*" +} + +/** + * Join an array of CSP directives into a policy string. + */ +export function joinCspDirectives(directives: string[]): string { + return directives.join("; ") +} + +/** + * Build the full CSP policy string for a webview. + */ +export function buildCspString(cspSource: string, nonce: string, port?: number): string { + const connectSrc = buildConnectSrc(port) + const directives = [ + "default-src 'none'", + `style-src 'unsafe-inline' ${cspSource}`, + `script-src 'nonce-${nonce}' 'wasm-unsafe-eval'`, + `font-src ${cspSource}`, + `connect-src ${connectSrc}`, + `img-src ${cspSource} data: https:`, + ] + return joinCspDirectives(directives) +} 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 c0c1935da..968c25d6e 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,170 @@ describe("Agent Manager CSS/TSX Consistency", () => { expect(unused, `Classes defined in CSS but not used in TSX: ${unused.join(", ")}`).toEqual([]) }) }) + +describe("Agent Manager Provider Messages", () => { + function getMethodBody(name: string): string { + const project = new Project({ compilerOptions: { allowJs: true } }) + const source = project.addSourceFileAtPath(PROVIDER_FILE) + const cls = source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration) + const method = cls?.getMethod(name) + expect(method, `method ${name} not found in AgentManagerProvider`).toBeTruthy() + return method!.getText() + } + + /** + * Regression: onAddSessionToWorktree must NOT send agentManager.worktreeSetup + * because that triggers a full-screen overlay with a spinner. Adding a session + * to an existing worktree should use agentManager.sessionAdded instead. + */ + it("onAddSessionToWorktree should not send worktreeSetup messages", () => { + const body = getMethodBody("onAddSessionToWorktree") + expect(body).not.toContain("agentManager.worktreeSetup") + }) + + it("onAddSessionToWorktree should send sessionAdded message", () => { + const body = getMethodBody("onAddSessionToWorktree") + expect(body).toContain("agentManager.sessionAdded") + }) +}) + +// --------------------------------------------------------------------------- +// Provider message routing — static-analysis regression tests +// +// These tests use ts-morph to inspect the source code of AgentManagerProvider +// and verify structural invariants that prevent regressions without needing +// a VS Code test host. +// --------------------------------------------------------------------------- + +describe("Agent Manager Provider — onMessage routing", () => { + let source: import("ts-morph").SourceFile + let cls: import("ts-morph").ClassDeclaration + + function setup() { + if (source) return + const project = new Project({ compilerOptions: { allowJs: true } }) + source = project.addSourceFileAtPath(PROVIDER_FILE) + cls = source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration)! + } + + function body(name: string): string { + setup() + const method = cls.getMethod(name) + expect(method, `method ${name} not found`).toBeTruthy() + return method!.getText() + } + + // -- onMessage dispatches all expected message types ----------------------- + + it("onMessage handles all documented agentManager.* message types", () => { + const text = body("onMessage") + const expected = [ + "agentManager.createWorktree", + "agentManager.deleteWorktree", + "agentManager.promoteSession", + "agentManager.addSessionToWorktree", + "agentManager.closeSession", + "agentManager.configureSetupScript", + "agentManager.showTerminal", + "agentManager.requestRepoInfo", + "agentManager.requestState", + "agentManager.setTabOrder", + ] + for (const msg of expected) { + expect(text, `onMessage should handle "${msg}"`).toContain(msg) + } + }) + + it("onMessage handles loadMessages for terminal switching", () => { + const text = body("onMessage") + expect(text).toContain("loadMessages") + expect(text).toContain("showExisting") + }) + + it("onMessage handles clearSession for SSE re-registration", () => { + const text = body("onMessage") + expect(text).toContain("clearSession") + expect(text).toContain("trackSession") + }) + + // -- onDeleteWorktree invariants ------------------------------------------- + + /** + * Regression: deletion must clean up both disk (manager) and state, then + * push to webview. Missing any step leaves ghost worktrees or stale UI. + */ + it("onDeleteWorktree removes from disk, state, clears orphans, and pushes", () => { + const text = body("onDeleteWorktree") + expect(text).toContain("manager.removeWorktree") + expect(text).toContain("state.removeWorktree") + expect(text).toContain("clearSessionDirectory") + expect(text).toContain("this.pushState()") + }) + + // -- onCreateWorktree invariants ------------------------------------------- + + /** + * Regression: the setup script MUST run before session creation. + * If reversed, the agent starts in an unconfigured worktree (missing .env, + * deps, etc.) which causes hard-to-debug failures. + */ + it("onCreateWorktree runs setup script before creating session", () => { + const text = body("onCreateWorktree") + const setupIdx = text.indexOf("runSetupScriptForWorktree") + const sessionIdx = text.indexOf("createSessionInWorktree") + expect(setupIdx, "setup script call must exist").toBeGreaterThan(-1) + expect(sessionIdx, "session creation call must exist").toBeGreaterThan(-1) + expect(setupIdx, "setup script must run before session creation").toBeLessThan(sessionIdx) + }) + + /** + * Regression: if session creation fails after the worktree was already + * created on disk, the worktree must be cleaned up to avoid orphaned dirs. + */ + it("onCreateWorktree cleans up worktree on session creation failure", () => { + const text = body("onCreateWorktree") + expect(text).toContain("removeWorktree") + }) + + // -- onPromoteSession invariants ------------------------------------------- + + /** + * Regression: same setup-before-move ordering as onCreateWorktree. + */ + it("onPromoteSession runs setup script before modifying session", () => { + const text = body("onPromoteSession") + const setupIdx = text.indexOf("runSetupScriptForWorktree") + const moveIdx = text.indexOf("moveSession") + expect(setupIdx).toBeGreaterThan(-1) + expect(moveIdx).toBeGreaterThan(-1) + expect(setupIdx, "setup must run before move").toBeLessThan(moveIdx) + }) + + /** + * Regression: promote must handle the case where the session doesn't + * exist in state yet (e.g. a workspace session that was never tracked). + * It must branch between addSession (new) and moveSession (existing). + */ + it("onPromoteSession handles both new and existing sessions", () => { + const text = body("onPromoteSession") + expect(text).toContain("getSession") + expect(text).toContain("addSession") + expect(text).toContain("moveSession") + }) + + // -- notifyWorktreeReady invariants ---------------------------------------- + + /** + * Regression: pushState must come before the ready/meta messages. + * If reversed, the webview receives the "ready" signal but can't find + * the worktree/session in state, causing a blank panel. + */ + it("notifyWorktreeReady pushes state before sending ready message", () => { + const text = body("notifyWorktreeReady") + const pushIdx = text.indexOf("this.pushState()") + const readyIdx = text.indexOf("agentManager.worktreeSetup") + expect(pushIdx, "pushState must come before worktreeSetup").toBeLessThan(readyIdx) + // Must also send sessionMeta so the webview knows the branch/path + expect(text).toContain("agentManager.sessionMeta") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-inline-utils.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-inline-utils.test.ts new file mode 100644 index 000000000..7f171f84b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-inline-utils.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from "bun:test" +import { + findMatchingSuggestion, + applyFirstLineOnly, + countLines, + shouldShowOnlyFirstLine, + getFirstLine, + calcDebounceDelay, +} from "../../src/services/autocomplete/classic-auto-complete/inline-utils" +import type { FillInAtCursorSuggestion } from "../../src/services/autocomplete/types" + +function makeSuggestion(prefix: string, text: string, suffix = ""): FillInAtCursorSuggestion { + return { prefix, suffix, text } +} + +describe("countLines", () => { + it("returns 0 for empty string", () => { + expect(countLines("")).toBe(0) + }) + + it("returns 1 for single line without newline", () => { + expect(countLines("hello")).toBe(1) + }) + + it("returns 1 for single line with trailing newline", () => { + expect(countLines("hello\n")).toBe(1) + }) + + it("returns 2 for two lines", () => { + expect(countLines("line1\nline2")).toBe(2) + }) + + it("returns 2 for two lines with trailing newline", () => { + expect(countLines("line1\nline2\n")).toBe(2) + }) + + it("handles CRLF line endings", () => { + expect(countLines("a\r\nb\r\nc")).toBe(3) + }) + + it("handles CRLF with trailing newline", () => { + expect(countLines("a\r\nb\r\n")).toBe(2) + }) +}) + +describe("getFirstLine", () => { + it("returns the first line of multi-line text", () => { + expect(getFirstLine("line1\nline2\nline3")).toBe("line1") + }) + + it("returns the full text when single line", () => { + expect(getFirstLine("hello")).toBe("hello") + }) + + it("returns empty string for empty input", () => { + expect(getFirstLine("")).toBe("") + }) + + it("handles CRLF line endings", () => { + expect(getFirstLine("line1\r\nline2")).toBe("line1") + }) +}) + +describe("shouldShowOnlyFirstLine", () => { + it("returns false when suggestion starts with newline", () => { + expect(shouldShowOnlyFirstLine("const x = ", "\n return x")).toBe(false) + }) + + it("returns true when cursor is mid-line with code", () => { + expect(shouldShowOnlyFirstLine("function foo() { return ", "bar\nbaz")).toBe(true) + }) + + it("returns false when prefix last line has no word chars (empty line)", () => { + expect(shouldShowOnlyFirstLine("code\n", "line1\nline2\nline3")).toBe(false) + }) + + it("returns false for 2-line suggestion at start of line", () => { + expect(shouldShowOnlyFirstLine(" ", "line1\nline2")).toBe(false) + }) + + it("returns true for 3-line suggestion at start of line with word chars", () => { + expect(shouldShowOnlyFirstLine(" code", "line1\nline2\nline3")).toBe(true) + }) + + it("returns false for empty prefix", () => { + expect(shouldShowOnlyFirstLine("", "any text")).toBe(false) + }) +}) + +describe("findMatchingSuggestion", () => { + it("returns null for empty history", () => { + expect(findMatchingSuggestion("prefix", "suffix", [])).toBeNull() + }) + + it("returns exact match", () => { + const hist = [makeSuggestion("hello ", "world")] + const result = findMatchingSuggestion("hello ", "", hist) + expect(result?.matchType).toBe("exact") + expect(result?.text).toBe("world") + }) + + it("returns partial_typing match when user typed beginning of suggestion", () => { + const hist = [makeSuggestion("he", "llo world")] + const result = findMatchingSuggestion("hell", "", hist) + expect(result?.matchType).toBe("partial_typing") + expect(result?.text).toBe("o world") + }) + + it("returns backward_deletion match when user deleted chars", () => { + const hist = [makeSuggestion("hello world", "more", "suffix")] + const result = findMatchingSuggestion("hello", "suffix", hist) + expect(result?.matchType).toBe("backward_deletion") + expect(result?.text).toBe(" worldmore") + }) + + it("prefers most recent suggestion (searches from end)", () => { + const hist = [makeSuggestion("prefix", "old suggestion"), makeSuggestion("prefix", "new suggestion")] + const result = findMatchingSuggestion("prefix", "", hist) + expect(result?.text).toBe("new suggestion") + }) + + it("returns null when no match found", () => { + const hist = [makeSuggestion("different", "no match")] + expect(findMatchingSuggestion("unrelated", "suffix", hist)).toBeNull() + }) + + it("does not match empty suggestion text for partial_typing", () => { + const hist = [makeSuggestion("prefix", "")] + const result = findMatchingSuggestion("prefix more", "", hist) + expect(result).toBeNull() + }) +}) + +describe("applyFirstLineOnly", () => { + it("returns null when input is null", () => { + expect(applyFirstLineOnly(null, "prefix")).toBeNull() + }) + + it("returns empty result unchanged", () => { + const hist = [makeSuggestion("p", "")] + const result = findMatchingSuggestion("p", "", hist)! + const applied = applyFirstLineOnly(result, "p") + expect(applied?.text).toBe("") + }) + + it("truncates to first line when mid-line suggestion", () => { + const hist = [makeSuggestion("function foo() { return ", "x\n const y = 1\n}")] + const result = findMatchingSuggestion("function foo() { return ", "", hist)! + const applied = applyFirstLineOnly(result, "function foo() { return ") + expect(applied?.text).toBe("x") + }) + + it("preserves full multi-line suggestion when starting with newline", () => { + const hist = [makeSuggestion("foo", "\n const x = 1\n const y = 2")] + const result = findMatchingSuggestion("foo", "", hist)! + const applied = applyFirstLineOnly(result, "foo") + expect(applied?.text).toBe("\n const x = 1\n const y = 2") + }) +}) + +describe("calcDebounceDelay", () => { + it("returns MIN when history is empty", () => { + expect(calcDebounceDelay([])).toBe(150) + }) + + it("returns average of latencies clamped to min", () => { + expect(calcDebounceDelay([50, 50, 50])).toBe(150) + }) + + it("returns average of latencies in normal range", () => { + expect(calcDebounceDelay([400, 400, 400])).toBe(400) + }) + + it("clamps to MAX for very high latencies", () => { + expect(calcDebounceDelay([2000, 2000, 2000])).toBe(1000) + }) + + it("rounds to nearest integer", () => { + const result = calcDebounceDelay([300, 301]) + expect(Number.isInteger(result)).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts new file mode 100644 index 000000000..9060db822 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-statusbar-utils.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "bun:test" +import { humanFormatSessionCost, formatTime } from "../../src/services/autocomplete/statusbar-utils" + +describe("humanFormatSessionCost", () => { + it("returns '$0.00' for 0 cost", () => { + expect(humanFormatSessionCost(0)).toBe("$0.00") + }) + + it("returns '<$0.01' for very small cost", () => { + expect(humanFormatSessionCost(0.001)).toBe("<$0.01") + }) + + it("formats exactly $0.01 as dollar string (not less-than-cent)", () => { + const result = humanFormatSessionCost(0.01) + expect(result).toBe("$0.01") + }) + + it("formats $0.12 correctly", () => { + expect(humanFormatSessionCost(0.12)).toBe("$0.12") + }) + + it("formats $1.00 correctly", () => { + expect(humanFormatSessionCost(1.0)).toBe("$1.00") + }) + + it("formats $1.005 rounded to 2 decimal places", () => { + const result = humanFormatSessionCost(1.005) + expect(result.startsWith("$")).toBe(true) + expect(result).toMatch(/^\$\d+\.\d{2}$/) + }) + + it("formats $0.009 as '<$0.01'", () => { + expect(humanFormatSessionCost(0.009)).toBe("<$0.01") + }) +}) + +describe("formatTime", () => { + it("contains at least two colon-separated time components", () => { + const result = formatTime(Date.now()) + expect(result).toMatch(/\d+:\d+/) + }) + + it("formats a known timestamp with correct hour and minute", () => { + const ts = new Date("2024-01-15T14:30:45").getTime() + const result = formatTime(ts) + expect(result).toMatch(/30/) + expect(result).toMatch(/45/) + }) + + it("produces different output for different timestamps", () => { + const ts1 = new Date("2024-01-01T10:00:00").getTime() + const ts2 = new Date("2024-01-01T15:30:00").getTime() + expect(formatTime(ts1)).not.toBe(formatTime(ts2)) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/autocomplete-telemetry-utils.test.ts b/packages/kilo-vscode/tests/unit/autocomplete-telemetry-utils.test.ts new file mode 100644 index 000000000..9848c21c9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/autocomplete-telemetry-utils.test.ts @@ -0,0 +1,78 @@ +import { describe, it, expect } from "bun:test" +import { + getSuggestionKey, + insertWithLRUEviction, +} from "../../src/services/autocomplete/classic-auto-complete/telemetry-utils" + +describe("getSuggestionKey", () => { + it("combines prefix, suffix, and text with pipe separators", () => { + const key = getSuggestionKey({ prefix: "hello ", suffix: "\n}", text: "world" }) + expect(key).toBe("hello |\n}|world") + }) + + it("produces unique keys for different suggestions", () => { + const k1 = getSuggestionKey({ prefix: "a", suffix: "c", text: "b" }) + const k2 = getSuggestionKey({ prefix: "a", suffix: "c", text: "x" }) + expect(k1).not.toBe(k2) + }) + + it("same content produces same key (stable)", () => { + const s = { prefix: "const x = ", suffix: ";", text: "42" } + expect(getSuggestionKey(s)).toBe(getSuggestionKey(s)) + }) + + it("different prefix produces different key", () => { + const k1 = getSuggestionKey({ prefix: "a", suffix: "", text: "t" }) + const k2 = getSuggestionKey({ prefix: "b", suffix: "", text: "t" }) + expect(k1).not.toBe(k2) + }) + + it("handles empty strings", () => { + const key = getSuggestionKey({ prefix: "", suffix: "", text: "" }) + expect(key).toBe("||") + }) +}) + +describe("insertWithLRUEviction", () => { + it("inserts key into map", () => { + const map = new Map() + insertWithLRUEviction(map, "k1", 5) + expect(map.has("k1")).toBe(true) + }) + + it("does not evict when under limit", () => { + const map = new Map() + insertWithLRUEviction(map, "k1", 3) + insertWithLRUEviction(map, "k2", 3) + insertWithLRUEviction(map, "k3", 3) + expect(map.size).toBe(3) + expect(map.has("k1")).toBe(true) + }) + + it("evicts oldest key when limit exceeded", () => { + const map = new Map() + insertWithLRUEviction(map, "k1", 3) + insertWithLRUEviction(map, "k2", 3) + insertWithLRUEviction(map, "k3", 3) + insertWithLRUEviction(map, "k4", 3) + expect(map.size).toBe(3) + expect(map.has("k1")).toBe(false) + expect(map.has("k4")).toBe(true) + }) + + it("handles maxSize of 1", () => { + const map = new Map() + insertWithLRUEviction(map, "k1", 1) + insertWithLRUEviction(map, "k2", 1) + expect(map.size).toBe(1) + expect(map.has("k2")).toBe(true) + expect(map.has("k1")).toBe(false) + }) + + it("updating existing key does not increase size", () => { + const map = new Map() + insertWithLRUEviction(map, "k1", 2) + insertWithLRUEviction(map, "k1", 2) + expect(map.size).toBe(1) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts b/packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts new file mode 100644 index 000000000..415362ab7 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/chat-autocomplete-utils.test.ts @@ -0,0 +1,100 @@ +import { describe, it, expect } from "bun:test" +import { + finalizeChatSuggestion, + buildChatPrefix, +} from "../../src/services/autocomplete/chat-autocomplete/chat-autocomplete-utils" + +describe("finalizeChatSuggestion", () => { + it("returns empty string for empty input", () => { + expect(finalizeChatSuggestion("")).toBe("") + }) + + it("filters suggestions starting with // (JS comment)", () => { + expect(finalizeChatSuggestion("// this is a comment")).toBe("") + }) + + it("filters suggestions starting with /* (block comment)", () => { + expect(finalizeChatSuggestion("/* block */")).toBe("") + }) + + it("filters suggestions starting with * (JSDoc line)", () => { + expect(finalizeChatSuggestion("* @param foo")).toBe("") + }) + + it("filters suggestions starting with # (shell/Python comment)", () => { + expect(finalizeChatSuggestion("# python comment")).toBe("") + }) + + it("returns the suggestion as-is for normal text", () => { + expect(finalizeChatSuggestion("hello world")).toBe("hello world") + }) + + it("truncates at first newline", () => { + expect(finalizeChatSuggestion("first line\nsecond line")).toBe("first line") + }) + + it("trims trailing whitespace", () => { + expect(finalizeChatSuggestion("hello ")).toBe("hello") + }) + + it("truncates AND trims", () => { + expect(finalizeChatSuggestion("first line \nsecond")).toBe("first line") + }) + + it("handles single word without newline", () => { + expect(finalizeChatSuggestion("world")).toBe("world") + }) +}) + +describe("buildChatPrefix", () => { + it("includes user message without editor context", () => { + const result = buildChatPrefix("fix this bug") + expect(result).toContain("fix this bug") + expect(result).toContain("User's message") + }) + + it("does not include editor header when no editors provided", () => { + const result = buildChatPrefix("hello") + expect(result).not.toContain("Code visible in editor") + }) + + it("includes editor context when editors provided", () => { + const editors = [ + { + filePath: "/workspace/src/foo.ts", + languageId: "typescript", + visibleRanges: [{ content: "const x = 1" }], + }, + ] + const result = buildChatPrefix("fix this", editors) + expect(result).toContain("Code visible in editor") + expect(result).toContain("foo.ts (typescript)") + expect(result).toContain("const x = 1") + expect(result).toContain("fix this") + }) + + it("includes multiple editors", () => { + const editors = [ + { filePath: "/a.ts", languageId: "typescript", visibleRanges: [{ content: "code a" }] }, + { filePath: "/b.py", languageId: "python", visibleRanges: [{ content: "code b" }] }, + ] + const result = buildChatPrefix("question", editors) + expect(result).toContain("a.ts") + expect(result).toContain("b.py") + expect(result).toContain("code a") + expect(result).toContain("code b") + }) + + it("uses last segment of file path as filename", () => { + const editors = [{ filePath: "/deep/path/to/myfile.ts", languageId: "typescript", visibleRanges: [] }] + const result = buildChatPrefix("q", editors) + expect(result).toContain("myfile.ts") + expect(result).not.toContain("deep/path") + }) + + it("handles empty editors array as if no context", () => { + const result = buildChatPrefix("hi", []) + expect(result).not.toContain("Code visible in editor") + expect(result).toContain("hi") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/code-action-provider.test.ts b/packages/kilo-vscode/tests/unit/code-action-provider.test.ts new file mode 100644 index 000000000..669210689 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/code-action-provider.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect, mock } from "bun:test" + +const makeAction = (title: string, kind: string) => ({ title, kind }) +const makeKind = (value: string) => ({ + value, + append: (v: string) => makeKind(`${value}.${v}`), +}) + +const QuickFix = makeKind("quickfix") +const RefactorRewrite = makeKind("refactor.rewrite") + +const mockVscode = { + CodeAction: class { + command?: { command: string; title: string } + isPreferred?: boolean + constructor( + public title: string, + public kind: { value: string }, + ) {} + }, + CodeActionKind: { + QuickFix, + RefactorRewrite, + }, +} + +mock.module("vscode", () => mockVscode) + +const { KiloCodeActionProvider } = await import("../../src/services/code-actions/code-action-provider") + +const provider = new KiloCodeActionProvider() + +function makeRange(isEmpty: boolean) { + return { isEmpty } +} + +function makeContext(diagnosticCount: number) { + return { diagnostics: Array.from({ length: diagnosticCount }) } +} + +describe("KiloCodeActionProvider", () => { + describe("provideCodeActions", () => { + it("returns empty array when range is empty", () => { + const result = provider.provideCodeActions({} as never, makeRange(true) as never, makeContext(0) as never) + expect(result).toEqual([]) + }) + + it("returns empty array when range is empty even with diagnostics", () => { + const result = provider.provideCodeActions({} as never, makeRange(true) as never, makeContext(3) as never) + expect(result).toEqual([]) + }) + + describe("non-empty range, no diagnostics", () => { + it("returns Add, Explain, Improve actions", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never) + const titles = result.map((a) => a.title) + expect(titles).toContain("Add to Kilo Code") + expect(titles).toContain("Explain with Kilo Code") + expect(titles).toContain("Improve with Kilo Code") + }) + + it("does not include Fix action", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never) + expect(result.map((a) => a.title)).not.toContain("Fix with Kilo Code") + }) + + it("returns exactly 3 actions", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never) + expect(result).toHaveLength(3) + }) + + it("uses correct command IDs", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never) + const commands = result.map((a) => a.command?.command) + expect(commands).toContain("kilo-code.new.addToContext") + expect(commands).toContain("kilo-code.new.explainCode") + expect(commands).toContain("kilo-code.new.improveCode") + }) + + it("no action is preferred", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(0) as never) + expect(result.every((a) => !a.isPreferred)).toBe(true) + }) + }) + + describe("non-empty range, with diagnostics", () => { + it("returns Add and Fix actions", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(2) as never) + const titles = result.map((a) => a.title) + expect(titles).toContain("Add to Kilo Code") + expect(titles).toContain("Fix with Kilo Code") + }) + + it("does not include Explain or Improve actions", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never) + const titles = result.map((a) => a.title) + expect(titles).not.toContain("Explain with Kilo Code") + expect(titles).not.toContain("Improve with Kilo Code") + }) + + it("returns exactly 2 actions", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never) + expect(result).toHaveLength(2) + }) + + it("Fix action is preferred", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never) + const fix = result.find((a) => a.title === "Fix with Kilo Code") + expect(fix?.isPreferred).toBe(true) + }) + + it("Fix action uses QuickFix kind", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never) + const fix = result.find((a) => a.title === "Fix with Kilo Code") + expect(fix?.kind.value).toBe("quickfix") + }) + + it("uses correct Fix command ID", () => { + const result = provider.provideCodeActions({} as never, makeRange(false) as never, makeContext(1) as never) + const fix = result.find((a) => a.title === "Fix with Kilo Code") + expect(fix?.command?.command).toBe("kilo-code.new.fixCode") + }) + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts new file mode 100644 index 000000000..e981e7df6 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from "bun:test" +import { resolveEventSessionId } from "../../src/services/cli-backend/connection-utils" +import type { SSEEvent } from "../../src/services/cli-backend/types" + +const noLookup = (_: string) => undefined + +describe("resolveEventSessionId", () => { + it("returns session id from session.created", () => { + const event: SSEEvent = { + type: "session.created", + properties: { + info: { id: "s1", title: "", directory: "", time: { created: 0, updated: 0 } }, + }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s1") + }) + + it("returns session id from session.updated", () => { + const event: SSEEvent = { + type: "session.updated", + properties: { + info: { id: "s2", title: "", directory: "", time: { created: 0, updated: 0 } }, + }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s2") + }) + + it("returns sessionID from session.status", () => { + const event: SSEEvent = { + type: "session.status", + properties: { sessionID: "s3", status: { type: "idle" } }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s3") + }) + + it("returns sessionID from todo.updated", () => { + const event: SSEEvent = { + type: "todo.updated", + properties: { sessionID: "s4", items: [] }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s4") + }) + + it("returns sessionID from message.updated and calls onMessageUpdated", () => { + const event: SSEEvent = { + type: "message.updated", + properties: { + info: { id: "m1", sessionID: "s5", role: "assistant", time: { created: 0 } }, + }, + } + const recorded: [string, string][] = [] + const result = resolveEventSessionId(event, noLookup, (mid, sid) => recorded.push([mid, sid])) + expect(result).toBe("s5") + expect(recorded).toEqual([["m1", "s5"]]) + }) + + it("message.updated does not require onMessageUpdated callback", () => { + const event: SSEEvent = { + type: "message.updated", + properties: { + info: { id: "m1", sessionID: "s5", role: "assistant", time: { created: 0 } }, + }, + } + expect(() => resolveEventSessionId(event, noLookup)).not.toThrow() + }) + + it("returns sessionID directly from message.part.updated when part has sessionID", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { + part: { type: "text", id: "p1", text: "", sessionID: "s6", messageID: "m1" }, + }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s6") + }) + + it("falls back to lookup when message.part.updated has no sessionID but has messageID", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { + part: { type: "text", id: "p1", text: "", messageID: "m2" }, + }, + } + const lookup = (id: string) => (id === "m2" ? "s7" : undefined) + expect(resolveEventSessionId(event, lookup)).toBe("s7") + }) + + it("returns undefined for message.part.updated with no sessionID and messageID not in map", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { + part: { type: "text", id: "p1", text: "", messageID: "unknown" }, + }, + } + expect(resolveEventSessionId(event, noLookup)).toBeUndefined() + }) + + it("returns undefined for message.part.updated with no messageID and no sessionID", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { + part: { type: "text", id: "p1", text: "" }, + }, + } + expect(resolveEventSessionId(event, noLookup)).toBeUndefined() + }) + + it("returns sessionID from permission.asked", () => { + const event: SSEEvent = { + type: "permission.asked", + properties: { + id: "p1", + sessionID: "s8", + permission: "read_file", + patterns: [], + metadata: {}, + always: [], + }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s8") + }) + + it("returns sessionID from question.asked", () => { + const event: SSEEvent = { + type: "question.asked", + properties: { id: "q1", sessionID: "s9", questions: [] }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s9") + }) + + it("returns sessionID from question.replied", () => { + const event: SSEEvent = { + type: "question.replied", + properties: { sessionID: "s10", requestID: "r1", answers: [] }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s10") + }) + + it("returns sessionID from question.rejected", () => { + const event: SSEEvent = { + type: "question.rejected", + properties: { sessionID: "s11", requestID: "r2" }, + } + expect(resolveEventSessionId(event, noLookup)).toBe("s11") + }) + + it("returns undefined for server.connected (global event)", () => { + const event: SSEEvent = { type: "server.connected", properties: {} } + expect(resolveEventSessionId(event, noLookup)).toBeUndefined() + }) + + it("returns undefined for server.heartbeat (global event)", () => { + const event: SSEEvent = { type: "server.heartbeat", properties: {} } + expect(resolveEventSessionId(event, noLookup)).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/contextual-skip.test.ts b/packages/kilo-vscode/tests/unit/contextual-skip.test.ts new file mode 100644 index 000000000..a02e69c4c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/contextual-skip.test.ts @@ -0,0 +1,126 @@ +import { describe, it, expect } from "bun:test" +import { + shouldSkipAutocomplete, + getTerminatorsForLanguage, +} from "../../src/services/autocomplete/classic-auto-complete/contextualSkip" + +describe("getTerminatorsForLanguage", () => { + it("returns c-like terminators for typescript", () => { + const t = getTerminatorsForLanguage("typescript") + expect(t).toContain(";") + expect(t).toContain("}") + expect(t).toContain(")") + expect(t).not.toContain(",") + }) + + it("returns python terminators (brackets, no semicolon)", () => { + const t = getTerminatorsForLanguage("python") + expect(t).toContain(")") + expect(t).toContain("]") + expect(t).toContain("}") + expect(t).not.toContain(";") + }) + + it("returns empty terminators for html", () => { + expect(getTerminatorsForLanguage("html")).toHaveLength(0) + }) + + it("returns shell terminators including fi and done", () => { + const t = getTerminatorsForLanguage("shellscript") + expect(t).toContain(";") + expect(t).toContain("fi") + expect(t).toContain("done") + }) + + it("returns default terminators for unknown language", () => { + const t = getTerminatorsForLanguage("unknown-lang") + expect(t).toContain(";") + expect(t).toContain("}") + expect(t).toContain(")") + }) + + it("returns default terminators when languageId is undefined", () => { + const t = getTerminatorsForLanguage(undefined) + expect(t).toContain(";") + }) +}) + +describe("shouldSkipAutocomplete - end of statement", () => { + it("skips after semicolon in typescript", () => { + expect(shouldSkipAutocomplete("const x = 5;", "\n", "typescript")).toBe(true) + }) + + it("skips after closing brace in typescript", () => { + expect(shouldSkipAutocomplete("}", "\n", "typescript")).toBe(true) + }) + + it("skips after closing paren in typescript", () => { + expect(shouldSkipAutocomplete("myFunction()", "\n", "typescript")).toBe(true) + }) + + it("does not skip after colon in typescript", () => { + expect(shouldSkipAutocomplete(" key:", "\n", "typescript")).toBe(false) + }) + + it("does not skip after opening brace", () => { + expect(shouldSkipAutocomplete("if (condition) {", "\n", "typescript")).toBe(false) + }) + + it("does not skip when suffix has non-whitespace on same line", () => { + expect(shouldSkipAutocomplete("const x = ", " + 1;\n", "typescript")).toBe(false) + }) + + it("does not skip on empty line", () => { + expect(shouldSkipAutocomplete("", "\n", "typescript")).toBe(false) + }) + + it("skips after semicolon with trailing whitespace", () => { + expect(shouldSkipAutocomplete("const x = 5; ", "\n", "typescript")).toBe(true) + }) + + it("does not skip in python after colon (block start)", () => { + expect(shouldSkipAutocomplete("def foo():", "\n", "python")).toBe(false) + }) + + it("skips in python after closing paren", () => { + expect(shouldSkipAutocomplete("print('hello')", "\n", "python")).toBe(true) + }) + + it("skips in html due to mid-word typing (not terminator)", () => { + expect(shouldSkipAutocomplete(" { + expect(shouldSkipAutocomplete("", "\n", "html")).toBe(false) + }) +}) + +describe("shouldSkipAutocomplete - mid-word typing", () => { + it("skips when typing a word longer than 2 chars", () => { + expect(shouldSkipAutocomplete("myVariable", "\n", "typescript")).toBe(true) + }) + + it("does not skip for 1-2 char word", () => { + expect(shouldSkipAutocomplete("my", "\n", "typescript")).toBe(false) + expect(shouldSkipAutocomplete("x", "\n", "typescript")).toBe(false) + }) + + it("skips when suffix starts with word character", () => { + expect(shouldSkipAutocomplete("if (", "condition) {\n", "typescript")).toBe(true) + }) + + it("does not skip when prefix is empty", () => { + expect(shouldSkipAutocomplete("", "", "typescript")).toBe(false) + }) +}) + +describe("shouldSkipAutocomplete - defaults", () => { + it("uses default terminators when no languageId provided", () => { + expect(shouldSkipAutocomplete("const x = 5;", "\n")).toBe(true) + expect(shouldSkipAutocomplete("}", "\n")).toBe(true) + }) + + it("does not skip on empty input with no language", () => { + expect(shouldSkipAutocomplete("", "")).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/date.test.ts b/packages/kilo-vscode/tests/unit/date.test.ts new file mode 100644 index 000000000..fa84447bb --- /dev/null +++ b/packages/kilo-vscode/tests/unit/date.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect } from "bun:test" +import { formatRelativeDate } from "../../webview-ui/src/utils/date" + +function ago(ms: number): string { + return new Date(Date.now() - ms).toISOString() +} + +const SEC = 1000 +const MIN = 60 * SEC +const HOUR = 60 * MIN +const DAY = 24 * HOUR +const MONTH = 30 * DAY + +describe("formatRelativeDate", () => { + it("returns 'just now' for future timestamps", () => { + const future = new Date(Date.now() + 5000).toISOString() + expect(formatRelativeDate(future)).toBe("just now") + }) + + it("returns 'just now' for 0 seconds ago", () => { + expect(formatRelativeDate(new Date().toISOString())).toBe("just now") + }) + + it("returns 'just now' for 30 seconds ago", () => { + expect(formatRelativeDate(ago(30 * SEC))).toBe("just now") + }) + + it("returns 'just now' for 59 seconds ago", () => { + expect(formatRelativeDate(ago(59 * SEC))).toBe("just now") + }) + + it("returns '1 min ago' for exactly 1 minute ago", () => { + expect(formatRelativeDate(ago(MIN))).toBe("1 min ago") + }) + + it("returns '5 min ago' for 5 minutes ago", () => { + expect(formatRelativeDate(ago(5 * MIN))).toBe("5 min ago") + }) + + it("returns '59 min ago' for 59 minutes ago", () => { + expect(formatRelativeDate(ago(59 * MIN))).toBe("59 min ago") + }) + + it("returns '1h ago' for exactly 1 hour ago", () => { + expect(formatRelativeDate(ago(HOUR))).toBe("1h ago") + }) + + it("returns '12h ago' for 12 hours ago", () => { + expect(formatRelativeDate(ago(12 * HOUR))).toBe("12h ago") + }) + + it("returns '23h ago' for 23 hours ago", () => { + expect(formatRelativeDate(ago(23 * HOUR))).toBe("23h ago") + }) + + it("returns '1d ago' for exactly 1 day ago", () => { + expect(formatRelativeDate(ago(DAY))).toBe("1d ago") + }) + + it("returns '7d ago' for 7 days ago", () => { + expect(formatRelativeDate(ago(7 * DAY))).toBe("7d ago") + }) + + it("returns '29d ago' for 29 days ago", () => { + expect(formatRelativeDate(ago(29 * DAY))).toBe("29d ago") + }) + + it("returns '1mo ago' for exactly 30 days ago", () => { + expect(formatRelativeDate(ago(MONTH))).toBe("1mo ago") + }) + + it("returns '6mo ago' for 6 months ago", () => { + expect(formatRelativeDate(ago(6 * MONTH))).toBe("6mo ago") + }) + + it("returns 'just now' for invalid ISO string (fallback to now)", () => { + expect(formatRelativeDate("not-a-date")).toBe("just now") + }) + + it("returns 'just now' for empty string (fallback to now)", () => { + expect(formatRelativeDate("")).toBe("just now") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts new file mode 100644 index 000000000..bc294c5f4 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/file-mention-utils.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "bun:test" +import { + AT_PATTERN, + syncMentionedPaths, + buildTextAfterMentionSelect, + buildFileAttachments, +} from "../../webview-ui/src/hooks/file-mention-utils" + +describe("AT_PATTERN", () => { + it("matches @mention at start of string", () => { + expect(AT_PATTERN.test("@foo")).toBe(true) + }) + + it("matches @mention after whitespace", () => { + expect(AT_PATTERN.test("hello @foo")).toBe(true) + }) + + it("does not match @mention in middle of word", () => { + expect(AT_PATTERN.test("hello@foo")).toBe(false) + }) + + it("captures the path after @", () => { + const match = "hello @path/to/file.ts".match(AT_PATTERN) + expect(match?.[1]).toBe("path/to/file.ts") + }) + + it("matches empty @", () => { + expect(AT_PATTERN.test("@")).toBe(true) + }) +}) + +describe("syncMentionedPaths", () => { + it("keeps paths still referenced in text", () => { + const paths = new Set(["foo.ts", "bar.ts"]) + const result = syncMentionedPaths(paths, "see @foo.ts for details") + expect(result.has("foo.ts")).toBe(true) + expect(result.has("bar.ts")).toBe(false) + }) + + it("returns empty set when text has no @references", () => { + const paths = new Set(["foo.ts"]) + const result = syncMentionedPaths(paths, "no references here") + expect(result.size).toBe(0) + }) + + it("keeps multiple paths that are all referenced", () => { + const paths = new Set(["a.ts", "b.ts"]) + const result = syncMentionedPaths(paths, "@a.ts and @b.ts are both here") + expect(result.size).toBe(2) + }) + + it("does not mutate the original set", () => { + const paths = new Set(["foo.ts"]) + syncMentionedPaths(paths, "no references") + expect(paths.has("foo.ts")).toBe(true) + }) +}) + +describe("buildTextAfterMentionSelect", () => { + it("replaces @mention with selected path", () => { + const before = "hello @par" + const after = " world" + const result = buildTextAfterMentionSelect(before, after, "src/component.ts") + expect(result).toBe("hello @src/component.ts world") + }) + + it("handles @mention at start of string", () => { + const result = buildTextAfterMentionSelect("@par", "", "foo.ts") + expect(result).toBe("@foo.ts") + }) + + it("preserves space prefix before @mention", () => { + const result = buildTextAfterMentionSelect("text @par", "", "foo.ts") + expect(result).toBe("text @foo.ts") + }) + + it("appends suffix after replacement", () => { + const result = buildTextAfterMentionSelect("before @q", " after text", "file.ts") + expect(result).toContain("after text") + }) +}) + +describe("buildFileAttachments", () => { + it("returns empty array for empty paths set", () => { + expect(buildFileAttachments("hello @foo.ts", new Set(), "/workspace")).toEqual([]) + }) + + it("returns attachment for mentioned path", () => { + const paths = new Set(["src/foo.ts"]) + const result = buildFileAttachments("check @src/foo.ts", paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.mime).toBe("text/plain") + expect(result[0]!.url).toContain("file://") + expect(result[0]!.url).toContain("src/foo.ts") + }) + + it("skips paths not in text", () => { + const paths = new Set(["foo.ts", "bar.ts"]) + const result = buildFileAttachments("only @foo.ts here", paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.url).toContain("foo.ts") + }) + + it("handles absolute paths directly", () => { + const paths = new Set(["/abs/path/file.ts"]) + const result = buildFileAttachments("@/abs/path/file.ts", paths, "/workspace") + expect(result).toHaveLength(1) + expect(result[0]!.url).toContain("/abs/path/file.ts") + }) + + it("normalizes Windows backslashes in workspaceDir", () => { + const paths = new Set(["foo.ts"]) + const result = buildFileAttachments("@foo.ts", paths, "C:\\Users\\workspace") + expect(result[0]!.url).not.toContain("\\") + }) +}) 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 000000000..564b8a9ef --- /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/hole-filler-utils.test.ts b/packages/kilo-vscode/tests/unit/hole-filler-utils.test.ts new file mode 100644 index 000000000..9623b7947 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/hole-filler-utils.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "bun:test" +import { parseAutocompleteResponse } from "../../src/services/autocomplete/classic-auto-complete/hole-filler-utils" + +describe("parseAutocompleteResponse", () => { + const prefix = "function foo() {\n " + const suffix = "\n}" + + it("extracts content between COMPLETION tags", () => { + const result = parseAutocompleteResponse("return 42;", prefix, suffix) + expect(result.text).toBe("return 42;") + expect(result.prefix).toBe(prefix) + expect(result.suffix).toBe(suffix) + }) + + it("returns empty text when no COMPLETION tags", () => { + const result = parseAutocompleteResponse("return 42;", prefix, suffix) + expect(result.text).toBe("") + }) + + it("handles multiline completion content", () => { + const result = parseAutocompleteResponse("const x = 1;\nreturn x;", prefix, suffix) + expect(result.text).toBe("const x = 1;\nreturn x;") + }) + + it("handles case-insensitive tags", () => { + const result = parseAutocompleteResponse("return x;", prefix, suffix) + expect(result.text).toBe("return x;") + }) + + it("handles empty COMPLETION tags", () => { + const result = parseAutocompleteResponse("", prefix, suffix) + expect(result.text).toBe("") + }) + + it("handles whitespace-only content in tags", () => { + const result = parseAutocompleteResponse(" ", prefix, suffix) + expect(result.text).toBe(" ") + }) + + it("handles response with prose before and after tags", () => { + const response = "Here is your completion:\nreturn value;\nHope that helps!" + const result = parseAutocompleteResponse(response, prefix, suffix) + expect(result.text).toBe("return value;") + }) + + it("removes accidentally captured tag remnants", () => { + const result = parseAutocompleteResponse("inner", prefix, suffix) + expect(result.text).toBe("inner") + }) + + it("returns empty text for empty response", () => { + const result = parseAutocompleteResponse("", prefix, suffix) + expect(result.text).toBe("") + }) + + it("preserves prefix and suffix in result", () => { + const result = parseAutocompleteResponse("x", "pre", "suf") + expect(result.prefix).toBe("pre") + expect(result.suffix).toBe("suf") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/http-client-utils.test.ts b/packages/kilo-vscode/tests/unit/http-client-utils.test.ts new file mode 100644 index 000000000..80704b76a --- /dev/null +++ b/packages/kilo-vscode/tests/unit/http-client-utils.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "bun:test" +import { extractHttpErrorMessage, parseSSEDataLine } from "../../src/services/cli-backend/http-utils" + +describe("extractHttpErrorMessage", () => { + it("extracts error field from JSON", () => { + const result = extractHttpErrorMessage("Bad Request", '{"error":"invalid token"}') + expect(result).toBe("invalid token") + }) + + it("extracts message field from JSON when error is absent", () => { + const result = extractHttpErrorMessage("Not Found", '{"message":"resource not found"}') + expect(result).toBe("resource not found") + }) + + it("prefers error over message when both present", () => { + const result = extractHttpErrorMessage("Bad Request", '{"error":"err","message":"msg"}') + expect(result).toBe("err") + }) + + it("falls back to statusText when JSON has neither error nor message", () => { + const result = extractHttpErrorMessage("Bad Request", '{"code":400}') + expect(result).toBe("Bad Request") + }) + + it("falls back to raw text when JSON parse fails", () => { + const result = extractHttpErrorMessage("Internal Server Error", "not json at all") + expect(result).toBe("not json at all") + }) + + it("returns statusText when rawText is empty", () => { + expect(extractHttpErrorMessage("Unauthorized", "")).toBe("Unauthorized") + }) + + it("returns statusText when rawText is whitespace only", () => { + expect(extractHttpErrorMessage("Forbidden", " ")).toBe("Forbidden") + }) + + it("falls back to statusText when error field is falsy empty string", () => { + const result = extractHttpErrorMessage("Bad Request", '{"error":""}') + expect(result).toBe("Bad Request") + }) +}) + +describe("parseSSEDataLine", () => { + it("returns null for non-data lines", () => { + expect(parseSSEDataLine("event: message")).toBeNull() + expect(parseSSEDataLine("id: 123")).toBeNull() + expect(parseSSEDataLine(": heartbeat")).toBeNull() + expect(parseSSEDataLine("")).toBeNull() + }) + + it("returns null for [DONE] sentinel", () => { + expect(parseSSEDataLine("data: [DONE]")).toBeNull() + }) + + it("returns null for malformed JSON", () => { + expect(parseSSEDataLine("data: {not json}")).toBeNull() + }) + + it("extracts content from choices delta", () => { + const line = 'data: {"choices":[{"delta":{"content":"hello"}}]}' + const result = parseSSEDataLine(line) + expect(result?.content).toBe("hello") + }) + + it("omits content when delta content is empty string", () => { + const line = 'data: {"choices":[{"delta":{"content":""}}]}' + const result = parseSSEDataLine(line) + expect(result?.content).toBeUndefined() + }) + + it("omits content when choices array is empty", () => { + const line = 'data: {"choices":[]}' + const result = parseSSEDataLine(line) + expect(result?.content).toBeUndefined() + }) + + it("extracts usage tokens", () => { + const line = 'data: {"usage":{"prompt_tokens":10,"completion_tokens":20}}' + const result = parseSSEDataLine(line) + expect(result?.inputTokens).toBe(10) + expect(result?.outputTokens).toBe(20) + }) + + it("defaults token counts to 0 when usage fields are missing", () => { + const line = 'data: {"usage":{}}' + const result = parseSSEDataLine(line) + expect(result?.inputTokens).toBe(0) + expect(result?.outputTokens).toBe(0) + }) + + it("extracts cost", () => { + const line = 'data: {"cost":0.0042}' + const result = parseSSEDataLine(line) + expect(result?.cost).toBe(0.0042) + }) + + it("extracts all fields in one chunk", () => { + const line = + 'data: {"choices":[{"delta":{"content":"world"}}],"usage":{"prompt_tokens":5,"completion_tokens":3},"cost":0.001}' + const result = parseSSEDataLine(line) + expect(result?.content).toBe("world") + expect(result?.inputTokens).toBe(5) + expect(result?.outputTokens).toBe(3) + expect(result?.cost).toBe(0.001) + }) + + it("returns empty object for valid JSON with no recognized fields", () => { + const line = 'data: {"id":"abc"}' + const result = parseSSEDataLine(line) + expect(result).not.toBeNull() + expect(result?.content).toBeUndefined() + expect(result?.cost).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/i18n-shim.test.ts b/packages/kilo-vscode/tests/unit/i18n-shim.test.ts new file mode 100644 index 000000000..ce586428c --- /dev/null +++ b/packages/kilo-vscode/tests/unit/i18n-shim.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from "bun:test" +import { t } from "../../src/services/autocomplete/shims/i18n" + +describe("t()", () => { + it("returns translated string for known key", () => { + const result = t("kilocode:autocomplete.statusBar.enabled") + expect(typeof result).toBe("string") + expect(result.length).toBeGreaterThan(0) + expect(result).not.toBe("kilocode:autocomplete.statusBar.enabled") + }) + + it("returns the key itself for unknown key", () => { + expect(t("nonexistent.key.that.does.not.exist")).toBe("nonexistent.key.that.does.not.exist") + }) + + it("returns empty string for empty key", () => { + expect(t("")).toBe("") + }) + + it("interpolates a single variable", () => { + const result = t("kilocode:autocomplete.statusBar.tooltip.noUsableProvider", { + providers: "OpenAI, Anthropic", + }) + expect(result).toContain("OpenAI, Anthropic") + expect(result).not.toContain("{{providers}}") + }) + + it("interpolates multiple variables", () => { + const result = t("kilocode:autocomplete.statusBar.tooltip.completionSummary", { + count: "5", + startTime: "10:00", + endTime: "11:00", + cost: "$0.05", + }) + expect(result).toContain("5") + expect(result).toContain("10:00") + expect(result).toContain("11:00") + expect(result).toContain("$0.05") + expect(result).not.toContain("{{") + }) + + it("interpolates numeric variable as string", () => { + const result = t("kilocode:autocomplete.statusBar.tooltip.noUsableProvider", { + providers: 42 as unknown as string, + }) + expect(result).toContain("42") + }) + + it("leaves unreferenced vars intact in template", () => { + const key = "kilocode:autocomplete.statusBar.tooltip.noUsableProvider" + const result = t(key, { unrelated: "value" }) + expect(result).toContain("{{providers}}") + }) + + it("returns the raw key when called without vars on a template key", () => { + const result = t("kilocode:autocomplete.statusBar.tooltip.noUsableProvider") + expect(result).toContain("{{providers}}") + }) + + it("handles empty vars object (no interpolation)", () => { + const result = t("kilocode:autocomplete.statusBar.enabled", {}) + expect(typeof result).toBe("string") + expect(result).not.toContain("{{") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/image-attachments-utils.test.ts b/packages/kilo-vscode/tests/unit/image-attachments-utils.test.ts new file mode 100644 index 000000000..c5b50bbae --- /dev/null +++ b/packages/kilo-vscode/tests/unit/image-attachments-utils.test.ts @@ -0,0 +1,58 @@ +import { describe, it, expect } from "bun:test" +import { + ACCEPTED_IMAGE_TYPES, + isAcceptedImageType, + isDragLeavingComponent, +} from "../../webview-ui/src/hooks/image-attachments-utils" + +describe("ACCEPTED_IMAGE_TYPES", () => { + it("includes the standard image MIME types", () => { + expect(ACCEPTED_IMAGE_TYPES).toContain("image/png") + expect(ACCEPTED_IMAGE_TYPES).toContain("image/jpeg") + expect(ACCEPTED_IMAGE_TYPES).toContain("image/gif") + expect(ACCEPTED_IMAGE_TYPES).toContain("image/webp") + }) +}) + +describe("isAcceptedImageType", () => { + it("returns true for accepted types", () => { + expect(isAcceptedImageType("image/png")).toBe(true) + expect(isAcceptedImageType("image/jpeg")).toBe(true) + expect(isAcceptedImageType("image/gif")).toBe(true) + expect(isAcceptedImageType("image/webp")).toBe(true) + }) + + it("returns false for non-image types", () => { + expect(isAcceptedImageType("application/pdf")).toBe(false) + expect(isAcceptedImageType("text/plain")).toBe(false) + expect(isAcceptedImageType("video/mp4")).toBe(false) + }) + + it("returns false for empty string", () => { + expect(isAcceptedImageType("")).toBe(false) + }) + + it("returns false for image types not in the accepted list", () => { + expect(isAcceptedImageType("image/svg+xml")).toBe(false) + expect(isAcceptedImageType("image/bmp")).toBe(false) + }) +}) + +describe("isDragLeavingComponent", () => { + it("returns true when relatedTarget is null (left the page)", () => { + const el = { contains: () => false } as unknown as HTMLElement + expect(isDragLeavingComponent(null, el)).toBe(true) + }) + + it("returns false when relatedTarget is a child (contains returns true)", () => { + const child = {} as EventTarget + const parent = { contains: (n: Node) => n === child } as unknown as HTMLElement + expect(isDragLeavingComponent(child, parent)).toBe(false) + }) + + it("returns true when relatedTarget is outside (contains returns false)", () => { + const outside = {} as EventTarget + const container = { contains: () => false } as unknown as HTMLElement + expect(isDragLeavingComponent(outside, container)).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts new file mode 100644 index 000000000..380676377 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-utils.test.ts @@ -0,0 +1,339 @@ +import { describe, it, expect } from "bun:test" +import { + sessionToWebview, + normalizeProviders, + filterVisibleAgents, + buildSettingPath, + mapSSEEventToWebviewMessage, +} from "../../src/kilo-provider-utils" +import type { SessionInfo, AgentInfo, Provider, SSEEvent } from "../../src/services/cli-backend/types" + +function makeSession(overrides: Partial = {}): SessionInfo { + return { + id: "sess-1", + title: "Test Session", + directory: "/tmp", + time: { created: 1700000000000, updated: 1700001000000 }, + ...overrides, + } +} + +function makeProvider(id: string): Provider { + return { id, name: id.toUpperCase(), models: {} } +} + +function makeAgent(overrides: Partial = {}): AgentInfo { + return { name: "code", mode: "primary", ...overrides } +} + +describe("sessionToWebview", () => { + it("converts epoch timestamps to ISO strings", () => { + const result = sessionToWebview(makeSession()) + expect(result.createdAt).toBe(new Date(1700000000000).toISOString()) + expect(result.updatedAt).toBe(new Date(1700001000000).toISOString()) + }) + + it("preserves id and title", () => { + const result = sessionToWebview(makeSession({ id: "abc", title: "My Session" })) + expect(result.id).toBe("abc") + expect(result.title).toBe("My Session") + }) + + it("produces valid ISO format", () => { + const result = sessionToWebview(makeSession()) + expect(() => new Date(result.createdAt)).not.toThrow() + expect(new Date(result.createdAt).getTime()).toBe(1700000000000) + }) +}) + +describe("normalizeProviders", () => { + it("re-keys providers from numeric indices to provider.id", () => { + const input = { "0": makeProvider("openai"), "1": makeProvider("anthropic") } + const result = normalizeProviders(input as Record) + expect(result["openai"]).toBeDefined() + expect(result["anthropic"]).toBeDefined() + expect(result["0"]).toBeUndefined() + expect(result["1"]).toBeUndefined() + }) + + it("handles empty input", () => { + expect(normalizeProviders({})).toEqual({}) + }) + + it("preserves provider data", () => { + const p = makeProvider("openai") + const result = normalizeProviders({ "0": p }) + expect(result["openai"]).toEqual(p) + }) + + it("handles already-keyed-by-id input (idempotent)", () => { + const p = makeProvider("openai") + const result = normalizeProviders({ openai: p }) + expect(result["openai"]).toEqual(p) + }) +}) + +describe("filterVisibleAgents", () => { + it("filters out subagent mode", () => { + const agents = [makeAgent({ name: "code", mode: "primary" }), makeAgent({ name: "sub", mode: "subagent" })] + const { visible } = filterVisibleAgents(agents) + expect(visible).toHaveLength(1) + expect(visible[0]!.name).toBe("code") + }) + + it("filters out hidden agents", () => { + const agents = [makeAgent({ name: "code" }), makeAgent({ name: "hidden", hidden: true })] + const { visible } = filterVisibleAgents(agents) + expect(visible).toHaveLength(1) + expect(visible[0]!.name).toBe("code") + }) + + it("uses first visible agent as default", () => { + const agents = [makeAgent({ name: "first" }), makeAgent({ name: "second" })] + const { defaultAgent } = filterVisibleAgents(agents) + expect(defaultAgent).toBe("first") + }) + + it("falls back to 'code' when no visible agents", () => { + const agents = [makeAgent({ mode: "subagent" }), makeAgent({ hidden: true })] + const { defaultAgent } = filterVisibleAgents(agents) + expect(defaultAgent).toBe("code") + }) + + it("handles empty agent list", () => { + const { visible, defaultAgent } = filterVisibleAgents([]) + expect(visible).toHaveLength(0) + expect(defaultAgent).toBe("code") + }) + + it("passes through all modes that are primary or all", () => { + const agents = [makeAgent({ name: "a", mode: "primary" }), makeAgent({ name: "b", mode: "all" })] + const { visible } = filterVisibleAgents(agents) + expect(visible).toHaveLength(2) + }) +}) + +describe("buildSettingPath", () => { + it("splits single-segment key into empty section and leaf", () => { + const { section, leaf } = buildSettingPath("enabled") + expect(section).toBe("") + expect(leaf).toBe("enabled") + }) + + it("splits two-segment key", () => { + const { section, leaf } = buildSettingPath("browserAutomation.enabled") + expect(section).toBe("browserAutomation") + expect(leaf).toBe("enabled") + }) + + it("splits three-segment key", () => { + const { section, leaf } = buildSettingPath("a.b.c") + expect(section).toBe("a.b") + expect(leaf).toBe("c") + }) + + it("handles empty-looking intermediate segments", () => { + const { section, leaf } = buildSettingPath("foo..bar") + expect(leaf).toBe("bar") + expect(section).toBe("foo.") + }) +}) + +describe("mapSSEEventToWebviewMessage", () => { + it("maps message.part.updated to partUpdated", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { + part: { type: "text", id: "p1", text: "hello", messageID: "m1" }, + delta: "hello", + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("partUpdated") + if (msg?.type === "partUpdated") { + expect(msg.sessionID).toBe("sess-1") + expect(msg.messageID).toBe("m1") + expect(msg.delta).toEqual({ type: "text-delta", textDelta: "hello" }) + } + }) + + it("returns null for message.part.updated when sessionID is undefined", () => { + const event: SSEEvent = { + type: "message.part.updated", + properties: { part: { type: "text", id: "p1", text: "" } }, + } + expect(mapSSEEventToWebviewMessage(event, undefined)).toBeNull() + }) + + it("maps message.updated to messageCreated with ISO date", () => { + const event: SSEEvent = { + type: "message.updated", + properties: { + info: { + id: "msg-1", + sessionID: "sess-1", + role: "assistant", + time: { created: 1700000000000 }, + cost: 0.001, + }, + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("messageCreated") + if (msg?.type === "messageCreated") { + expect(msg.message.createdAt).toBe(new Date(1700000000000).toISOString()) + expect(msg.message.cost).toBe(0.001) + } + }) + + it("maps session.status idle to sessionStatus", () => { + const event: SSEEvent = { + type: "session.status", + properties: { sessionID: "sess-1", status: { type: "idle" } }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("sessionStatus") + if (msg?.type === "sessionStatus") { + expect(msg.status).toBe("idle") + expect(msg.attempt).toBeUndefined() + } + }) + + it("maps session.status retry with attempt/message/next", () => { + const event: SSEEvent = { + type: "session.status", + properties: { + sessionID: "sess-1", + status: { type: "retry", attempt: 2, message: "trying again", next: 5000 }, + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + if (msg?.type === "sessionStatus") { + expect(msg.attempt).toBe(2) + expect(msg.message).toBe("trying again") + expect(msg.next).toBe(5000) + } + }) + + it("maps permission.asked to permissionRequest", () => { + const event: SSEEvent = { + type: "permission.asked", + properties: { + id: "perm-1", + sessionID: "sess-1", + permission: "read_file", + patterns: ["**/*.ts"], + metadata: { path: "/foo" }, + always: [], + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("permissionRequest") + if (msg?.type === "permissionRequest") { + expect(msg.permission.toolName).toBe("read_file") + expect(msg.permission.args).toEqual({ path: "/foo" }) + expect(msg.permission.message).toBe("Permission required: read_file") + expect(msg.permission.patterns).toEqual(["**/*.ts"]) + } + }) + + it("defaults patterns to [] when not provided in permission.asked", () => { + const event = { + type: "permission.asked" as const, + properties: { + id: "p1", + sessionID: "s1", + permission: "write_file", + metadata: {}, + always: [], + }, + } + const msg = mapSSEEventToWebviewMessage(event, "s1") + if (msg?.type === "permissionRequest") { + expect(msg.permission.patterns).toEqual([]) + } + }) + + it("maps todo.updated to todoUpdated", () => { + const event: SSEEvent = { + type: "todo.updated", + properties: { + sessionID: "sess-1", + items: [{ id: "t1", content: "do something", status: "pending" }], + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("todoUpdated") + if (msg?.type === "todoUpdated") { + expect(msg.items).toHaveLength(1) + } + }) + + it("maps question.asked to questionRequest", () => { + const event: SSEEvent = { + type: "question.asked", + properties: { + id: "q1", + sessionID: "sess-1", + questions: [], + }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("questionRequest") + }) + + it("maps question.replied to questionResolved", () => { + const event: SSEEvent = { + type: "question.replied", + properties: { sessionID: "sess-1", requestID: "req-1", answers: [] }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("questionResolved") + if (msg?.type === "questionResolved") { + expect(msg.requestID).toBe("req-1") + } + }) + + it("maps question.rejected to questionResolved", () => { + const event: SSEEvent = { + type: "question.rejected", + properties: { sessionID: "sess-1", requestID: "req-2" }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("questionResolved") + if (msg?.type === "questionResolved") { + expect(msg.requestID).toBe("req-2") + } + }) + + it("maps session.created to sessionCreated with ISO dates", () => { + const event: SSEEvent = { + type: "session.created", + properties: { info: makeSession() }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-1") + expect(msg?.type).toBe("sessionCreated") + if (msg?.type === "sessionCreated") { + expect(msg.session.createdAt).toBe(new Date(1700000000000).toISOString()) + } + }) + + it("maps session.updated to sessionUpdated with ISO dates", () => { + const event: SSEEvent = { + type: "session.updated", + properties: { info: makeSession({ id: "sess-2" }) }, + } + const msg = mapSSEEventToWebviewMessage(event, "sess-2") + expect(msg?.type).toBe("sessionUpdated") + }) + + it("returns null for server.connected (no webview message)", () => { + const event: SSEEvent = { type: "server.connected", properties: {} } + expect(mapSSEEventToWebviewMessage(event, undefined)).toBeNull() + }) + + it("returns null for server.heartbeat", () => { + const event: SSEEvent = { type: "server.heartbeat", properties: {} } + expect(mapSSEEventToWebviewMessage(event, undefined)).toBeNull() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/language-utils.test.ts b/packages/kilo-vscode/tests/unit/language-utils.test.ts new file mode 100644 index 000000000..75ad9bd5f --- /dev/null +++ b/packages/kilo-vscode/tests/unit/language-utils.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from "bun:test" +import { normalizeLocale, resolveTemplate } from "../../webview-ui/src/context/language-utils" + +describe("normalizeLocale", () => { + it("returns 'en' for English", () => { + expect(normalizeLocale("en")).toBe("en") + expect(normalizeLocale("en-US")).toBe("en") + expect(normalizeLocale("en-GB")).toBe("en") + }) + + it("returns 'zh' for Simplified Chinese", () => { + expect(normalizeLocale("zh")).toBe("zh") + expect(normalizeLocale("zh-CN")).toBe("zh") + expect(normalizeLocale("zh-Hans")).toBe("zh") + }) + + it("returns 'zht' for Traditional Chinese", () => { + expect(normalizeLocale("zh-Hant")).toBe("zht") + expect(normalizeLocale("zh-TW")).toBe("zh") + expect(normalizeLocale("zh-hant-TW")).toBe("zht") + }) + + it("returns 'de' for German", () => { + expect(normalizeLocale("de")).toBe("de") + expect(normalizeLocale("de-AT")).toBe("de") + }) + + it("returns 'ko' for Korean", () => { + expect(normalizeLocale("ko")).toBe("ko") + expect(normalizeLocale("ko-KR")).toBe("ko") + }) + + it("returns 'no' for Norwegian Bokmål", () => { + expect(normalizeLocale("nb")).toBe("no") + expect(normalizeLocale("nb-NO")).toBe("no") + }) + + it("returns 'no' for Norwegian Nynorsk", () => { + expect(normalizeLocale("nn")).toBe("no") + }) + + it("returns 'br' for Portuguese", () => { + expect(normalizeLocale("pt")).toBe("br") + expect(normalizeLocale("pt-BR")).toBe("br") + expect(normalizeLocale("pt-PT")).toBe("br") + }) + + it("falls back to 'en' for unknown locale", () => { + expect(normalizeLocale("xx")).toBe("en") + expect(normalizeLocale("xyz-ZZ")).toBe("en") + }) + + it("is case-insensitive", () => { + expect(normalizeLocale("EN")).toBe("en") + expect(normalizeLocale("DE")).toBe("de") + expect(normalizeLocale("ZH-HANT")).toBe("zht") + }) +}) + +describe("resolveTemplate", () => { + it("returns text unchanged when no params", () => { + expect(resolveTemplate("hello world")).toBe("hello world") + }) + + it("returns text unchanged when params is undefined", () => { + expect(resolveTemplate("no {{var}} here", undefined)).toBe("no {{var}} here") + }) + + it("interpolates a single variable", () => { + expect(resolveTemplate("Hello {{name}}!", { name: "World" })).toBe("Hello World!") + }) + + it("interpolates multiple variables", () => { + const result = resolveTemplate("{{a}} + {{b}} = {{c}}", { a: "1", b: "2", c: "3" }) + expect(result).toBe("1 + 2 = 3") + }) + + it("replaces missing variable with empty string", () => { + expect(resolveTemplate("{{missing}}", {})).toBe("") + }) + + it("handles numeric variable values", () => { + expect(resolveTemplate("count: {{n}}", { n: 42 })).toBe("count: 42") + }) + + it("handles whitespace around key in braces", () => { + expect(resolveTemplate("{{ name }}", { name: "test" })).toBe("test") + }) + + it("leaves unrelated text intact", () => { + const result = resolveTemplate("prefix {{x}} suffix", { x: "VALUE" }) + expect(result).toBe("prefix VALUE suffix") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/message-contract.test.ts b/packages/kilo-vscode/tests/unit/message-contract.test.ts new file mode 100644 index 000000000..c26c830c8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/message-contract.test.ts @@ -0,0 +1,105 @@ +/** + * Message contract consistency tests. + * + * These tests verify that: + * 1. Every message type named in ExtensionMessage has a corresponding interface/type definition + * 2. Every WebviewMessage type handled in KiloProvider has a corresponding member in the WebviewMessage union + * 3. The message types used by mapSSEEventToWebviewMessage exist in ExtensionMessage + * + * These are static analysis tests - they read source files and check consistency. + */ + +import { describe, it, expect } from "bun:test" +import fs from "node:fs" +import path from "node:path" + +const ROOT = path.resolve(import.meta.dir, "../..") +const MESSAGES_FILE = path.join(ROOT, "webview-ui/src/types/messages.ts") +const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") +const KILO_PROVIDER_UTILS_FILE = path.join(ROOT, "src/kilo-provider-utils.ts") + +function readFile(filePath: string): string { + return fs.readFileSync(filePath, "utf-8") +} + +describe("ExtensionMessage type members", () => { + it("all members of ExtensionMessage union are defined as interfaces/types in messages.ts", () => { + const content = readFile(MESSAGES_FILE) + + // Extract ExtensionMessage union members + const unionMatch = content.match( + /export type ExtensionMessage\s*=\s*([\s\S]*?)(?=\nexport type|\nexport interface|\nexport function|\n\/\/|$)/, + ) + if (!unionMatch) { + expect(false, "Could not find ExtensionMessage union in messages.ts").toBe(true) + return + } + + const unionBody = unionMatch[1]! + const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!) + + const missing = memberNames.filter((name) => { + return !new RegExp(`(interface|type)\\s+${name}\\b`).test(content) + }) + + expect(missing, `ExtensionMessage members without definitions: ${missing.join(", ")}`).toEqual([]) + }) + + it("all members of WebviewMessage union are defined as interfaces/types in messages.ts", () => { + const content = readFile(MESSAGES_FILE) + + const unionMatch = content.match(/export type WebviewMessage\s*=\s*([\s\S]*?)(?=\n\/\/|$)/) + if (!unionMatch) { + expect(false, "Could not find WebviewMessage union in messages.ts").toBe(true) + return + } + + const unionBody = unionMatch[1]! + const memberNames = [...unionBody.matchAll(/\|\s*([A-Z]\w+)\b/g)].map((m) => m[1]!) + + const missing = memberNames.filter((name) => { + return !new RegExp(`(interface|type)\\s+${name}\\b`).test(content) + }) + + expect(missing, `WebviewMessage members without definitions: ${missing.join(", ")}`).toEqual([]) + }) +}) + +describe("KiloProvider message handler coverage", () => { + it("all WebviewMessage switch cases in KiloProvider exist in WebviewMessage union", () => { + const providerContent = readFile(KILO_PROVIDER_FILE) + const messagesContent = readFile(MESSAGES_FILE) + + // Extract case labels from handleWebviewMessage switch + const caseMatches = [...providerContent.matchAll(/case "([a-zA-Z]+)":/g)].map((m) => m[1]!) + + // Get all type values from WebviewMessage members + const typeValues = [...messagesContent.matchAll(/type:\s*"([a-zA-Z]+)"/g)].map((m) => m[1]!) + const typeSet = new Set(typeValues) + + const unrecognized = caseMatches.filter((c) => !typeSet.has(c)) + + expect( + unrecognized, + `KiloProvider switch cases not found in any message type definition: ${unrecognized.join(", ")}`, + ).toEqual([]) + }) +}) + +describe("mapSSEEventToWebviewMessage output types", () => { + it("all output types from mapSSEEventToWebviewMessage exist in ExtensionMessage", () => { + const utilsContent = readFile(KILO_PROVIDER_UTILS_FILE) + const messagesContent = readFile(MESSAGES_FILE) + + // Extract type literals used in the return values of mapSSEEventToWebviewMessage + const typeMatches = [...utilsContent.matchAll(/type:\s*"([a-zA-Z]+)"/g)].map((m) => m[1]!) + + // Get all type values defined in messages.ts + const allTypes = [...messagesContent.matchAll(/type:\s*"([a-zA-Z]+)"/g)].map((m) => m[1]!) + const typeSet = new Set(allTypes) + + const missing = typeMatches.filter((t) => !typeSet.has(t)) + + expect(missing, `Types in mapSSEEventToWebviewMessage not in messages.ts: ${missing.join(", ")}`).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts new file mode 100644 index 000000000..a2c035fe7 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/model-selector-utils.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect } from "bun:test" +import { + providerSortKey, + isFree, + buildTriggerLabel, + KILO_GATEWAY_ID, + PROVIDER_ORDER, +} from "../../webview-ui/src/components/chat/model-selector-utils" + +const labels = { select: "Select model", noProviders: "No providers", notSet: "Not set" } + +describe("providerSortKey", () => { + it("returns 0 for kilo gateway", () => { + expect(providerSortKey(KILO_GATEWAY_ID)).toBe(0) + }) + + it("returns correct index for known providers", () => { + expect(providerSortKey("anthropic")).toBe(1) + expect(providerSortKey("openai")).toBe(2) + expect(providerSortKey("google")).toBe(3) + }) + + it("returns order length for unknown provider", () => { + expect(providerSortKey("unknown-provider")).toBe(PROVIDER_ORDER.length) + }) + + it("is case-insensitive", () => { + expect(providerSortKey("Anthropic")).toBe(providerSortKey("anthropic")) + expect(providerSortKey("OpenAI")).toBe(providerSortKey("openai")) + }) + + it("respects custom order array", () => { + const order = ["z-provider", "a-provider"] + expect(providerSortKey("z-provider", order)).toBe(0) + expect(providerSortKey("a-provider", order)).toBe(1) + expect(providerSortKey("other", order)).toBe(2) + }) + + it("sorts providers correctly when used with sort", () => { + const ids = ["google", "anthropic", "kilo", "openai"] + const sorted = ids.slice().sort((a, b) => providerSortKey(a) - providerSortKey(b)) + expect(sorted).toEqual(["kilo", "anthropic", "openai", "google"]) + }) +}) + +describe("isFree", () => { + it("returns true when inputPrice is 0", () => { + expect(isFree({ inputPrice: 0 })).toBe(true) + }) + + it("returns false when inputPrice is positive", () => { + expect(isFree({ inputPrice: 0.001 })).toBe(false) + }) + + it("returns false when inputPrice is non-zero", () => { + expect(isFree({ inputPrice: 5 })).toBe(false) + }) +}) + +describe("buildTriggerLabel", () => { + it("returns resolved model name when available", () => { + expect(buildTriggerLabel("GPT-4o", null, false, "", true, labels)).toBe("GPT-4o") + }) + + it("returns modelID for kilo gateway raw selection", () => { + const raw = { providerID: "kilo", modelID: "kilo/auto" } + expect(buildTriggerLabel(undefined, raw, false, "", true, labels)).toBe("kilo/auto") + }) + + it("returns providerID / modelID for non-kilo raw selection", () => { + const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" } + expect(buildTriggerLabel(undefined, raw, false, "", true, labels)).toBe("anthropic / claude-3-5-sonnet") + }) + + it("returns clearLabel when allowClear and no selection", () => { + expect(buildTriggerLabel(undefined, null, true, "None", true, labels)).toBe("None") + }) + + it("falls back to labels.notSet when allowClear and clearLabel is empty", () => { + expect(buildTriggerLabel(undefined, null, true, "", true, labels)).toBe("Not set") + }) + + it("returns labels.select when providers exist and no selection", () => { + expect(buildTriggerLabel(undefined, null, false, "", true, labels)).toBe("Select model") + }) + + it("returns labels.noProviders when no providers available", () => { + expect(buildTriggerLabel(undefined, null, false, "", false, labels)).toBe("No providers") + }) + + it("prefers resolvedName over raw selection", () => { + const raw = { providerID: "anthropic", modelID: "claude-3-5-sonnet" } + expect(buildTriggerLabel("Claude Sonnet", raw, false, "", true, labels)).toBe("Claude Sonnet") + }) + + it("ignores partial raw selection (only providerID)", () => { + const raw = { providerID: "anthropic", modelID: "" } + expect(buildTriggerLabel(undefined, raw, false, "", true, labels)).toBe("Select model") + }) + + it("ignores partial raw selection (only modelID)", () => { + const raw = { providerID: "", modelID: "claude-3-5-sonnet" } + expect(buildTriggerLabel(undefined, raw, false, "", true, labels)).toBe("Select model") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/navigate.test.ts b/packages/kilo-vscode/tests/unit/navigate.test.ts index b63bb7b88..219fb495b 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 000000000..19b036864 --- /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 000000000..11a0684ed --- /dev/null +++ b/packages/kilo-vscode/tests/unit/permission-queue.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from "bun:test" +import { upsertPermission, removeSessionPermissions } from "../../webview-ui/src/context/permission-queue" +import type { PermissionRequest } from "../../webview-ui/src/types/messages" + +function perm(id: string, sessionID: string): PermissionRequest { + return { id, sessionID, toolName: "read_file", patterns: [], args: {} } +} + +describe("upsertPermission", () => { + it("appends new permission to empty list", () => { + const result = upsertPermission([], perm("p1", "s1")) + expect(result).toHaveLength(1) + expect(result[0]!.id).toBe("p1") + }) + + it("appends new permission to non-empty list", () => { + const list = [perm("p1", "s1")] + const result = upsertPermission(list, perm("p2", "s1")) + expect(result).toHaveLength(2) + expect(result[1]!.id).toBe("p2") + }) + + it("replaces existing permission with same id", () => { + const list = [perm("p1", "s1")] + const updated = { ...perm("p1", "s1"), toolName: "write_file" } + const result = upsertPermission(list, updated) + expect(result).toHaveLength(1) + expect(result[0]!.toolName).toBe("write_file") + }) + + it("does not mutate the original list on append", () => { + const list = [perm("p1", "s1")] + upsertPermission(list, perm("p2", "s1")) + expect(list).toHaveLength(1) + }) + + it("does not mutate the original list on replace", () => { + const list = [perm("p1", "s1")] + upsertPermission(list, { ...perm("p1", "s1"), toolName: "write_file" }) + expect(list[0]!.toolName).toBe("read_file") + }) + + it("replaces by id regardless of position", () => { + const list = [perm("p1", "s1"), perm("p2", "s1"), perm("p3", "s1")] + const updated = { ...perm("p2", "s1"), toolName: "write_file" } + const result = upsertPermission(list, updated) + expect(result).toHaveLength(3) + expect(result[1]!.toolName).toBe("write_file") + expect(result[0]!.toolName).toBe("read_file") + expect(result[2]!.toolName).toBe("read_file") + }) + + it("handles upsert of same permission idempotently", () => { + const list: PermissionRequest[] = [] + const r1 = upsertPermission(list, perm("p1", "s1")) + const r2 = upsertPermission(r1, perm("p1", "s1")) + expect(r2).toHaveLength(1) + }) +}) + +describe("removeSessionPermissions", () => { + it("returns empty list when input is empty", () => { + expect(removeSessionPermissions([], "s1")).toEqual([]) + }) + + it("removes all permissions for given session", () => { + const list = [perm("p1", "s1"), perm("p2", "s1"), perm("p3", "s2")] + const result = removeSessionPermissions(list, "s1") + expect(result).toHaveLength(1) + expect(result[0]!.sessionID).toBe("s2") + }) + + it("returns all items when session has no permissions", () => { + const list = [perm("p1", "s1"), perm("p2", "s2")] + const result = removeSessionPermissions(list, "s3") + expect(result).toHaveLength(2) + }) + + it("does not mutate the original list", () => { + const list = [perm("p1", "s1"), perm("p2", "s1")] + removeSessionPermissions(list, "s1") + expect(list).toHaveLength(2) + }) + + it("removes all items when all share the session", () => { + const list = [perm("p1", "s1"), perm("p2", "s1")] + const result = removeSessionPermissions(list, "s1") + expect(result).toHaveLength(0) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts new file mode 100644 index 000000000..e1460413e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/prompt-input-utils.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from "bun:test" +import { fileName, dirName, buildHighlightSegments } from "../../webview-ui/src/components/chat/prompt-input-utils" + +describe("fileName", () => { + it("extracts the last segment of a unix path", () => { + expect(fileName("src/components/chat/PromptInput.tsx")).toBe("PromptInput.tsx") + }) + + it("extracts the last segment of a Windows path", () => { + expect(fileName("src\\components\\chat\\PromptInput.tsx")).toBe("PromptInput.tsx") + }) + + it("returns the path itself when no separator present", () => { + expect(fileName("README.md")).toBe("README.md") + }) + + it("returns the filename for a single directory segment", () => { + expect(fileName("src/foo.ts")).toBe("foo.ts") + }) + + it("handles mixed separators", () => { + expect(fileName("src\\components/chat/File.tsx")).toBe("File.tsx") + }) +}) + +describe("dirName", () => { + it("returns empty string for a file with no directory", () => { + expect(dirName("README.md")).toBe("") + }) + + it("returns the directory for a simple path", () => { + expect(dirName("src/foo.ts")).toBe("src") + }) + + it("returns full directory for a short path", () => { + expect(dirName("src/components/foo.ts")).toBe("src/components") + }) + + it("truncates long directories to last two segments", () => { + const path = "packages/kilo-vscode/webview-ui/src/components/chat/foo.ts" + const result = dirName(path) + expect(result).toMatch(/^…\//) + expect(result).toContain("components/chat") + }) + + it("does not truncate directories at exactly 30 chars", () => { + const dir = "a".repeat(15) + "/" + "b".repeat(14) + const result = dirName(`${dir}/file.ts`) + expect(result).toBe(dir) + }) + + it("truncates directories longer than 30 chars", () => { + const dir = "a".repeat(16) + "/" + "b".repeat(15) + const result = dirName(`${dir}/file.ts`) + expect(result.startsWith("…/")).toBe(true) + }) + + it("normalizes Windows backslashes before measuring length", () => { + const result = dirName("src\\foo.ts") + expect(result).toBe("src") + }) +}) + +describe("buildHighlightSegments", () => { + it("returns single non-highlighted segment when paths set is empty", () => { + const result = buildHighlightSegments("hello world", new Set()) + expect(result).toEqual([{ text: "hello world", highlight: false }]) + }) + + it("returns single non-highlighted segment when no mention present", () => { + const result = buildHighlightSegments("hello world", new Set(["foo.ts"])) + expect(result).toEqual([{ text: "hello world", highlight: false }]) + }) + + it("highlights a single mention token", () => { + const result = buildHighlightSegments("@foo.ts", new Set(["foo.ts"])) + expect(result).toEqual([{ text: "@foo.ts", highlight: true }]) + }) + + it("splits text before and highlight token", () => { + const result = buildHighlightSegments("see @foo.ts here", new Set(["foo.ts"])) + expect(result).toEqual([ + { text: "see ", highlight: false }, + { text: "@foo.ts", highlight: true }, + { text: " here", highlight: false }, + ]) + }) + + it("highlights multiple mentions in order", () => { + const result = buildHighlightSegments("@a.ts and @b.ts done", new Set(["a.ts", "b.ts"])) + expect(result).toEqual([ + { text: "@a.ts", highlight: true }, + { text: " and ", highlight: false }, + { text: "@b.ts", highlight: true }, + { text: " done", highlight: false }, + ]) + }) + + it("picks the earliest mention when multiple paths could match", () => { + const result = buildHighlightSegments("@b.ts then @a.ts", new Set(["a.ts", "b.ts"])) + expect(result[0]).toEqual({ text: "@b.ts", highlight: true }) + expect(result[2]).toEqual({ text: "@a.ts", highlight: true }) + }) + + it("handles back-to-back mentions with no separator", () => { + const result = buildHighlightSegments("@a.ts@b.ts", new Set(["a.ts", "b.ts"])) + const highlighted = result.filter((s) => s.highlight) + expect(highlighted).toHaveLength(2) + }) + + it("returns empty array for empty string", () => { + const result = buildHighlightSegments("", new Set(["foo.ts"])) + expect(result).toEqual([]) + }) + + it("does not partially match longer paths", () => { + const result = buildHighlightSegments("@foo.ts", new Set(["foo.tsx"])) + expect(result).toEqual([{ text: "@foo.ts", highlight: false }]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/provider-utils.test.ts b/packages/kilo-vscode/tests/unit/provider-utils.test.ts new file mode 100644 index 000000000..315a589f9 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/provider-utils.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect } from "bun:test" +import { flattenModels, findModel } from "../../webview-ui/src/context/provider-utils" +import type { Provider } from "../../webview-ui/src/types/messages" + +function makeProvider(id: string, name: string, modelIds: string[]): Provider { + const models: Provider["models"] = {} + for (const mid of modelIds) { + models[mid] = { id: mid, name: mid.toUpperCase() } + } + return { id, name, models } +} + +describe("flattenModels", () => { + it("returns empty array for empty providers", () => { + expect(flattenModels({})).toEqual([]) + }) + + it("enriches each model with providerID and providerName", () => { + const providers = { openai: makeProvider("openai", "OpenAI", ["gpt-4"]) } + const models = flattenModels(providers) + expect(models).toHaveLength(1) + expect(models[0]!.providerID).toBe("openai") + expect(models[0]!.providerName).toBe("OpenAI") + expect(models[0]!.id).toBe("gpt-4") + }) + + it("flattens multiple providers", () => { + const providers = { + openai: makeProvider("openai", "OpenAI", ["gpt-4", "gpt-3.5"]), + anthropic: makeProvider("anthropic", "Anthropic", ["claude-3"]), + } + const models = flattenModels(providers) + expect(models).toHaveLength(3) + const ids = models.map((m) => m.id) + expect(ids).toContain("gpt-4") + expect(ids).toContain("gpt-3.5") + expect(ids).toContain("claude-3") + }) + + it("handles provider with no models", () => { + const providers = { empty: makeProvider("empty", "Empty", []) } + expect(flattenModels(providers)).toEqual([]) + }) +}) + +describe("findModel", () => { + const providers = { + openai: makeProvider("openai", "OpenAI", ["gpt-4", "gpt-3.5"]), + anthropic: makeProvider("anthropic", "Anthropic", ["claude-3"]), + } + const models = flattenModels(providers) + + it("returns undefined for null selection", () => { + expect(findModel(models, null)).toBeUndefined() + }) + + it("finds model by providerID and modelID", () => { + const result = findModel(models, { providerID: "openai", modelID: "gpt-4" }) + expect(result).not.toBeUndefined() + expect(result?.id).toBe("gpt-4") + expect(result?.providerID).toBe("openai") + }) + + it("returns undefined when providerID does not match", () => { + expect(findModel(models, { providerID: "unknown", modelID: "gpt-4" })).toBeUndefined() + }) + + it("returns undefined when modelID does not match", () => { + expect(findModel(models, { providerID: "openai", modelID: "unknown-model" })).toBeUndefined() + }) + + it("finds model from second provider", () => { + const result = findModel(models, { providerID: "anthropic", modelID: "claude-3" }) + expect(result?.providerName).toBe("Anthropic") + }) + + it("returns undefined for empty model list", () => { + expect(findModel([], { providerID: "openai", modelID: "gpt-4" })).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/qrcode.test.ts b/packages/kilo-vscode/tests/unit/qrcode.test.ts new file mode 100644 index 000000000..47a87be67 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/qrcode.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect } from "bun:test" +import { generateQRCode } from "../../webview-ui/src/utils/qrcode" + +describe("generateQRCode", () => { + it("returns a data URL for a valid string", async () => { + const result = await generateQRCode("https://example.com") + expect(result).toMatch(/^data:image\/png;base64,/) + }) + + it("returns a non-empty base64 payload", async () => { + const result = await generateQRCode("hello") + const base64 = result.replace("data:image/png;base64,", "") + expect(base64.length).toBeGreaterThan(0) + }) + + it("produces different outputs for different inputs", async () => { + const a = await generateQRCode("https://example.com/a") + const b = await generateQRCode("https://example.com/b") + expect(a).not.toBe(b) + }) + + it("throws on empty string", async () => { + expect(generateQRCode("")).rejects.toThrow() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts new file mode 100644 index 000000000..8d84dd7bc --- /dev/null +++ b/packages/kilo-vscode/tests/unit/question-dock-utils.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from "bun:test" +import { toggleAnswer, buildSubtitleText } from "../../webview-ui/src/components/chat/question-dock-utils" + +describe("toggleAnswer", () => { + it("adds answer when not present", () => { + expect(toggleAnswer([], "option-a")).toEqual(["option-a"]) + }) + + it("removes answer when already present", () => { + expect(toggleAnswer(["option-a"], "option-a")).toEqual([]) + }) + + it("adds to existing answers without removing others", () => { + const result = toggleAnswer(["a", "b"], "c") + expect(result).toEqual(["a", "b", "c"]) + }) + + it("removes from the middle without affecting other entries", () => { + const result = toggleAnswer(["a", "b", "c"], "b") + expect(result).toEqual(["a", "c"]) + }) + + it("does not mutate the original array", () => { + const original = ["a", "b"] + toggleAnswer(original, "c") + expect(original).toEqual(["a", "b"]) + }) + + it("handles empty answer string", () => { + expect(toggleAnswer([], "")).toEqual([""]) + expect(toggleAnswer([""], "")).toEqual([]) + }) + + it("only removes the first occurrence (deduplication edge case)", () => { + const result = toggleAnswer(["a", "a"], "a") + expect(result).toEqual(["a"]) + }) +}) + +describe("buildSubtitleText", () => { + it("returns empty string for count of 0", () => { + expect(buildSubtitleText(0, "question", "questions")).toBe("") + }) + + it("uses singular form for count of 1", () => { + expect(buildSubtitleText(1, "question", "questions")).toBe("1 question") + }) + + it("uses plural form for count of 2", () => { + expect(buildSubtitleText(2, "question", "questions")).toBe("2 questions") + }) + + it("uses plural form for large counts", () => { + expect(buildSubtitleText(10, "question", "questions")).toBe("10 questions") + }) + + it("works with i18n-style translation strings", () => { + expect(buildSubtitleText(1, "ui.common.question.one", "ui.common.question.other")).toBe("1 ui.common.question.one") + expect(buildSubtitleText(3, "ui.common.question.one", "ui.common.question.other")).toBe( + "3 ui.common.question.other", + ) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts new file mode 100644 index 000000000..84a1c5219 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "bun:test" +import { parseServerPort } from "../../src/services/cli-backend/server-utils" + +describe("parseServerPort", () => { + it("parses port from standard CLI startup message", () => { + expect(parseServerPort("kilo server listening on http://127.0.0.1:12345")).toBe(12345) + }) + + it("parses port from localhost variant", () => { + expect(parseServerPort("listening on http://localhost:8080")).toBe(8080) + }) + + it("parses port when embedded in longer output", () => { + const output = "[INFO] 2024-01-01 kilo server listening on http://127.0.0.1:54321\n[INFO] ready" + expect(parseServerPort(output)).toBe(54321) + }) + + it("returns null for output without listening message", () => { + expect(parseServerPort("Starting server...")).toBeNull() + }) + + it("returns null for empty string", () => { + expect(parseServerPort("")).toBeNull() + }) + + it("returns null when no port in URL", () => { + expect(parseServerPort("listening on http://127.0.0.1")).toBeNull() + }) + + it("parses high port numbers", () => { + expect(parseServerPort("listening on http://127.0.0.1:65535")).toBe(65535) + }) + + it("parses port 1 (edge case)", () => { + expect(parseServerPort("listening on http://127.0.0.1:1")).toBe(1) + }) + + it("returns null for stderr-style messages without port", () => { + expect(parseServerPort("[ERROR] failed to bind port")).toBeNull() + }) + + it("matches only first occurrence when multiple ports present", () => { + const output = "listening on http://127.0.0.1:3000 and http://127.0.0.1:4000" + expect(parseServerPort(output)).toBe(3000) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts new file mode 100644 index 000000000..d3ce08e78 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-terminal-manager.test.ts @@ -0,0 +1,78 @@ +/** + * SessionTerminalManager tests. + * + * The class is tightly coupled to VS Code terminal APIs. We use ts-morph + * static analysis to verify structural invariants that protect against + * real regressions — focusing on ordering constraints and cleanup logic + * that are easy to break during refactoring. + */ + +import { describe, it, expect } from "bun:test" +import path from "node:path" +import { Project, SyntaxKind } from "ts-morph" + +const ROOT = path.resolve(import.meta.dir, "../..") +const FILE = path.join(ROOT, "src/agent-manager/SessionTerminalManager.ts") + +function getClass() { + const project = new Project({ compilerOptions: { allowJs: true } }) + const source = project.addSourceFileAtPath(FILE) + return source.getFirstDescendantByKind(SyntaxKind.ClassDeclaration)! +} + +function body(name: string): string { + const cls = getClass() + const method = cls.getMethod(name) + expect(method, `method ${name} not found in SessionTerminalManager`).toBeTruthy() + return method!.getText() +} + +describe("SessionTerminalManager structure", () => { + it("constructor registers both terminal lifecycle listeners", () => { + const cls = getClass() + const ctor = cls.getConstructors()[0] + expect(ctor).toBeTruthy() + const text = ctor!.getText() + // Both listeners are required: close (cleanup) and active-change (context key) + expect(text).toContain("onDidCloseTerminal") + expect(text).toContain("onDidChangeActiveTerminal") + }) + + it("dispose clears the context key, disposes terminals, and clears the map", () => { + const text = body("dispose") + // All three are required for clean shutdown — missing any would leak resources + expect(text).toContain("kilo-code.agentTerminalFocus") + expect(text).toContain("terminal.dispose()") + expect(text).toContain("terminals.clear()") + }) + + it("showTerminal resolves CWD from worktree with workspace fallback", () => { + const text = body("showTerminal") + // The fallback chain must be worktreePath ?? workspacePath, not the reverse. + // Getting this wrong would run agents in the wrong directory. + expect(text).toContain("worktreePath ?? workspacePath") + }) + + /** + * Regression: showOrCreate must check exitStatus before checking CWD changes. + * If reversed, a stale exited terminal with a different CWD would hit the + * dispose path instead of the cleanup path, potentially leaving ghost entries. + */ + it("showOrCreate checks exit status before CWD mismatch", () => { + const text = body("showOrCreate") + const exitIdx = text.indexOf("exitStatus") + const cwdIdx = text.indexOf("entry.cwd !== cwd") + expect(exitIdx).toBeGreaterThan(-1) + expect(cwdIdx).toBeGreaterThan(-1) + expect(exitIdx, "exit check must come before cwd check").toBeLessThan(cwdIdx) + }) + + it("showOrCreate updates context key after showing terminal", () => { + const text = body("showOrCreate") + const showIdx = text.lastIndexOf("entry.terminal.show") + const contextIdx = text.lastIndexOf("this.updateContextKey()") + expect(showIdx).toBeGreaterThan(-1) + expect(contextIdx).toBeGreaterThan(-1) + expect(showIdx, "show must precede updateContextKey").toBeLessThan(contextIdx) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/session-utils.test.ts b/packages/kilo-vscode/tests/unit/session-utils.test.ts new file mode 100644 index 000000000..bd18a874e --- /dev/null +++ b/packages/kilo-vscode/tests/unit/session-utils.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "bun:test" +import { computeStatus, calcTotalCost, calcContextUsage } from "../../webview-ui/src/context/session-utils" +import type { Part } from "../../webview-ui/src/types/messages" + +const t = (key: string) => key + +describe("computeStatus", () => { + it("returns undefined for undefined part", () => { + expect(computeStatus(undefined, t)).toBeUndefined() + }) + + it("maps task tool to delegating status", () => { + const part: Part = { type: "tool", id: "p1", tool: "task", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.delegating") + }) + + it("maps todowrite tool to planning status", () => { + const part: Part = { type: "tool", id: "p1", tool: "todowrite", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.planning") + }) + + it("maps todoread tool to planning status", () => { + const part: Part = { type: "tool", id: "p1", tool: "todoread", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.planning") + }) + + it("maps read tool to gatheringContext status", () => { + const part: Part = { type: "tool", id: "p1", tool: "read", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.gatheringContext") + }) + + it("maps list/grep/glob tools to searchingCodebase status", () => { + for (const tool of ["list", "grep", "glob"] as const) { + const part: Part = { type: "tool", id: "p1", tool, state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.searchingCodebase") + } + }) + + it("maps webfetch tool to searchingWeb status", () => { + const part: Part = { type: "tool", id: "p1", tool: "webfetch", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.searchingWeb") + }) + + it("maps edit/write tools to makingEdits status", () => { + for (const tool of ["edit", "write"] as const) { + const part: Part = { type: "tool", id: "p1", tool, state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.makingEdits") + } + }) + + it("maps bash tool to runningCommands status", () => { + const part: Part = { type: "tool", id: "p1", tool: "bash", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.runningCommands") + }) + + it("returns undefined for unknown tool", () => { + const part: Part = { type: "tool", id: "p1", tool: "unknown_tool", state: { status: "running", input: {} } } + expect(computeStatus(part, t)).toBeUndefined() + }) + + it("maps reasoning part to thinking status", () => { + const part: Part = { type: "reasoning", id: "p1", text: "thinking..." } + expect(computeStatus(part, t)).toBe("ui.sessionTurn.status.thinking") + }) + + it("maps text part to writingResponse status", () => { + const part: Part = { type: "text", id: "p1", text: "hello" } + expect(computeStatus(part, t)).toBe("session.status.writingResponse") + }) +}) + +describe("calcTotalCost", () => { + it("returns 0 for empty messages", () => { + expect(calcTotalCost([])).toBe(0) + }) + + it("sums costs from assistant messages only", () => { + const msgs = [ + { role: "user", cost: 1 }, + { role: "assistant", cost: 0.05 }, + { role: "assistant", cost: 0.03 }, + ] + expect(calcTotalCost(msgs)).toBeCloseTo(0.08) + }) + + it("ignores user messages", () => { + const msgs = [ + { role: "user", cost: 999 }, + { role: "assistant", cost: 0.01 }, + ] + expect(calcTotalCost(msgs)).toBeCloseTo(0.01) + }) + + it("handles missing cost as 0", () => { + const msgs = [{ role: "assistant" }, { role: "assistant", cost: 0.02 }] + expect(calcTotalCost(msgs)).toBeCloseTo(0.02) + }) +}) + +describe("calcContextUsage", () => { + it("sums all token types", () => { + const tokens = { input: 100, output: 50, reasoning: 20, cache: { read: 10, write: 5 } } + const result = calcContextUsage(tokens, undefined) + expect(result.tokens).toBe(185) + }) + + it("returns null percentage when no context limit", () => { + const result = calcContextUsage({ input: 100, output: 50 }, undefined) + expect(result.percentage).toBeNull() + }) + + it("calculates percentage correctly", () => { + const result = calcContextUsage({ input: 1000, output: 1000 }, 4000) + expect(result.percentage).toBe(50) + }) + + it("rounds percentage to integer", () => { + const result = calcContextUsage({ input: 1, output: 2 }, 3) + expect(Number.isInteger(result.percentage)).toBe(true) + }) + + it("handles missing optional fields as 0", () => { + const result = calcContextUsage({ input: 100, output: 0 }, 1000) + expect(result.tokens).toBe(100) + expect(result.percentage).toBe(10) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/sse-client-utils.test.ts b/packages/kilo-vscode/tests/unit/sse-client-utils.test.ts new file mode 100644 index 000000000..1670d25e8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/sse-client-utils.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect } from "bun:test" +import { unwrapSSEPayload } from "../../src/services/cli-backend/sse-utils" + +describe("unwrapSSEPayload", () => { + it("unwraps global endpoint payload wrapper", () => { + const raw = { + directory: "/workspace", + payload: { type: "session.created", properties: { info: {} } }, + } + const event = unwrapSSEPayload(raw) + expect(event?.type).toBe("session.created") + }) + + it("returns direct event when no payload wrapper", () => { + const raw = { type: "server.connected", properties: {} } + const event = unwrapSSEPayload(raw) + expect(event?.type).toBe("server.connected") + }) + + it("returns null when no type field in direct event", () => { + const raw = { properties: {} } + expect(unwrapSSEPayload(raw)).toBeNull() + }) + + it("returns null when payload wrapper exists but has no type", () => { + const raw = { directory: "/workspace", payload: { properties: {} } } + expect(unwrapSSEPayload(raw)).toBeNull() + }) + + it("returns null for null input", () => { + expect(unwrapSSEPayload(null)).toBeNull() + }) + + it("returns null for empty object", () => { + expect(unwrapSSEPayload({})).toBeNull() + }) + + it("returns null for non-object input", () => { + expect(unwrapSSEPayload("string")).toBeNull() + expect(unwrapSSEPayload(42)).toBeNull() + }) + + it("uses payload over root when both have type", () => { + const raw = { + type: "root-type", + payload: { type: "payload-type", properties: {} }, + } + const event = unwrapSSEPayload(raw) + expect(event?.type).toBe("payload-type") + }) + + it("handles nested event types correctly", () => { + const raw = { + payload: { + type: "message.updated", + properties: { + info: { id: "m1", sessionID: "s1", role: "assistant", time: { created: 0 } }, + }, + }, + } + const event = unwrapSSEPayload(raw) + expect(event?.type).toBe("message.updated") + }) +}) diff --git a/packages/kilo-vscode/tests/unit/support-prompt.test.ts b/packages/kilo-vscode/tests/unit/support-prompt.test.ts new file mode 100644 index 000000000..76934ecda --- /dev/null +++ b/packages/kilo-vscode/tests/unit/support-prompt.test.ts @@ -0,0 +1,182 @@ +import { describe, it, expect } from "bun:test" +import { createPrompt } from "../../src/services/code-actions/support-prompt" + +const base = { + filePath: "src/foo.ts", + startLine: "10", + endLine: "20", + selectedText: "const x = 1", + userInput: "", +} + +describe("createPrompt", () => { + describe("EXPLAIN", () => { + it("includes file path and line range", () => { + const result = createPrompt("EXPLAIN", base) + expect(result).toContain("src/foo.ts:10-20") + }) + + it("includes selected text in code fence", () => { + const result = createPrompt("EXPLAIN", base) + expect(result).toContain("```\nconst x = 1\n```") + }) + + it("includes userInput when provided", () => { + const result = createPrompt("EXPLAIN", { ...base, userInput: "explain this" }) + expect(result).toContain("explain this") + }) + + it("does not include diagnostics section", () => { + const result = createPrompt("EXPLAIN", base) + expect(result).not.toContain("Current problems detected") + }) + }) + + describe("FIX", () => { + it("includes file path and line range", () => { + const result = createPrompt("FIX", base) + expect(result).toContain("src/foo.ts:10-20") + }) + + it("includes no diagnostics section when diagnostics is empty", () => { + const result = createPrompt("FIX", { ...base, diagnostics: [] }) + expect(result).not.toContain("Current problems detected") + }) + + it("includes diagnostics section when diagnostics provided", () => { + const diag = [{ source: "ts", message: "Type error", code: 2322 }] + const result = createPrompt("FIX", { ...base, diagnostics: diag }) + expect(result).toContain("Current problems detected") + expect(result).toContain("[ts] Type error (2322)") + }) + + it("formats diagnostic without code", () => { + const diag = [{ source: "eslint", message: "no-unused-vars" }] + const result = createPrompt("FIX", { ...base, diagnostics: diag }) + expect(result).toContain("[eslint] no-unused-vars") + expect(result).not.toContain("undefined") + }) + + it("formats diagnostic without source using 'Error' fallback", () => { + const diag = [{ message: "something went wrong" }] + const result = createPrompt("FIX", { ...base, diagnostics: diag }) + expect(result).toContain("[Error] something went wrong") + }) + + it("includes multiple diagnostics", () => { + const diag = [ + { source: "ts", message: "Err1", code: 1 }, + { source: "ts", message: "Err2", code: 2 }, + ] + const result = createPrompt("FIX", { ...base, diagnostics: diag }) + expect(result).toContain("[ts] Err1 (1)") + expect(result).toContain("[ts] Err2 (2)") + }) + }) + + describe("IMPROVE", () => { + it("includes file path and line range", () => { + const result = createPrompt("IMPROVE", base) + expect(result).toContain("src/foo.ts:10-20") + }) + + it("includes selected text in code fence", () => { + const result = createPrompt("IMPROVE", base) + expect(result).toContain("```\nconst x = 1\n```") + }) + + it("does not render diagnosticText placeholder", () => { + const result = createPrompt("IMPROVE", base) + expect(result).not.toContain("${diagnosticText}") + }) + }) + + describe("ADD_TO_CONTEXT", () => { + it("produces compact file reference with code fence", () => { + const result = createPrompt("ADD_TO_CONTEXT", base) + expect(result).toContain("src/foo.ts:10-20") + expect(result).toContain("```\nconst x = 1\n```") + }) + + it("does not include explanatory prose", () => { + const result = createPrompt("ADD_TO_CONTEXT", base) + expect(result).not.toContain("Please") + }) + }) + + describe("TERMINAL_ADD_TO_CONTEXT", () => { + it("includes terminalContent in code fence", () => { + const result = createPrompt("TERMINAL_ADD_TO_CONTEXT", { + userInput: "", + terminalContent: "npm install", + }) + expect(result).toContain("```\nnpm install\n```") + }) + + it("includes userInput when provided", () => { + const result = createPrompt("TERMINAL_ADD_TO_CONTEXT", { + userInput: "context here", + terminalContent: "ls", + }) + expect(result).toContain("context here") + }) + + it("renders empty string for missing terminalContent", () => { + const result = createPrompt("TERMINAL_ADD_TO_CONTEXT", { userInput: "" }) + expect(result).toContain("```\n\n```") + }) + }) + + describe("TERMINAL_FIX", () => { + it("includes terminalContent in code fence", () => { + const result = createPrompt("TERMINAL_FIX", { + userInput: "", + terminalContent: "gti status", + }) + expect(result).toContain("```\ngti status\n```") + }) + + it("asks to fix the command", () => { + const result = createPrompt("TERMINAL_FIX", { userInput: "", terminalContent: "" }) + expect(result).toContain("Fix this terminal command") + }) + }) + + describe("TERMINAL_EXPLAIN", () => { + it("includes terminalContent in code fence", () => { + const result = createPrompt("TERMINAL_EXPLAIN", { + userInput: "", + terminalContent: "grep -r foo .", + }) + expect(result).toContain("```\ngrep -r foo .\n```") + }) + + it("asks to explain the command", () => { + const result = createPrompt("TERMINAL_EXPLAIN", { userInput: "", terminalContent: "" }) + expect(result).toContain("Explain this terminal command") + }) + }) + + describe("missing params", () => { + it("renders empty string for unknown template variable", () => { + const result = createPrompt("ADD_TO_CONTEXT", { + filePath: "f.ts", + startLine: "1", + endLine: "2", + }) + expect(result).not.toContain("${selectedText}") + expect(result).toContain("```\n\n```") + }) + + it("does not include literal placeholder text", () => { + const result = createPrompt("EXPLAIN", { + filePath: "x.ts", + startLine: "1", + endLine: "1", + selectedText: "code", + userInput: "", + }) + expect(result).not.toMatch(/\$\{[a-zA-Z]+\}/) + }) + }) +}) 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 000000000..999bb9b26 --- /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/telemetry-errors.test.ts b/packages/kilo-vscode/tests/unit/telemetry-errors.test.ts new file mode 100644 index 000000000..c4ef37b8b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/telemetry-errors.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect } from "bun:test" +import { + ApiProviderError, + isApiProviderError, + getApiProviderErrorProperties, + ConsecutiveMistakeError, + isConsecutiveMistakeError, + getConsecutiveMistakeErrorProperties, +} from "../../src/services/telemetry/errors" + +describe("ApiProviderError", () => { + it("constructs with required fields", () => { + const err = new ApiProviderError("failed", "openai", "gpt-4", "chat") + expect(err.message).toBe("failed") + expect(err.provider).toBe("openai") + expect(err.modelId).toBe("gpt-4") + expect(err.operation).toBe("chat") + expect(err.errorCode).toBeUndefined() + expect(err.name).toBe("ApiProviderError") + }) + + it("constructs with optional errorCode", () => { + const err = new ApiProviderError("rate limited", "anthropic", "claude-3", "stream", 429) + expect(err.errorCode).toBe(429) + }) + + it("is an instance of Error", () => { + const err = new ApiProviderError("x", "p", "m", "o") + expect(err instanceof Error).toBe(true) + }) + + it("errorCode of 0 is preserved", () => { + const err = new ApiProviderError("x", "p", "m", "o", 0) + expect(err.errorCode).toBe(0) + }) +}) + +describe("isApiProviderError", () => { + it("returns true for ApiProviderError instance", () => { + const err = new ApiProviderError("x", "openai", "gpt-4", "chat") + expect(isApiProviderError(err)).toBe(true) + }) + + it("returns false for plain Error", () => { + expect(isApiProviderError(new Error("x"))).toBe(false) + }) + + it("returns false for null", () => { + expect(isApiProviderError(null)).toBe(false) + }) + + it("returns false for undefined", () => { + expect(isApiProviderError(undefined)).toBe(false) + }) + + it("returns false for plain object", () => { + expect(isApiProviderError({ name: "ApiProviderError", provider: "x" })).toBe(false) + }) + + it("returns false when name differs", () => { + const err = new ApiProviderError("x", "p", "m", "o") + err.name = "SomethingElse" + expect(isApiProviderError(err)).toBe(false) + }) + + it("returns true for deserialized error with matching name and properties", () => { + const err = Object.assign(new Error("x"), { + name: "ApiProviderError", + provider: "openai", + modelId: "gpt-4", + operation: "chat", + }) + expect(isApiProviderError(err)).toBe(true) + }) +}) + +describe("getApiProviderErrorProperties", () => { + it("returns all required properties", () => { + const err = new ApiProviderError("x", "openai", "gpt-4", "chat") + const props = getApiProviderErrorProperties(err) + expect(props).toEqual({ provider: "openai", modelId: "gpt-4", operation: "chat" }) + }) + + it("includes errorCode when present", () => { + const err = new ApiProviderError("x", "openai", "gpt-4", "chat", 429) + const props = getApiProviderErrorProperties(err) + expect(props).toEqual({ provider: "openai", modelId: "gpt-4", operation: "chat", errorCode: 429 }) + }) + + it("omits errorCode when undefined", () => { + const err = new ApiProviderError("x", "openai", "gpt-4", "chat") + const props = getApiProviderErrorProperties(err) + expect("errorCode" in props).toBe(false) + }) + + it("includes errorCode of 0", () => { + const err = new ApiProviderError("x", "openai", "gpt-4", "chat", 0) + const props = getApiProviderErrorProperties(err) + expect(props.errorCode).toBe(0) + }) +}) + +describe("ConsecutiveMistakeError", () => { + it("constructs with required fields and default reason", () => { + const err = new ConsecutiveMistakeError("too many", "task-1", 3, 5) + expect(err.message).toBe("too many") + expect(err.taskId).toBe("task-1") + expect(err.consecutiveMistakeCount).toBe(3) + expect(err.consecutiveMistakeLimit).toBe(5) + expect(err.reason).toBe("unknown") + expect(err.provider).toBeUndefined() + expect(err.modelId).toBeUndefined() + expect(err.name).toBe("ConsecutiveMistakeError") + }) + + it("constructs with all optional fields", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3, "no_tools_used", "openai", "gpt-4") + expect(err.reason).toBe("no_tools_used") + expect(err.provider).toBe("openai") + expect(err.modelId).toBe("gpt-4") + }) + + it("is an instance of Error", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3) + expect(err instanceof Error).toBe(true) + }) +}) + +describe("isConsecutiveMistakeError", () => { + it("returns true for ConsecutiveMistakeError instance", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3) + expect(isConsecutiveMistakeError(err)).toBe(true) + }) + + it("returns false for plain Error", () => { + expect(isConsecutiveMistakeError(new Error("x"))).toBe(false) + }) + + it("returns false for ApiProviderError", () => { + const err = new ApiProviderError("x", "p", "m", "o") + expect(isConsecutiveMistakeError(err)).toBe(false) + }) + + it("returns false for null", () => { + expect(isConsecutiveMistakeError(null)).toBe(false) + }) + + it("returns false when name differs", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3) + err.name = "OtherError" + expect(isConsecutiveMistakeError(err)).toBe(false) + }) +}) + +describe("getConsecutiveMistakeErrorProperties", () => { + it("returns all required properties", () => { + const err = new ConsecutiveMistakeError("x", "task-1", 3, 5) + const props = getConsecutiveMistakeErrorProperties(err) + expect(props).toEqual({ + taskId: "task-1", + consecutiveMistakeCount: 3, + consecutiveMistakeLimit: 5, + reason: "unknown", + }) + }) + + it("includes provider and modelId when present", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3, "tool_repetition", "anthropic", "claude-3") + const props = getConsecutiveMistakeErrorProperties(err) + expect(props.provider).toBe("anthropic") + expect(props.modelId).toBe("claude-3") + }) + + it("omits provider and modelId when undefined", () => { + const err = new ConsecutiveMistakeError("x", "t", 1, 3) + const props = getConsecutiveMistakeErrorProperties(err) + expect("provider" in props).toBe(false) + expect("modelId" in props).toBe(false) + }) + + it("includes reason correctly for each reason type", () => { + const reasons = ["no_tools_used", "tool_repetition", "unknown"] as const + for (const reason of reasons) { + const err = new ConsecutiveMistakeError("x", "t", 1, 3, reason) + expect(getConsecutiveMistakeErrorProperties(err).reason).toBe(reason) + } + }) +}) diff --git a/packages/kilo-vscode/tests/unit/telemetry-proxy-utils.test.ts b/packages/kilo-vscode/tests/unit/telemetry-proxy-utils.test.ts new file mode 100644 index 000000000..a82e64df8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/telemetry-proxy-utils.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "bun:test" +import { buildTelemetryPayload, buildTelemetryAuthHeader } from "../../src/services/telemetry/telemetry-proxy-utils" + +describe("buildTelemetryPayload", () => { + it("includes event name in payload", () => { + const result = buildTelemetryPayload("test.event", {}, undefined) + expect(result.event).toBe("test.event") + }) + + it("merges provider properties with event properties", () => { + const result = buildTelemetryPayload("test.event", { eventProp: "value" }, { providerProp: "providerValue" }) + expect(result.properties.eventProp).toBe("value") + expect(result.properties.providerProp).toBe("providerValue") + }) + + it("event properties override provider properties", () => { + const result = buildTelemetryPayload("test.event", { shared: "from-event" }, { shared: "from-provider" }) + expect(result.properties.shared).toBe("from-event") + }) + + it("handles undefined event properties", () => { + const result = buildTelemetryPayload("test.event", undefined, { providerProp: "x" }) + expect(result.properties.providerProp).toBe("x") + }) + + it("handles undefined provider properties", () => { + const result = buildTelemetryPayload("test.event", { key: "val" }, undefined) + expect(result.properties.key).toBe("val") + }) + + it("handles both undefined", () => { + const result = buildTelemetryPayload("test.event", undefined, undefined) + expect(result.properties).toEqual({}) + }) +}) + +describe("buildTelemetryAuthHeader", () => { + it("returns a Basic auth header string", () => { + const result = buildTelemetryAuthHeader("mypassword") + expect(result.startsWith("Basic ")).toBe(true) + }) + + it("encodes kilo:password in base64", () => { + const result = buildTelemetryAuthHeader("secret") + const encoded = Buffer.from("kilo:secret").toString("base64") + expect(result).toBe(`Basic ${encoded}`) + }) + + it("handles empty password", () => { + const result = buildTelemetryAuthHeader("") + const encoded = Buffer.from("kilo:").toString("base64") + expect(result).toBe(`Basic ${encoded}`) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/useless-suggestion-filter.test.ts b/packages/kilo-vscode/tests/unit/useless-suggestion-filter.test.ts new file mode 100644 index 000000000..ae6011007 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/useless-suggestion-filter.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "bun:test" +import { + suggestionConsideredDuplication, + postprocessAutocompleteSuggestion, +} from "../../src/services/autocomplete/classic-auto-complete/uselessSuggestionFilter" + +describe("suggestionConsideredDuplication", () => { + describe("DuplicatesFromPrefixOrSuffix", () => { + it("filters empty suggestion", () => { + expect(suggestionConsideredDuplication({ suggestion: "", prefix: "abc", suffix: "" })).toBe(true) + }) + + it("filters whitespace-only suggestion", () => { + expect(suggestionConsideredDuplication({ suggestion: " ", prefix: "abc", suffix: "" })).toBe(true) + }) + + it("filters suggestion already at end of prefix", () => { + expect( + suggestionConsideredDuplication({ suggestion: "return x", prefix: "function foo() {\n return x", suffix: "" }), + ).toBe(true) + }) + + it("filters suggestion already at start of suffix", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "const y = 2", + prefix: "const x = 1\n", + suffix: "const y = 2\n", + }), + ).toBe(true) + }) + + it("passes unique suggestion not in prefix or suffix", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "const result = x + y", + prefix: "function add(x, y) {\n ", + suffix: "\n}", + }), + ).toBe(false) + }) + }) + + describe("DuplicatesFromEdgeLines (multiline)", () => { + it("filters multiline when first line matches last prefix line", () => { + expect( + suggestionConsideredDuplication({ + suggestion: " return x\n return y", + prefix: "function foo() {\n return x", + suffix: "\n}", + }), + ).toBe(true) + }) + + it("filters multiline when last line matches first suffix line", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "const a = 1\nconst b = 2", + prefix: "function setup() {\n", + suffix: "const b = 2\n}", + }), + ).toBe(true) + }) + + it("does not treat single-line suggestion as edge-line duplicate", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "const x = 1", + prefix: "function foo() {\n", + suffix: "const x = 2\n}", + }), + ).toBe(false) + }) + }) + + describe("containsRepetitivePhraseFromPrefix", () => { + it("filters looping suggestion with repeated phrase", () => { + const phrase = "the beginning. We are going to start from " + const suggestion = phrase + phrase + phrase + phrase + expect( + suggestionConsideredDuplication({ + suggestion, + prefix: "Let's start from ", + suffix: "", + }), + ).toBe(true) + }) + + it("passes short suggestion without repetition", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "const x = getValue()", + prefix: "// compute\n", + suffix: "", + }), + ).toBe(false) + }) + }) + + describe("normalizeToCompleteLine", () => { + it("expands partial prefix tail + suffix head and detects duplication", () => { + expect( + suggestionConsideredDuplication({ + suggestion: "onst x = 1", + prefix: "// line\nc", + suffix: " // end\nmore", + }), + ).toBe(false) + }) + }) +}) + +describe("postprocessAutocompleteSuggestion", () => { + it("returns undefined for duplicate suggestion", () => { + const result = postprocessAutocompleteSuggestion({ + suggestion: "return x", + prefix: "function foo() {\n return x", + suffix: "", + model: "codestral", + }) + expect(result).toBeUndefined() + }) + + it("returns the suggestion when it is unique", () => { + const result = postprocessAutocompleteSuggestion({ + suggestion: " return x + y;", + prefix: "function add(x, y) {\n", + suffix: "\n}", + model: "codestral", + }) + expect(result).toBe(" return x + y;") + }) + + it("returns undefined for empty suggestion", () => { + const result = postprocessAutocompleteSuggestion({ + suggestion: "", + prefix: "const x = ", + suffix: "", + model: "gpt-4", + }) + expect(result).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/visible-code-utils.test.ts b/packages/kilo-vscode/tests/unit/visible-code-utils.test.ts new file mode 100644 index 000000000..2609cc449 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/visible-code-utils.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect } from "bun:test" +import { extractDiffInfo } from "../../src/services/autocomplete/context/visible-code-utils" + +describe("extractDiffInfo", () => { + it("extracts info for git scheme with ref query param", () => { + const result = extractDiffInfo("git", "ref=HEAD", "/workspace/foo.ts") + expect(result).not.toBeUndefined() + expect(result?.scheme).toBe("git") + expect(result?.side).toBe("old") + expect(result?.gitRef).toBe("HEAD") + expect(result?.originalPath).toBe("/workspace/foo.ts") + }) + + it("extracts info for gitfs scheme", () => { + const result = extractDiffInfo("gitfs", "ref=abc123", "/workspace/bar.ts") + expect(result?.scheme).toBe("gitfs") + expect(result?.gitRef).toBe("abc123") + }) + + it("extracts ref with additional query params", () => { + const result = extractDiffInfo("git", "ref=main&other=value", "/f.ts") + expect(result?.gitRef).toBe("main") + }) + + it("extracts commit SHA as ref", () => { + const result = extractDiffInfo("git", "ref=a1b2c3d4e5f6", "/f.ts") + expect(result?.gitRef).toBe("a1b2c3d4e5f6") + }) + + it("handles git scheme with no query (no ref)", () => { + const result = extractDiffInfo("git", "", "/f.ts") + expect(result).not.toBeUndefined() + expect(result?.gitRef).toBeUndefined() + }) + + it("returns undefined for file scheme", () => { + expect(extractDiffInfo("file", "", "/f.ts")).toBeUndefined() + }) + + it("returns undefined for unknown scheme", () => { + expect(extractDiffInfo("https", "", "/f.ts")).toBeUndefined() + }) + + it("returns undefined for vscode-remote scheme", () => { + expect(extractDiffInfo("vscode-remote", "", "/f.ts")).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/webview-html.test.ts b/packages/kilo-vscode/tests/unit/webview-html.test.ts new file mode 100644 index 000000000..2b930f163 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/webview-html.test.ts @@ -0,0 +1,75 @@ +import { describe, it, expect } from "bun:test" +import { buildConnectSrc, buildCspString } from "../../src/webview-html-utils" + +describe("buildConnectSrc", () => { + it("uses wildcard ports when no port specified", () => { + const result = buildConnectSrc() + expect(result).toContain("http://127.0.0.1:*") + expect(result).toContain("http://localhost:*") + expect(result).toContain("ws://127.0.0.1:*") + expect(result).toContain("ws://localhost:*") + }) + + it("restricts to specific port when port provided", () => { + const result = buildConnectSrc(3000) + expect(result).toContain("http://127.0.0.1:3000") + expect(result).toContain("http://localhost:3000") + expect(result).toContain("ws://127.0.0.1:3000") + expect(result).toContain("ws://localhost:3000") + }) + + it("does not include wildcard when port is provided", () => { + const result = buildConnectSrc(3000) + expect(result).not.toContain(":*") + }) + + it("uses the exact port number", () => { + expect(buildConnectSrc(54321)).toContain(":54321") + }) +}) + +describe("buildCspString", () => { + const cspSource = "vscode-resource://test" + const nonce = "abc123" + + it("includes default-src 'none'", () => { + expect(buildCspString(cspSource, nonce)).toContain("default-src 'none'") + }) + + it("includes nonce in script-src", () => { + const result = buildCspString(cspSource, nonce) + expect(result).toContain(`'nonce-${nonce}'`) + expect(result).toContain("'wasm-unsafe-eval'") + }) + + it("includes cspSource in style-src and font-src", () => { + const result = buildCspString(cspSource, nonce) + expect(result).toContain(`style-src 'unsafe-inline' ${cspSource}`) + expect(result).toContain(`font-src ${cspSource}`) + }) + + it("includes cspSource and https: in img-src", () => { + const result = buildCspString(cspSource, nonce) + expect(result).toContain("img-src") + expect(result).toContain(cspSource) + expect(result).toContain("https:") + expect(result).toContain("data:") + }) + + it("uses wildcard connect-src when no port provided", () => { + const result = buildCspString(cspSource, nonce) + expect(result).toContain("http://127.0.0.1:*") + }) + + it("uses specific port in connect-src when port provided", () => { + const result = buildCspString(cspSource, nonce, 9000) + expect(result).toContain("http://127.0.0.1:9000") + expect(result).not.toContain(":*") + }) + + it("joins directives with semicolons", () => { + const result = buildCspString(cspSource, nonce) + const parts = result.split(";") + expect(parts.length).toBeGreaterThanOrEqual(5) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index 131fb0043..6a15a2cf1 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -372,3 +372,81 @@ describe("WorktreeManager.ensureGitExclude", () => { expect(count).toBe(1) }) }) + +// --------------------------------------------------------------------------- +// WorktreeManager -- branch name collision retry +// --------------------------------------------------------------------------- + +describe("WorktreeManager.createWorktree branch collision", () => { + /** + * Exercise the retry path at WorktreeManager.ts:77-86. + * + * The collision happens when `git worktree add -b ` fails because + * a branch with that name already exists. generateBranchName appends + * Date.now() making it hard to predict. We force the collision by + * monkey-patching Date.now to return a fixed value for the duration of + * the branch name generation, guaranteeing the same name is produced + * twice. + */ + it("retries with a unique suffix when generated branch name collides", async () => { + const root = await createTempRepo() + const git = simpleGit(root) + const mgr = createManager(root) + + // Create a first worktree — this consumes a branch name + const first = await mgr.createWorktree({ prompt: "collide" }) + const firstBranch = first.branch + + // Remove the worktree via git but keep the branch ref alive + await git.raw(["worktree", "remove", "--force", first.path]) + + // Verify the branch still exists (worktree is gone, branch is not) + const branches = await git.branch() + expect(branches.all).toContain(firstBranch) + + // Force Date.now to return the same timestamp that produced firstBranch. + // firstBranch is "collide-", extract that timestamp. + const timestamp = firstBranch.replace("collide-", "") + const real = Date.now + Date.now = () => Number(timestamp) + try { + // This will generate the same branch name and hit the collision retry + const second = await mgr.createWorktree({ prompt: "collide" }) + + // The retry appends a new timestamp suffix, so the branch name differs + expect(second.branch).not.toBe(firstBranch) + expect(second.branch).toStartWith("collide-") + + const stat = await fs.stat(path.join(second.path, ".git")) + expect(stat.isFile()).toBe(true) + } finally { + Date.now = real + } + }) +}) + +// --------------------------------------------------------------------------- +// WorktreeManager -- removeWorktree safety guard +// --------------------------------------------------------------------------- + +describe("WorktreeManager.removeWorktree safety", () => { + it("refuses to remove paths outside the worktrees directory", async () => { + const root = await createTempRepo() + const mgr = createManager(root) + + // Create a directory outside .kilocode/worktrees/ + const outside = path.join(root, "important-data") + await fs.mkdir(outside, { recursive: true }) + await fs.writeFile(path.join(outside, "file.txt"), "precious") + + // Attempt to remove it — should be silently refused + await mgr.removeWorktree(outside) + + // Directory should still exist + const exists = await fs + .stat(outside) + .then(() => true) + .catch(() => false) + expect(exists).toBe(true) + }) +}) 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 decdcc9e8..d905f9d6e 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,115 @@ 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("sessionsCollapsed", () => { + it("defaults to false", () => { + expect(manager.getSessionsCollapsed()).toBe(false) + }) + + it("sets and gets collapsed state", () => { + manager.setSessionsCollapsed(true) + expect(manager.getSessionsCollapsed()).toBe(true) + + manager.setSessionsCollapsed(false) + expect(manager.getSessionsCollapsed()).toBe(false) + }) + + it("persists and loads collapsed state", async () => { + manager.setSessionsCollapsed(true) + await manager.flush() + await manager.save() + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getSessionsCollapsed()).toBe(true) + }) + + it("does not persist when false", async () => { + manager.setSessionsCollapsed(false) + 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.sessionsCollapsed).toBeUndefined() + }) + }) + describe("validate", () => { it("removes worktrees whose directories do not exist", async () => { const existing = path.join(root, "wt-exists") @@ -202,4 +311,125 @@ describe("WorktreeStateManager", () => { expect(manager.getWorktrees()).toHaveLength(1) }) }) + + describe("concurrent save serialization", () => { + it("rapid mutations do not lose data after flush", async () => { + // Fire many mutations without awaiting saves individually + for (let i = 0; i < 20; i++) { + manager.addWorktree({ branch: `b-${i}`, path: `/tmp/b-${i}`, parentBranch: "main" }) + } + for (let i = 0; i < 20; i++) { + manager.addSession(`s-${i}`, null) + } + + // Wait for all fire-and-forget saves to settle + await manager.flush() + await manager.save() + + // Reload from disk and verify all data persisted + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + + expect(loaded.getWorktrees()).toHaveLength(20) + expect(loaded.getSessions()).toHaveLength(20) + for (let i = 0; i < 20; i++) { + expect(loaded.getWorktrees().find((w) => w.branch === `b-${i}`)).toBeTruthy() + expect(loaded.getSession(`s-${i}`)).toBeTruthy() + } + }) + + it("interleaved add and remove persists correctly", async () => { + const wt1 = manager.addWorktree({ branch: "keep", path: "/tmp/keep", parentBranch: "main" }) + const wt2 = manager.addWorktree({ branch: "remove", path: "/tmp/remove", parentBranch: "main" }) + manager.addSession("s1", wt1.id) + manager.addSession("s2", wt2.id) + manager.removeWorktree(wt2.id) + manager.addSession("s3", wt1.id) + + await manager.flush() + await manager.save() + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + + expect(loaded.getWorktrees()).toHaveLength(1) + expect(loaded.getWorktrees()[0].branch).toBe("keep") + // s2 was orphaned when wt2 was removed, s1 and s3 belong to wt1 + expect(loaded.getSession("s1")?.worktreeId).toBe(wt1.id) + expect(loaded.getSession("s2")?.worktreeId).toBeNull() + expect(loaded.getSession("s3")?.worktreeId).toBe(wt1.id) + }) + + it("concurrent save() calls resolve without data loss", async () => { + manager.addWorktree({ branch: "first", path: "/tmp/first", parentBranch: "main" }) + + // Trigger multiple saves concurrently — the second should queue behind the first + const p1 = manager.save() + manager.addWorktree({ branch: "second", path: "/tmp/second", parentBranch: "main" }) + const p2 = manager.save() + await Promise.all([p1, p2]) + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktrees()).toHaveLength(2) + }) + + it("flush resolves after in-flight save completes", async () => { + manager.addWorktree({ branch: "flush-test", path: "/tmp/flush", parentBranch: "main" }) + // Don't await — let save fire in background + void manager.save() + // flush must wait for it + await manager.flush() + + const loaded = new WorktreeStateManager(root, () => {}) + await loaded.load() + expect(loaded.getWorktrees().find((w) => w.branch === "flush-test")).toBeTruthy() + }) + }) + + describe("load with corrupt data", () => { + it("handles malformed JSON gracefully", async () => { + const file = path.join(root, ".kilocode", "agent-manager.json") + fs.writeFileSync(file, "not-valid-json{{{", "utf-8") + + await manager.load() + + // State should be empty — no crash + expect(manager.getWorktrees()).toHaveLength(0) + expect(manager.getSessions()).toHaveLength(0) + // Should have logged an error + expect(logs.some((l) => l.includes("Failed to load state"))).toBe(true) + }) + + it("handles partial data with missing sessions key", async () => { + const file = path.join(root, ".kilocode", "agent-manager.json") + fs.writeFileSync( + file, + JSON.stringify({ + worktrees: { "wt-1": { branch: "a", path: "/a", parentBranch: "main", createdAt: new Date().toISOString() } }, + }), + "utf-8", + ) + + await manager.load() + + expect(manager.getWorktrees()).toHaveLength(1) + expect(manager.getWorktrees()[0].branch).toBe("a") + expect(manager.getSessions()).toHaveLength(0) + }) + + it("handles partial data with missing worktrees key", async () => { + const file = path.join(root, ".kilocode", "agent-manager.json") + fs.writeFileSync( + file, + JSON.stringify({ sessions: { "s-1": { worktreeId: null, createdAt: new Date().toISOString() } } }), + "utf-8", + ) + + await manager.load() + + expect(manager.getWorktrees()).toHaveLength(0) + expect(manager.getSessions()).toHaveLength(1) + }) + }) }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 214d286bf..a89556074 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -1,28 +1,47 @@ // 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, + AgentManagerMultiVersionProgressMessage, + AgentManagerSendInitialMessage, 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 { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" 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 { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import { HoverCard } from "@kilocode/kilo-ui/hover-card" import { VSCodeProvider, useVSCode } from "../src/context/vscode" import { ServerProvider } from "../src/context/server" import { ProviderProvider } from "../src/context/provider" @@ -30,9 +49,13 @@ import { ConfigProvider } from "../src/context/config" import { SessionProvider, useSession } from "../src/context/session" import { WorktreeModeProvider } from "../src/context/worktree-mode" import { ChatView } from "../src/components/chat" +import { ModelSelectorBase } from "../src/components/chat/ModelSelector" +import { ModeSwitcherBase } from "../src/components/chat/ModeSwitcher" 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 { @@ -40,32 +63,212 @@ interface SetupState { message: string branch?: string error?: boolean + worktreeId?: string } -/** Sidebar selection: "local" for workspace, worktree ID for a worktree, or null for an unassigned session. */ -type SidebarSelection = "local" | string | null +interface WorktreeBusyState { + reason: "setting-up" | "deleting" + message?: string + branch?: string +} + +/** 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", + agentManagerOpen: isMac ? "⌘⇧M" : "Ctrl+Shift+M", + focusPanel: isMac ? "⌘." : "Ctrl+.", +} + +/** 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 } +} + +/** Shortcut category definition for the keyboard shortcuts dialog */ +interface ShortcutEntry { + label: string + binding: string +} + +interface ShortcutCategory { + title: string + shortcuts: ShortcutEntry[] +} + +/** Build the categorized list of keyboard shortcuts from the current bindings */ +function buildShortcutCategories(bindings: Record): ShortcutCategory[] { + return [ + { + title: "Sidebar", + shortcuts: [ + { label: "Previous item", binding: bindings.previousSession ?? "" }, + { label: "Next item", binding: bindings.nextSession ?? "" }, + { label: "New worktree", binding: bindings.newWorktree ?? "" }, + { label: "Delete worktree", binding: bindings.closeWorktree ?? "" }, + ], + }, + { + title: "Tabs", + shortcuts: [ + { label: "Previous tab", binding: bindings.previousTab ?? "" }, + { label: "Next tab", binding: bindings.nextTab ?? "" }, + { label: "New tab", binding: bindings.newTab ?? "" }, + { label: "Close tab", binding: bindings.closeTab ?? "" }, + ], + }, + { + title: "Terminal", + shortcuts: [ + { label: "Toggle terminal", binding: bindings.showTerminal ?? "" }, + { label: "Focus panel", binding: bindings.focusPanel ?? "" }, + ], + }, + { + title: "Global", + shortcuts: [{ label: "Open Agent Manager", binding: bindings.agentManagerOpen ?? "" }].filter((s) => s.binding), + }, + ].filter((c) => c.shortcuts.length > 0) +} + +/** Parse a display keybinding string into separate key tokens for rendering. + * Windows/Linux format ("Ctrl+Shift+W") splits on "+". + * Mac format ("⌘⇧W") splits on known modifier symbols. */ +function parseBindingTokens(binding: string): string[] { + if (!binding) return [] + // Windows/Linux: "Ctrl+Shift+W" → ["Ctrl", "Shift", "W"] + if (binding.includes("+")) return binding.split("+") + // Mac: "⌘⇧W" → ["⌘", "⇧", "W"] — peel off known modifier symbols + const tokens: string[] = [] + let rest = binding + const modifiers = ["⌘", "⇧", "⌃", "⌥"] + while (rest.length > 0) { + const mod = modifiers.find((m) => rest.startsWith(m)) + if (mod) { + tokens.push(mod) + rest = rest.slice(mod.length) + } else { + tokens.push(rest) + break + } + } + return tokens +} 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 [busyWorktrees, setBusyWorktrees] = createSignal>(new Map()) + const [worktreesLoaded, setWorktreesLoaded] = createSignal(false) + const [sessionsLoaded, setSessionsLoaded] = createSignal(false) + const [isGitRepo, setIsGitRepo] = createSignal(true) + + 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) + const [sessionsCollapsed, setSessionsCollapsed] = createSignal(false) // 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 +277,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 +343,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 +367,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 +375,70 @@ 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) + } + + /** Worktrees sorted so that grouped items are always adjacent, ordered by creation time. */ + const sortedWorktrees = createMemo(() => { + const all = worktrees() + if (all.length === 0) return [] + + // Collect grouped worktrees by groupId + const grouped = new Map() + for (const wt of all) { + if (!wt.groupId) continue + const list = grouped.get(wt.groupId) ?? [] + list.push(wt) + grouped.set(wt.groupId, list) + } + + // Build output: interleave groups at the position of their earliest member + const result: WorktreeState[] = [] + const placed = new Set() + for (const wt of all) { + if (placed.has(wt.id)) continue + if (wt.groupId) { + if (placed.has(wt.groupId)) continue + placed.add(wt.groupId) + const group = grouped.get(wt.groupId) ?? [] + for (const g of group) { + result.push(g) + placed.add(g.id) + } + } else { + result.push(wt) + placed.add(wt.id) + } + } + return result + }) + + /** Check if this worktree is part of a group. */ + const isGrouped = (wt: WorktreeState) => !!wt.groupId + + /** Check if this is the first item in its group. */ + const isGroupStart = (wt: WorktreeState, idx: number) => { + if (!wt.groupId) return false + const list = sortedWorktrees() + if (idx === 0) return true + return list[idx - 1]?.groupId !== wt.groupId + } + + /** Check if this is the last item in its group. */ + const isGroupEnd = (wt: WorktreeState, idx: number) => { + if (!wt.groupId) return false + const list = sortedWorktrees() + if (idx === list.length - 1) return true + return list[idx + 1]?.groupId !== wt.groupId } const scrollIntoView = (el: HTMLElement) => { @@ -171,8 +447,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 +460,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 +494,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 +514,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,26 +537,49 @@ 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() + else if (msg.action === "focusInput") window.dispatchEvent(new Event("focusPrompt")) } 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")) + // and clear any stale body styles left by Kobalte modal overlays (dropdowns/dialogs + // set pointer-events:none and overflow:hidden on body, but cleanup never runs if + // focus leaves the webview before the overlay closes). + const onWindowFocus = () => { + document.body.style.pointerEvents = "" + document.body.style.overflow = "" + window.dispatchEvent(new Event("focusPrompt")) + } window.addEventListener("focus", onWindowFocus) // 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() @@ -285,6 +592,11 @@ const AgentManagerContent: Component = () => { } }) + // Mark sessions loaded as soon as the session context receives data (even if empty) + const unsubSessions = vscode.onMessage((msg) => { + if (msg.type === "sessionsLoaded" && !sessionsLoaded()) setSessionsLoaded(true) + }) + const unsub = vscode.onMessage((msg) => { if (msg.type === "agentManager.repoInfo") { const info = msg as AgentManagerRepoInfoMessage @@ -295,33 +607,146 @@ const AgentManagerContent: Component = () => { const ev = msg as AgentManagerWorktreeSetupMessage if (ev.status === "ready" || ev.status === "error") { const error = ev.status === "error" - setSetup({ active: true, message: ev.message, branch: ev.branch, error }) + // Remove from busy map + if (ev.worktreeId) { + setBusyWorktrees((prev) => { + const next = new Map(prev) + next.delete(ev.worktreeId!) + return next + }) + } + setSetup({ active: true, message: ev.message, branch: ev.branch, error, worktreeId: ev.worktreeId }) 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 }) + // Track this worktree as setting up and auto-select it in the sidebar + if (ev.worktreeId) { + setBusyWorktrees( + (prev) => + new Map([...prev, [ev.worktreeId!, { reason: "setting-up", message: ev.message, branch: ev.branch }]]), + ) + setSelection(ev.worktreeId) + } + setSetup({ active: true, message: ev.message, branch: ev.branch, worktreeId: ev.worktreeId }) } } + 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.isGitRepo !== undefined) setIsGitRepo(state.isGitRepo) + if (!worktreesLoaded()) setWorktreesLoaded(true) + 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) + } + // Recover sessions collapsed state from extension-persisted state + if (state.sessionsCollapsed !== undefined) setSessionsCollapsed(state.sessionsCollapsed) + // Clear busy state for worktrees that have been removed + const ids = new Set(state.worktrees.map((wt) => wt.id)) + setBusyWorktrees((prev) => { + const next = new Map([...prev].filter(([id]) => ids.has(id))) + return next.size === prev.size ? prev : next + }) + } + + // When a multi-version progress update arrives, mark newly created worktrees as loading + if ((msg as { type: string }).type === "agentManager.multiVersionProgress") { + const ev = msg as unknown as AgentManagerMultiVersionProgressMessage + if (ev.status === "done" && ev.groupId) { + // Clear busy state for all worktrees in this group + setBusyWorktrees((prev) => { + const next = new Map(prev) + for (const wt of worktrees()) { + if (wt.groupId === ev.groupId) next.delete(wt.id) + } + return next + }) + } + } + + // When state updates arrive, mark new grouped worktrees as loading + // (they were just created and haven't received their prompt yet) + if (msg.type === "agentManager.worktreeSetup") { + const ev = msg as AgentManagerWorktreeSetupMessage + if (ev.status === "ready" && ev.sessionId) { + const ms = managedSessions().find((s) => s.id === ev.sessionId) + const wt = ms?.worktreeId ? worktrees().find((w) => w.id === ms.worktreeId) : undefined + if (wt?.groupId) { + setBusyWorktrees((prev) => new Map([...prev, [wt.id, { reason: "setting-up" as const }]])) + } + } + } + + // Handle initial message send for multi-version sessions. + // The extension creates the worktrees/sessions, then asks the webview + // to send the prompt through the normal KiloProvider sendMessage path. + // Once the message is sent, clear the loading state for that worktree. + if ((msg as { type: string }).type === "agentManager.sendInitialMessage") { + const ev = msg as unknown as AgentManagerSendInitialMessage + + // Set model and agent selections for this session so the UI reflects them + if (ev.providerID && ev.modelID) { + session.setSessionModel(ev.sessionId, ev.providerID, ev.modelID) + } + if (ev.agent) { + session.setSessionAgent(ev.sessionId, ev.agent) + } + + vscode.postMessage({ + type: "sendMessage", + text: ev.text, + sessionID: ev.sessionId, + providerID: ev.providerID, + modelID: ev.modelID, + agent: ev.agent, + files: ev.files, + }) + // Clear busy state — use worktreeId from the message directly + // to avoid race condition where managedSessions() hasn't updated yet + if (ev.worktreeId) { + setBusyWorktrees((prev) => { + const next = new Map(prev) + next.delete(ev.worktreeId) + return next + }) + } } }) onCleanup(() => { window.removeEventListener("message", handler) - window.removeEventListener("keydown", preventScroll) + window.removeEventListener("keydown", preventDefaults) window.removeEventListener("focus", onWindowFocus) unsubCreate() + unsubSessions() unsub() }) }) @@ -329,20 +754,107 @@ const AgentManagerContent: Component = () => { // Always select local on mount to initialize branch info and session state onMount(() => { selectLocal() + // Request worktree/session state from extension — handles race where + // initializeState() pushState fires before the webview is mounted + vscode.postMessage({ type: "agentManager.requestState" }) // Open a pending "New Session" tab if there are no persisted local sessions if (localSessionIDs().length === 0) { addPendingTab() } }) + const handleConfigureSetupScript = () => { + vscode.postMessage({ type: "agentManager.configureSetupScript" }) + } + + const handleShowKeyboardShortcuts = () => { + const categories = buildShortcutCategories(kb()) + dialog.show(() => ( + +
+ + {(category) => ( +
+
{category.title}
+
+ + {(shortcut) => ( +
+ {shortcut.label} + + + {(token) => {token}} + + +
+ )} +
+
+
+ )} +
+
+
+ )) + } + const handleCreateWorktree = () => { vscode.postMessage({ type: "agentManager.createWorktree" }) } + // Advanced worktree dialog — opens a full dialog with prompt, versions, model, mode + const showAdvancedWorktreeDialog = () => { + dialog.show(() => dialog.close()} />) + } + + const confirmDeleteWorktree = (worktreeId: string) => { + const wt = worktrees().find((w) => w.id === worktreeId) + if (!wt) return + const doDelete = () => { + setBusyWorktrees((prev) => new Map([...prev, [wt.id, { reason: "deleting" as const }]])) + 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 +864,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 +906,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 */} {/* WORKTREES section */} -
+
- -
-
- - {(wt) => ( -
selectWorktree(wt.id)} - > - - - {worktreeLabel(wt)} - - +
+ + handleDeleteWorktree(wt.id, e)} + label="Worktree settings" /> + + + + Keyboard Shortcuts + + + + Worktree Setup Script + + + + +
+ + + + + + + + + New Worktree + + + + + New with Versions... + + + +
- )} - - - +
+ +
+
+ +
+
+
+
+
+ } + > + +
+ + Not a git repository +
+
+ + {(() => { + const [hoveredWt, setHoveredWt] = createSignal(null) + const [overClose, setOverClose] = createSignal(false) + return ( + + {(wt, idx) => { + const grouped = () => isGrouped(wt) + const start = () => isGroupStart(wt, idx()) + const end = () => isGroupEnd(wt, idx()) + const busy = () => busyWorktrees().has(wt.id) + const groupSize = () => { + if (!wt.groupId) return 0 + return sortedWorktrees().filter((w) => w.groupId === wt.groupId).length + } + const sessions = createMemo(() => managedSessions().filter((ms) => ms.worktreeId === wt.id)) + const navHint = () => { + const flat = [ + LOCAL as string, + ...sortedWorktrees().map((w) => w.id), + ...unassignedSessions().map((s) => s.id), + ] + const active = selection() ?? session.currentSessionID() ?? "" + return adjacentHint(wt.id, active, flat, kb().previousSession ?? "", kb().nextSession ?? "") + } + return ( + <> + +
+ + {groupSize()} versions +
+
+ setHoveredWt(open ? wt.id : null)} + trigger={ +
selectWorktree(wt.id)} + > + } + > + + + {worktreeLabel(wt)} + +
setOverClose(true)} + onMouseLeave={() => setOverClose(false)} + > + + handleDeleteWorktree(wt.id, e)} + /> + +
+
+
+ } + > +
+
+
+
BRANCH
+
{wt.branch}
+
{formatRelativeDate(wt.createdAt)}
+
+ + {navHint()} + +
+ +
+
+ Base + {wt.parentBranch} +
+ +
+
+ Sessions + {sessions().length} +
+
+ + + ) + }} + + ) + })()} + + + +
- {/* SESSIONS section (unassigned) */} -
-
- -
-
- - {(s) => ( - - )} - -
+ {/* SESSIONS section (unassigned) — collapsible */} +
+ + +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+ } + > + + {(s) => ( + + )} + + +
+
{/* 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,27 +1401,53 @@ const AgentManagerContent: Component = () => {
No sessions open
- -
-
- -
Setting up workspace
- -
{setup().branch}
-
-
- }> - - - {setup().message} -
-
-
-
+ {(() => { + // Show setup overlay: either the transient ready/error state for the selected worktree, + // or if the selected worktree is still being set up (from busyWorktrees map) + const overlayState = () => { + const s = setup() + const sel = selection() + // Transient ready/error overlay for the selected worktree (or worktree-less setup) + if (s.active && (!s.worktreeId || sel === s.worktreeId)) return s + // Persistent setup-in-progress for the currently selected worktree + if (typeof sel === "string" && sel !== LOCAL) { + const busy = busyWorktrees().get(sel) + if (busy?.reason === "setting-up") { + const wt = worktrees().find((w) => w.id === sel) + return { active: true, message: busy.message, branch: busy.branch ?? wt?.branch } + } + } + return null + } + return ( + + {(state) => ( +
+
+ +
+ {state().error ? "Workspace setup failed" : "Setting up workspace"} +
+ +
{state().branch}
+
+
+ }> + + + {state().message} +
+
+
+ )} +
+ ) + })()}
{ ) } +// --------------------------------------------------------------------------- +// Advanced "New Worktree" dialog — prompt, versions, model, mode +// --------------------------------------------------------------------------- + +type VersionCount = 1 | 2 | 3 | 4 +const VERSION_OPTIONS: VersionCount[] = [1, 2, 3, 4] + +const NewWorktreeDialog: Component<{ onClose: () => void }> = (props) => { + const vscode = useVSCode() + const session = useSession() + + const [prompt, setPrompt] = createSignal("") + const [versions, setVersions] = createSignal(2) + const [model, setModel] = createSignal<{ providerID: string; modelID: string } | null>(null) + const [agent, setAgent] = createSignal(session.selectedAgent()) + const [starting, setStarting] = createSignal(false) + + let textareaRef: HTMLTextAreaElement | undefined + + onMount(() => { + requestAnimationFrame(() => { + if (!textareaRef) return + textareaRef.focus() + textareaRef.select() + }) + }) + + const canSubmit = () => prompt().trim().length > 0 && !starting() + + const handleSubmit = () => { + const text = prompt().trim() + if (!text || starting()) return + setStarting(true) + + const count = versions() + const sel = model() + const defaultAgent = session.agents()[0]?.name + const selectedAgent = agent() !== defaultAgent ? agent() : undefined + vscode.postMessage({ + type: "agentManager.createMultiVersion", + text, + versions: count, + providerID: sel?.providerID, + modelID: sel?.modelID, + agent: selectedAgent, + }) + + props.onClose() + } + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + handleSubmit() + } + } + + const adjustHeight = () => { + if (!textareaRef) return + textareaRef.style.height = "auto" + textareaRef.style.height = `${Math.min(textareaRef.scrollHeight, 200)}px` + } + + return ( + +
+ {/* Prompt input — reuses the same CSS as the sidebar chat input */} +
+
+
+