diff --git a/.changeset/folder-mentions.md b/.changeset/folder-mentions.md deleted file mode 100644 index 2d7a9983a2e..00000000000 --- a/.changeset/folder-mentions.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"kilo-code": minor -"@kilocode/cli": patch ---- - -Support mentioning folders in the prompt with @ references, including top-level folder file contents. diff --git a/.changeset/settings-save-error.md b/.changeset/settings-save-error.md deleted file mode 100644 index c6a24f3e073..00000000000 --- a/.changeset/settings-save-error.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Show an inline error in the Settings save bar when the configuration fails to save (for example, due to an invalid value) so the user can correct the config and retry instead of losing their unsaved changes silently. diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7d4046df932..0fdee1361bb 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -254,89 +254,25 @@ jobs: # APPLE_API_KEY_PATH: ${{ runner.temp }}/apple-api-key.p8 # kilocode_change start - # Smoke test disabled as a release gate due to infrastructure issues. - # The job is skipped via `if: false` so it no longer blocks publishing. - # Re-enable by restoring the original `if:` condition and uncommenting - # `- smoke-test` in the publish job's `needs` list below. + # Run smoke tests against CLI assets uploaded to the draft GitHub release + # before publishing the release and package artifacts. smoke-test: - name: Smoke Test (pre-publish gate) [DISABLED] + name: Smoke Test (pre-publish gate) needs: - version - build-cli - if: false # was: github.repository == 'Kilo-Org/kilocode' - runs-on: ubuntu-24.04 - steps: - - name: Trigger kilo-bench smoke test - id: trigger - env: - GH_TOKEN: ${{ secrets.BENCH_GITHUB_TOKEN }} - run: | - BEFORE=$(date -u -d '60 seconds ago' +%Y-%m-%dT%H:%M:%SZ) - - gh api repos/Kilo-Org/kilo-bench/dispatches \ - --method POST \ - -f event_type=smoke-test \ - -f 'client_payload[release_tag]=${{ needs.version.outputs.tag }}' \ - -f 'client_payload[source_run_id]=${{ github.run_id }}' - - # Poll for the run created after our dispatch timestamp. - # The dispatch API returns no run ID, so we query by created time - # and pick the oldest match to avoid grabbing an unrelated run. - echo "Waiting for smoke-test run to appear (dispatched after $BEFORE)..." - for attempt in $(seq 1 30); do - RUN_ID=$(gh api \ - "repos/Kilo-Org/kilo-bench/actions/workflows/smoke-test.yml/runs?event=repository_dispatch&created=>=$BEFORE" \ - --jq '.workflow_runs | sort_by(.created_at) | .[0].id // empty') - - if [[ -n "$RUN_ID" && "$RUN_ID" != "null" ]]; then - echo "run_id=$RUN_ID" >> "$GITHUB_OUTPUT" - echo "::notice::Smoke test run: https://github.com/Kilo-Org/kilo-bench/actions/runs/$RUN_ID" - exit 0 - fi - - echo " attempt $attempt: run not yet registered, retrying in 10s..." - sleep 10 - done - - echo "::error::Smoke test run did not appear within 5 minutes after dispatch." - exit 1 - - - name: Wait for smoke test to complete - env: - GH_TOKEN: ${{ secrets.BENCH_GITHUB_TOKEN }} - run: | - RUN_ID="${{ steps.trigger.outputs.run_id }}" - echo "Waiting for run $RUN_ID..." - - for i in $(seq 1 60); do - CONCLUSION=$(gh run view "$RUN_ID" \ - --repo Kilo-Org/kilo-bench \ - --json conclusion \ - --jq '.conclusion') - - echo " attempt $i: $CONCLUSION" - - if [[ "$CONCLUSION" == "success" ]]; then - echo "::notice::Smoke test passed." - exit 0 - elif [[ "$CONCLUSION" != "null" && "$CONCLUSION" != "" ]]; then - echo "::error::Smoke test failed with conclusion: $CONCLUSION" - echo "See: https://github.com/Kilo-Org/kilo-bench/actions/runs/$RUN_ID" - exit 1 - fi - - sleep 30 - done - - echo "::error::Smoke test did not complete within 30 minutes." - exit 1 + if: false # Disable the smoketest job until it works when called on draft releases + uses: ./.github/workflows/smoke-test.yml + with: + cli_version: ${{ needs.version.outputs.version }} + secrets: inherit # kilocode_change end publish: needs: - version - build-cli - build-vscode - # - smoke-test # disabled: infrastructure issues (see smoke-test job comment) + # - smoke-test # Disable the smoketest job until it works when called on draft releases # - build-tauri runs-on: ubuntu-24.04 steps: diff --git a/.github/workflows/smoke-test.yml b/.github/workflows/smoke-test.yml index e115234ac3e..bd18344b3d2 100644 --- a/.github/workflows/smoke-test.yml +++ b/.github/workflows/smoke-test.yml @@ -7,7 +7,7 @@ # # Triggers: # - workflow_dispatch: manually from Actions tab (optionally pass a CLI version) -# - push to main: automatically after every merge +# - workflow_call: from publish.yml after draft release assets are uploaded # # Required secrets: # KILO_API_KEY — Kilo Gateway key @@ -23,6 +23,12 @@ on: description: "CLI version to test (e.g. 7.0.36). Leave blank for latest npm release." required: false type: string + workflow_call: + inputs: + cli_version: + description: "CLI version to test from draft release assets." + required: false + type: string concurrency: group: smoke-test diff --git a/AGENTS.md b/AGENTS.md index d71e9235a4d..3ff62660676 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -203,6 +203,8 @@ We regularly merge upstream changes from opencode. To minimize merge conflicts a 4. **Avoid restructuring upstream code** - Don't refactor or reorganize code that comes from opencode unless absolutely necessary. +5. **Mirror new config keys to the cloud schema** - When adding a `kilocode_change` key to `Config.Info` in `packages/opencode/src/config/config.ts`, also add the matching JSON Schema entry in `apps/web/src/app/config.json/extras.ts` in the [cloud repo](https://github.com/Kilo-Org/cloud). See [CLI Config Schema](packages/kilo-docs/pages/contributing/architecture/config-schema.md) for the step-by-step. + The goal is to keep our diff from upstream as small as possible, making regular merges straightforward and reducing the risk of conflicts. ### Kilocode Change Markers diff --git a/bun.lock b/bun.lock index 9f2f3c02bcd..0713f4b6d91 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-ui": "workspace:*", @@ -86,7 +86,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -119,7 +119,7 @@ }, "packages/desktop-electron": { "name": "@opencode-ai/desktop-electron", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "@opencode-ai/app": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -170,7 +170,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -199,7 +199,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "@ai-sdk/anthropic": "3.0.64", "@ai-sdk/openai": "3.0.48", @@ -234,7 +234,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.2.12", + "version": "7.2.14", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -247,7 +247,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "@opentelemetry/api": "1.9.0", @@ -267,7 +267,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "@kobalte/core": "0.13.11", "@opencode-ai/util": "workspace:*", @@ -302,7 +302,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-i18n": "workspace:*", @@ -322,6 +322,7 @@ "simple-git": "3.35.2", "solid-js": "^1.9.11", "uri-js": "^4.4.1", + "virtua": "catalog:", "web-tree-sitter": "^0.24.7", "yaml": "2.8.3", "zod": "^3.24.2", @@ -355,7 +356,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.2.12", + "version": "7.2.14", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -502,7 +503,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "@kilocode/sdk": "workspace:*", "effect": "catalog:", @@ -527,7 +528,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "semver": "^7.6.3", }, @@ -538,7 +539,7 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "cross-spawn": "catalog:", }, @@ -560,7 +561,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.2.12", + "version": "7.2.14", "devDependencies": { "@opencode-ai/ui": "workspace:*", "@solidjs/meta": "catalog:", @@ -583,7 +584,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -633,7 +634,7 @@ }, "packages/util": { "name": "@opencode-ai/util", - "version": "7.2.12", + "version": "7.2.14", "dependencies": { "zod": "catalog:", }, diff --git a/nix/hashes.json b/nix/hashes.json index f3da74cd064..f719341c46c 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-B8kyomBd8nlqFG+9idfs+y8P1M+4eVUBP3sDJDh6upw=", - "aarch64-linux": "sha256-Xzcrz1R3Gunp0aVgNpEVPLd+SahV8wyuwgUgVNOTgfI=", - "aarch64-darwin": "sha256-5Yq09XbErOYRsO+DOqOYAUmz2go7kAQFnLvTXb2CDcc=", - "x86_64-darwin": "sha256-c4Cxv5jFTbb1v56c9I18SgGaigtgHeeyEwfn7QjCUdY=" + "x86_64-linux": "sha256-eEuIR+GbjhIU5+LMlqYSMlP+8K1jhMdqkH5x+IN4gN8=", + "aarch64-linux": "sha256-SBL6g8ad7apxtRH865XOVObm4krJS2whvLHERuliSKU=", + "aarch64-darwin": "sha256-gf5MCF06yN6JQCtsWlMcFfMU1BpK2DFXgo1hK+ZPeT4=", + "x86_64-darwin": "sha256-ySFUIToMSy9vLzX8s/A7BwO+qrtRlxleJziWmikgePw=" } } diff --git a/package.json b/package.json index 22e3898ace2..69b39343ee8 100644 --- a/package.json +++ b/package.json @@ -136,6 +136,6 @@ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" }, - "version": "7.2.12", + "version": "7.2.14", "peerDependencies": {} } diff --git a/packages/app/package.json b/packages/app/package.json index 29875997b91..166e9b860ed 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "7.2.12", + "version": "7.2.14", "description": "", "type": "module", "exports": { diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 7d688ab2525..101b5ae6a50 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop-electron", "private": true, - "version": "7.2.12", + "version": "7.2.14", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index e955e6e35fc..21d53b38e90 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "7.2.12", + "version": "7.2.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index 029f55707e1..ae55d6f1431 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 = "7.2.12" +version = "7.2.14" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.14/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.14/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.14/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.14/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.12/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.2.14/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/kilo-docs/lib/nav/contributing.ts b/packages/kilo-docs/lib/nav/contributing.ts index 34fb344f30f..74e75c5ede6 100644 --- a/packages/kilo-docs/lib/nav/contributing.ts +++ b/packages/kilo-docs/lib/nav/contributing.ts @@ -38,6 +38,10 @@ export const ContributingNav: NavSection[] = [ href: "/contributing/architecture/benchmarking", children: "Benchmarking", }, + { + href: "/contributing/architecture/config-schema", + children: "CLI Config Schema", + }, { href: "/contributing/architecture/enterprise-mcp-controls", children: "Enterprise MCP Controls", diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index 58b177678d8..9d3bdb2e478 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.2.12", + "version": "7.2.14", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md index c96989df403..7cf19caf359 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/auto-model.md @@ -23,11 +23,15 @@ That's it. No configuration needed. You can see which underlying models are used, as well as the cost, in the expanded model picker. Model mapping information is also available on the [Gateway Model page](/docs/gateway/models-and-providers#kilo-autofrontier). +{% callout type="info" title="Models can change" %} +The underlying models behind each Auto Model tier are updated server-side as better options become available or as providers change pricing and availability. The tier you select stays the same; the model it routes to may change over time. +{% /callout %} + ## Tiers - **Frontier** — Routes to the latest and most capable paid models. Uses different models for reasoning-heavy tasks (planning, architecture, debugging) versus implementation tasks (coding, building, exploring), pairing the right capability to each type of work. -- **Balanced** — Follows the same mode-based routing structure as Frontier but uses a more cost-effective model across all modes. A good default for most developers who want strong AI assistance without paying frontier prices. -- **Free** — Routes to the best available free model on OpenRouter. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the model may change over time. +- **Balanced** — Uses a single cost-effective model across all modes. A good default for most developers who want strong AI assistance without paying frontier prices. +- **Free** — Routes to the best available free models on OpenRouter, splitting traffic across them. Because free model availability shifts over time as providers change promotional periods, the mapping is updated server-side — you always get the best free option without having to track what's currently available. Quality will be lower than paid tiers, and the models may change over time. ## Benefits diff --git a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md index e2e13938ce2..0821d610f95 100644 --- a/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md +++ b/packages/kilo-docs/pages/contributing/architecture/auto-model-tiers.md @@ -52,7 +52,7 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Cost-conscious developers who want better results than free models at a fraction of frontier cost. -**What it does**: Follows the same mode-based routing structure as Frontier but uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — across all modes. +**What it does**: Uses GPT 5.3 Codex — a cost-effective model with strong reasoning and coding capabilities — for every mode. Unlike Frontier, Balanced does not vary its underlying model by mode. **Pricing**: Paid, but significantly cheaper than Frontier. @@ -62,17 +62,17 @@ For the current mode-to-model mappings, see the [Auto Model user docs](/docs/cod **Who it's for**: Users who want to try Kilo without a credit card, students, hobbyists, and anyone exploring AI-assisted coding. -**What it does**: Automatically maps to the best available free model(s) for each mode. As free model availability changes due to promotional periods, the mapping updates transparently. Users always get the best free option without having to track which models are currently available. +**What it does**: Splits requests across the best available free models, weighted by a deterministic per-session hash so a given session sticks with one model. As free model availability changes due to promotional periods, the split and the underlying models are updated transparently server-side. Users always get the best free option without having to track which models are currently available. **Pricing**: Free. No credits required. -**Constraints**: Free models may not provide sufficient breadth to justify different models per mode. In that case, a single model may be used for all modes. Quality will be lower than Frontier or Balanced tiers — this is a tradeoff users accept by choosing free. +**Constraints**: Free models do not vary by mode — the same model is used for every mode within a session. Quality will be lower than Frontier or Balanced tiers — this is a tradeoff users accept by choosing free. ### Auto: Small (internal) **Who it's for**: Not user-facing. Used internally by Kilo for lightweight background tasks (session titles, commit messages, conversation summaries). -**What it does**: Automatically selects the right small model for lightweight tasks. When credits are available, it uses a fast paid small model. +**What it does**: Automatically selects the right small model for lightweight tasks. When the account has a positive balance, it uses a fast paid small model; otherwise it falls back to a free small model. **Why it matters**: Users never think about background tasks, and they shouldn't have to. Auto: Small ensures these tasks always work, always feel fast, and never waste credits on an expensive model when a cheap one will do. @@ -115,8 +115,8 @@ The Kilo API at `api.kilo.ai` defines which underlying models each `kilo-auto/*` { "opencode": { "variants": { - "architect": { "model": "anthropic/claude-opus-4-6", ... }, - "code": { "model": "anthropic/claude-sonnet-4-6", ... } + "architect": { "model": "anthropic/claude-opus-4.7", ... }, + "code": { "model": "anthropic/claude-sonnet-4.6", ... } } } } diff --git a/packages/kilo-docs/pages/contributing/architecture/config-schema.md b/packages/kilo-docs/pages/contributing/architecture/config-schema.md new file mode 100644 index 00000000000..c4cba931c05 --- /dev/null +++ b/packages/kilo-docs/pages/contributing/architecture/config-schema.md @@ -0,0 +1,33 @@ +--- +title: "CLI Config Schema" +description: "How the Kilo CLI config JSON Schema is served at app.kilo.ai/config.json" +--- + +# CLI Config Schema + +The JSON Schema referenced by `"$schema": "https://app.kilo.ai/config.json"` in `kilo.json` files is served by the cloud repo. It is a runtime overlay of the upstream opencode schema with Kilo-specific additions on top. + +## Flow + +1. Client fetches `https://app.kilo.ai/config.json`. +2. Cloud route `apps/web/src/app/config.json/route.ts` fetches `https://opencode.ai/config.json`, runs `merge()` on it, and returns the result. +3. `merge()` overlays three sections from `apps/web/src/app/config.json/extras.ts`: + - `top` — top-level keys like `commit_message`, `remote_control`, nullable `model` / `small_model` + - `agents` — Kilo primary agents (`ask`, `debug`, `orchestrator`) + - `experimental` — `codebase_search`, `openTelemetry` + +## Adding a new Kilo-only config key + +The source of truth is the zod schema in `packages/opencode/src/config/config.ts`. The cloud overlay must match it. + +1. Add the zod field with a `kilocode_change` marker in `config.ts`. +2. Generate the JSON Schema shape: `bun --bun packages/opencode/script/schema.ts /tmp/kilo.json`, then `jq '.properties.' /tmp/kilo.json`. +3. Paste the shape into the correct bucket in `apps/web/src/app/config.json/extras.ts` in the [cloud repo](https://github.com/Kilo-Org/cloud). + - Top-level → `top`; under `experimental` → `experimental`; new primary agent → `agents`; anywhere else → add a new bucket and extend `merge()` in `route.ts`. +4. Add an assertion in `apps/web/src/tests/cli-config-schema.test.ts`. + +If step 3 is skipped, users with `$schema: https://app.kilo.ai/config.json` will see "unknown property" warnings for the new key. + +## Caching + +The cloud route caches the upstream fetch for 1 hour (`next: { revalidate: 3600 }`) and emits `s-maxage=3600, stale-while-revalidate=3600`, so the response is served from the Cloudflare + Vercel edge cache for all but one request per hour per region. diff --git a/packages/kilo-docs/pages/contributing/index.md b/packages/kilo-docs/pages/contributing/index.md index 1b8fa9e8604..a3f17423b64 100644 --- a/packages/kilo-docs/pages/contributing/index.md +++ b/packages/kilo-docs/pages/contributing/index.md @@ -58,6 +58,33 @@ git checkout -b docs/your-change-description - Reference issue numbers when applicable - Keep commits focused on a single change +### Changesets + +User-facing changes (features, fixes, breaking changes) require a changeset file so the update shows up in the next release notes. Run the interactive tool, or create the file by hand: + +```bash +bunx changeset add +``` + +Or create `.changeset/.md` manually: + +```md +--- +"kilo-code": minor +--- + +Short description of the change for the changelog. +``` + +Guidelines: + +- Use `patch` for bug fixes, `minor` for new features, `major` for breaking changes. +- Descriptions are read by end users in release notes — keep them concise and feature-oriented. Describe **what changed from the user's perspective**, not implementation details. +- Write in imperative mood (e.g. "Support exporting conversations as markdown" rather than "Add a new export handler that serializes session messages to .md files"). +- Changesets are consumed at release time by the `publish.yml` workflow, which generates changelog entries for the GitHub release notes. + +Skip the changeset only for internal refactors, CI tweaks, test-only changes, or docs that do not affect users. + ### Testing Your Changes - Run the test suite: diff --git a/packages/kilo-docs/pages/gateway/models-and-providers.md b/packages/kilo-docs/pages/gateway/models-and-providers.md index 455eae7831a..2eb81138698 100644 --- a/packages/kilo-docs/pages/gateway/models-and-providers.md +++ b/packages/kilo-docs/pages/gateway/models-and-providers.md @@ -41,7 +41,7 @@ This returns model information including pricing, context window, and supported | Model ID | Provider | Description | | ------------------------------- | --------- | ----------------------------------------------- | -| `anthropic/claude-opus-4.6` | Anthropic | Most capable Claude model for complex reasoning | +| `anthropic/claude-opus-4.7` | Anthropic | Most capable Claude model for complex reasoning | | `anthropic/claude-sonnet-4.6` | Anthropic | Balanced performance and cost | | `anthropic/claude-haiku-4.5` | Anthropic | Fast and cost-effective | | `openai/gpt-5.4` | OpenAI | Latest GPT model | @@ -76,42 +76,45 @@ Provided under the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia Kilo Auto virtual models automatically select the best underlying model based on the task type. The selection is controlled by the `x-kilocode-mode` request header. +{% callout type="info" title="Underlying models can change" %} +The mappings below reflect the current routing. The underlying models behind each `kilo-auto/*` tier are updated server-side as better options become available or as providers change pricing and availability — the tier IDs themselves remain stable. +{% /callout %} + ### `kilo-auto/frontier` -Highest performance and capability for any task. +Highest performance and capability for any task. Frontier requests are sent with medium reasoning effort and medium verbosity. | Mode | Resolved Model | | -------------------------------------------------------------- | ----------------------------- | -| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `anthropic/claude-opus-4.6` | +| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `anthropic/claude-opus-4.7` | | `build`, `explore`, `code` | `anthropic/claude-sonnet-4.6` | -| Default (no mode specified) | `anthropic/claude-sonnet-4.6` | +| Default (no / unknown mode) | `anthropic/claude-sonnet-4.6` | ### `kilo-auto/balanced` -Great balance of price and capability. - -| Mode | Resolved Model | -| -------------------------------------------------------------- | ---------------------- | -| `plan`, `general`, `architect`, `orchestrator`, `ask`, `debug` | `openai/gpt-5.3-codex` | -| `build`, `explore`, `code` | `openai/gpt-5.3-codex` | -| Default (no mode specified) | `openai/gpt-5.3-codex` | - -### `kilo-auto/free` - -Free with limited capability. No credits required. +Great balance of price and capability. Balanced routes to the same model regardless of mode, with low reasoning effort. | Mode | Resolved Model | | --------- | ---------------------- | -| All modes | `minimax/minimax-m2.5` | +| All modes | `openai/gpt-5.3-codex` | + +### `kilo-auto/free` + +Free with limited capability. No credits required. Requests are split across the available free models; the mapping updates server-side as free model availability shifts. + +| Routing | Resolved Model | +| ------- | ----------------------------- | +| 80% | `minimax/minimax-m2.5:free` | +| 20% | `stepfun/step-3.5-flash:free` | ### `kilo-auto/small` -Automatically routes to a small, fast model. +Automatically routes to a small, fast model for lightweight background tasks (session titles, commit messages, summaries). -| Mode | Resolved Model | -| ------------- | -------------------- | -| Default | `openai/gpt-5-nano` | -| Free fallback | `openai/gpt-oss-20b` | +| Condition | Resolved Model | +| ------------------------- | -------------------------------- | +| Account has paid balance | `google/gemma-4-31b-it` | +| No balance / free account | `google/gemma-4-26b-a4b-it:free` | ### Example usage diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index af07ff3de52..e2de274ff60 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": "7.2.12", + "version": "7.2.14", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 15b8d8c5337..46c1fff9b89 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": "7.2.12", + "version": "7.2.14", "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 69096f72578..af676db0cec 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": "7.2.12", + "version": "7.2.14", "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 ae60ce27df3..a0ecdda7658 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.2.12", + "version": "7.2.14", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/components/basic-tool.css b/packages/kilo-ui/src/components/basic-tool.css index d8a66e011cb..17cb5d9d07c 100644 --- a/packages/kilo-ui/src/components/basic-tool.css +++ b/packages/kilo-ui/src/components/basic-tool.css @@ -156,6 +156,24 @@ html[data-theme="kilo-vscode"] [data-component="tool-part-wrapper"][data-part-ty } } + /* Reposition copy button tooltip to appear below (not above) to avoid clipping */ + [data-component="tool-output"] [data-slot="markdown-copy-button"]::after { + bottom: auto; + top: calc(100% + 4px); + } + + [data-slot="mcp-section-label"] { + padding: 6px 12px 0; + font-size: 11px; + color: var(--text-weak, var(--vscode-descriptionForeground)); + } + + [data-slot="mcp-tool-divider"] { + height: 1px; + background: var(--border-weak-base, var(--vscode-panel-border)); + margin-top: 4px; + } + /* Expandable tool output content */ [data-component="tool-output"] { padding: 8px 12px; diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index aa30400b5d2..1bbbdb1eb7a 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -1026,24 +1026,84 @@ function ToolFileAccordion(props: { path: string; actions?: JSX.Element; childre // GenericTool (upstream) does not render output; this override does. // When hideDetails is true, render as a row (no content), otherwise as a panel with markdown output. function McpTool(props: ToolProps) { + const i18n = useI18n() + const labelKeys = ["description", "query", "url", "filePath", "path", "pattern", "name"] + const skipKeys = new Set(labelKeys) + + const subtitle = () => + labelKeys + .map((key) => props.input?.[key]) + .find((value): value is string => typeof value === "string" && value.length > 0) + + const inputArgs = () => { + if (!props.input) return [] + return Object.entries(props.input) + .filter(([key]) => !skipKeys.has(key)) + .flatMap(([key, value]) => { + if (typeof value === "string") return [`${key}=${value}`] + if (typeof value === "number") return [`${key}=${value}`] + if (typeof value === "boolean") return [`${key}=${value}`] + return [] + }) + .slice(0, 3) + } + + const formatted = createMemo(() => { + if (!props.input || Object.keys(props.input).length === 0) return "" + return "```json\n" + JSON.stringify(props.input, null, 2) + "\n```" + }) + + const formattedOutput = createMemo(() => { + if (!props.output) return undefined + try { + const parsed = JSON.parse(props.output) + return "```json\n" + JSON.stringify(parsed, null, 2) + "\n```" + } catch { + return props.output + } + }) + return ( } + fallback={ + + } > - - {(output) => ( -
- -
+ + {(text) => ( + <> +
{i18n.t("ui.messagePart.mcp.input")}
+
+ +
+ + )} +
+ + {(text) => ( + <> + +
+ +
{i18n.t("ui.messagePart.mcp.output")}
+
+ +
+ )} @@ -1068,7 +1128,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { return ( -
+
{(error) => { diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index 8dcc5af4826..265535291a6 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,33 @@ # kilo-code +## 7.2.14 + +### Minor Changes + +- [#8976](https://github.com/Kilo-Org/kilocode/pull/8976) [`4ef6bbf`](https://github.com/Kilo-Org/kilocode/commit/4ef6bbff5093dc68a607f1f268e6ab662781922e) - Support browsing and resuming sessions from Agent Manager with `/sessions`. + +- [#9023](https://github.com/Kilo-Org/kilocode/pull/9023) [`5301258`](https://github.com/Kilo-Org/kilocode/commit/530125828e891d3c50fe8d783201b65e3c4db8e4) - Support mentioning folders in the prompt with @ references, including top-level folder file contents. + +### Patch Changes + +- [#9121](https://github.com/Kilo-Org/kilocode/pull/9121) [`c8fd421`](https://github.com/Kilo-Org/kilocode/commit/c8fd4218236afb7d9f525ca667ddf53734c47d4a) - Fix the sidebar "Show Changes" diff viewer: the file tree now renders correctly (previously the file rows were cramped onto a single line due to missing styles), and per-file revert buttons are available, matching the Agent Manager. + +- [#9046](https://github.com/Kilo-Org/kilocode/pull/9046) [`671129d`](https://github.com/Kilo-Org/kilocode/commit/671129d4d70587352f963f9f409d6c24e9e86436) - Fix a native memory leak on Windows where `kilo serve` would grow to several GB of RAM within minutes of opening the Agent Manager. Git diff polling now runs directly in the extension host instead of routing through the CLI subprocess, and the diff detail view caps per-file reads at 20 MB to prevent memory spikes when opening very large files. + +- [#9118](https://github.com/Kilo-Org/kilocode/pull/9118) [`343455b`](https://github.com/Kilo-Org/kilocode/commit/343455b87895a0551760b5710b1ffe58fae21efd) - Respect per-agent model selections when an agent has a `model` configured in `kilo.jsonc`. Switching the model for such an agent now sticks across agent switches and CLI restarts. To pick up a newly edited agent default, re-select the model once (or clear `~/.local/share/kilo/storage/model.json`). + +- [#9067](https://github.com/Kilo-Org/kilocode/pull/9067) [`959a8b4`](https://github.com/Kilo-Org/kilocode/commit/959a8b498de6efd28756683162296dd40eb9b454) - Fix "assistant prefill" errors when a user queues a prompt while the previous turn is still streaming. The queued message no longer lands in the middle of the prior turn's history, so the next request always ends with the user prompt. + +- [#9123](https://github.com/Kilo-Org/kilocode/pull/9123) [`9749cc1`](https://github.com/Kilo-Org/kilocode/commit/9749cc178d999f96669cc815709a7cdf3129aefd) - Show MCP tool call inputs alongside outputs in chat, with JSON syntax highlighting for both. + +- [#8911](https://github.com/Kilo-Org/kilocode/pull/8911) [`eac2dba`](https://github.com/Kilo-Org/kilocode/commit/eac2dbafa009adedeb4b44016956f2c6cd96b715) - Make switching between sessions in Agent Manager near-instant. Long sessions no longer freeze the UI when selected, and the chat view self-heals if it missed any messages while the session was in the background. + +- [`f270639`](https://github.com/Kilo-Org/kilocode/commit/f27063987765bba2443f559629bc8c05fad996df) - Show an inline error in the Settings save bar when the configuration fails to save (for example, due to an invalid value) so the user can correct the config and retry instead of losing their unsaved changes silently. + +- Updated dependencies [[`eac2dba`](https://github.com/Kilo-Org/kilocode/commit/eac2dbafa009adedeb4b44016956f2c6cd96b715)]: + - @opencode-ai/ui@7.2.13 + - @kilocode/kilo-ui@7.2.13 + ## 7.2.12 ### Minor Changes diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 13e49b3ac9d..5c4935dd5f2 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.2.12", + "version": "7.2.14", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", @@ -880,6 +880,7 @@ "simple-git": "3.35.2", "solid-js": "^1.9.11", "uri-js": "^4.4.1", + "virtua": "catalog:", "web-tree-sitter": "^0.24.7", "yaml": "2.8.3", "zod": "^3.24.2" diff --git a/packages/kilo-vscode/src/DiffViewerProvider.ts b/packages/kilo-vscode/src/DiffViewerProvider.ts index 84196113351..a26b71a18a7 100644 --- a/packages/kilo-vscode/src/DiffViewerProvider.ts +++ b/packages/kilo-vscode/src/DiffViewerProvider.ts @@ -2,6 +2,7 @@ import * as vscode from "vscode" import type { KiloConnectionService } from "./services/cli-backend" import { buildWebviewHtml } from "./utils" import { GitOps } from "./agent-manager/GitOps" +import { WorktreeDiffClient, type DiffTarget } from "./worktree-diff-client" import { appendOutput, getWorkspaceRoot, @@ -20,7 +21,7 @@ export class DiffViewerProvider implements vscode.Disposable { private panel: vscode.WebviewPanel | undefined private diffInterval: ReturnType | undefined private lastDiffHash: string | undefined - private cachedDiffTarget: { directory: string; baseBranch: string } | undefined + private cachedDiffTarget: DiffTarget | undefined private gitOps: GitOps private outputChannel: vscode.OutputChannel private onSendComments: ((comments: unknown[], autoSend: boolean) => void) | undefined @@ -107,12 +108,48 @@ export class DiffViewerProvider implements vscode.Disposable { return } + if (type === "diffViewer.revertFile" && typeof msg.file === "string") { + void this.revertFile(msg.file) + return + } + if (type === "openFile" && typeof msg.filePath === "string") { openWorkspaceRelativeFile(msg.filePath, typeof msg.line === "number" ? msg.line : undefined) } } - private async resolveLocalDiffTarget(): Promise<{ directory: string; baseBranch: string } | undefined> { + private async revertFile(file: string): Promise { + const target = this.cachedDiffTarget ?? (await this.resolveLocalDiffTarget()) + if (!target) { + this.post({ + type: "diffViewer.revertFileResult", + file, + status: "error", + message: "Could not resolve diff target", + }) + return + } + + try { + const diff = new WorktreeDiffClient(this.connectionService.getClient(), this.gitOps, (...args) => + this.log(...args), + ) + const result = await diff.revertFile(target, file) + this.post({ + type: "diffViewer.revertFileResult", + file, + status: result.ok ? "success" : "error", + message: result.message, + }) + if (result.ok) void this.pollDiff() + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + this.log("Failed to revert file:", message) + this.post({ type: "diffViewer.revertFileResult", file, status: "error", message }) + } + } + + private async resolveLocalDiffTarget(): Promise { return await resolveLocalDiffTarget(this.gitOps, (...args) => this.log(...args), getWorkspaceRoot()) } diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index d7c66637e03..7ad1817f744 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -37,6 +37,7 @@ import { } from "./kilo-provider-utils" import { GitOps } from "./agent-manager/GitOps" import { GitStatsPoller, type LocalStats } from "./agent-manager/GitStatsPoller" +import { diffSummary as localDiffSummary } from "./agent-manager/local-diff" import { getWorkspaceRoot } from "./review-utils" import { MarketplaceService, type MarketplaceItem, type RemoveResult } from "./services/marketplace" import type { RemoteStatusService } from "./services/RemoteStatusService" @@ -49,6 +50,8 @@ import { parseMessageFiles, type MessageFile } from "./kilo-provider/message-fil import { handleFileSearch } from "./kilo-provider/file-search" import { getTerminalContents } from "./services/terminal/context" import { matchFollowup, recordFollowup, type Followup } from "./kilo-provider/followup-session" +import { clearCommandsCache, loadCommands } from "./kilo-provider/commands" +import { fetchMessagePage, MESSAGE_PAGE_LIMIT } from "./kilo-provider/message-page" import { childID } from "./kilo-provider/task-session" import { handleNetworkEvent, clearNetworkWaits } from "./kilo-provider/network" import { abortSession, parseQueued } from "./kilo-provider/abort" @@ -110,6 +113,8 @@ type KiloProviderOptions = { slimEditMetadata?: boolean } +type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" + // Helper to map agent data to the subset of fields sent to the webview const mapAgent = (a: Agent) => ({ name: a.name, @@ -172,6 +177,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper private projectID: string | undefined /** Abort controller for the current loadMessages request; aborted when a new session is selected. */ private loadMessagesAbort: AbortController | null = null + /** Per-session last focus-mode reconcile timestamp — throttles rapid tab switching. */ + private lastReconciledAt = new Map() /** Set when refreshSessions() is called before the client is ready. * Cleared and retried once the connection transitions to "connected". */ private pendingSessionRefresh = false @@ -453,6 +460,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.trackedSessionIds.add(sessionId) } + public loadMessages(sessionID: string): Promise { + // Sub-agent viewer: full transcript (no "load earlier" UI, no pagination). + return this.handleLoadMessages(sessionID, { limit: 0 }) + } + /** * Register a directory override for a session (e.g., worktree path). * When set, all operations for this session use this directory instead of the workspace root. @@ -638,7 +650,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper case "loadMessages": // Don't await: allow parallel loads so rapid session switching // isn't blocked by slow responses for earlier sessions. - void this.handleLoadMessages(message.sessionID) + void this.handleLoadMessages(message.sessionID, { + mode: message.mode, + before: message.before, + limit: message.limit, + }) break case "syncSession": this.handleSyncSession(message.sessionID, message.parentSessionID).catch((e) => @@ -1273,109 +1289,103 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - /** - * Handle loading messages for a session. - */ - private async handleLoadMessages(sessionID: string): Promise { - // Track the session so we receive its SSE events - this.trackedSessionIds.add(sessionID) - this.focusSession(sessionID) - this.contextSessionID = sessionID - - if (!this.client) { - this.postMessage({ - type: "error", - message: "Not connected to CLI backend", - sessionID, + /** Non-blocking: refresh session metadata + status for the webview after switching. */ + private refreshSessionDetails(sessionID: string, dir: string, signal?: AbortSignal): void { + if (!this.client) return + this.client.session + .get({ sessionID, directory: dir }) + .then((r) => { + if (r.data && !signal?.aborted) { + this.currentSession = r.data + this.contextSessionID = r.data.id + } }) + .catch((e: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", e)) + this.postMessage({ type: "workspaceDirectoryChanged", directory: this.getWorkspaceDirectory(sessionID) }) + this.client.session + .status({ directory: dir }) + .then((r) => { + if (!r.data || signal?.aborted) return + for (const [sid, info] of Object.entries(r.data) as [string, SessionStatus][]) { + 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((e: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", e)) + } + + private async handleLoadMessages( + sessionID: string, + options: { mode?: MessageLoadMode; before?: string; limit?: number } = {}, + ): Promise { + const mode = options.mode ?? "replace" + if (mode !== "prepend") { + this.trackedSessionIds.add(sessionID) + this.focusSession(sessionID) + this.contextSessionID = sessionID + } + if (!this.client) { + 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 - + const dir = this.getWorkspaceDirectory(sessionID) + if (mode === "focus") { + this.refreshSessionDetails(sessionID, dir) + // Reconcile tail so SSE drops self-heal. Throttled to skip rapid tab-switching bursts. + if (Date.now() - (this.lastReconciledAt.get(sessionID) ?? 0) < 1000) return + await this.handleLoadMessages(sessionID, { mode: "reconcile", limit: options.limit ?? MESSAGE_PAGE_LIMIT }) + return + } + // Replace competes for the spinner and cancels earlier loads; prepend/reconcile run in parallel. + const abort = mode === "replace" ? new AbortController() : undefined + if (abort) { + this.loadMessagesAbort?.abort() + this.loadMessagesAbort = abort + this.refreshSessionDetails(sessionID, dir, abort.signal) + } try { - const workspaceDir = this.getWorkspaceDirectory(sessionID) - const { data: messagesData } = await retry(() => - this.client!.session.messages( - { sessionID, directory: workspaceDir }, - { throwOnError: true, signal: 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. loadMessages is the - // canonical "user switched to this session" signal, so always update — - // the old guard `this.currentSession.id === sessionID` prevented updates - // when switching between different sessions. - // 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.client.session - .get({ sessionID, directory: workspaceDir }) - .then((result) => { - if (result.data && !abort.signal.aborted) { - this.currentSession = result.data - this.contextSessionID = result.data.id - } - }) - .catch((err: unknown) => console.warn("[Kilo New] KiloProvider: getSession failed (non-critical):", err)) - - this.postMessage({ - type: "workspaceDirectoryChanged", - directory: this.getWorkspaceDirectory(sessionID), + const page = await fetchMessagePage(this.client, { + sessionID, + workspaceDir: dir, + limit: options.limit ?? MESSAGE_PAGE_LIMIT, + before: options.before, + signal: abort?.signal, }) - - // Fetch current session status so the webview has the correct busy/idle - // state after switching tabs (SSE events may have been missed). - this.client.session - .status({ directory: workspaceDir }) - .then((result) => { - if (!result.data) return - for (const [sid, info] of Object.entries(result.data) as [string, SessionStatus][]) { - 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: unknown) => console.error("[Kilo New] KiloProvider: Failed to fetch session statuses:", err)) - - const messages = messagesData.map((m) => ({ + if (abort?.signal.aborted) return + // Drop results for a session deleted mid-fetch. Prepend/reconcile have + // no abort controller, so this guard prevents ghost entries. + if (!this.trackedSessionIds.has(sessionID)) return + const messages = page.items.map((m) => ({ ...m.info, parts: this.slimParts(m.parts), createdAt: new Date(m.info.time.created).toISOString(), })) - for (const message of messages) { this.connectionService.recordMessageSessionId(message.id, message.sessionID) } - - // Snapshot must reflect every SSE event up to its taken-time; any - // delta still queued here is either already applied in the snapshot - // (re-emitting would duplicate streamed text) or trails the snapshot - // and is silently lost via drop(). - this.streams.drop(sessionID) - this.postMessage({ type: "messagesLoaded", sessionID, messages }) + // Authoritative snapshot: drop queued deltas. Prepend is older history + // and must not clobber live deltas. + if (mode === "replace" || mode === "reconcile") this.streams.drop(sessionID) + if (mode === "reconcile") this.lastReconciledAt.set(sessionID, Date.now()) + this.postMessage({ + type: "messagesLoaded", + sessionID, + messages, + mode, + cursor: page.cursor, + hasMore: Boolean(page.cursor), + }) // Recover any prompts missed while the webview was loading or during an SSE reconnection. this.recoverPendingPrompts() } catch (error) { - // Silently ignore aborted requests — the user switched to a different session - if (abort.signal.aborted) return + if (abort?.signal.aborted) return console.error("[Kilo New] KiloProvider: Failed to load messages:", error) - this.postMessage({ - type: "error", - message: getErrorMessage(error) || "Failed to load messages", - sessionID, - }) + this.postMessage({ type: "error", message: getErrorMessage(error) || "Failed to load messages", sessionID }) } } @@ -1420,7 +1430,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper // Snapshot supersedes any queued deltas (see handleLoadMessages for the // snapshot-freshness assumption that governs drop() here). this.streams.drop(sessionID) - this.postMessage({ type: "messagesLoaded", sessionID, messages }) + this.postMessage({ + type: "messagesLoaded", + sessionID, + messages, + mode: "replace", + hasMore: false, + }) + // Recover any prompts emitted by the child before we started tracking it. this.recoverPendingPrompts() } catch (err) { @@ -1516,6 +1533,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.streams.drop(sessionID) this.syncedChildSessions.delete(sessionID) this.sessionDirectories.delete(sessionID) + this.lastReconciledAt.delete(sessionID) this.connectionService.pruneSession(sessionID) if (this.currentSession?.id === sessionID) { this.currentSession = null @@ -1735,6 +1753,11 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } + private clearCommandsCache(): void { + this.cachedCommandsMessage = null + clearCommandsCache() + } + private async fetchAndSendCommands(): Promise { if (!this.client) { if (this.cachedCommandsMessage) { @@ -1745,19 +1768,8 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper try { const dir = this.getWorkspaceDirectory() - const { data: commands } = await retry(() => - this.client!.command.list({ directory: dir }, { throwOnError: true }), - ) + const message = await loadCommands(this.client, dir) - const message = { - type: "commandsLoaded", - commands: commands.map((c) => ({ - name: c.name, - description: c.description, - source: c.source, - hints: c.hints, - })), - } this.cachedCommandsMessage = message this.postMessage(message) } catch (error) { @@ -1790,7 +1802,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (result.error) { console.error("[Kilo New] removeSkill returned error:", result.error) this.cachedSkillsMessage = null - this.cachedCommandsMessage = null + this.clearCommandsCache() await Promise.all([this.fetchAndSendSkills(), this.fetchAndSendCommands()]) return false } @@ -3282,7 +3294,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.statsPoller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => getWorkspaceRoot(), - getClient: () => this.connectionService.getClient(), + localDiff: (dir, base) => localDiffSummary(git, dir, base), git, onStats: () => {}, onLocalStats: (stats: LocalStats) => { diff --git a/packages/kilo-vscode/src/SubAgentViewerProvider.ts b/packages/kilo-vscode/src/SubAgentViewerProvider.ts index c50d3acba2d..277a2b606b7 100644 --- a/packages/kilo-vscode/src/SubAgentViewerProvider.ts +++ b/packages/kilo-vscode/src/SubAgentViewerProvider.ts @@ -61,18 +61,8 @@ export class SubAgentViewerProvider implements vscode.Disposable { // sessionCreated to the webview. provider.registerSession(session) - // Fetch and send existing messages - const { data: messagesData } = await client.session.messages({ sessionID }, { throwOnError: true }) - const messages = messagesData.map((m) => ({ - ...m.info, - parts: m.parts, - createdAt: new Date(m.info.time.created).toISOString(), - })) - provider.postMessage({ - type: "messagesLoaded", - sessionID, - messages, - }) + // Fetch the newest page before navigating so the tab opens on the latest turn. + await provider.loadMessages(sessionID) // Navigate to the sub-agent viewer provider.postMessage({ type: "viewSubAgentSession", sessionID }) diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index d9ffd42f01d..a0b0ac9fd52 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -26,6 +26,7 @@ import { forkSession } from "./fork-session" import { continueInWorktree } from "./continue-in-worktree" import { WorktreeDiffController } from "./worktree-diff-controller" import { WorktreeImporter } from "./worktree-importer" +import { diffSummary as localDiffSummary, diffFile as localDiffFile } from "./local-diff" import { buildKeybindingMap } from "./format-keybinding" import { resolveVersionModels, buildInitialMessages, type CreatedVersion } from "./multi-version" @@ -104,13 +105,15 @@ export class AgentManagerProvider implements Disposable { getStateReady: () => this.stateReady, getClient: () => this.connectionService.getClient(), git: this.gitOps, + localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)), + localDiffFile: (dir, base, file) => localDiffFile(this.gitOps, dir, base, file, (...args) => this.log(...args)), post: (msg) => this.postToWebview(msg), log: (...args) => this.log(...args), }) this.statsPoller = new GitStatsPoller({ getWorktrees: () => this.state?.getWorktrees() ?? [], getWorkspaceRoot: () => this.getRoot(), - getClient: () => this.connectionService.getClient(), + localDiff: (dir, base) => localDiffSummary(this.gitOps, dir, base, (...args) => this.log(...args)), semaphore, onStats: (stats) => { const msg = { type: "agentManager.worktreeStats" as const, stats } diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 66c74cb464b..f835cf3832c 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -36,7 +36,7 @@ interface ExecOptions { stdin?: string } -interface ExecResult { +export interface ExecResult { code: number stdout: string stderr: string @@ -473,6 +473,17 @@ export class GitOps { return [{ reason: "Patch does not apply cleanly" }] } + /** + * Run a git command returning `{code, stdout, stderr}`. Gated by the shared + * semaphore and respects the dispose abort signal. Never throws — commands + * with non-zero exit codes resolve normally (nothrow semantics), making this + * suitable for callers that need to tolerate legitimate failures (e.g. + * `merge-base` on an orphan branch, `ls-files --error-unmatch`). + */ + execGit(args: string[], cwd: string, options?: { stdin?: string }): Promise { + return this.exec(args, cwd, options) + } + private exec(args: string[], cwd: string, options?: ExecOptions): Promise { if (this.controller.signal.aborted) { return Promise.resolve({ code: 1, stdout: "", stderr: "GitOps disposed" }) diff --git a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts index 30a79b827c5..6b08f9f6ba2 100644 --- a/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts +++ b/packages/kilo-vscode/src/agent-manager/GitStatsPoller.ts @@ -1,10 +1,10 @@ import * as fs from "fs" import * as path from "path" -import type { KiloClient, SnapshotFileDiff } from "@kilocode/sdk/v2/client" import { remoteRef, type Worktree } from "./WorktreeStateManager" import type { GitOps } from "./GitOps" import type { Semaphore } from "./semaphore" import { normalizePath } from "./git-import" +import type { WorktreeDiffEntry } from "./types" export interface WorktreeStats { worktreeId: string @@ -39,7 +39,12 @@ export interface WorktreePresenceResult { interface GitStatsPollerOptions { getWorktrees: () => Worktree[] getWorkspaceRoot: () => string | undefined - getClient: () => KiloClient + /** + * Compute diff summaries locally (in the extension host) rather than over + * HTTP to `kilo serve`. Keeps git spawning out of the Bun process, which + * leaks native memory on Windows (oven-sh/bun#18265). + */ + localDiff: (dir: string, base: string) => Promise git: GitOps onStats: (stats: WorktreeStats[]) => void onLocalStats: (stats: LocalStats) => void @@ -142,27 +147,16 @@ export class GitStatsPoller { } private async fetch(): Promise { - const client = (() => { - try { - return this.options.getClient() - } catch (err) { - this.options.log("Failed to get client for stats:", err) - return undefined - } - })() - - await Promise.all([this.fetchWorktreeStats(client), this.fetchLocalStats(client)]) + await Promise.all([this.fetchWorktreeStats(), this.fetchLocalStats()]) } - private async fetchWorktreeStats(client: KiloClient | undefined): Promise { + private async fetchWorktreeStats(): Promise { const worktrees = this.options.getWorktrees() if (worktrees.length === 0) return const presence = await this.probeWorktreePresence(worktrees) this.options.onWorktreePresence?.(presence) - if (!client) return - const missing = new Set( presence.degraded ? [] : presence.worktrees.filter((item) => item.missing).map((item) => item.worktreeId), ) @@ -181,23 +175,21 @@ export class GitStatsPoller { return } - // Gate the HTTP diffSummary call through the semaphore but NOT the - // aheadBehind call — that goes through GitOps.raw() which already - // acquires the same semaphore. Wrapping both would deadlock. - const gate = this.options.semaphore - const diff = (dir: string, base: string) => { - const invoke = () => client.worktree.diffSummary({ directory: dir, base }, { throwOnError: true }) - return gate ? gate.run(invoke) : invoke() - } + // localDiff runs in-process via GitOps.execGit() which already acquires + // the shared semaphore internally; same goes for aheadBehind via + // GitOps.raw(). Wrapping either again here would deadlock. const stats = ( await Promise.all( active.map(async (wt) => { try { const base = remoteRef(wt) - const [{ data: diffs }, ab] = await Promise.all([diff(wt.path, base), this.git.aheadBehind(wt.path, base)]) + const [diffs, ab] = await Promise.all([ + this.options.localDiff(wt.path, base), + this.git.aheadBehind(wt.path, base), + ]) const files = diffs.length - const additions = diffs.reduce((sum: number, diff: SnapshotFileDiff) => sum + diff.additions, 0) - const deletions = diffs.reduce((sum: number, diff: SnapshotFileDiff) => sum + diff.deletions, 0) + const additions = diffs.reduce((sum, diff) => sum + diff.additions, 0) + const deletions = diffs.reduce((sum, diff) => sum + diff.deletions, 0) return { worktreeId: wt.id, files, additions, deletions, ahead: ab.ahead, behind: ab.behind } } catch (err) { this.options.log(`Failed to fetch worktree stats for ${wt.branch} (${wt.path}):`, err) @@ -257,7 +249,7 @@ export class GitStatsPoller { return { worktrees: worktreeStatuses, degraded: false } } - private async fetchLocalStats(client: KiloClient | undefined): Promise { + private async fetchLocalStats(): Promise { const root = this.options.getWorkspaceRoot() if (!root) return @@ -274,21 +266,16 @@ export class GitStatsPoller { let ahead: number let behind: number try { - if (base && client) { - this.options.log(`Local stats: using HTTP client with base=${base}`) - const gate = this.options.semaphore - const invoke = () => client.worktree.diffSummary({ directory: root, base }, { throwOnError: true }) - const [{ data: diffs }, ab] = await Promise.all([ - gate ? gate.run(invoke) : invoke(), - this.git.aheadBehind(root, base), - ]) + if (base) { + this.options.log(`Local stats: using localDiff with base=${base}`) + const [diffs, ab] = await Promise.all([this.options.localDiff(root, base), this.git.aheadBehind(root, base)]) files = diffs.length - additions = diffs.reduce((sum: number, d: SnapshotFileDiff) => sum + d.additions, 0) - deletions = diffs.reduce((sum: number, d: SnapshotFileDiff) => sum + d.deletions, 0) + additions = diffs.reduce((sum, d) => sum + d.additions, 0) + deletions = diffs.reduce((sum, d) => sum + d.deletions, 0) ahead = ab.ahead behind = ab.behind } else { - this.options.log(`Local stats: fallback to workingTreeStats (base=${base ?? "none"} client=${!!client})`) + this.options.log(`Local stats: fallback to workingTreeStats (no base branch)`) const wt = await this.git.workingTreeStats(root) files = wt.files additions = wt.additions diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts new file mode 100644 index 00000000000..977b01de13d --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -0,0 +1,386 @@ +import * as fs from "fs/promises" +import * as path from "path" +import type { GitOps } from "./GitOps" +import type { WorktreeDiffEntry } from "./types" + +type Status = "added" | "deleted" | "modified" + +type Meta = { + file: string + additions: number + deletions: number + status: Status + tracked: boolean + generatedLike: boolean + stamp: string +} + +type Log = (...args: unknown[]) => void + +/** Cap untracked file reads so line-counting a multi-megabyte log file does + * not stall the poll. Matches `GitOps.workingTreeStats()`. */ +const MAX_UNTRACKED_BYTES = 1_000_000 + +/** Cap per-side reads in the detail view. Opening very large tracked files + * used to spike `kilo serve`; now that the detail path runs in the + * extension host, the same file would spike VS Code's RSS. Over this + * threshold we return a summarized entry (empty `before`/`after`/`patch`, + * metadata preserved) so the webview can render counts without + * materializing the content. */ +export const MAX_DETAIL_BYTES = 20_000_000 + +/** + * Local, Node.js-side replacement for the server's `WorktreeDiff.summary()` and + * `WorktreeDiff.detail()` routes. Keeps Agent Manager polling out of the Bun + * `kilo serve` process, which leaks native memory on every `Bun.spawn` on + * Windows (oven-sh/bun#18265). + * + * All git calls go through `GitOps.execGit()` → `child_process.spawn` with + * `windowsHide: true` and the shared semaphore. No Bun involvement. + */ + +/** Ported from `packages/opencode/src/file/ignore.ts` — identical patterns, + * no runtime dependency on minimatch/picomatch. */ +const FOLDERS = new Set([ + "node_modules", + "bower_components", + ".pnpm-store", + "vendor", + ".npm", + "dist", + "build", + "out", + ".next", + "target", + "bin", + "obj", + ".git", + ".svn", + ".hg", + ".vscode", + ".idea", + ".turbo", + ".output", + "desktop", + ".sst", + ".cache", + ".webkit-cache", + "__pycache__", + ".pytest_cache", + "mypy_cache", + ".history", + ".gradle", +]) + +const SUFFIXES = [".swp", ".swo", ".pyc", ".log"] +const BASENAMES = new Set([".DS_Store", "Thumbs.db"]) +const CONTAINS_SEGMENTS = ["logs", "tmp", "temp", "coverage", ".nyc_output"] + +export function generatedLike(file: string): boolean { + const parts = file.split(/[/\\]/) + for (const part of parts) { + if (FOLDERS.has(part)) return true + if (CONTAINS_SEGMENTS.includes(part)) return true + } + for (const suffix of SUFFIXES) { + if (file.endsWith(suffix)) return true + } + const base = parts[parts.length - 1] ?? "" + if (BASENAMES.has(base)) return true + return false +} + +async function ancestor(git: GitOps, dir: string, base: string, log?: Log): Promise { + const result = await git.execGit(["merge-base", "HEAD", base], dir) + if (result.code !== 0) { + log?.("git merge-base failed", { code: result.code, stderr: result.stderr.trim(), dir, base }) + return undefined + } + return result.stdout.trim() +} + +async function numstat(git: GitOps, dir: string, base: string, file?: string) { + const args = ["-c", "core.quotepath=false", "diff", "--numstat", "--no-renames", base] + if (file) args.push("--", file) + const result = await git.execGit(args, dir) + const map = new Map() + if (result.code !== 0) return map + for (const line of result.stdout.trim().split("\n")) { + if (!line) continue + const parts = line.split("\t") + const add = parts[0] + const del = parts[1] + const name = parts.slice(2).join("\t") + if (!name) continue + map.set(name, { + additions: add === "-" ? 0 : parseInt(add || "0", 10) || 0, + deletions: del === "-" ? 0 : parseInt(del || "0", 10) || 0, + }) + } + return map +} + +async function statStamp(dir: string, file: string): Promise { + const stat = await fs.stat(path.join(dir, file)).catch(() => undefined) + if (!stat) return `missing:${file}` + return `${stat.size}:${stat.mtimeMs}` +} + +async function lineCount(file: string): Promise { + const stat = await fs.stat(file).catch(() => undefined) + if (!stat || stat.size === 0) return 0 + if (stat.size > MAX_UNTRACKED_BYTES) return 0 + const content = await fs.readFile(file, "utf-8").catch(() => "") + if (!content) return 0 + if (content.endsWith("\n")) return content.split("\n").length - 1 + return content.split("\n").length +} + +function statusFromCode(code: string): Status { + if (code === "A") return "added" + if (code === "D") return "deleted" + return "modified" +} + +async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise { + const nameStatus = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc], + dir, + ) + if (nameStatus.code !== 0) { + log?.("git diff --name-status failed", { code: nameStatus.code, stderr: nameStatus.stderr.trim() }) + return [] + } + + const counts = await numstat(git, dir, anc) + const result: Meta[] = [] + const seen = new Set() + + for (const line of nameStatus.stdout.trim().split("\n")) { + if (!line) continue + const parts = line.split("\t") + const code = parts[0] + const file = parts.slice(1).join("\t") + if (!file || !code) continue + seen.add(file) + const status = statusFromCode(code) + const stat = counts.get(file) ?? { additions: 0, deletions: 0 } + result.push({ + file, + additions: stat.additions, + deletions: stat.deletions, + status, + tracked: true, + generatedLike: generatedLike(file), + stamp: status === "deleted" ? `deleted:${anc}` : await statStamp(dir, file), + }) + } + + const untracked = await git.execGit(["ls-files", "--others", "--exclude-standard"], dir) + if (untracked.code !== 0) { + log?.("git ls-files --others failed", { code: untracked.code, stderr: untracked.stderr.trim() }) + return result + } + + const files = untracked.stdout.trim() + if (!files) return result + + for (const file of files.split("\n")) { + if (!file || seen.has(file)) continue + const full = path.join(dir, file) + const exists = await fs.stat(full).catch(() => undefined) + if (!exists) continue + result.push({ + file, + additions: await lineCount(full), + deletions: 0, + status: "added", + tracked: false, + generatedLike: generatedLike(file), + stamp: await statStamp(dir, file), + }) + } + + return result +} + +function summarize(meta: Meta): WorktreeDiffEntry { + return { + file: meta.file, + patch: "", + before: "", + after: "", + additions: meta.additions, + deletions: meta.deletions, + status: meta.status, + tracked: meta.tracked, + generatedLike: meta.generatedLike, + summarized: true, + stamp: meta.stamp, + } +} + +/** + * Hot polling path. Returns one summarized entry per changed file (tracked or + * untracked) relative to `merge-base HEAD base`. No file contents are read — + * `before`/`after`/`patch` are empty strings. Matches the shape the server's + * `WorktreeDiff.summary` emits. + */ +export async function diffSummary(git: GitOps, dir: string, base: string, log?: Log): Promise { + const anc = await ancestor(git, dir, base, log) + if (!anc) return [] + const items = await list(git, dir, anc, log) + return items.map(summarize) +} + +async function detailMeta(git: GitOps, dir: string, anc: string, file: string): Promise { + const tracked = await git.execGit(["ls-files", "--error-unmatch", "--", file], dir) + if (tracked.code !== 0) { + const full = path.join(dir, file) + const exists = await fs.stat(full).catch(() => undefined) + if (!exists) return undefined + return { + file, + additions: await lineCount(full), + deletions: 0, + status: "added", + tracked: false, + generatedLike: generatedLike(file), + stamp: await statStamp(dir, file), + } + } + + const nameStatus = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--name-status", "--no-renames", anc, "--", file], + dir, + ) + if (nameStatus.code !== 0) return undefined + const line = nameStatus.stdout.trim().split("\n")[0] + if (!line) return undefined + const parts = line.split("\t") + const code = parts[0] + const pathPart = parts.slice(1).join("\t") || file + if (!code) return undefined + + const counts = await numstat(git, dir, anc, file) + const stat = counts.get(file) ?? counts.get(pathPart) ?? { additions: 0, deletions: 0 } + const status = statusFromCode(code) + return { + file: pathPart, + additions: stat.additions, + deletions: stat.deletions, + status, + tracked: true, + generatedLike: generatedLike(pathPart), + stamp: status === "deleted" ? `deleted:${anc}` : await statStamp(dir, pathPart), + } +} + +async function blobSize(git: GitOps, dir: string, anc: string, file: string): Promise { + const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir) + if (result.code !== 0) return 0 + return parseInt(result.stdout.trim(), 10) || 0 +} + +async function fileSize(dir: string, file: string): Promise { + const stat = await fs.stat(path.join(dir, file)).catch(() => undefined) + return stat?.size ?? 0 +} + +async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise { + if (status === "added") return "" + const result = await git.execGit(["show", `${anc}:${file}`], dir) + return result.code === 0 ? result.stdout : "" +} + +async function readAfter(dir: string, file: string, status: Status): Promise { + if (status === "deleted") return "" + const full = path.join(dir, file) + const exists = await fs.stat(full).catch(() => undefined) + if (!exists) return "" + return fs.readFile(full, "utf-8").catch(() => "") +} + +async function unifiedPatch(git: GitOps, dir: string, anc: string, file: string): Promise { + const result = await git.execGit( + ["-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-renames", anc, "--", file], + dir, + ) + return result.code === 0 ? result.stdout : "" +} + +function linesOf(text: string): number { + if (!text) return 0 + return text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length +} + +/** + * Single-file detail view (infrequent — opened on demand when the user clicks + * a file in the review panel). Returns full `before`, `after`, and unified + * patch. Returns `null` if the file cannot be resolved. + */ +export async function diffFile( + git: GitOps, + dir: string, + base: string, + file: string, + log?: Log, +): Promise { + const anc = await ancestor(git, dir, base, log) + if (!anc) return null + const meta = await detailMeta(git, dir, anc, file) + if (!meta) return null + + // Cheap size probe before materializing content — protects the extension + // host from OOM on huge tracked files. `git cat-file -s` returns the blob + // size without streaming its contents, and `fs.stat` is a plain syscall. + const beforeBytes = meta.status === "added" ? 0 : await blobSize(git, dir, anc, meta.file) + const afterBytes = meta.status === "deleted" ? 0 : await fileSize(dir, meta.file) + if (beforeBytes > MAX_DETAIL_BYTES || afterBytes > MAX_DETAIL_BYTES) { + log?.("diffFile: file too large for detail view, returning summarized entry", { + file: meta.file, + beforeBytes, + afterBytes, + cap: MAX_DETAIL_BYTES, + }) + return summarize(meta) + } + + const before = await readBefore(git, dir, anc, meta.file, meta.status) + const after = await readAfter(dir, meta.file, meta.status) + const patch = meta.tracked ? await unifiedPatch(git, dir, anc, meta.file) : buildUntrackedPatch(meta.file, after) + const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? linesOf(after) : meta.additions + return { + file: meta.file, + patch, + before, + after, + additions, + deletions: meta.deletions, + status: meta.status, + tracked: meta.tracked, + generatedLike: meta.generatedLike, + summarized: false, + stamp: meta.stamp, + } +} + +/** Synthesize a unified-diff patch for an untracked (new) file. `git diff` + * only covers tracked paths, so we render the "everything added" patch + * ourselves. Format matches `git diff --no-index /dev/null `. */ +function buildUntrackedPatch(file: string, content: string): string { + if (!content) { + return `diff --git a/${file} b/${file}\nnew file mode 100644\n--- /dev/null\n+++ b/${file}\n` + } + const lines = content.split("\n") + const trailing = content.endsWith("\n") + const body = trailing ? lines.slice(0, -1) : lines + const header = + `diff --git a/${file} b/${file}\n` + + `new file mode 100644\n` + + `--- /dev/null\n` + + `+++ b/${file}\n` + + `@@ -0,0 +1,${body.length} @@\n` + const hunk = body.map((line) => `+${line}`).join("\n") + return header + hunk + (trailing ? "\n" : "\n\\ No newline at end of file\n") +} diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 314aa077aee..8392b332014 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -24,6 +24,8 @@ type SessionMode = "worktree" | "local" export type ApplyDiffStatus = "checking" | "applying" | "success" | "conflict" | "error" export type WorktreeDiffEntry = SnapshotFileDiff & { + before?: string + after?: string tracked?: boolean generatedLike?: boolean summarized?: boolean @@ -532,6 +534,9 @@ interface PreviewImageIn { interface LoadMessagesIn { type: "loadMessages" sessionID: string + mode?: "replace" | "prepend" | "focus" + before?: string + limit?: number } interface FileSourceIn { diff --git a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts index a4ec1dabbd9..26cef806ba2 100644 --- a/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts +++ b/packages/kilo-vscode/src/agent-manager/worktree-diff-controller.ts @@ -1,21 +1,31 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" import { hashFileDiffs, resolveLocalDiffTarget } from "../review-utils" +import { WorktreeDiffClient } from "../worktree-diff-client" import type { ApplyConflict, GitOps } from "./GitOps" import { shouldStopDiffPolling } from "./delete-worktree" import { remoteRef, type ManagedSession, type WorktreeStateManager } from "./WorktreeStateManager" -import type { AgentManagerOutMessage } from "./types" +import type { AgentManagerOutMessage, WorktreeDiffEntry } from "./types" const LOCAL_DIFF_ID = "local" as const type Target = { sessionId: string; directory: string; baseBranch: string } -type Status = "added" | "deleted" | "modified" export interface WorktreeDiffControllerContext { getState: () => WorktreeStateManager | undefined getRoot: () => string | undefined getStateReady: () => Promise | undefined + /** + * SDK client — used by `revert()` via `WorktreeDiffClient` for the one-shot + * file-status lookup. Hot polling paths (`request`, `requestFile`, `poll`) + * deliberately bypass the client and go through `localDiff`/`localDiffFile` + * to keep git spawns out of the Bun `kilo serve` process (see oven-sh/bun#18265). + */ getClient: () => KiloClient git: GitOps + /** In-process diff summary (replaces client.worktree.diffSummary). */ + localDiff: (dir: string, base: string) => Promise + /** In-process single-file diff (replaces client.worktree.diffFile). */ + localDiffFile: (dir: string, base: string, file: string) => Promise post: (msg: AgentManagerOutMessage) => void log: (...args: unknown[]) => void } @@ -111,12 +121,8 @@ export class WorktreeDiffController { } try { - const result = await this.ctx.git.revertFile( - target.directory, - target.baseBranch, - file, - await this.status(target, file), - ) + const diff = new WorktreeDiffClient(this.ctx.getClient(), this.ctx.git, (...args) => this.ctx.log(...args)) + const result = await diff.revertFile(target, file) this.ctx.post({ type: "agentManager.revertWorktreeFileResult", sessionId, @@ -149,11 +155,7 @@ export class WorktreeDiffController { this.ctx.post({ type: "agentManager.worktreeDiffLoading", sessionId, loading: true }) try { - const { data } = await this.ctx - .getClient() - .worktree.diffSummary({ directory: target.directory, base: target.baseBranch }, { throwOnError: true }) - - const files = data ?? [] + const files = await this.ctx.localDiff(target.directory, target.baseBranch) this.ctx.log(`Worktree diff returned ${files.length} file(s) for session ${sessionId}`) this.hash = hashFileDiffs(files) this.session = sessionId @@ -175,10 +177,8 @@ export class WorktreeDiffController { this.target = { sessionId, directory: target.directory, baseBranch: target.baseBranch } try { - const { data } = await this.ctx - .getClient() - .worktree.diffFile({ directory: target.directory, base: target.baseBranch, file }, { throwOnError: true }) - this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: data ?? null }) + const data = await this.ctx.localDiffFile(target.directory, target.baseBranch, file) + this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: data }) } catch (error) { this.ctx.log("Failed to fetch worktree diff file:", error) this.ctx.post({ type: "agentManager.worktreeDiffFile", sessionId, file, diff: null }) @@ -219,11 +219,7 @@ export class WorktreeDiffController { if (!target) return try { - const { data } = await this.ctx - .getClient() - .worktree.diffSummary({ directory: target.directory, base: target.baseBranch }, { throwOnError: true }) - - const files = data ?? [] + const files = await this.ctx.localDiff(target.directory, target.baseBranch) const hash = hashFileDiffs(files) if (hash === this.hash && this.session === sessionId) return this.hash = hash @@ -266,18 +262,6 @@ export class WorktreeDiffController { return await resolveLocalDiffTarget(this.ctx.git, (...args) => this.ctx.log(...args), this.ctx.getRoot()) } - private async status(target: { directory: string; baseBranch: string }, file: string): Promise { - try { - const { data } = await this.ctx - .getClient() - .worktree.diffFile({ directory: target.directory, base: target.baseBranch, file }, { throwOnError: true }) - return data?.status - } catch (error) { - this.ctx.log("Failed to look up file status for revert:", error) - return undefined - } - } - private async ready(msg: string): Promise { await this.ctx.getStateReady()?.catch((err) => this.ctx.log(msg, err)) } diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 450aba09be7..a6ea2c31b03 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -326,6 +326,7 @@ export function activate(context: vscode.ExtensionContext) { const match = uri.path.match(/^\/kilocode\/s\/([a-zA-Z0-9_-]+)$/) if (!match) return const sessionId = match[1] + if (!sessionId) return console.log("[Kilo New] URI handler: opening cloud session:", sessionId) await vscode.commands.executeCommand(`${KiloProvider.viewType}.focus`) provider.openCloudSession(sessionId) diff --git a/packages/kilo-vscode/src/kilo-provider/commands.ts b/packages/kilo-vscode/src/kilo-provider/commands.ts new file mode 100644 index 00000000000..d641fff1f38 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/commands.ts @@ -0,0 +1,34 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { retry } from "../services/cli-backend/retry" + +const promises = new Map>() + +export function clearCommandsCache(): void { + promises.clear() +} + +export async function loadCommands(client: KiloClient, dir: string): Promise { + const pending = promises.get(dir) + if (pending) return pending + + const promise = retry(() => client.command.list({ directory: dir }, { throwOnError: true })).then(({ data }) => ({ + type: "commandsLoaded", + commands: data.map((cmd) => ({ + name: cmd.name, + description: cmd.description, + source: cmd.source, + hints: cmd.hints, + })), + })) + + promises.set(dir, promise) + try { + return await promise + } finally { + // Clear the cache entry once the request settles so subsequent calls + // fetch fresh data. Identity check guards against clear-then-restart + // races: if clearCommandsCache() wiped the map and a new loadCommands() + // already stored a fresh promise, don't delete its entry. + if (promises.get(dir) === promise) promises.delete(dir) + } +} diff --git a/packages/kilo-vscode/src/kilo-provider/message-page.ts b/packages/kilo-vscode/src/kilo-provider/message-page.ts new file mode 100644 index 00000000000..b8fef78f2f3 --- /dev/null +++ b/packages/kilo-vscode/src/kilo-provider/message-page.ts @@ -0,0 +1,64 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import { retry } from "../services/cli-backend/retry" + +export const MESSAGE_PAGE_LIMIT = 80 + +/** + * Build the same base64url-encoded cursor format the server emits so a + * synthesized cursor round-trips through `session.messages({ before })`. + * Server contract: `{ id, time }` JSON → base64url. See MessageV2.cursor. + */ +function synthesizeCursor(oldest: { info: { id: string; time: { created: number } } }): string { + const payload = JSON.stringify({ id: oldest.info.id, time: oldest.info.time.created }) + return Buffer.from(payload, "utf8").toString("base64url") +} + +export async function fetchMessagePage( + client: KiloClient, + input: { + sessionID: string + workspaceDir: string + limit: number + before?: string + signal?: AbortSignal + }, +) { + // limit: 0 is the server contract for "return every message" — used by + // the sub-agent viewer, which has no "load earlier" UI. + const full = input.limit === 0 + const read = async (before?: string) => { + const result = await retry(() => + client.session.messages( + { sessionID: input.sessionID, directory: input.workspaceDir, limit: input.limit, before }, + { throwOnError: true, signal: input.signal }, + ), + ) + // When a proxy/auth gateway strips X-Next-Cursor but the response fills + // the requested limit, synthesize a cursor from the oldest item so the + // "load earlier" path keeps working. Risk of one extra empty request is + // preferable to silently hiding older history. Never synthesize for + // full loads — those return everything by contract. + const items = result.data + const header = result.response.headers.get("X-Next-Cursor") + const cursor = full + ? undefined + : (header ?? (items.length >= input.limit && items[0] ? synthesizeCursor(items[0]) : undefined)) + return { items, cursor } + } + + const suffix = (items: Awaited>["items"]) => { + const index = [...items].reverse().findIndex((item) => item.info.role === "user") + if (index === -1) return items + return items.slice(items.length - index - 1) + } + + const fill = async (page: Awaited>): Promise>> => { + if (page.items[0]?.info.role !== "assistant") return page + if (!page.cursor || input.signal?.aborted) return page + const next = await read(page.cursor) + const items = [...suffix(next.items), ...page.items] + return fill({ items, cursor: next.cursor }) + } + + return fill(await read(input.before)) +} diff --git a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts index 0e55b48d825..1c5c3eafe49 100644 --- a/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts +++ b/packages/kilo-vscode/src/kilo-provider/slim-metadata.ts @@ -139,16 +139,22 @@ function slimWrite(state: Record): Record { return next } -/** bash: truncate metadata.output (up to 30KB) and state.output (up to 50KB). */ -function slimBash(state: Record): Record { +/** read/list/search: keep the rendered tool details lightweight on historical loads. */ +function slimOutput(state: Record): Record { const next = { ...state } + if (typeof state.output === "string" && state.output.length > OUTPUT_CAP) { + next.output = cap(state.output) + } + return next +} + +/** bash: truncate metadata.output and state.output. */ +function slimBash(state: Record): Record { + const next = slimOutput(state) const meta = state.metadata if (isObj(meta) && typeof meta.output === "string" && meta.output.length > OUTPUT_CAP) { next.metadata = { ...meta, output: cap(meta.output) } } - if (typeof state.output === "string" && (state.output as string).length > OUTPUT_CAP) { - next.output = cap(state.output) - } return next } @@ -157,6 +163,10 @@ function slimBash(state: Record): Record { // --------------------------------------------------------------------------- const slimmers: Record) => Record> = { + read: slimOutput, + list: slimOutput, + glob: slimOutput, + grep: slimOutput, edit: slimEdit, apply_patch: slimPatch, multiedit: slimMultiedit, 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 4359add5a40..e63488e8b20 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -73,6 +73,13 @@ export class ServerManager { cwd: spawnCwd, env: { ...process.env, + // Force mimalloc (the allocator Bun ships with) to return freed pages + // to the OS immediately instead of retaining them in its arenas. + // Without this, Bun.spawn's piped stdio accumulates ~2 MB of native + // RSS per call on Windows, causing the Agent Manager (which polls git + // once per second per worktree) to reach multi-GB RSS in minutes. + // See oven-sh/bun#18265 and Jarred's workaround note in #21560. + MIMALLOC_PURGE_DELAY: "0", KILO_SERVER_PASSWORD: password, KILO_CLIENT: "vscode", KILO_ENABLE_QUESTION_TOOL: "true", diff --git a/packages/kilo-vscode/src/worktree-diff-client.ts b/packages/kilo-vscode/src/worktree-diff-client.ts new file mode 100644 index 00000000000..0b8ecce7df6 --- /dev/null +++ b/packages/kilo-vscode/src/worktree-diff-client.ts @@ -0,0 +1,54 @@ +import type { KiloClient } from "@kilocode/sdk/v2/client" +import type { GitOps } from "./agent-manager/GitOps" + +/** + * A worktree diff target: the working directory and the base branch we diff + * against (usually the tracking branch). + */ +export type DiffTarget = { directory: string; baseBranch: string } + +type Status = "added" | "deleted" | "modified" + +/** + * Thin coordinator that wraps (KiloClient, GitOps, DiffTarget) and exposes the + * small set of operations used by both the sidebar DiffViewerProvider and the + * agent manager's WorktreeDiffController. + * + * Keeping the helper off review-utils.ts: this deals in HTTP + git orchestration, + * not the small path/vscode helpers that file is scoped to. + */ +export class WorktreeDiffClient { + constructor( + private readonly client: KiloClient, + private readonly git: GitOps, + private readonly log: (...args: unknown[]) => void, + ) {} + + /** + * Look up the diff status for a single file. Used by revert flows to pick + * the right git strategy (added → delete, modified/deleted → checkout). + * Returns `undefined` on error so callers can still attempt a best-effort + * revert — `GitOps.revertFile` defaults to a modified-file strategy. + */ + async fileStatus(target: DiffTarget, file: string): Promise { + try { + const { data } = await this.client.worktree.diffFile( + { directory: target.directory, base: target.baseBranch, file }, + { throwOnError: true }, + ) + return data?.status + } catch (err) { + this.log("Failed to look up file status for revert:", err) + return undefined + } + } + + /** + * Revert a single file in the worktree. Composes `fileStatus` + `GitOps.revertFile`. + * Returns a normalized result; callers handle UI/messaging. + */ + async revertFile(target: DiffTarget, file: string): Promise<{ ok: boolean; message: string }> { + const status = await this.fileStatus(target, file) + return this.git.revertFile(target.directory, target.baseBranch, file, status) + } +} diff --git a/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts b/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts new file mode 100644 index 00000000000..ce6614c519b --- /dev/null +++ b/packages/kilo-vscode/tests/unit/diff-viewer-css-arch.test.ts @@ -0,0 +1,42 @@ +/** + * Architecture test: FullScreenDiffView CSS co-location. + * + * `FullScreenDiffView` and its children (`FileTree`, etc.) rely on classes + * defined in BOTH `agent-manager.css` and `agent-manager-review.css`. The + * component is shared by multiple webview bundles (sidebar diff viewer, + * agent manager, storybook). Historically, each bundle was responsible for + * importing its own CSS, which led to regressions when someone forgot to + * wire the review stylesheet into a new entry point (see PR #7455 fallout). + * + * Current invariant: `FullScreenDiffView.tsx` imports both stylesheets at the + * top of the file, so any bundle pulling in the component transitively gets + * the styles via esbuild's CSS bundling. + * + * If this test fails, do NOT move the CSS imports elsewhere — fix the + * component file to import the missing stylesheet, or add a new stylesheet + * to the REQUIRED list if you intentionally split the styles. + */ + +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 FULL_SCREEN_DIFF_VIEW = path.join(ROOT, "webview-ui/agent-manager/FullScreenDiffView.tsx") +const REQUIRED = ["./agent-manager.css", "./agent-manager-review.css"] as const + +describe("FullScreenDiffView — CSS co-location", () => { + it("imports every stylesheet required to render correctly", () => { + const src = fs.readFileSync(FULL_SCREEN_DIFF_VIEW, "utf-8") + const missing = REQUIRED.filter((css) => !src.includes(`import "${css}"`)) + + expect( + missing, + `FullScreenDiffView is missing required CSS imports:\n` + + missing.map((m) => ` - import "${m}"`).join("\n") + + `\n\nAdd them at the top of FullScreenDiffView.tsx. The component is\n` + + `shared by multiple webview bundles (sidebar diff viewer, agent manager,\n` + + `storybook) and every bundle relies on these imports for complete styling.\n`, + ).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts index 2df698e3d0e..8b1844f7641 100644 --- a/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts +++ b/packages/kilo-vscode/tests/unit/git-stats-poller.test.ts @@ -2,11 +2,11 @@ import { describe, it, expect } from "bun:test" import * as fs from "fs" import * as os from "os" import * as path from "path" -import type { KiloClient } from "@kilocode/sdk/v2/client" import { GitStatsPoller, type WorktreePresenceResult } from "../../src/agent-manager/GitStatsPoller" import { GitOps } from "../../src/agent-manager/GitOps" import { Semaphore } from "../../src/agent-manager/semaphore" import type { Worktree } from "../../src/agent-manager/WorktreeStateManager" +import type { WorktreeDiffEntry } from "../../src/agent-manager/types" function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) @@ -31,8 +31,22 @@ function worktree(id: string, remote = "origin"): Worktree { } } -function diff(additions: number, deletions: number) { - return [{ file: "file.ts", before: "", after: "", additions, deletions, status: "modified" as const }] +function diff(additions: number, deletions: number): WorktreeDiffEntry[] { + return [ + { + file: "file.ts", + patch: "", + before: "", + after: "", + additions, + deletions, + status: "modified", + tracked: true, + generatedLike: false, + summarized: true, + stamp: `${additions}:${deletions}`, + }, + ] } function gitOps(handler: (args: string[], cwd: string) => Promise): GitOps { @@ -45,23 +59,19 @@ describe("GitStatsPoller", () => { let max = 0 let calls = 0 - const client = { - worktree: { - diffSummary: async () => { - calls += 1 - running += 1 - max = Math.max(max, running) - await sleep(40) - running -= 1 - return { data: diff(2, 1) } - }, - }, - } as unknown as KiloClient + const localDiff = async () => { + calls += 1 + running += 1 + max = Math.max(max, running) + await sleep(40) + running -= 1 + return diff(2, 1) + } const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a")], getWorkspaceRoot: () => undefined, - getClient: () => client, + localDiff, onStats: () => undefined, onLocalStats: () => undefined, log: () => undefined, @@ -85,20 +95,16 @@ describe("GitStatsPoller", () => { Array<{ worktreeId: string; files: number; additions: number; deletions: number; ahead: number; behind: number }> > = [] - const client = { - worktree: { - diffSummary: async () => { - calls += 1 - if (calls === 1) return { data: diff(7, 3) } - throw new Error("transient backend failure") - }, - }, - } as unknown as KiloClient + const localDiff = async () => { + calls += 1 + if (calls === 1) return diff(7, 3) + throw new Error("transient backend failure") + } const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a")], getWorkspaceRoot: () => undefined, - getClient: () => client, + localDiff, onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, log: () => undefined, @@ -134,8 +140,8 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], getWorkspaceRoot: () => root, - getClient: () => { - throw new Error("backend unavailable") + localDiff: async () => { + throw new Error("should not be called when backend unavailable path") }, onStats: () => undefined, onLocalStats: () => undefined, @@ -171,9 +177,7 @@ describe("GitStatsPoller", () => { const poller = new GitStatsPoller({ getWorktrees: () => [{ ...worktree("a"), path: wtPath }], getWorkspaceRoot: () => root, - getClient: () => { - throw new Error("backend unavailable") - }, + localDiff: async () => diff(0, 0), onStats: () => undefined, onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -202,25 +206,19 @@ describe("GitStatsPoller", () => { fs.mkdirSync(wtAPath, { recursive: true }) const calls: string[] = [] - const emitted: Array> = [] + const emitted: Array> = [] const presence: WorktreePresenceResult[] = [] - const client = { - worktree: { - diffSummary: async ({ directory }: { directory: string }) => { - calls.push(directory) - return { data: diff(1, 1) } - }, - }, - } as unknown as KiloClient - const poller = new GitStatsPoller({ getWorktrees: () => [ { ...worktree("a"), path: wtAPath }, { ...worktree("b"), path: wtBPath }, ], getWorkspaceRoot: () => root, - getClient: () => client, + localDiff: async (dir) => { + calls.push(dir) + return diff(1, 1) + }, onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, onWorktreePresence: (result) => presence.push(result), @@ -252,7 +250,7 @@ describe("GitStatsPoller", () => { expect(emitted[0]?.map((item) => item.worktreeId)).toEqual(["a"]) }) - it("preserves local stats when client fails after initial success", async () => { + it("preserves local stats when diff fails after initial success", async () => { let diffCalls = 0 const emitted: Array<{ branch: string @@ -263,20 +261,16 @@ describe("GitStatsPoller", () => { behind: number }> = [] - const client = { - worktree: { - diffSummary: async () => { - diffCalls += 1 - if (diffCalls === 1) return { data: diff(5, 2) } - throw new Error("transient backend failure") - }, - }, - } as unknown as KiloClient + const localDiff = async () => { + diffCalls += 1 + if (diffCalls === 1) return diff(5, 2) + throw new Error("transient backend failure") + } const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - getClient: () => client, + localDiff, onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -310,14 +304,10 @@ describe("GitStatsPoller", () => { behind: number }> = [] - const client = { - worktree: { diffSummary: async () => ({ data: diff(10, 4) }) }, - } as unknown as KiloClient - const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - getClient: () => client, + localDiff: async () => diff(10, 4), onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -358,14 +348,10 @@ describe("GitStatsPoller", () => { behind: number }> = [] - const client = { - worktree: { diffSummary: async () => ({ data: diff(0, 0) }) }, - } as unknown as KiloClient - const poller = new GitStatsPoller({ getWorktrees: () => [], getWorkspaceRoot: () => "/workspace", - getClient: () => client, + localDiff: async () => diff(0, 0), onStats: () => undefined, onLocalStats: (stats) => emitted.push(stats), log: () => undefined, @@ -405,14 +391,10 @@ describe("GitStatsPoller", () => { Array<{ worktreeId: string; files: number; additions: number; deletions: number; ahead: number; behind: number }> > = [] - const client = { - worktree: { diffSummary: async () => ({ data: diff(0, 0) }) }, - } as unknown as KiloClient - const poller = new GitStatsPoller({ getWorktrees: () => [worktree("a", "upstream"), worktree("b", "upstream")], getWorkspaceRoot: () => undefined, - getClient: () => client, + localDiff: async () => diff(0, 0), onStats: (stats) => emitted.push(stats), onLocalStats: () => undefined, log: () => undefined, @@ -432,32 +414,57 @@ describe("GitStatsPoller", () => { expect(fetches.length).toBe(0) }) - it("limits concurrent diffSummary calls when semaphore is provided", async () => { + it("runs diffs in parallel without stalling (no extra semaphore layer)", async () => { + // localDiff is a synchronous promise — since the poller no longer wraps + // it in a semaphore (GitOps.execGit() gates at the child-process layer), + // many worktrees can have their diffs computed concurrently without + // contending for a dedicated outer gate. let running = 0 let peak = 0 let ticks = 0 - const sem = new Semaphore(2) - const client = { - worktree: { - diffSummary: async () => { - running++ - peak = Math.max(peak, running) - await sleep(20) - running-- - return { data: diff(1, 0) } - }, - }, - } as unknown as KiloClient - - // Wire the SAME semaphore into GitOps to prove there's no deadlock — - // aheadBehind acquires the semaphore independently, not nested inside - // the diffSummary gate. const wts = Array.from({ length: 5 }, (_, i) => worktree(String(i))) const poller = new GitStatsPoller({ getWorktrees: () => wts, getWorkspaceRoot: () => undefined, - getClient: () => client, + localDiff: async () => { + running++ + peak = Math.max(peak, running) + await sleep(20) + running-- + return diff(1, 0) + }, + onStats: () => { + ticks++ + }, + onLocalStats: () => undefined, + log: () => undefined, + intervalMs: 5, + git: gitOps(async (args) => { + if (args[0] === "rev-list" && args[1] === "--left-right") return "0\t0" + return "" + }), + }) + + poller.setEnabled(true) + await waitFor(() => ticks >= 1) + poller.stop() + + // All 5 diffs can run in parallel (no artificial cap at this layer). + expect(peak).toBeGreaterThan(1) + }) + + it("runs concurrent diffs without deadlock when GitOps semaphore is shared", async () => { + // Wire the SAME semaphore into GitOps to prove the aheadBehind path + // (which goes through GitOps.raw) does not deadlock with the diff path. + const sem = new Semaphore(2) + let ticks = 0 + + const wts = Array.from({ length: 5 }, (_, i) => worktree(String(i))) + const poller = new GitStatsPoller({ + getWorktrees: () => wts, + getWorkspaceRoot: () => undefined, + localDiff: async () => diff(1, 0), onStats: () => { ticks++ }, @@ -479,7 +486,6 @@ describe("GitStatsPoller", () => { await waitFor(() => ticks >= 1) poller.stop() - // Only diffSummary calls are tracked — they should be bounded. - expect(peak).toBeLessThanOrEqual(2) + expect(ticks).toBeGreaterThan(0) }) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts new file mode 100644 index 00000000000..04a10c4f5bb --- /dev/null +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -0,0 +1,244 @@ +import { describe, it, expect } from "bun:test" + +// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts) +const { KiloProvider } = await import("../../src/KiloProvider") + +type State = "connecting" | "connected" | "disconnected" | "error" + +interface Deferred { + promise: Promise + resolve: (value: T) => void + reject: (reason?: unknown) => void +} + +function defer(): Deferred { + let resolve!: (value: T) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +function mkMessage(id: string, role: "user" | "assistant", time = 0) { + return { + info: { + id, + sessionID: "s1", + role, + time: { created: time }, + }, + parts: [], + } +} + +function mkResult(items: unknown[]) { + return { data: items, response: { headers: new Headers() } } +} + +function createClient(options?: { + messagesDeferred?: Deferred<{ data: unknown[]; response: { headers: Headers } }> + messagesData?: unknown[] + deleteDeferred?: Deferred +}) { + const calls: { before?: string; limit?: number }[] = [] + return { + calls, + session: { + list: async () => ({ data: [] }), + get: async () => ({ data: null }), + status: async () => ({ data: {} }), + messages: async (params: { before?: string; limit?: number }) => { + calls.push({ before: params.before, limit: params.limit }) + if (options?.messagesDeferred) return options.messagesDeferred.promise + return mkResult(options?.messagesData ?? []) + }, + delete: async () => { + if (options?.deleteDeferred) return options.deleteDeferred.promise + return { data: {} } + }, + }, + provider: { list: async () => ({ data: { all: [], connected: {}, default: {} } }) }, + app: { agents: async () => ({ data: [] }) }, + config: { get: async () => ({ data: {} }) }, + kilo: { + notifications: async () => ({ data: [] }), + profile: async () => ({ data: {} }), + }, + command: { list: async () => ({ data: [] }) }, + } +} + +function createConnection(client: ReturnType) { + return { + connect: async () => {}, + getClient: () => client, + onEventFiltered: () => () => undefined, + onStateChange: (_l: (s: State) => void) => () => undefined, + onNotificationDismissed: () => () => undefined, + onLanguageChanged: () => () => undefined, + onProfileChanged: () => () => undefined, + onMigrationComplete: () => () => undefined, + onFavoritesChanged: () => () => undefined, + onClearPendingPrompts: () => () => undefined, + registerDirectoryProvider: () => () => undefined, + getServerInfo: () => ({ port: 12345 }), + getConnectionState: () => "connected" as const, + resolveEventSessionId: () => undefined, + recordMessageSessionId: () => undefined, + notifyNotificationDismissed: () => undefined, + pruneSession: () => undefined, + registerFocused: () => undefined, + unregisterFocused: () => undefined, + } +} + +type ProviderInternals = { + connectionState: State + webview: { postMessage: (message: unknown) => Promise } | null + trackedSessionIds: Set + handleLoadMessages: (sid: string, opts?: { mode?: string; before?: string; limit?: number }) => Promise + handleDeleteSession: (sid: string) => Promise +} + +function makeProvider(client: ReturnType) { + const connection = createConnection(client) + const provider = new KiloProvider({} as never, connection as never) + const internal = provider as unknown as ProviderInternals + internal.connectionState = "connected" + const sent: unknown[] = [] + internal.webview = { + postMessage: async (message: unknown) => { + sent.push(message) + }, + } + return { provider, internal, sent } +} + +describe("KiloProvider.handleLoadMessages / focus mode freshness", () => { + it("refetches the tail page on focus-mode reselection and posts a reconcile snapshot", async () => { + // Regression: switching to an already-loaded session sent mode: "focus" + // which only refreshed session metadata and status — not messages. If + // SSE dropped events during the gap (reconnect, missed child-task + // messages, backend crash-restart) the webview showed stale content with + // no way to recover short of reloading the extension. Focus mode must + // still reconcile the tail against the server snapshot so silent drift + // self-heals on the next session switch. + const messages = [ + mkMessage("m1", "user", 1), + mkMessage("m2", "assistant", 2), + mkMessage("m3", "user", 3), // delivered after SSE reconnect, missed by webview + ] + const client = createClient({ messagesData: messages }) + const { internal, sent } = makeProvider(client) + internal.trackedSessionIds.add("s1") + + await internal.handleLoadMessages("s1", { mode: "focus" }) + + // Server must be hit to reconcile the current state. + expect(client.calls.length).toBeGreaterThanOrEqual(1) + + // Must post a messagesLoaded snapshot tagged reconcile — not replace — + // so the webview merges without tearing down existing reactive proxies. + const loaded = sent.find( + (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded", + ) as { mode?: string; messages: { id: string }[] } | undefined + expect(loaded).toBeDefined() + expect(loaded!.mode).toBe("reconcile") + expect(loaded!.messages.map((m) => m.id)).toContain("m3") + }) + + it("throttles repeat focus-mode reconciles within 1s", async () => { + // Regression: rapid session tab switching (A→B→A) used to stack up one + // reconcile fetch per click, each doing a full-page fetch + 80-message + // reactive-store reconcile. A 1s throttle kills the redundant work while + // still catching SSE drops on normal use patterns. + const client = createClient({ messagesData: [mkMessage("m1", "user", 1)] }) + const { internal } = makeProvider(client) + internal.trackedSessionIds.add("s1") + + await internal.handleLoadMessages("s1", { mode: "focus" }) + const callsAfterFirst = client.calls.length + + // Second focus within the throttle window — no fetch should happen. + await internal.handleLoadMessages("s1", { mode: "focus" }) + expect(client.calls.length).toBe(callsAfterFirst) + }) + + it("does not post messagesLoaded on focus when the session is no longer tracked", async () => { + // Defensive: if the user deletes the session while the background focus + // refetch is in flight, drop the response (same invariant as prepend). + const messages = defer<{ data: unknown[]; response: { headers: Headers } }>() + const client = createClient({ messagesDeferred: messages }) + const { internal, sent } = makeProvider(client) + internal.trackedSessionIds.add("s1") + + const load = internal.handleLoadMessages("s1", { mode: "focus" }) + await internal.handleDeleteSession("s1") + messages.resolve(mkResult([mkMessage("m1", "user", 10)])) + await load + + const loaded = sent.filter( + (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded", + ) + expect(loaded).toEqual([]) + }) +}) + +describe("KiloProvider.loadMessages / sub-agent viewer full history", () => { + it("loads all messages without the MESSAGE_PAGE_LIMIT cap (sub-agent viewer needs full turn history)", async () => { + // Regression: SubAgentViewerProvider used to call client.session.messages + // with no limit, loading every turn. After switching to provider.loadMessages + // it inherited the 80-message page cap and sub-agents with more than 80 + // turns would open truncated with no visible indicator. loadMessages() is + // the sub-agent viewer's single entry point — it must request the full + // transcript. + const big = Array.from({ length: 200 }, (_, i) => mkMessage(`m${i}`, i % 2 === 0 ? "user" : "assistant", i)) + const client = createClient({ messagesData: big }) + const { provider, sent } = makeProvider(client) + + await provider.loadMessages("s1") + + const loaded = sent.find( + (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded", + ) as { messages: unknown[] } | undefined + expect(loaded).toBeDefined() + expect(loaded!.messages).toHaveLength(200) + + // Server contract: limit: 0 (or undefined) returns everything. + expect(client.calls).toHaveLength(1) + const limit = client.calls[0]?.limit + expect(limit === undefined || limit === 0).toBe(true) + }) +}) + +describe("KiloProvider.handleLoadMessages / prepend into deleted session", () => { + it("does not post messagesLoaded for a session deleted mid-prepend", async () => { + // Regression: handleLoadMessages fires fire-and-forget from the webview + // message dispatcher. If the user deletes the session while a prepend + // fetch is in flight, the response still arrives and posts messagesLoaded + // for a now-dead session ID, resurrecting a ghost entry in the webview + // store until something else clears it. + const messages = defer<{ data: unknown[]; response: { headers: Headers } }>() + const client = createClient({ messagesDeferred: messages }) + const { internal, sent } = makeProvider(client) + + // Simulate the session being tracked (as it would after the initial load). + internal.trackedSessionIds.add("s1") + + const load = internal.handleLoadMessages("s1", { mode: "prepend", before: "cursor-1", limit: 80 }) + + // User deletes the session while the fetch is still pending. + await internal.handleDeleteSession("s1") + + // Fetch finally resolves after deletion. + messages.resolve(mkResult([mkMessage("m1", "user", 10)])) + await load + + const loaded = sent.filter( + (msg) => typeof msg === "object" && msg && (msg as { type?: unknown }).type === "messagesLoaded", + ) + expect(loaded).toEqual([]) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts new file mode 100644 index 00000000000..0c95da09150 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -0,0 +1,274 @@ +import { describe, it, expect } from "bun:test" +import * as fs from "fs/promises" +import * as os from "os" +import * as path from "path" +import { diffSummary, diffFile, generatedLike, MAX_DETAIL_BYTES } from "../../src/agent-manager/local-diff" +import { GitOps } from "../../src/agent-manager/GitOps" + +function git(): GitOps { + return new GitOps({ log: () => undefined }) +} + +function runSync(cwd: string, args: string[]): string { + const result = Bun.spawnSync({ + cmd: ["git", ...args], + cwd, + stdout: "pipe", + stderr: "pipe", + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_AUTHOR_NAME: "Test", + GIT_AUTHOR_EMAIL: "test@example.com", + GIT_COMMITTER_NAME: "Test", + GIT_COMMITTER_EMAIL: "test@example.com", + }, + }) + if (result.exitCode !== 0) { + throw new Error(Buffer.from(result.stderr).toString("utf8") || Buffer.from(result.stdout).toString("utf8")) + } + return Buffer.from(result.stdout).toString("utf8").trim() +} + +async function withRepo(run: (dir: string, base: string) => Promise): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "local-diff-test-")) + try { + runSync(dir, ["init", "-b", "main"]) + runSync(dir, ["config", "user.email", "test@example.com"]) + runSync(dir, ["config", "user.name", "Test"]) + runSync(dir, ["config", "commit.gpgsign", "false"]) + // Seed commit so `merge-base HEAD main` resolves. + await fs.writeFile(path.join(dir, "seed.txt"), "seed\n") + runSync(dir, ["add", "seed.txt"]) + runSync(dir, ["commit", "-m", "seed"]) + runSync(dir, ["branch", "base-branch"]) + await run(dir, "base-branch") + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } +} + +describe("generatedLike", () => { + it("matches files in ignored folders", () => { + expect(generatedLike("node_modules/foo.js")).toBe(true) + expect(generatedLike("packages/app/node_modules/foo/index.js")).toBe(true) + expect(generatedLike("dist/bundle.js")).toBe(true) + expect(generatedLike("build/out.js")).toBe(true) + expect(generatedLike(".git/HEAD")).toBe(true) + expect(generatedLike("__pycache__/mod.cpython-39.pyc")).toBe(true) + }) + + it("matches files by suffix", () => { + expect(generatedLike("src/app.log")).toBe(true) + expect(generatedLike("something.swp")).toBe(true) + expect(generatedLike("something.swo")).toBe(true) + expect(generatedLike("src/module.pyc")).toBe(true) + }) + + it("matches known basenames", () => { + expect(generatedLike("src/.DS_Store")).toBe(true) + expect(generatedLike("Thumbs.db")).toBe(true) + }) + + it("matches contained directory segments", () => { + expect(generatedLike("src/logs/app.txt")).toBe(true) + expect(generatedLike("tmp/foo")).toBe(true) + expect(generatedLike("a/temp/b")).toBe(true) + expect(generatedLike("coverage/report.html")).toBe(true) + expect(generatedLike(".nyc_output/out.json")).toBe(true) + }) + + it("rejects normal source files", () => { + expect(generatedLike("src/index.ts")).toBe(false) + expect(generatedLike("README.md")).toBe(false) + expect(generatedLike("packages/kilo-vscode/src/extension.ts")).toBe(false) + }) + + it("handles Windows-style separators", () => { + expect(generatedLike("node_modules\\foo\\bar.js")).toBe(true) + expect(generatedLike("src\\index.ts")).toBe(false) + }) +}) + +describe("diffSummary", () => { + it("returns empty array when ancestor cannot be resolved", async () => { + await withRepo(async (dir) => { + const result = await diffSummary(git(), dir, "nonexistent-branch") + expect(result).toEqual([]) + }) + }) + + it("reports modified, added, and deleted tracked files", async () => { + await withRepo(async (dir, base) => { + // seed.txt is tracked on base. Modify it; add new.txt; delete seed.txt on HEAD. + await fs.writeFile(path.join(dir, "seed.txt"), "seed\nextra line\n") + await fs.writeFile(path.join(dir, "new.txt"), "hello\nworld\n") + runSync(dir, ["add", "."]) + runSync(dir, ["commit", "-m", "modify+add"]) + await fs.rm(path.join(dir, "seed.txt")) + runSync(dir, ["add", "-A"]) + runSync(dir, ["commit", "-m", "delete seed"]) + + const result = await diffSummary(git(), dir, base) + const byFile = new Map(result.map((entry) => [entry.file, entry])) + + expect(byFile.get("new.txt")?.status).toBe("added") + expect(byFile.get("new.txt")?.additions).toBe(2) + expect(byFile.get("new.txt")?.tracked).toBe(true) + expect(byFile.get("seed.txt")?.status).toBe("deleted") + }) + }) + + it("includes untracked files as added with tracked=false", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "untracked.txt"), "a\nb\nc\n") + const result = await diffSummary(git(), dir, base) + const entry = result.find((e) => e.file === "untracked.txt") + expect(entry).toBeTruthy() + expect(entry?.status).toBe("added") + expect(entry?.tracked).toBe(false) + expect(entry?.additions).toBe(3) + }) + }) + + it("all entries are summarized with empty before/after/patch", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "untracked.txt"), "x\n") + await fs.writeFile(path.join(dir, "seed.txt"), "changed\n") + runSync(dir, ["add", "seed.txt"]) + runSync(dir, ["commit", "-m", "change seed"]) + const result = await diffSummary(git(), dir, base) + expect(result.length).toBeGreaterThan(0) + for (const entry of result) { + expect(entry.summarized).toBe(true) + expect(entry.before).toBe("") + expect(entry.after).toBe("") + expect(entry.patch).toBe("") + expect(typeof entry.stamp).toBe("string") + } + }) + }) + + it("marks generated-like files via generatedLike flag", async () => { + await withRepo(async (dir, base) => { + await fs.mkdir(path.join(dir, "dist"), { recursive: true }) + await fs.writeFile(path.join(dir, "dist/app.js"), "console.log(1)\n") + await fs.writeFile(path.join(dir, "src.ts"), "export {}\n") + const result = await diffSummary(git(), dir, base) + const dist = result.find((e) => e.file === "dist/app.js") + const src = result.find((e) => e.file === "src.ts") + expect(dist?.generatedLike).toBe(true) + expect(src?.generatedLike).toBe(false) + }) + }) +}) + +describe("diffFile", () => { + it("returns null when ancestor cannot be resolved", async () => { + await withRepo(async (dir) => { + const result = await diffFile(git(), dir, "nonexistent-branch", "any.txt") + expect(result).toBeNull() + }) + }) + + it("returns null for a missing file that isn't tracked either", async () => { + await withRepo(async (dir, base) => { + const result = await diffFile(git(), dir, base, "does-not-exist.txt") + expect(result).toBeNull() + }) + }) + + it("returns before/after/patch for a modified tracked file", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\nmore\n") + runSync(dir, ["add", "seed.txt"]) + runSync(dir, ["commit", "-m", "modify seed"]) + const result = await diffFile(git(), dir, base, "seed.txt") + expect(result).toBeTruthy() + expect(result?.status).toBe("modified") + expect(result?.tracked).toBe(true) + expect(result?.before).toBe("seed\n") + expect(result?.after).toBe("seed\nmore\n") + expect(result?.patch.length).toBeGreaterThan(0) + expect(result?.summarized).toBe(false) + }) + }) + + it("returns synthetic patch for an untracked added file", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "fresh.txt"), "one\ntwo\n") + const result = await diffFile(git(), dir, base, "fresh.txt") + expect(result).toBeTruthy() + expect(result?.status).toBe("added") + expect(result?.tracked).toBe(false) + expect(result?.before).toBe("") + expect(result?.after).toBe("one\ntwo\n") + expect(result?.patch).toContain("new file mode") + expect(result?.patch).toContain("+one") + expect(result?.patch).toContain("+two") + }) + }) + + it("falls back to summarized entry when the working-copy file exceeds the detail cap", async () => { + await withRepo(async (dir, base) => { + // Write a tracked file that's ~2.5x the cap on the working-copy side. + const big = "a".repeat(MAX_DETAIL_BYTES + 500_000) + "\n" + await fs.writeFile(path.join(dir, "seed.txt"), big) + runSync(dir, ["add", "seed.txt"]) + runSync(dir, ["commit", "-m", "grow seed"]) + + const result = await diffFile(git(), dir, base, "seed.txt") + expect(result).toBeTruthy() + // Metadata (status, counts, stamp) is preserved so the UI can still + // show the file and its add/delete totals. + expect(result?.status).toBe("modified") + expect(result?.tracked).toBe(true) + expect(result?.additions).toBeGreaterThan(0) + // Content is intentionally blank — the cap prevents materialization. + expect(result?.before).toBe("") + expect(result?.after).toBe("") + expect(result?.patch).toBe("") + expect(result?.summarized).toBe(true) + }) + }) + + it("falls back to summarized entry when the ancestor blob exceeds the detail cap", async () => { + await withRepo(async (dir, base) => { + // Put the large content in the base commit, then delete the file on HEAD. + // `before` is read from the base blob (over cap); `after` is empty. + const big = "b".repeat(MAX_DETAIL_BYTES + 500_000) + "\n" + await fs.writeFile(path.join(dir, "big.txt"), big) + runSync(dir, ["add", "big.txt"]) + runSync(dir, ["commit", "-m", "add big"]) + // Re-create the base-branch pointer so it includes the big blob. + runSync(dir, ["branch", "-f", base]) + // Shrink on HEAD. + await fs.writeFile(path.join(dir, "big.txt"), "small\n") + runSync(dir, ["add", "big.txt"]) + runSync(dir, ["commit", "-m", "shrink"]) + + const result = await diffFile(git(), dir, base, "big.txt") + expect(result).toBeTruthy() + expect(result?.tracked).toBe(true) + expect(result?.before).toBe("") + expect(result?.after).toBe("") + expect(result?.patch).toBe("") + expect(result?.summarized).toBe(true) + }) + }) + + it("still returns full detail when both sides are under the cap", async () => { + await withRepo(async (dir, base) => { + // Modest file, well under cap — behaves as before. + const content = "a".repeat(50_000) + "\n" + await fs.writeFile(path.join(dir, "seed.txt"), content) + runSync(dir, ["add", "seed.txt"]) + runSync(dir, ["commit", "-m", "modest change"]) + + const result = await diffFile(git(), dir, base, "seed.txt") + expect(result?.summarized).toBe(false) + expect((result?.after ?? "").length).toBeGreaterThan(0) + expect((result?.patch ?? "").length).toBeGreaterThan(0) + }) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/message-page.test.ts b/packages/kilo-vscode/tests/unit/message-page.test.ts new file mode 100644 index 00000000000..4fed55e19d0 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/message-page.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "bun:test" +import { fetchMessagePage } from "../../src/kilo-provider/message-page" + +type Message = { info: { id: string; role: "user" | "assistant"; time: { created: number } }; parts: unknown[] } + +function message(id: string, role: "user" | "assistant", time: number): Message { + return { info: { id, role, time: { created: time } }, parts: [] } +} + +function mockClient(pages: { items: Message[]; cursor?: string }[]) { + const calls: { before?: string; limit?: number }[] = [] + let idx = 0 + const client = { + session: { + messages: async ( + params: { sessionID: string; directory: string; limit: number; before?: string }, + _opts: { throwOnError: boolean; signal?: AbortSignal }, + ) => { + calls.push({ before: params.before, limit: params.limit }) + const page = pages[idx++] + if (!page) throw new Error("no more mock pages") + const headers = new Headers() + if (page.cursor) headers.set("X-Next-Cursor", page.cursor) + return { + data: page.items, + response: { headers } as Response, + } + }, + }, + } + return { client, calls } +} + +describe("fetchMessagePage / cursor fallback", () => { + it("returns server cursor when X-Next-Cursor header is present", async () => { + const { client } = mockClient([ + { + items: [message("m1", "user", 1), message("m2", "assistant", 2), message("m3", "user", 3)], + cursor: "server-cursor-abc", + }, + ]) + const page = await fetchMessagePage(client as never, { + sessionID: "s1", + workspaceDir: "/repo", + limit: 3, + }) + expect(page.cursor).toBe("server-cursor-abc") + }) + + it("synthesizes a cursor when server omits X-Next-Cursor but page is full (header stripped by proxy / missing permission)", async () => { + // Regression: if a proxy or auth layer strips X-Next-Cursor, the webview + // loses access to older messages even when they exist. When the response + // fills the requested limit, derive a cursor from the oldest item so the + // "load earlier" path keeps working. + const { client } = mockClient([ + { + items: [ + message("m1", "user", 10), + message("m2", "assistant", 20), + message("m3", "user", 30), + message("m4", "assistant", 40), + ], + // Intentionally no cursor — simulating a stripped header. + }, + ]) + const page = await fetchMessagePage(client as never, { + sessionID: "s1", + workspaceDir: "/repo", + limit: 4, + }) + expect(page.cursor).toBeDefined() + // Cursor must be a base64url-encoded { id, time } of the oldest item so + // the server's before parser accepts it on the next request. + const decoded = JSON.parse(Buffer.from(page.cursor!, "base64url").toString("utf8")) + expect(decoded).toEqual({ id: "m1", time: 10 }) + }) + + it("leaves cursor undefined when server omits header AND page is not full (truly no more)", async () => { + const { client } = mockClient([ + { + items: [message("m1", "user", 10), message("m2", "assistant", 20)], + }, + ]) + const page = await fetchMessagePage(client as never, { + sessionID: "s1", + workspaceDir: "/repo", + limit: 80, + }) + expect(page.cursor).toBeUndefined() + }) + + it("synthesized cursor round-trips through the server's before parameter", async () => { + // First page: server strips header, items fill limit → cursor synthesized. + // Next page request uses that cursor and returns more items. + const { client, calls } = mockClient([ + { + items: [message("m3", "user", 30), message("m4", "assistant", 40)], + }, + { + items: [message("m1", "user", 10), message("m2", "assistant", 20)], + }, + ]) + const first = await fetchMessagePage(client as never, { + sessionID: "s1", + workspaceDir: "/repo", + limit: 2, + }) + expect(first.cursor).toBeDefined() + + await fetchMessagePage(client as never, { + sessionID: "s1", + workspaceDir: "/repo", + limit: 2, + before: first.cursor, + }) + expect(calls[1]?.before).toBe(first.cursor) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/part-stash.test.ts b/packages/kilo-vscode/tests/unit/part-stash.test.ts new file mode 100644 index 00000000000..58d25f6aff8 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/part-stash.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "bun:test" +import { PartStash } from "../../webview-ui/src/context/part-stash" +import type { Part } from "../../webview-ui/src/types/messages" + +function text(id: string, messageID: string, value: string): Part { + return { type: "text", id, messageID, text: value } as Part +} + +describe("PartStash", () => { + it("put / peek round-trips parts", () => { + const stash = new PartStash() + stash.put("m1", [text("p1", "m1", "hi")]) + const peeked = stash.peek("m1") + expect(peeked?.[0] && "text" in peeked[0] ? peeked[0].text : undefined).toBe("hi") + }) + + it("remove() clears stashed parts — regression for handleMessageRemoved leak", () => { + // Before the fix, handleMessageRemoved wiped reactive parts but left the + // stash entry alive. If an off-screen message was removed before its turn + // mounted, its parts would sit in the stash forever. Worse: a later call + // to peek() or getParts() could surface the parts of a deleted message. + const stash = new PartStash() + stash.put("m1", [text("p1", "m1", "stale")]) + stash.remove("m1") + expect(stash.peek("m1")).toBeUndefined() + expect(stash.size()).toBe(0) + }) + + it("take() consumes stashed parts atomically", () => { + const stash = new PartStash() + stash.put("m1", [text("p1", "m1", "a")]) + stash.put("m2", [text("p2", "m2", "b")]) + const taken = stash.take(["m1", "m2"]) + expect(Object.keys(taken).sort()).toEqual(["m1", "m2"]) + expect(stash.size()).toBe(0) + }) + + it("take() skips IDs already hydrated into the reactive store", () => { + const stash = new PartStash() + stash.put("m1", [text("p1", "m1", "stash")]) + const taken = stash.take(["m1"], (id) => id === "m1") + expect(taken).toEqual({}) + // The stash entry should be preserved — hydrateParts will noop and + // subsequent SSE updates that target the message can still merge into it + // if needed. + expect(stash.peek("m1")).toBeDefined() + }) + + it("take() returns empty when no IDs match", () => { + const stash = new PartStash() + stash.put("m1", [text("p1", "m1", "a")]) + expect(stash.take(["m2", "m3"])).toEqual({}) + }) +}) diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png index fa65a4f8a84..92378b5e3c5 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/chat/message-list-tool-to-queued-user-spacing-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:123f32340a93af21146d08bb40738710bdd84099a27d0310316bb937360ca9f9 -size 14605 +oid sha256:07b3bf094c0a82e64fc7cb4e22aa37ada3dd5904a19a313879c6016c2ab55a00 +size 14586 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png index e09f627f07c..4d612a7ad03 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/components-shell/shell-execution-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9dbdd4b71bea71529e8835def452bbb627837d5bb6dbe460d882b20c4add6e98 -size 17650 +oid sha256:74a42ec77a8ac8d5a1c555b46e3d550c9acf3198f45cc87da6582c0ff924d5f9 +size 17677 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png index 887e38a6c50..4ec7183fdad 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-cards-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4f50e8ca1c012b64797cf57d5293ac59afefa79c8f1d703b66ac796e5b85fbaf -size 6402 +oid sha256:c583144b11ac9608ec755e9a683dced8ae6ffae9a6f3833e1c0eec6b664f358e +size 7720 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png index cb685b058e1..790e8c736a0 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/mcp-tool-expanded-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3fca4a95e3754b2d1316635fd756fa3fe8895213de7332d130a2f44075f06485 -size 19072 +oid sha256:4294f36eea4005ca5f3f6d4a3f7ed84d5113f7454c2149280f5182ccbc686124 +size 26407 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png index 4a06e7da60d..fa1a4b2466a 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/multiple-tool-calls-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f539f72e2c53c6ce84d26397715bb9c4e80c2c107e4584023f2bdbeba0086e62 -size 6958 +oid sha256:15116299b4700cceb501114e8fdca8b3ddfa5fb9d793f7cbab44b2647bbe64a4 +size 8070 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png index 5a6833dfadf..85da22e8f7e 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/question-dismissed-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4ea685888b585c50e8902a3df4a0fc85104a217b41bf6763d8e7e2f113c32ab -size 4356 +oid sha256:ed66186a7aacdd4d31ff250a9c31fb1b244834cbb23e36abc51af706545f4518 +size 5059 diff --git a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png index 7f614a23d6f..7959059a155 100644 --- a/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png +++ b/packages/kilo-vscode/tests/visual-regression.spec.ts-snapshots/composite-webview/tool-cards-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8e0fecc40d49bdd601a7d1ed6c8d4bb6c3854843c3e67eca408d3cd0a42b9f4f -size 8193 +oid sha256:1d21dc25072b1c52cacaf0a91369f45e89f43ccc181d643e6b65030b829e5b1e +size 8591 diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index da51d775c96..ffdd0c1031b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -78,6 +78,7 @@ import { NotificationsProvider } from "../src/context/notifications" import { SessionProvider, useSession } from "../src/context/session" import { WorktreeModeProvider } from "../src/context/worktree-mode" import { ChatView } from "../src/components/chat" +import HistoryView from "../src/components/history/HistoryView" import { NewWorktreeDialog } from "./NewWorktreeDialog" import { LanguageBridge, DataBridge } from "../src/App" import { useLanguage } from "../src/context/language" @@ -348,6 +349,7 @@ const AgentManagerContent: Component = () => { let diffRaf: number | undefined let pendingDiffWidth: number | undefined + const [history, setHistory] = createSignal(false) const [sidePanel, setSidePanel] = createSignal(null) const diffOpen = () => sidePanel() === "diff" const [diffDatas, setDiffDatas] = createSignal>({}) @@ -697,28 +699,23 @@ const AgentManagerContent: Component = () => { const valid = prev.filter((lid) => isPending(lid) || validateLocalSession(lid, ids)) if (valid.length !== prev.length) { const removed = prev.filter((lid) => !isPending(lid) && !valid.includes(lid)) - for (const id of removed) { - vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id }) - } + for (const id of removed) vscode.postMessage({ type: "agentManager.forgetSession", sessionId: id }) setLocalSessionIDs(valid) } }) // Drop in-memory review state for worktrees that no longer exist. createEffect(() => { const ids = new Set(worktrees().map((wt) => wt.id)) - setReviewOpenByContext((prev) => { const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id))) if (Object.keys(next).length === Object.keys(prev).length) return prev return next }) - setReviewCommentsByContext((prev) => { const next = Object.fromEntries(Object.entries(prev).filter(([id]) => id === LOCAL || ids.has(id))) if (Object.keys(next).length === Object.keys(prev).length) return prev return next }) - setApplyStates((prev) => { const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id))) if (Object.keys(next).length === Object.keys(prev).length) return prev @@ -765,20 +762,28 @@ const AgentManagerContent: Component = () => { return result }) - // Sessions for the currently selected worktree (tab bar), respecting custom order if set + // Oldest-first sort before applyTabOrder — worktree label and tab bar must agree on "first session". + const sessionsForWorktree = (worktreeId: string): SessionInfo[] => { + const ids = new Set( + managedSessions() + .filter((ms) => ms.worktreeId === worktreeId) + .map((ms) => ms.id), + ) + return applyTabOrder( + session + .sessions() + .filter((s) => ids.has(s.id)) + .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()), + worktreeTabOrder()[worktreeId], + ) + } + const activeWorktreeSessions = createMemo((): SessionInfo[] => { const sel = selection() if (!sel || sel === LOCAL) return [] - const managed = managedSessions().filter((ms) => ms.worktreeId === sel) - const ids = new Set(managed.map((ms) => ms.id)) - 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]) + return sessionsForWorktree(sel) }) - // Active tab sessions: local sessions when on "local", worktree sessions otherwise const activeTabs = createMemo((): SessionInfo[] => { const sel = selection() if (sel === LOCAL) return localSessions() @@ -786,11 +791,10 @@ const AgentManagerContent: Component = () => { return [] }) - // Whether the selected context has zero sessions const contextEmpty = createMemo(() => { const sel = selection() if (sel === LOCAL) return localSessionIDs().length === 0 - if (sel) return activeWorktreeSessions().length === 0 + if (sel) return activeWorktreeSessions().length === 0 && managedSessions().every((ms) => ms.worktreeId !== sel) return false }) @@ -805,8 +809,6 @@ const AgentManagerContent: Component = () => { } }) - // Scroll the sidebar to the focused item whenever selection changes (covers keyboard - // navigation, new worktree creation, and any other programmatic selection change). createEffect(() => { const id = selection() ?? session.currentSessionID() if (!id) return @@ -816,22 +818,16 @@ const AgentManagerContent: Component = () => { }) }) - // Read-only mode: viewing an unassigned session (not in a worktree or local) const readOnly = createMemo(() => selection() === null && !!session.currentSessionID()) - // Tab scroll: hidden scrollbar with fade overflow indicators const visibleTabId = createMemo(() => reviewActive() ? REVIEW_TAB_ID : (session.currentSessionID() ?? activePendingId()), ) const tabScroll = useTabScroll(activeTabs, visibleTabId) - // Display name for worktree — prefers persisted label, then first session title, then branch const worktreeLabel = (wt: WorktreeState): string => { if (wt.label) return wt.label - const managed = managedSessions().filter((ms) => ms.worktreeId === wt.id) - const ids = new Set(managed.map((ms) => ms.id)) - const sessions = session.sessions().filter((s) => ids.has(s.id)) - return firstOrderedTitle(sessions, worktreeTabOrder()[wt.id], wt.branch) + return firstOrderedTitle(sessionsForWorktree(wt.id), worktreeTabOrder()[wt.id], wt.branch) } const worktreeSubtitle = (wt: WorktreeState): string | undefined => { @@ -841,7 +837,6 @@ const AgentManagerContent: Component = () => { const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId) - /** True when any session in the given ID list is actively working (busy/retry and not blocked by permissions/questions). */ const isAnySessionBusy = (ids: string[]): boolean => { if (ids.length === 0) return false const statuses = session.allStatusMap() @@ -1000,12 +995,8 @@ const AgentManagerContent: Component = () => { if (fallback && !isPending(fallback.id)) { setActivePendingId(undefined) session.selectSession(fallback.id) - } else if (fallback && isPending(fallback.id)) { - setActivePendingId(fallback.id) - session.clearCurrentSession() - vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" }) } else { - setActivePendingId(undefined) + setActivePendingId(fallback && isPending(fallback.id) ? fallback.id : undefined) session.clearCurrentSession() vscode.postMessage({ type: "agentManager.showExistingLocalTerminal" }) } @@ -1015,17 +1006,17 @@ const AgentManagerContent: Component = () => { const selectWorktree = (worktreeId: string) => { saveTabMemory() setSelection(worktreeId) + // Try rich session list first, fall back to managed session IDs when + // session.sessions() hasn't been populated yet for this worktree. + const rich = sessionsForWorktree(worktreeId) const managed = managedSessions().filter((ms) => ms.worktreeId === worktreeId) - const ids = new Set(managed.map((ms) => ms.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) - } + const target = remembered + ? (rich.find((s) => s.id === remembered) ?? managed.find((ms) => ms.id === remembered)) + : undefined + const fallback = target ?? rich[0] ?? managed[0] + if (fallback) session.selectSession(fallback.id) + else session.setCurrentSessionID(undefined) setReviewActive(remembered === REVIEW_TAB_ID && reviewOpenByContext()[worktreeId] === true) } @@ -1048,7 +1039,8 @@ const AgentManagerContent: Component = () => { onMount(() => { const handler = (event: MessageEvent) => { - const msg = event.data as ExtensionMessage + const msg = event.data + if (msg?.type === "navigate" && msg.view === "history") return setHistory(true) if (msg?.type !== "action") return if (msg.action === "sessionPrevious") navigate("up") else if (msg.action === "sessionNext") navigate("down") @@ -1062,9 +1054,7 @@ const AgentManagerContent: Component = () => { if (reviewActive()) { closeReviewTab() setSidePanel("diff") - } else { - setSidePanel((prev) => (prev === "diff" ? null : "diff")) - } + } else setSidePanel((prev) => (prev === "diff" ? null : "diff")) } else if (msg.action === "newTab") handleNewTabForCurrentSelection() else if (msg.action === "closeTab") closeActiveTab() else if (msg.action === "newWorktree") handleNewWorktreeOrPromote() @@ -1886,9 +1876,7 @@ const AgentManagerContent: Component = () => { if (pending) { setLocalSessionIDs((prev) => prev.map((id) => (id === pending ? sid : id))) setActivePendingId(undefined) - } else { - setLocalSessionIDs((prev) => [...prev, sid]) - } + } else setLocalSessionIDs((prev) => [...prev, sid]) setSelection(LOCAL) setReviewActive(false) session.selectSession(sid) @@ -1897,20 +1885,14 @@ const AgentManagerContent: Component = () => { const handleAddSession = () => { const sel = selection() - if (sel === LOCAL) { - addPendingTab() - } else if (sel) { - vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel }) - } + if (sel === LOCAL) addPendingTab() + else if (sel) vscode.postMessage({ type: "agentManager.addSessionToWorktree", worktreeId: sel }) } const handleForkSession = (sessionId: string) => { const sel = selection() - if (sel === LOCAL) { - vscode.postMessage({ type: "agentManager.forkSession", sessionId }) - } else if (sel) { - vscode.postMessage({ type: "agentManager.forkSession", sessionId, worktreeId: sel }) - } + if (sel === LOCAL) vscode.postMessage({ type: "agentManager.forkSession", sessionId }) + else if (sel) vscode.postMessage({ type: "agentManager.forkSession", sessionId, worktreeId: sel }) } const handleCloseTab = (sessionId: string) => { @@ -1920,14 +1902,12 @@ const AgentManagerContent: Component = () => { const tabs = activeTabs() const idx = tabs.findIndex((s) => s.id === sessionId) const next = tabs[idx + 1] ?? tabs[idx - 1] - if (next) { - if (isPending(next.id)) { - setActivePendingId(next.id) - session.clearCurrentSession() - } else { - setActivePendingId(undefined) - session.selectSession(next.id) - } + if (next && isPending(next.id)) { + setActivePendingId(next.id) + session.clearCurrentSession() + } else if (next) { + setActivePendingId(undefined) + session.selectSession(next.id) } else { setActivePendingId(undefined) session.clearCurrentSession() @@ -1935,9 +1915,7 @@ const AgentManagerContent: Component = () => { } if (pending || localSet().has(sessionId)) { setLocalSessionIDs((prev) => prev.filter((id) => id !== sessionId)) - if (!pending) { - vscode.postMessage({ type: "agentManager.forgetSession", sessionId }) - } + if (!pending) vscode.postMessage({ type: "agentManager.forgetSession", sessionId }) } else { vscode.postMessage({ type: "agentManager.closeSession", sessionId }) } @@ -2921,7 +2899,29 @@ const AgentManagerContent: Component = () => { ) })()} - + + { + setHistory(false) + if (localSessionIDs().includes(id)) { + saveTabMemory() + session.selectSession(id) + setSelection(LOCAL) + return + } + const ms = worktreeSessionIds().has(id) ? managedSessions().find((s) => s.id === id) : undefined + if (ms?.worktreeId) { + selectWorktree(ms.worktreeId) + session.selectSession(id) + setReviewActive(false) + return + } + openLocally(id) + }} + onBack={() => setHistory(false)} + /> + + {/* Chat + side diff panel (hidden when review tab is active) */}
{ } openLocally(id) }} + onShowHistory={() => setHistory(true)} readonly={readOnly()} continueInWorktree={selection() === LOCAL} promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx index 0870c1c4b9e..bba856b6a9a 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/FullScreenDiffView.tsx @@ -1,4 +1,9 @@ import { type Component, createSignal, createMemo, createEffect, on, onCleanup, For, Show } from "solid-js" +// Styles are co-located with the component so every consumer (sidebar diff viewer, +// agent manager, storybook) picks them up automatically. Do not move these out — +// see tests/unit/diff-viewer-css-arch.test.ts for the invariant. +import "./agent-manager.css" +import "./agent-manager-review.css" import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" import { StickyAccordionHeader } from "@kilocode/kilo-ui/sticky-accordion-header" diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx index e8aecd76bad..d009445202e 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx +++ b/packages/kilo-vscode/webview-ui/diff-viewer/DiffViewerApp.tsx @@ -26,6 +26,16 @@ const DiffViewerContent: Component = () => { const [loading, setLoading] = createSignal(true) const [comments, setComments] = createSignal([]) const [diffStyle, setDiffStyle] = createSignal("unified") + const [reverting, setReverting] = createSignal>(new Set()) + + const markReverting = (file: string, active: boolean) => { + setReverting((prev) => { + const next = new Set(prev) + if (active) next.add(file) + else next.delete(file) + return next + }) + } const unsubscribe = vscode.onMessage((msg) => { if (msg.type === "diffViewer.diffs") { @@ -37,6 +47,11 @@ const DiffViewerContent: Component = () => { setLoading(msg.loading) return } + + if (msg.type === "diffViewer.revertFileResult") { + markReverting(msg.file, false) + return + } }) const handler = (event: MessageEvent) => { @@ -67,6 +82,11 @@ const DiffViewerContent: Component = () => { onOpenFile={(relativePath) => { post({ type: "openFile", filePath: relativePath }) }} + onRevertFile={(file) => { + markReverting(file, true) + post({ type: "diffViewer.revertFile", file }) + }} + revertingFiles={reverting()} onClose={() => { post({ type: "diffViewer.close" }) }} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 752bebe3964..9f5801e121f 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -1,13 +1,13 @@ /** * MessageList component - * Scrollable turn-based message list. + * Scrollable turn-based message list with virtualization. * Each user message is rendered as a VscodeSessionTurn — a custom component that * renders all assistant parts as a flat, verbose list with no context grouping, * and fully expands sub-agent (task tool) parts inline. * Shows recent sessions in the empty state for quick resumption. */ -import { Component, For, Show, createEffect, createMemo, onCleanup, JSX } from "solid-js" +import { Component, For, Show, createEffect, createMemo, createSignal, on, onCleanup, JSX } from "solid-js" import { Icon } from "@kilocode/kilo-ui/icon" import { Spinner } from "@kilocode/kilo-ui/spinner" import { useDialog } from "@kilocode/kilo-ui/context/dialog" @@ -17,12 +17,13 @@ import { useServer } from "../../context/server" import { useLanguage } from "../../context/language" import { formatRelativeDate } from "../../utils/date" import { FeedbackDialog } from "./FeedbackDialog" -import { VscodeSessionTurn } from "./VscodeSessionTurn" +import { VscodeSessionTurn, type VscodeTurn } from "./VscodeSessionTurn" import { RevertBanner } from "./RevertBanner" import { AccountSwitcher } from "../shared/AccountSwitcher" import { KiloNotifications } from "./KiloNotifications" import { WorkingIndicator } from "../shared/WorkingIndicator" import { QuestionDock } from "./QuestionDock" +import { Virtualizer } from "virtua/solid" import { SuggestBar } from "./SuggestBar" import { activeUserMessageID as getActiveUserMessageID } from "../../context/session-queue" import type { QuestionRequest, SuggestionRequest } from "../../types/messages" @@ -74,14 +75,25 @@ export const MessageList: Component = (props) => { } }) - const allUserMessages = () => session.userMessages() + const [scrollEl, setScrollEl] = createSignal() + const positions = new Map() + const boundary = () => session.revert()?.messageID - const userMessages = createMemo(() => { + const turns = createMemo(() => { + const result: VscodeTurn[] = [] const b = boundary() - if (!b) return allUserMessages() - return allUserMessages().filter((m) => m.id < b) + for (const msg of session.messages()) { + if (msg.role === "user") { + if (b && msg.id >= b) break + result.push({ id: msg.id, user: msg, assistant: [] }) + continue + } + const turn = result[result.length - 1] + if (turn && msg.role === "assistant") turn.assistant.push(msg) + } + return result }) - const isEmpty = () => userMessages().length === 0 && !session.loading() && !boundary() + const isEmpty = () => turns().length === 0 && !session.loading() && !boundary() const recent = createMemo(() => [...session.sessions()] @@ -94,9 +106,67 @@ export const MessageList: Component = (props) => { const activeUserIndex = createMemo(() => { const active = activeUserID() if (!active) return -1 - return userMessages().findIndex((msg) => msg.id === active) + return turns().findIndex((turn) => turn.user.id === active) }) + const save = (id: string | undefined) => { + const el = scrollEl() + if (!id || !el) return + positions.set(id, { top: el.scrollTop, userScrolled: autoScroll.userScrolled() }) + } + + const maybeLoadOlder = () => { + const el = scrollEl() + if (!el || el.scrollTop > 600) return + session.loadOlderMessages() + } + + const handleScroll = () => { + autoScroll.handleScroll() + maybeLoadOlder() + } + + const setScrollRef = (el: HTMLElement | undefined) => { + setScrollEl(el) + autoScroll.scrollRef(el) + } + + const [pendingRestore, setPendingRestore] = createSignal() + + createEffect( + on(session.currentSessionID, (id, prev) => { + save(prev) + setPendingRestore(id) + }), + ) + + createEffect(() => { + const id = pendingRestore() + if (!id || session.loading()) return + turns().length + // Double-rAF: the first frame lets the browser paint the new DOM from + // the messagesLoaded batch. The second frame restores scroll position + // without forcing a synchronous layout reflow mid-paint. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (pendingRestore() !== id) return + const el = scrollEl() + if (!el) return + const pos = positions.get(id) + if (pos?.userScrolled) { + el.scrollTop = pos.top + autoScroll.pause() + } else { + autoScroll.forceScrollToBottom() + } + setPendingRestore(undefined) + maybeLoadOlder() + }) + }) + }) + + onCleanup(() => save(session.currentSessionID())) + return (
@@ -105,13 +175,7 @@ export const MessageList: Component = (props) => {
-
+
@@ -153,24 +217,37 @@ export const MessageList: Component = (props) => {
- - - {(msg, index) => { - const queued = createMemo(() => { - const active = activeUserIndex() - if (active === -1) return false - return index() > active - }) + + +
+ + {language.t("session.messages.loadingEarlier")} +
+
+ + + + + + {(turn, index) => { + const queued = createMemo(() => { + const active = activeUserIndex() + if (active === -1) return false + return index() > active + }) - return ( - - ) - }} -
+ return + }} + +
diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index 98937c95713..c9f6c2bec0a 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -70,8 +70,7 @@ export const PromptInput: Component = (props) => { () => session.currentSessionID() ?? props.pendingSessionID ?? session.draftSessionID(), ) const terminal = useTerminalContext(vscode) - const excluded = worktree ? new Set(["sessions"]) : undefined - const slash = useSlashCommand(vscode, excluded) + const slash = useSlashCommand(vscode) const imageAttach = useImageAttachments() imageAttach.setFilePathDropHandler((paths) => { const cwd = server.workspaceDirectory() diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx index 49fdda93a4a..4daaaa6f318 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/VscodeSessionTurn.tsx @@ -32,6 +32,7 @@ import { ErrorDisplay } from "./ErrorDisplay" import { useServer } from "../../context/server" import { useSession } from "../../context/session" import { useLanguage } from "../../context/language" +import type { Message as WebMessage } from "../../types/messages" function getDirectory(path: string): string { const sep = path.includes("/") ? "/" : "\\" @@ -45,9 +46,14 @@ function getFilename(path: string): string { return idx === -1 ? path : path.slice(idx + 1) } +export interface VscodeTurn { + id: string + user: WebMessage + assistant: WebMessage[] +} + interface VscodeSessionTurnProps { - sessionID: string - messageID: string + turn: VscodeTurn queued?: boolean } @@ -59,45 +65,22 @@ export const VscodeSessionTurn: Component = (props) => { const session = useSession() const language = useLanguage() - const emptyMessages: SDKMessage[] = [] const emptyParts: SDKPart[] = [] const emptyDiffs: SnapshotFileDiff[] = [] - const allMessages = createMemo(() => { - const msgs = data.store.message?.[props.sessionID] - return (msgs ?? emptyMessages) as SDKMessage[] + createEffect(() => { + const turn = props.turn + session.hydrateParts([turn.user.id, ...turn.assistant.map((m) => m.id)]) }) - const message = createMemo(() => { - return allMessages().find((m) => m.id === props.messageID && m.role === "user") as - | (SDKMessage & { role: "user" }) - | undefined - }) + const message = createMemo(() => props.turn.user as SDKMessage & { role: "user" }) const parts = createMemo(() => { const msg = message() - if (!msg) return emptyParts return (data.store.part?.[msg.id] ?? emptyParts) as SDKPart[] }) - const messageIndex = createMemo(() => { - const msgs = allMessages() - return msgs.findIndex((m) => m.id === props.messageID) - }) - - const assistantMessages = createMemo(() => { - const index = messageIndex() - if (index < 0) return [] as SDKAssistantMessage[] - const msgs = allMessages() - const result: SDKAssistantMessage[] = [] - for (let i = index + 1; i < msgs.length; i++) { - const m = msgs[i] - if (!m) continue - if (m.role === "user") break - if (m.role === "assistant") result.push(m as SDKAssistantMessage) - } - return result - }) + const assistantMessages = createMemo(() => props.turn.assistant as SDKAssistantMessage[]) const interrupted = createMemo(() => assistantMessages().some((m) => m.error?.name === "MessageAbortedError")) @@ -174,7 +157,7 @@ export const VscodeSessionTurn: Component = (props) => { assistantMessages().length > 0 && !session.revert() ? () => { if (session.status() !== "idle") return - session.revertSession(props.messageID) + session.revertSession(msg().id) } : undefined } diff --git a/packages/kilo-vscode/webview-ui/src/context/part-stash.ts b/packages/kilo-vscode/webview-ui/src/context/part-stash.ts new file mode 100644 index 00000000000..9d622ce9fc4 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/src/context/part-stash.ts @@ -0,0 +1,63 @@ +/** + * PartStash holds message parts outside the reactive Solid store until a + * turn is actually rendered by the virtualizer. Writing parts for off-screen + * messages into the reactive store triggers expensive DOM work for invisible + * content — parking them here keeps initial-load churn cheap. + * + * The stash lives alongside (not inside) the reactive store. Every lifecycle + * event that invalidates a message must reach both the store and the stash. + * Centralising stash access behind this helper keeps that invariant easy to + * audit (and easy to unit-test, since the store is Solid-specific). + */ +import type { Part } from "../types/messages" + +export class PartStash { + private map = new Map() + + /** Stash parts for a message that hasn't been rendered yet. */ + put(messageID: string, parts: Part[]): void { + this.map.set(messageID, parts) + } + + /** Read without consuming. Returns `undefined` if absent. */ + peek(messageID: string): Part[] | undefined { + return this.map.get(messageID) + } + + /** + * Invalidate any stashed parts for a message. Callers MUST invoke this in + * every path that removes a message from state (messageRemoved, + * sendMessageFailed, sessionDeleted) or promotes it into the reactive + * store (messageCreated, partUpdated, hydrateParts). Missing a call here + * leaks memory and, worse, can resurface stale parts via `peek()` after + * the message is gone. + */ + remove(messageID: string): void { + this.map.delete(messageID) + } + + /** + * Collect parts for the given IDs, consuming the stash. Used by the + * virtualizer when a turn is about to render: the returned parts should + * be written to the reactive store atomically by the caller. + * + * IDs already present in the reactive store are skipped — pass an optional + * `isHydrated` predicate for that check. + */ + take(ids: string[], isHydrated?: (id: string) => boolean): Record { + const out: Record = {} + for (const id of ids) { + if (isHydrated?.(id)) continue + const parts = this.map.get(id) + if (!parts) continue + out[id] = parts + this.map.delete(id) + } + return out + } + + /** Diagnostics and tests only. */ + size(): number { + return this.map.size + } +} diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 2890c8a18f5..d4dcdfa4842 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -32,6 +32,7 @@ import type { FileAttachment, SendMessageFailedMessage, McpStatusEntry, + MessageLoadMode, } from "../types/messages" import { removeSessionPermissions, upsertPermission } from "./permission-queue" import { @@ -46,9 +47,29 @@ import { Identifier } from "../utils/id" import { resolveModelSelection } from "./model-selection" import { resolveSessionAgent } from "./session-agent" import { queuedUserMessageIDs } from "./session-queue" +import { PartStash } from "./part-stash" import { KILO_AUTO, parseModelString } from "../../../src/shared/provider-model" const RECENT_LIMIT = 5 +const MESSAGE_PAGE_LIMIT = 80 + +type MessageMutation = Exclude | "append" | "update" + +interface MessagePageState { + initialLoaded: boolean + loadingInitial: boolean + loadingOlder: boolean + before?: string + hasMore: boolean + lastMutation?: MessageMutation +} + +const emptyPageState: MessagePageState = { + initialLoaded: false, + loadingInitial: false, + loadingOlder: false, + hasMore: false, +} // Store structure for messages and parts interface SessionStore { @@ -79,6 +100,9 @@ interface SessionContextValue { statusText: Accessor busySince: Accessor loading: Accessor + loadingOlderMessages: Accessor + hasOlderMessages: Accessor + messageMutation: Accessor // Messages for current session messages: Accessor @@ -105,6 +129,10 @@ interface SessionContextValue { // Parts for a specific message getParts: (messageID: string) => Part[] + // Move stashed parts into the reactive store for the given message IDs. + // Called by VscodeSessionTurn when the virtualizer renders a turn. + hydrateParts: (messageIDs: string[]) => void + // Todos for current session todos: Accessor @@ -202,6 +230,7 @@ interface SessionContextValue { createSession: () => void clearCurrentSession: () => void loadSessions: () => void + loadOlderMessages: () => void selectSession: (id: string) => void deleteSession: (id: string) => void renameSession: (id: string, title: string) => void @@ -246,6 +275,13 @@ export const SessionProvider: ParentComponent = (props) => { const [loading, setLoading] = createSignal(false) const [loaded, setLoaded] = createSignal>(new Set()) + const [pages, setPages] = createStore>({}) + + // Parts stash: holds parts from messagesLoaded outside the reactive store + // until a VscodeSessionTurn is rendered by the virtualizer and calls + // hydrateParts(). This avoids writing parts for off-screen messages into + // the store, which would trigger expensive DOM work for invisible content. + const stash = new PartStash() // Pending permissions const [permissions, setPermissions] = createSignal([]) @@ -641,6 +677,11 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "requestFavorites" }) onCleanup(unsubFavorites) + function handleError(message: Extract) { + if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false) + if (message.sessionID) patchPage(message.sessionID, { loadingInitial: false, loadingOlder: false }) + } + function toggleFavorite(providerID: string, modelID: string) { const key = `${providerID}/${modelID}` const idx = store.favoriteModels.findIndex((f) => `${f.providerID}/${f.modelID}` === key) @@ -679,7 +720,11 @@ export const SessionProvider: ParentComponent = (props) => { break case "messagesLoaded": - handleMessagesLoaded(message.sessionID, message.messages) + handleMessagesLoaded(message.sessionID, message.messages, { + mode: message.mode, + cursor: message.cursor, + hasMore: message.hasMore, + }) break case "messageCreated": @@ -751,9 +796,7 @@ export const SessionProvider: ParentComponent = (props) => { } case "error": - // Only clear loading if the error is for the current session - // (or has no sessionID for backwards compatibility) - if (!message.sessionID || message.sessionID === currentSessionID()) setLoading(false) + handleError(message) break case "sendMessageFailed": @@ -822,7 +865,72 @@ export const SessionProvider: ParentComponent = (props) => { }) } - function handleMessagesLoaded(sessionID: string, messages: Message[]) { + function patchPage(sessionID: string, patch: Partial) { + setPages(sessionID, { ...(pages[sessionID] ?? emptyPageState), ...patch }) + } + + function mergeMessages(current: Message[], incoming: Message[], mode: Exclude) { + if (mode === "reconcile") { + // Tail reconcile: incoming is the authoritative newest-N snapshot. + // Local state may already hold some of those IDs and may also hold + // newer optimistic entries created after the fetch was taken. Merge + // by id (server wins on collision) then sort by createdAt so new + // server messages land in the right position and optimistic tail + // entries stay at the end. + const byId = new Map() + for (const msg of current) byId.set(msg.id, msg) + for (const msg of incoming) byId.set(msg.id, msg) + return [...byId.values()].sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()) + } + const seen = new Set() + const source = mode === "prepend" ? [...incoming, ...current] : incoming + return source.filter((msg) => { + if (seen.has(msg.id)) return false + seen.add(msg.id) + return true + }) + } + + function withPending(sessionID: string, messages: Message[]) { + const pending = pendingOptimistic.get(sessionID) + if (!pending || pending.size === 0) return messages + const ids = new Set(messages.map((msg) => msg.id)) + const current = store.messages[sessionID] ?? [] + const orphans = current.filter((msg) => pending.has(msg.id) && !ids.has(msg.id)) + return [...messages, ...orphans] + } + + // Cheap shape check: same ids in same order AND same part counts per message. + // Short-circuits reconcile when the server snapshot matches local state + // (the common case — SSE didn't actually miss anything), avoiding the + // 80 setStore("parts", ...) calls per session switch. + function sameReconcileShape(current: Message[], incoming: Message[]): boolean { + if (current.length !== incoming.length) return false + for (let i = 0; i < incoming.length; i++) { + const c = current[i]! + const n = incoming[i]! + if (c.id !== n.id) return false + if ((c.parts?.length ?? 0) !== (n.parts?.length ?? 0)) return false + } + return true + } + + function handleMessagesLoaded( + sessionID: string, + messages: Message[], + input: { mode?: Exclude; cursor?: string; hasMore?: boolean } = {}, + ) { + const mode = input.mode ?? "replace" + const reset = mode === "prepend" + + // Reconcile fast-path: if the tail matches local state shape-wise, every + // message+part-count already agrees with the server. Skip the reactive + // store churn entirely — virtualizer and rendering stay untouched. + if (mode === "reconcile" && sameReconcileShape(store.messages[sessionID] ?? [], messages)) { + patchPage(sessionID, { initialLoaded: true, lastMutation: "update" }) + return + } + batch(() => { setLoaded((prev) => { if (prev.has(sessionID)) return prev @@ -832,31 +940,59 @@ export const SessionProvider: ParentComponent = (props) => { }) if (sessionID === currentSessionID()) setLoading(false) - // Preserve optimistic messages that haven't been confirmed yet. - // The server may not have created the message record by the time - // this session's messages are loaded (e.g. on session switch). - const pending = pendingOptimistic.get(sessionID) - if (pending && pending.size > 0) { - const loadedIds = new Set(messages.map((m) => m.id)) - const current = store.messages[sessionID] ?? [] - const orphans = current.filter((m) => pending.has(m.id) && !loadedIds.has(m.id)) - setStore("messages", sessionID, reconcile([...messages, ...orphans], { key: "id" })) + const current = store.messages[sessionID] ?? [] + const merged = + mode === "prepend" || mode === "reconcile" + ? mergeMessages(current, messages, mode) + : withPending(sessionID, messages) + // "replace" mode (session switch): assign directly — reconcile's O(n) + // diff is unnecessary when the entire list is new, and its reactive + // proxy creation for each message object dominated the trace (~900ms). + // "prepend" / "reconcile": reconcile to preserve existing proxies. + if (mode === "replace") { + setStore("messages", sessionID, merged) } else { - setStore("messages", sessionID, reconcile(messages, { key: "id" })) + setStore("messages", sessionID, reconcile(merged, { key: "id" })) } - // Also extract parts from messages for (const msg of messages) { - if (msg.parts && msg.parts.length > 0) { + if (!msg.parts || msg.parts.length === 0) continue + if (mode === "reconcile" && store.parts[msg.id]) { + // Reconcile on a message already hydrated into the reactive store: + // write parts directly so visible turns pick up the server- + // authoritative state immediately instead of waiting for the + // virtualizer to re-render. setStore("parts", msg.id, reconcile(msg.parts, { key: "id" })) + stash.remove(msg.id) + } else { + // Stash parts outside the reactive store — they'll be hydrated + // on demand when the virtualizer renders the corresponding turn. + stash.put(msg.id, msg.parts) } } - const agent = resolveSessionAgent(messages, agentNames()) + // "reconcile" is a background tail refresh, not a page navigation — + // preserve the existing pagination cursor/hasMore so "load earlier" + // keeps working. + if (mode === "reconcile") { + patchPage(sessionID, { initialLoaded: true, lastMutation: "update" }) + } else { + setPages(sessionID, { + initialLoaded: true, + loadingInitial: false, + loadingOlder: false, + before: input.cursor, + hasMore: input.hasMore ?? Boolean(input.cursor), + lastMutation: mode, + }) + } + + const agent = resolveSessionAgent(merged, agentNames()) if (agent) { setStore("agentSelections", sessionID, agent) } }) + if (reset) requestAnimationFrame(() => patchPage(sessionID, { lastMutation: undefined })) } function handleMessageCreated(message: Message) { @@ -877,6 +1013,7 @@ export const SessionProvider: ParentComponent = (props) => { ) } + const exists = (store.messages[message.sessionID] ?? []).some((msg) => msg.id === message.id) setStore("messages", message.sessionID, (msgs = []) => { // Check if message already exists (optimistic or update case). // Since we now use the same messageID for optimistic and server messages, @@ -889,6 +1026,7 @@ export const SessionProvider: ParentComponent = (props) => { } return [...msgs, message] }) + patchPage(message.sessionID, { initialLoaded: true, lastMutation: exists ? "update" : "append" }) // Sync mode picker from any message role (user or assistant). // agentNames() already excludes subagent/hidden agents, so subtask @@ -899,6 +1037,7 @@ export const SessionProvider: ParentComponent = (props) => { } if (message.parts && message.parts.length > 0) { + stash.remove(message.id) setStore("parts", message.id, message.parts) } } @@ -917,6 +1056,16 @@ export const SessionProvider: ParentComponent = (props) => { return } + if (sessionID) patchPage(sessionID, { lastMutation: "update" }) + + // If the stash has parts for this message, hydrate them first so the + // SSE update merges into the full part list rather than an empty array. + const stashed = stash.peek(effectiveMessageID) + if (stashed) { + stash.remove(effectiveMessageID) + setStore("parts", effectiveMessageID, stashed) + } + setStore( "parts", produce((parts) => { @@ -1094,6 +1243,7 @@ export const SessionProvider: ParentComponent = (props) => { function handleSendMessageFailed(message: SendMessageFailedMessage) { if (message.sessionID && message.messageID) { pendingOptimistic.get(message.sessionID)?.delete(message.messageID) + stash.remove(message.messageID) batch(() => { setStore("messages", message.sessionID!, (msgs = []) => msgs.filter((m) => m.id !== message.messageID)) setStore( @@ -1239,9 +1389,10 @@ export const SessionProvider: ParentComponent = (props) => { function handleSessionDeleted(sessionID: string) { pendingOptimistic.delete(sessionID) batch(() => { - // Collect message IDs so we can clean up their parts + // Collect message IDs so we can clean up their parts (store + stash) const msgs = store.messages[sessionID] ?? [] const msgIds = msgs.map((m) => m.id) + for (const id of msgIds) stash.remove(id) setStore( "sessions", @@ -1269,6 +1420,11 @@ export const SessionProvider: ParentComponent = (props) => { delete todos[sessionID] }), ) + setPages( + produce((map) => { + delete map[sessionID] + }), + ) setStore( "agentSelections", produce((selections) => { @@ -1334,6 +1490,10 @@ export const SessionProvider: ParentComponent = (props) => { delete parts[messageID] }), ) + // Also clear any stashed parts for this message. Without this, a + // removed-before-hydrated message leaks parts in the stash and can + // resurface them via getParts() after the message is gone. + stash.remove(messageID) } function handleCloudSessionDataLoaded(cloudSessionId: string, title: string, messages: Message[]) { @@ -1352,6 +1512,7 @@ export const SessionProvider: ParentComponent = (props) => { createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }) + patchPage(key, { initialLoaded: true, hasMore: false, lastMutation: "replace" }) setStore("messages", key, messages) for (const msg of messages) { if (msg.parts && msg.parts.length > 0) { @@ -1425,7 +1586,8 @@ export const SessionProvider: ParentComponent = (props) => { }) // Load real messages in the background (picks up server-assigned IDs // and the new user message once the send completes via SSE) - vscode.postMessage({ type: "loadMessages", sessionID: session.id }) + patchPage(session.id, { loadingInitial: true, before: undefined, hasMore: false }) + vscode.postMessage({ type: "loadMessages", sessionID: session.id, mode: "replace", limit: MESSAGE_PAGE_LIMIT }) } // Actions @@ -1483,6 +1645,7 @@ export const SessionProvider: ParentComponent = (props) => { setStore("messages", sid, (msgs = []) => [...msgs, temp]) setStore("parts", messageID, parts) + patchPage(sid, { initialLoaded: true, lastMutation: "append" }) queueMicrotask(() => window.dispatchEvent(new CustomEvent("resumeAutoScroll"))) } @@ -1754,6 +1917,21 @@ export const SessionProvider: ParentComponent = (props) => { vscode.postMessage({ type: "loadSessions" }) } + function loadOlderMessages() { + const id = currentSessionID() + if (!id || !server.isConnected()) return + const page = pages[id] ?? emptyPageState + if (!page.hasMore || page.loadingOlder || page.loadingInitial || !page.before) return + patchPage(id, { loadingOlder: true }) + vscode.postMessage({ + type: "loadMessages", + sessionID: id, + mode: "prepend", + before: page.before, + limit: MESSAGE_PAGE_LIMIT, + }) + } + function selectSession(id: string) { if (!server.isConnected()) { console.warn("[Kilo New] Cannot select session: not connected") @@ -1763,10 +1941,16 @@ export const SessionProvider: ParentComponent = (props) => { console.warn("[Kilo New] Cannot select cloud preview session via selectSession") return } + const ready = loaded().has(id) setCurrentSessionID(id) setDraftSessionID(id) - setLoading(!loaded().has(id)) - vscode.postMessage({ type: "loadMessages", sessionID: id }) + setLoading(!ready) + if (ready) { + vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "focus" }) + return + } + patchPage(id, { loadingInitial: true, loadingOlder: false, before: undefined, hasMore: false }) + vscode.postMessage({ type: "loadMessages", sessionID: id, mode: "replace", limit: MESSAGE_PAGE_LIMIT }) } function selectCloudSession(cloudSessionId: string) { @@ -1817,13 +2001,33 @@ export const SessionProvider: ParentComponent = (props) => { return id ? store.sessions[id] : undefined } + const pageState = () => { + const id = currentSessionID() + return id ? (pages[id] ?? emptyPageState) : emptyPageState + } + + const loadingOlderMessages = () => pageState().loadingOlder + const hasOlderMessages = () => pageState().hasMore + const messageMutation = () => pageState().lastMutation + const messages = () => { const id = currentSessionID() return id ? store.messages[id] || [] : [] } const getParts = (messageID: string) => { - return store.parts[messageID] || [] + return store.parts[messageID] || stash.peek(messageID) || [] + } + + function hydrateParts(ids: string[]) { + const pending = stash.take(ids, (id) => Boolean(store.parts[id])) + if (Object.keys(pending).length === 0) return + setStore( + "parts", + produce((p) => { + for (const [id, parts] of Object.entries(pending)) p[id] = parts + }), + ) } const allMessages = () => store.messages @@ -1962,9 +2166,13 @@ export const SessionProvider: ParentComponent = (props) => { statusText, busySince, loading, + loadingOlderMessages, + hasOlderMessages, + messageMutation, messages, userMessages, getParts, + hydrateParts, todos, permissions, respondingPermissions, @@ -2042,6 +2250,7 @@ export const SessionProvider: ParentComponent = (props) => { createSession, clearCurrentSession, loadSessions, + loadOlderMessages, selectSession, deleteSession, renameSession, diff --git a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts index 59881ed9f58..8221cc1bb52 100644 --- a/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts +++ b/packages/kilo-vscode/webview-ui/src/hooks/useSlashCommand.ts @@ -1,4 +1,4 @@ -import { createSignal, onCleanup, onMount } from "solid-js" +import { createSignal, onCleanup } from "solid-js" import type { Accessor } from "solid-js" import type { SlashCommandInfo, WebviewMessage, ExtensionMessage } from "../types/messages" @@ -39,6 +39,7 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set): S const [server, setServer] = createSignal([]) const [query, setQuery] = createSignal(null) const [index, setIndex] = createSignal(0) + const [requested, setRequested] = createSignal(false) const all: SlashCommandEntry[] = [ { @@ -118,6 +119,12 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set): S const show = () => query() !== null + const request = () => { + if (requested()) return + setRequested(true) + vscode.postMessage({ type: "requestCommands" }) + } + const results = () => { const q = query() if (q === null) return [] @@ -137,10 +144,6 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set): S setServer(message.commands) }) - onMount(() => { - vscode.postMessage({ type: "requestCommands" }) - }) - onCleanup(() => { unsubscribe() }) @@ -153,6 +156,7 @@ export function useSlashCommand(vscode: VSCodeContext, exclude?: Set): S const before = val.substring(0, cursor) const match = before.match(SLASH_PATTERN) if (match) { + request() setQuery(match[1]) setIndex(0) } else { diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index ab9e70e4bb8..aec050b64d4 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -150,6 +150,9 @@ export function mockSessionValue(overrides?: { statusText: () => (status === "idle" ? undefined : "Thinking…"), busySince: () => (status === "busy" ? Date.now() - 2000 : undefined), loading: () => false, + loadingOlderMessages: () => false, + hasOlderMessages: () => false, + messageMutation: () => undefined, messages: () => [], userMessages: () => [], allMessages: () => ({}), @@ -157,6 +160,7 @@ export function mockSessionValue(overrides?: { allStatusMap: () => ({}), familyData: () => ({ messages: {}, parts: {}, status: {} }), getParts: () => [], + hydrateParts: noop, todos: () => [], permissions: () => permissions, respondingPermissions: () => new Set(), @@ -209,6 +213,7 @@ export function mockSessionValue(overrides?: { createSession: noop, clearCurrentSession: noop, loadSessions: noop, + loadOlderMessages: noop, selectSession: noop, deleteSession: noop, renameSession: noop, diff --git a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx index 334b9cbea98..69a755de639 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/composite.stories.tsx @@ -1040,7 +1040,13 @@ export const DiffSummaryCollapsed: Story = {
- +
diff --git a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css index 04d89fbbcba..ffe3fdc35c0 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css @@ -63,6 +63,33 @@ font-size: 13px; } +.message-list-page-loader { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 0 12px; + color: var(--vscode-descriptionForeground); + font-size: 12px; +} + +.message-list-load-older { + display: block; + margin: 0 auto 12px; + border: 1px solid var(--vscode-button-border, transparent); + border-radius: 6px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + cursor: pointer; + padding: 5px 10px; + font: inherit; + font-size: 12px; +} + +.message-list-load-older:hover { + background: var(--vscode-button-secondaryHoverBackground); +} + .message-list-content { display: flex; min-height: 100%; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages.ts index 1e6c18dc35f..3b82ebc252e 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages.ts @@ -589,10 +589,15 @@ export interface MessageRemovedMessage { messageID: string } +export type MessageLoadMode = "replace" | "prepend" | "focus" | "reconcile" + export interface MessagesLoadedMessage { type: "messagesLoaded" sessionID: string messages: Message[] + mode?: Exclude + cursor?: string + hasMore?: boolean } export interface MessageCreatedMessage { @@ -1403,6 +1408,13 @@ export interface DiffViewerLoadingMessage { loading: boolean } +export interface DiffViewerRevertFileResultMessage { + type: "diffViewer.revertFileResult" + file: string + status: "success" | "error" + message: string +} + export interface ClearPendingPromptsMessage { type: "clearPendingPrompts" } @@ -1598,6 +1610,7 @@ export type ExtensionMessage = | ViewSubAgentSessionMessage | DiffViewerDiffsMessage | DiffViewerLoadingMessage + | DiffViewerRevertFileResultMessage | MarketplaceDataMessage | MarketplaceInstallResultMessage | MarketplaceRemoveResultMessage @@ -1677,6 +1690,9 @@ export interface ClearSessionRequest { export interface LoadMessagesRequest { type: "loadMessages" sessionID: string + mode?: MessageLoadMode + before?: string + limit?: number } export interface LoadSessionsRequest { diff --git a/packages/opencode/CHANGELOG.md b/packages/opencode/CHANGELOG.md index 78bbb240cfa..b653c80f295 100644 --- a/packages/opencode/CHANGELOG.md +++ b/packages/opencode/CHANGELOG.md @@ -1,5 +1,15 @@ # @kilocode/cli +## 7.2.14 + +### Patch Changes + +- [#9118](https://github.com/Kilo-Org/kilocode/pull/9118) [`343455b`](https://github.com/Kilo-Org/kilocode/commit/343455b87895a0551760b5710b1ffe58fae21efd) - Respect per-agent model selections when an agent has a `model` configured in `kilo.jsonc`. Switching the model for such an agent now sticks across agent switches and CLI restarts. To pick up a newly edited agent default, re-select the model once (or clear `~/.local/share/kilo/storage/model.json`). + +- [#9067](https://github.com/Kilo-Org/kilocode/pull/9067) [`959a8b4`](https://github.com/Kilo-Org/kilocode/commit/959a8b498de6efd28756683162296dd40eb9b454) - Fix "assistant prefill" errors when a user queues a prompt while the previous turn is still streaming. The queued message no longer lands in the middle of the prior turn's history, so the next request always ends with the user prompt. + +- [#9023](https://github.com/Kilo-Org/kilocode/pull/9023) [`5301258`](https://github.com/Kilo-Org/kilocode/commit/530125828e891d3c50fe8d783201b65e3c4db8e4) - Support mentioning folders in the prompt with @ references, including top-level folder file contents. + ## 7.2.12 ### Patch Changes diff --git a/packages/opencode/package.json b/packages/opencode/package.json index fa9afc2b1e8..606acde0190 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.2.12", + "version": "7.2.14", "name": "@kilocode/cli", "type": "module", "license": "MIT", diff --git a/packages/opencode/src/cli/cmd/tui/context/local.tsx b/packages/opencode/src/cli/cmd/tui/context/local.tsx index 895aad8d28e..879b0eb4c72 100644 --- a/packages/opencode/src/cli/cmd/tui/context/local.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/local.tsx @@ -216,6 +216,11 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ get ready() { return modelStore.ready }, + // kilocode_change start - expose saved per-agent pick for auto-apply guard + saved(name: string) { + return modelStore.model[name] + }, + // kilocode_change end recent() { return modelStore.recent }, @@ -409,21 +414,24 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ // Automatically update model when agent changes createEffect(() => { + // kilocode_change start - wait for persistence load and skip when a per-agent pick already exists (#9050) + if (!model.ready) return const value = agent.current() - if (!value) return // kilocode_change - guard against empty agent list during org switch - if (value.model) { - if (isModelValid(value.model)) - model.set({ - providerID: value.model.providerID, - modelID: value.model.modelID, - }) - else - toast.show({ - variant: "warning", - message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, - duration: 3000, - }) - } + if (!value) return // guard against empty agent list during org switch + if (!value.model) return + if (model.saved(value.name)) return + if (isModelValid(value.model)) + model.set({ + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + else + toast.show({ + variant: "warning", + message: `Agent ${value.name}'s configured model ${value.model.providerID}/${value.model.modelID} is not valid`, + duration: 3000, + }) + // kilocode_change end }) const result = { diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index aff56642555..8e2c4f7ddf2 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -971,10 +971,15 @@ export namespace Config { .boolean() .optional() .describe("@deprecated Use 'share' field instead. Share newly created sessions automatically"), - remote_control: z // kilocode_change + // kilocode_change start + // NOTE: Any new kilocode_change key added to Config.Info must also be mirrored in + // apps/web/src/app/config.json/extras.ts in the cloud repo, otherwise + // $schema: https://app.kilo.ai/config.json will not recognize it. + remote_control: z .boolean() .optional() .describe("Enable remote control of sessions via Kilo Cloud. Equivalent to running /remote on startup."), + // kilocode_change end autoupdate: z .union([z.boolean(), z.literal("notify")]) .optional() diff --git a/packages/opencode/src/kilocode/session/prompt-queue.ts b/packages/opencode/src/kilocode/session/prompt-queue.ts index 54adcb6c407..ab08c24346d 100644 --- a/packages/opencode/src/kilocode/session/prompt-queue.ts +++ b/packages/opencode/src/kilocode/session/prompt-queue.ts @@ -39,7 +39,25 @@ export namespace KiloSessionPromptQueue { if (item.info.role === "assistant") return !hidden.has(item.info.parentID) return true }) - return visible + + // When a user prompt is queued mid-turn, its time_created falls in the + // middle of the prior turn's messages (a later assistant step in that turn + // was written after the queue event). Ordering by time_created alone puts + // the queued prompt before the prior turn's final assistant reply, which + // makes the next request end with an assistant message and trips Anthropic's + // prefill rejection. Move the target user message and any of its own turn's + // assistant messages to the end so the request always ends with the queued + // user prompt (or with its own turn's latest assistant step). + const owns = (item: MessageV2.WithParts) => { + if (item.info.role === "user") return item.info.id === target + if (item.info.role === "assistant") return item.info.parentID === target + return false + } + const before: MessageV2.WithParts[] = [] + const after: MessageV2.WithParts[] = [] + for (const item of visible) (owns(item) ? after : before).push(item) + if (after.length === 0) return visible + return [...before, ...after] } export function enqueue( diff --git a/packages/opencode/src/session/prompt/codex.txt b/packages/opencode/src/session/prompt/codex.txt index 8524019eb56..2f31b1d9417 100644 --- a/packages/opencode/src/session/prompt/codex.txt +++ b/packages/opencode/src/session/prompt/codex.txt @@ -8,6 +8,7 @@ You are an interactive CLI tool that helps users with software engineering tasks - Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase). ## Tool usage +- If the Task tool is available, use it proactively to delegate focused subtasks to a subagent instance. You can spawn multiple subagents in parallel. - Prefer specialized tools over shell for file operations: - Use Read to view files, Edit to modify files, and Write only when needed. - Use Glob to find files by name and Grep to search file contents. diff --git a/packages/opencode/src/session/prompt/gpt.txt b/packages/opencode/src/session/prompt/gpt.txt index 76dc41063af..da9f94e6044 100644 --- a/packages/opencode/src/session/prompt/gpt.txt +++ b/packages/opencode/src/session/prompt/gpt.txt @@ -2,6 +2,7 @@ You are Kilo Code, You and the user share the same workspace and collaborate to You are a deeply pragmatic, effective software engineer. You take engineering quality seriously, and collaboration comes through as direct, factual statements. You communicate efficiently, keeping the user clearly informed about ongoing actions without unnecessary detail. You build context by examining the codebase first without making assumptions or jumping to conclusions. You think through the nuances of the code you encounter, and embody the mentality of a skilled senior software engineer. +- If the Task tool is available, use it proactively to delegate focused subtasks to a subagent instance. You can spawn multiple subagents in parallel. - When searching for text or files, prefer using Glob and Grep tools (they are powered by `rg`) - Parallelize tool calls whenever possible - especially file reads. Use `multi_tool_use.parallel` to parallelize tool calls and only this. Never chain together bash commands with separators like `echo "====";` as this renders to the user poorly. diff --git a/packages/opencode/src/tool/bash.ts b/packages/opencode/src/tool/bash.ts index 9f4c6742416..0d938216588 100644 --- a/packages/opencode/src/tool/bash.ts +++ b/packages/opencode/src/tool/bash.ts @@ -220,7 +220,7 @@ const parse = Effect.fn("BashTool.parse")(function* (command: string, ps: boolea return tree.rootNode }) -const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) { +const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan, command: string) { // kilocode_change if (scan.dirs.size > 0) { const globs = Array.from(scan.dirs).map((dir) => { if (process.platform === "win32") return AppFileSystem.normalizePathPattern(path.join(dir, "*")) @@ -239,7 +239,7 @@ const ask = Effect.fn("BashTool.ask")(function* (ctx: Tool.Context, scan: Scan) permission: "bash", patterns: Array.from(scan.patterns), always: Array.from(scan.always), - metadata: {}, + metadata: { command }, // kilocode_change }) }) @@ -489,7 +489,7 @@ export const BashTool = Tool.define( const root = yield* parse(params.command, ps) const scan = yield* collect(root, cwd, ps, shell) if (!Instance.containsPath(cwd)) scan.dirs.add(cwd) - yield* ask(ctx, scan) + yield* ask(ctx, scan, params.command) // kilocode_change return yield* run( { diff --git a/packages/opencode/test/kilocode/bash-permission-metadata.test.ts b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts new file mode 100644 index 00000000000..2fa282ec6c6 --- /dev/null +++ b/packages/opencode/test/kilocode/bash-permission-metadata.test.ts @@ -0,0 +1,47 @@ +// regression test for bash permission metadata.command +import { describe, expect, test } from "bun:test" +import { BashTool } from "../../src/tool/bash" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" +import { Shell } from "../../src/shell/shell" +import { SessionID, MessageID } from "../../src/session/schema" +import type { Permission } from "../../src/permission" + +Shell.acceptable.reset() + +const baseCtx = { + sessionID: SessionID.make("ses_test"), + messageID: MessageID.make(""), + callID: "", + agent: "code", + abort: AbortSignal.any([]), + messages: [], + metadata: () => {}, + ask: async () => {}, +} + +const capture = (requests: Array>) => ({ + ...baseCtx, + ask: async (req: Omit) => { + requests.push(req) + }, +}) + +describe("bash permission metadata.command", () => { + test("permission prompt shows raw command without tool name prefix", async () => { + await using tmp = await tmpdir() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const bash = await BashTool.init() + const requests: Array> = [] + const command = "echo hello" + await bash.execute({ command, description: "Echo hello" }, capture(requests)) + + const bashReq = requests.find((r) => r.permission === "bash") + expect(bashReq).toBeDefined() + expect(bashReq!.metadata.command).toBe(command) + }, + }) + }) +}) diff --git a/packages/opencode/test/kilocode/local-model.test.ts b/packages/opencode/test/kilocode/local-model.test.ts index 1920f1b7070..2f72403e929 100644 --- a/packages/opencode/test/kilocode/local-model.test.ts +++ b/packages/opencode/test/kilocode/local-model.test.ts @@ -189,8 +189,16 @@ async function initLocal(options?: { prewrite?: Record }): Promise< } async function readModelJson(): Promise { - const text = await fs.readFile(modelJsonPath, "utf-8") - return JSON.parse(text) + const until = Date.now() + 2000 + while (true) { + try { + const text = await fs.readFile(modelJsonPath, "utf-8") + return JSON.parse(text) + } catch (err) { + if (Date.now() >= until) throw err + await Bun.sleep(10) + } + } } async function removeModelJson() { @@ -473,3 +481,123 @@ describe("edge cases and error handling", () => { } }) }) + +// ── Regression tests for #9050 ────────────────────────────────────────────── +// The auto-apply createEffect in local.tsx previously clobbered user-selected +// per-agent models whenever it re-fired. The fix gates it on (a) modelStore.ready +// and (b) the absence of an existing saved entry for that agent. + +describe("#9050: auto-apply effect respects saved per-agent selection", () => { + test("13: fresh start — config model for active agent is applied after ready", async () => { + // plan is second; code (first) has no config model. Switch to plan post-init. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal() + try { + // Effect should not touch the code agent (no config model). + expect(local.model.saved("code")).toBeUndefined() + + local.agent.set("plan") + // Give the effect time to re-run now that agent.current() changed. + await Bun.sleep(50) + + // First-time application: no saved entry → config model applied and persisted. + expect(local.model.saved("plan")).toEqual(OPUS) + const data = await readModelJson() + expect(data.model.plan).toEqual(OPUS) + } finally { + dispose() + } + }) + + test("14: saved entry from model.json is preserved over a differing config model", async () => { + // Config says plan → OPUS; saved file says plan → SONNET. Saved must win. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal({ + prewrite: { + recent: [SONNET], + model: { plan: SONNET }, + favorite: [], + variant: {}, + }, + }) + try { + local.agent.set("plan") + await Bun.sleep(50) + + // The fix: effect sees an existing saved entry and leaves it alone. + expect(local.model.saved("plan")).toEqual(SONNET) + const data = await readModelJson() + expect(data.model.plan).toEqual(SONNET) + } finally { + dispose() + } + }) + + test("15: user override of a config-model agent sticks across agent switches", async () => { + // plan has config model OPUS; user picks SONNET for plan; switching away + // and back must not revert to OPUS. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { name: "plan", mode: "primary", hidden: false, model: OPUS, color: undefined, permission: {} }, + ] + const { local, dispose } = await initLocal() + try { + local.agent.set("plan") + await Bun.sleep(50) + // Effect applied config default (no saved entry yet). + expect(local.model.saved("plan")).toEqual(OPUS) + + // User picks a different model. + local.model.set(SONNET, { recent: true }) + await Bun.sleep(50) + expect(local.model.saved("plan")).toEqual(SONNET) + + // Bounce agents. + local.agent.set("code") + await Bun.sleep(50) + local.agent.set("plan") + await Bun.sleep(50) + + // Saved pick survives. + expect(local.model.saved("plan")).toEqual(SONNET) + const data = await readModelJson() + expect(data.model.plan).toEqual(SONNET) + } finally { + dispose() + } + }) + + test("16: invalid config model still emits a warning toast", async () => { + // Ensure the fix didn't silence the existing invalid-model warning path. + mockAgents = [ + { name: "code", mode: "primary", hidden: false, model: undefined, color: undefined, permission: {} }, + { + name: "plan", + mode: "primary", + hidden: false, + model: { providerID: "nonexistent", modelID: "fake-model" }, + color: undefined, + permission: {}, + }, + ] + const { local, dispose } = await initLocal() + try { + toastMessages = [] + local.agent.set("plan") + await Bun.sleep(50) + + const warnings = toastMessages.filter((t) => t.variant === "warning" && t.message.includes("not valid")) + expect(warnings.length).toBeGreaterThan(0) + // And no bogus value was written. + expect(local.model.saved("plan")).toBeUndefined() + } finally { + dispose() + } + }) +}) diff --git a/packages/opencode/test/kilocode/session-prompt-queue.test.ts b/packages/opencode/test/kilocode/session-prompt-queue.test.ts index 39969b645dc..5d5e93ac1d0 100644 --- a/packages/opencode/test/kilocode/session-prompt-queue.test.ts +++ b/packages/opencode/test/kilocode/session-prompt-queue.test.ts @@ -121,6 +121,73 @@ describe("session prompt queue", () => { expect(ids).toEqual([one, ans, two]) }) + test("moves queued target to the end when prior-turn messages come after it", async () => { + // Regression: when a user queues a prompt while a turn is still running, + // the queued message's time_created falls before later assistant steps of + // that turn. Ordering by time_created alone would leave the queued prompt + // in the middle of the prior turn's messages, ending the next model request + // with an assistant message and tripping Anthropic's prefill rejection. + const sessionID = SessionID.make("session_queue_mid_turn") + const m1 = MessageID.make("message_10") + const a1 = MessageID.make("message_20") + const m2 = MessageID.make("message_30") + const a2step1 = MessageID.make("message_40") + const m3 = MessageID.make("message_50") // queued mid-turn + const a2step2 = MessageID.make("message_60") + const a2final = MessageID.make("message_70") + const messages = [ + user(sessionID, m1), + assistant(sessionID, a1, m1), + user(sessionID, m2), + assistant(sessionID, a2step1, m2), + user(sessionID, m3), + assistant(sessionID, a2step2, m2), + assistant(sessionID, a2final, m2), + ] + + const ids = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + m3, + Effect.sync(() => KiloSessionPromptQueue.scope(sessionID, messages).map((item) => item.info.id)), + Effect.succeed([]), + ), + ) + + expect(ids).toEqual([m1, a1, m2, a2step1, a2step2, a2final, m3]) + expect(ids[ids.length - 1]).toBe(m3) + }) + + test("keeps the target turn's own assistant steps grouped at the end", async () => { + // After the first step of a queued turn has produced an assistant message, + // subsequent scope() calls should keep the target user together with its + // own turn's assistants (not interleaved with a prior turn's tail). + const sessionID = SessionID.make("session_queue_step_two") + const m1 = MessageID.make("message_01a") + const a1 = MessageID.make("message_02a") + const m2 = MessageID.make("message_03a") // queued mid-turn + const a1tail = MessageID.make("message_04a") + const a2step1 = MessageID.make("message_05a") + const messages = [ + user(sessionID, m1), + assistant(sessionID, a1, m1), + user(sessionID, m2), + assistant(sessionID, a1tail, m1), // prior turn's tail was written after m2 + assistant(sessionID, a2step1, m2), + ] + + const ids = await Effect.runPromise( + KiloSessionPromptQueue.enqueue( + sessionID, + m2, + Effect.sync(() => KiloSessionPromptQueue.scope(sessionID, messages).map((item) => item.info.id)), + Effect.succeed([]), + ), + ) + + expect(ids).toEqual([m1, a1, a1tail, m2, a2step1]) + }) + test("continues a queued prompt after the active run finishes", async () => { const ready = Promise.withResolvers() const release = Promise.withResolvers() diff --git a/packages/opencode/test/session/prompt-effect.test.ts b/packages/opencode/test/session/prompt-effect.test.ts index 5ff173b6169..fb9f4f35b36 100644 --- a/packages/opencode/test/session/prompt-effect.test.ts +++ b/packages/opencode/test/session/prompt-effect.test.ts @@ -1215,7 +1215,10 @@ it.live( 10_000, // kilocode_change ) -it.live( +// kilocode_change start - shell process timing is unreliable on Windows CI; +// aligns with every other shell-* test in this file that uses `unix(...)`. +unix( + // kilocode_change end "shell completion resumes queued loop callers", () => provideTmpdirServer( diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 53da44f54b6..c98ab5e12be 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/plugin", - "version": "7.2.12", + "version": "7.2.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/script/package.json b/packages/script/package.json index ec3c42e4095..d5e57862db5 100644 --- a/packages/script/package.json +++ b/packages/script/package.json @@ -12,6 +12,6 @@ "exports": { ".": "./src/index.ts" }, - "version": "7.2.12", + "version": "7.2.14", "peerDependencies": {} } diff --git a/packages/script/src/index.ts b/packages/script/src/index.ts index 090b2476a8c..10e5c9fc5c0 100644 --- a/packages/script/src/index.ts +++ b/packages/script/src/index.ts @@ -118,6 +118,7 @@ const team = [ "chrarnoldus", "codingelves", "darkogj", + "dependabot[bot]", "dosire", "DScdng", "emilieschario", @@ -131,6 +132,7 @@ const team = [ "alex-alecu", "imanolmzd-svg", "kilocode-bot", + "kilo-code-bot", "kilo-code-bot[bot]", "kirillk", "lambertjosh", diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 694ed95a525..87c08a72209 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sdk", - "version": "7.2.12", + "version": "7.2.14", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 83824960942..7895a2ce62f 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -464,40 +464,6 @@ export type EventTodoUpdated = { } } -export type SessionStatus = - | { - type: "idle" - } - | { - type: "retry" - attempt: number - message: string - next: number - } - | { - type: "busy" - } - | { - type: "offline" - requestID: string - message: string - } - -export type EventSessionStatus = { - type: "session.status" - properties: { - sessionID: string - status: SessionStatus - } -} - -export type EventSessionIdle = { - type: "session.idle" - properties: { - sessionID: string - } -} - export type SuggestionAction = { /** * Button or option label (1-5 words) @@ -557,6 +523,40 @@ export type EventSuggestionDismissed = { } } +export type SessionStatus = + | { + type: "idle" + } + | { + type: "retry" + attempt: number + message: string + next: number + } + | { + type: "busy" + } + | { + type: "offline" + requestID: string + message: string + } + +export type EventSessionStatus = { + type: "session.status" + properties: { + sessionID: string + status: SessionStatus + } +} + +export type EventSessionIdle = { + type: "session.idle" + properties: { + sessionID: string + } +} + export type EventSessionCompacted = { type: "session.compacted" properties: { diff --git a/packages/storybook/package.json b/packages/storybook/package.json index 148289f0469..ff0887b9858 100644 --- a/packages/storybook/package.json +++ b/packages/storybook/package.json @@ -26,7 +26,7 @@ "typescript": "catalog:", "vite": "catalog:" }, - "version": "7.2.12", + "version": "7.2.14", "dependencies": {}, "peerDependencies": {} } diff --git a/packages/ui/package.json b/packages/ui/package.json index 7ee16a91353..c40c1979bea 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "7.2.12", + "version": "7.2.14", "type": "module", "license": "MIT", "exports": { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index fa7fdac44ef..0d35b4d6226 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -6,6 +6,7 @@ import { checksum } from "@opencode-ai/util/encode" import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { isServer } from "solid-js/web" import { stream } from "./markdown-stream" +import { tryFastRender } from "../kilocode/markdown-fast-path" // kilocode_change type Entry = { hash: string @@ -308,6 +309,16 @@ export function Markdown( copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), } + + // kilocode_change start + const fast = tryFastRender(container, content, local.streaming, decorate, setupCodeCopy, () => labels, copyCleanup) + if (fast.handled) { + copyCleanup = fast.copyCleanup + kickHighlight(container, labels) + return + } + // kilocode_change end + const temp = document.createElement("div") temp.innerHTML = content decorate(temp, labels) @@ -356,14 +367,37 @@ export function Markdown( }) // kilocode_change end - if (!copyCleanup) - copyCleanup = setupCodeCopy(container, () => ({ - copy: i18n.t("ui.message.copy"), - copied: i18n.t("ui.message.copied"), - })) + kickHighlight(container, labels) }) + // kilocode_change start: progressive Shiki highlighting (issue #6221, PR #7102). + // Parser emits plain
 blocks; we upgrade them to
+  // Shiki-highlighted 
 here via setTimeout(0) so initial
+  // paint is instant and session switches with many code blocks don't freeze.
+  // The generation counter + abort signal cancel a previous in-flight pass
+  // when streaming tokens (or session switches) spawn a new render.
+  function kickHighlight(container: HTMLDivElement, labels: { copy: string; copied: string }) {
+    highlightState.signal.aborted = true
+    const gen = ++highlightState.gen
+    const signal = { aborted: false }
+    highlightState.signal = signal
+    void deferredHighlight(
+      container,
+      () => {
+        if (gen !== highlightState.gen) return
+        if (copyCleanup) copyCleanup()
+        copyCleanup = setupCodeCopy(container, () => labels)
+      },
+      signal,
+    )
+  }
+  // kilocode_change end
+
   onCleanup(() => {
+    // kilocode_change: cancel any in-flight deferredHighlight pass so its
+    // completion callback doesn't touch the unmounted DOM.
+    highlightState.signal.aborted = true
+    highlightState.gen++
     if (copyCleanup) copyCleanup()
   })
 
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 527fcc92931..fd13f832eaa 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -1596,6 +1596,7 @@ ToolRegistry.register({
       
         
@@ -1616,6 +1617,7 @@ ToolRegistry.register({
       
             
diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 10cb10f5f06..231f7c8ef10 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -1,6 +1,11 @@ import { marked } from "marked" import markedKatex from "marked-katex-extension" -import markedShiki from "marked-shiki" +// kilocode_change: marked-shiki highlighted code blocks synchronously during +// parse, freezing the main thread on session switches with many code blocks +// (issue #6221 / PR #7102). We render plain
 here
+// and hand off to deferredHighlight() in markdown.tsx for progressive Shiki.
+// This import was re-added by an upstream merge; removing it restores the
+// two-pass rendering design.
 import katex from "katex"
 import { bundledLanguages, type BundledLanguage } from "shiki"
 import { parseFilePath } from "../file-path" // kilocode_change
@@ -670,26 +675,10 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
         throwOnError: false,
         nonStandard: true,
       }),
-      markedShiki({
-        async highlight(code, lang) {
-          const highlighter = await getSharedHighlighter({
-            themes: ["Kilo"],
-            langs: [],
-            preferredHighlighter: "shiki-wasm",
-          })
-          if (!(lang in bundledLanguages)) {
-            lang = "text"
-          }
-          if (!highlighter.getLoadedLanguages().includes(lang)) {
-            await highlighter.loadLanguage(lang as BundledLanguage)
-          }
-          return highlighter.codeToHtml(code, {
-            lang: lang || "text",
-            theme: "Kilo",
-            tabindex: false,
-          })
-        },
-      }),
+      // kilocode_change: markedShiki removed — the custom `code` renderer
+      // above returns plain 
 and markdown.tsx
+      // calls deferredHighlight() after paint. Running Shiki inside parse
+      // blocks the main thread on session switches (issue #6221).
     )
     // kilocode_change end
 
diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts
index 72e834e5e0c..f31f108cb13 100644
--- a/packages/ui/src/i18n/ar.ts
+++ b/packages/ui/src/i18n/ar.ts
@@ -76,6 +76,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} قائمة",
   "ui.messagePart.context.list.other": "{{count}} قوائم",
   "ui.messagePart.diagnostic.error": "خطأ",
+  "ui.messagePart.mcp.input": "الإدخال",
+  "ui.messagePart.mcp.output": "الإخراج",
   "ui.messagePart.title.edit": "تحرير",
   "ui.messagePart.title.write": "كتابة",
   "ui.messagePart.option.typeOwnAnswer": "اكتب إجابتك الخاصة",
diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts
index e14a3fed47c..6d4a826bcde 100644
--- a/packages/ui/src/i18n/br.ts
+++ b/packages/ui/src/i18n/br.ts
@@ -76,6 +76,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} lista",
   "ui.messagePart.context.list.other": "{{count}} listas",
   "ui.messagePart.diagnostic.error": "Erro",
+  "ui.messagePart.mcp.input": "Entrada",
+  "ui.messagePart.mcp.output": "Saída",
   "ui.messagePart.title.edit": "Editar",
   "ui.messagePart.title.write": "Escrever",
   "ui.messagePart.option.typeOwnAnswer": "Digite sua própria resposta",
diff --git a/packages/ui/src/i18n/bs.ts b/packages/ui/src/i18n/bs.ts
index ccea10c1e6e..62970c7279f 100644
--- a/packages/ui/src/i18n/bs.ts
+++ b/packages/ui/src/i18n/bs.ts
@@ -80,6 +80,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} lista",
   "ui.messagePart.context.list.other": "{{count}} liste",
   "ui.messagePart.diagnostic.error": "Greška",
+  "ui.messagePart.mcp.input": "Ulaz",
+  "ui.messagePart.mcp.output": "Izlaz",
   "ui.messagePart.title.edit": "Uredi",
   "ui.messagePart.title.write": "Napiši",
   "ui.messagePart.option.typeOwnAnswer": "Unesi svoj odgovor",
diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts
index 71bb5236667..20303221038 100644
--- a/packages/ui/src/i18n/da.ts
+++ b/packages/ui/src/i18n/da.ts
@@ -75,6 +75,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} liste",
   "ui.messagePart.context.list.other": "{{count}} lister",
   "ui.messagePart.diagnostic.error": "Fejl",
+  "ui.messagePart.mcp.input": "Input",
+  "ui.messagePart.mcp.output": "Output",
   "ui.messagePart.title.edit": "Rediger",
   "ui.messagePart.title.write": "Skriv",
   "ui.messagePart.option.typeOwnAnswer": "Skriv dit eget svar",
diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts
index 9a0cb10cb93..bc0358ea056 100644
--- a/packages/ui/src/i18n/de.ts
+++ b/packages/ui/src/i18n/de.ts
@@ -81,6 +81,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} Liste",
   "ui.messagePart.context.list.other": "{{count}} Listen",
   "ui.messagePart.diagnostic.error": "Fehler",
+  "ui.messagePart.mcp.input": "Eingabe",
+  "ui.messagePart.mcp.output": "Ausgabe",
   "ui.messagePart.title.edit": "Bearbeiten",
   "ui.messagePart.title.write": "Schreiben",
   "ui.messagePart.option.typeOwnAnswer": "Eigene Antwort eingeben",
diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts
index 0450e757afb..4ed9e9c01b6 100644
--- a/packages/ui/src/i18n/en.ts
+++ b/packages/ui/src/i18n/en.ts
@@ -69,6 +69,8 @@ export const dict: Record = {
   "ui.sessionTurn.status.consideringNextSteps": "Considering next steps",
 
   "ui.messagePart.diagnostic.error": "Error",
+  "ui.messagePart.mcp.input": "Input",
+  "ui.messagePart.mcp.output": "Output",
   "ui.messagePart.title.edit": "Edit",
   "ui.messagePart.title.write": "Write",
   "ui.messagePart.option.typeOwnAnswer": "Type your own answer",
diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts
index e952c098be1..90899358219 100644
--- a/packages/ui/src/i18n/es.ts
+++ b/packages/ui/src/i18n/es.ts
@@ -76,6 +76,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} lista",
   "ui.messagePart.context.list.other": "{{count}} listas",
   "ui.messagePart.diagnostic.error": "Error",
+  "ui.messagePart.mcp.input": "Entrada",
+  "ui.messagePart.mcp.output": "Salida",
   "ui.messagePart.title.edit": "Editar",
   "ui.messagePart.title.write": "Escribir",
   "ui.messagePart.option.typeOwnAnswer": "Escribe tu propia respuesta",
diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts
index 9d48158a73c..35f6702c5d4 100644
--- a/packages/ui/src/i18n/fr.ts
+++ b/packages/ui/src/i18n/fr.ts
@@ -76,6 +76,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} liste",
   "ui.messagePart.context.list.other": "{{count}} listes",
   "ui.messagePart.diagnostic.error": "Erreur",
+  "ui.messagePart.mcp.input": "Entrée",
+  "ui.messagePart.mcp.output": "Sortie",
   "ui.messagePart.title.edit": "Modifier",
   "ui.messagePart.title.write": "Écrire",
   "ui.messagePart.option.typeOwnAnswer": "Tapez votre propre réponse",
diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts
index 71e78ffa85c..2daf8cf2443 100644
--- a/packages/ui/src/i18n/ja.ts
+++ b/packages/ui/src/i18n/ja.ts
@@ -75,6 +75,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} 件のリスト",
   "ui.messagePart.context.list.other": "{{count}} 件のリスト",
   "ui.messagePart.diagnostic.error": "エラー",
+  "ui.messagePart.mcp.input": "入力",
+  "ui.messagePart.mcp.output": "出力",
   "ui.messagePart.title.edit": "編集",
   "ui.messagePart.title.write": "作成",
   "ui.messagePart.option.typeOwnAnswer": "自分の回答を入力",
diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts
index cf56b257e37..ee6e1f83096 100644
--- a/packages/ui/src/i18n/ko.ts
+++ b/packages/ui/src/i18n/ko.ts
@@ -76,6 +76,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}}개 목록",
   "ui.messagePart.context.list.other": "{{count}}개 목록",
   "ui.messagePart.diagnostic.error": "오류",
+  "ui.messagePart.mcp.input": "입력",
+  "ui.messagePart.mcp.output": "출력",
   "ui.messagePart.title.edit": "편집",
   "ui.messagePart.title.write": "작성",
   "ui.messagePart.option.typeOwnAnswer": "직접 답변 입력",
diff --git a/packages/ui/src/i18n/nl.ts b/packages/ui/src/i18n/nl.ts
index b86fa54ea11..c3cab8540d2 100644
--- a/packages/ui/src/i18n/nl.ts
+++ b/packages/ui/src/i18n/nl.ts
@@ -69,6 +69,8 @@ export const dict: Record = {
   "ui.sessionTurn.status.consideringNextSteps": "Volgende stappen overwegen",
 
   "ui.messagePart.diagnostic.error": "Fout",
+  "ui.messagePart.mcp.input": "Invoer",
+  "ui.messagePart.mcp.output": "Uitvoer",
   "ui.messagePart.title.edit": "Bewerken",
   "ui.messagePart.title.write": "Schrijven",
   "ui.messagePart.option.typeOwnAnswer": "Typ je eigen antwoord",
diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts
index e2ac20fb836..377fa7a95a8 100644
--- a/packages/ui/src/i18n/no.ts
+++ b/packages/ui/src/i18n/no.ts
@@ -79,6 +79,8 @@ export const dict: Record = {
   "ui.messagePart.context.list.one": "{{count}} liste",
   "ui.messagePart.context.list.other": "{{count}} lister",
   "ui.messagePart.diagnostic.error": "Feil",
+  "ui.messagePart.mcp.input": "Inndata",
+  "ui.messagePart.mcp.output": "Utdata",
   "ui.messagePart.title.edit": "Rediger",
   "ui.messagePart.title.write": "Skriv",
   "ui.messagePart.option.typeOwnAnswer": "Skriv ditt eget svar",
diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts
index fa21eea0d4a..05a1a2e1662 100644
--- a/packages/ui/src/i18n/pl.ts
+++ b/packages/ui/src/i18n/pl.ts
@@ -75,6 +75,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} lista",
   "ui.messagePart.context.list.other": "{{count}} listy",
   "ui.messagePart.diagnostic.error": "Błąd",
+  "ui.messagePart.mcp.input": "Wejście",
+  "ui.messagePart.mcp.output": "Wyjście",
   "ui.messagePart.title.edit": "Edycja",
   "ui.messagePart.title.write": "Pisanie",
   "ui.messagePart.option.typeOwnAnswer": "Wpisz własną odpowiedź",
diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts
index 5c01b07d09d..067cbd0e67b 100644
--- a/packages/ui/src/i18n/ru.ts
+++ b/packages/ui/src/i18n/ru.ts
@@ -75,6 +75,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} список",
   "ui.messagePart.context.list.other": "{{count}} списков",
   "ui.messagePart.diagnostic.error": "Ошибка",
+  "ui.messagePart.mcp.input": "Ввод",
+  "ui.messagePart.mcp.output": "Вывод",
   "ui.messagePart.title.edit": "Редактировать",
   "ui.messagePart.title.write": "Написать",
   "ui.messagePart.option.typeOwnAnswer": "Введите свой ответ",
diff --git a/packages/ui/src/i18n/th.ts b/packages/ui/src/i18n/th.ts
index 15d94f0fb11..92299360185 100644
--- a/packages/ui/src/i18n/th.ts
+++ b/packages/ui/src/i18n/th.ts
@@ -77,6 +77,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "รายการ {{count}} รายการ",
   "ui.messagePart.context.list.other": "รายการ {{count}} รายการ",
   "ui.messagePart.diagnostic.error": "ข้อผิดพลาด",
+  "ui.messagePart.mcp.input": "อินพุต",
+  "ui.messagePart.mcp.output": "เอาต์พุต",
   "ui.messagePart.title.edit": "แก้ไข",
   "ui.messagePart.title.write": "เขียน",
   "ui.messagePart.option.typeOwnAnswer": "พิมพ์คำตอบของคุณเอง",
diff --git a/packages/ui/src/i18n/tr.ts b/packages/ui/src/i18n/tr.ts
index f3f9886ca6c..f7b55f6ad8e 100644
--- a/packages/ui/src/i18n/tr.ts
+++ b/packages/ui/src/i18n/tr.ts
@@ -82,6 +82,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} liste",
   "ui.messagePart.context.list.other": "{{count}} liste",
   "ui.messagePart.diagnostic.error": "Hata",
+  "ui.messagePart.mcp.input": "Giriş",
+  "ui.messagePart.mcp.output": "Çıkış",
   "ui.messagePart.title.edit": "Düzenle",
   "ui.messagePart.title.write": "Yaz",
   "ui.messagePart.option.typeOwnAnswer": "Kendi cevabınızı yazın",
diff --git a/packages/ui/src/i18n/uk.ts b/packages/ui/src/i18n/uk.ts
index 090a0481874..3ebf9ce3396 100644
--- a/packages/ui/src/i18n/uk.ts
+++ b/packages/ui/src/i18n/uk.ts
@@ -82,6 +82,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} список",
   "ui.messagePart.context.list.other": "{{count}} списків",
   "ui.messagePart.diagnostic.error": "Помилка",
+  "ui.messagePart.mcp.input": "Вхід",
+  "ui.messagePart.mcp.output": "Вихід",
   "ui.messagePart.title.edit": "Редагувати",
   "ui.messagePart.title.write": "Записати",
   "ui.messagePart.option.typeOwnAnswer": "Введіть власну відповідь",
diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts
index c9b235a2a76..48423ab232c 100644
--- a/packages/ui/src/i18n/zh.ts
+++ b/packages/ui/src/i18n/zh.ts
@@ -80,6 +80,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} 个列表",
   "ui.messagePart.context.list.other": "{{count}} 个列表",
   "ui.messagePart.diagnostic.error": "错误",
+  "ui.messagePart.mcp.input": "输入",
+  "ui.messagePart.mcp.output": "输出",
   "ui.messagePart.title.edit": "编辑",
   "ui.messagePart.title.write": "写入",
   "ui.messagePart.option.typeOwnAnswer": "输入自己的答案",
diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts
index 64728cb4565..276db511540 100644
--- a/packages/ui/src/i18n/zht.ts
+++ b/packages/ui/src/i18n/zht.ts
@@ -80,6 +80,8 @@ export const dict = {
   "ui.messagePart.context.list.one": "{{count}} 個清單",
   "ui.messagePart.context.list.other": "{{count}} 個清單",
   "ui.messagePart.diagnostic.error": "錯誤",
+  "ui.messagePart.mcp.input": "輸入",
+  "ui.messagePart.mcp.output": "輸出",
   "ui.messagePart.title.edit": "編輯",
   "ui.messagePart.title.write": "寫入",
   "ui.messagePart.option.typeOwnAnswer": "輸入自己的答案",
diff --git a/packages/ui/src/kilocode/markdown-fast-path.ts b/packages/ui/src/kilocode/markdown-fast-path.ts
new file mode 100644
index 00000000000..4e11f3314e8
--- /dev/null
+++ b/packages/ui/src/kilocode/markdown-fast-path.ts
@@ -0,0 +1,29 @@
+// Fast-path initial render for completed (non-streaming) markdown blocks.
+// Skips morphdom's expensive tree-matching by writing innerHTML directly
+// when the container is empty. On large session switches this avoids the
+// dominant "Parse HTML + morphdom diff" cost for historical messages.
+
+type CopyLabels = { copy: string; copied: string }
+
+/**
+ * If the content is a first paint of completed markdown (not streaming,
+ * container empty), render directly via innerHTML and return true.
+ * The caller should skip morphdom when this returns true.
+ */
+export function tryFastRender(
+  container: HTMLDivElement,
+  content: string,
+  streaming: boolean | undefined,
+  decorate: (root: HTMLDivElement, labels: CopyLabels) => void,
+  setupCopy: (root: HTMLDivElement, getLabels: () => CopyLabels) => (() => void) | undefined,
+  getLabels: () => CopyLabels,
+  copyCleanup: (() => void) | undefined,
+): { handled: boolean; copyCleanup: (() => void) | undefined } {
+  if (streaming || container.childNodes.length > 0) {
+    return { handled: false, copyCleanup }
+  }
+  container.innerHTML = content
+  decorate(container, getLabels())
+  const cleanup = copyCleanup ?? setupCopy(container, getLabels)
+  return { handled: true, copyCleanup: cleanup }
+}
diff --git a/packages/util/package.json b/packages/util/package.json
index 7305f0d747b..95817ea28bc 100644
--- a/packages/util/package.json
+++ b/packages/util/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@opencode-ai/util",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "private": true,
   "type": "module",
   "license": "MIT",
diff --git a/script/changelog-github.cjs b/script/changelog-github.cjs
index f0aecd0e21d..7669e589e8f 100644
--- a/script/changelog-github.cjs
+++ b/script/changelog-github.cjs
@@ -16,6 +16,7 @@ const team = new Set([
   "chrarnoldus",
   "codingelves",
   "darkogj",
+  "dependabot[bot]",
   "dosire",
   "DScdng",
   "emilieschario",
@@ -29,6 +30,7 @@ const team = new Set([
   "alex-alecu",
   "imanolmzd-svg",
   "kilocode-bot",
+  "kilo-code-bot",
   "kilo-code-bot[bot]",
   "kirillk",
   "lambertjosh",
diff --git a/script/upstream/package.json b/script/upstream/package.json
index feef48f5f98..dd9af5c076d 100644
--- a/script/upstream/package.json
+++ b/script/upstream/package.json
@@ -1,6 +1,6 @@
 {
   "name": "@kilocode/upstream-merge",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "private": true,
   "type": "module",
   "description": "Scripts for automating upstream opencode merges into Kilo",
diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json
index 3c36192e550..8eeea3b10e7 100644
--- a/sdks/vscode/package.json
+++ b/sdks/vscode/package.json
@@ -2,7 +2,7 @@
   "name": "opencode",
   "displayName": "opencode",
   "description": "opencode for VS Code",
-  "version": "7.2.12",
+  "version": "7.2.14",
   "publisher": "sst-dev",
   "repository": {
     "type": "git",