diff --git a/.changeset/agent-manager-history-routing-fix.md b/.changeset/agent-manager-history-routing-fix.md new file mode 100644 index 0000000000..a015ab3147 --- /dev/null +++ b/.changeset/agent-manager-history-routing-fix.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Fix project-scoped Agent Manager history activation and session placement. diff --git a/.changeset/agent-manager-history-switch-queue.md b/.changeset/agent-manager-history-switch-queue.md new file mode 100644 index 0000000000..9cfd32747a --- /dev/null +++ b/.changeset/agent-manager-history-switch-queue.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent overlapping Agent Manager history activations from leaving stale project-switch state. diff --git a/.changeset/agent-manager-sessions-button.md b/.changeset/agent-manager-sessions-button.md new file mode 100644 index 0000000000..d9a870b87e --- /dev/null +++ b/.changeset/agent-manager-sessions-button.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Replace the per-project SESSIONS list in the Agent Manager sidebar with a per-project history button. The sessions view is now scoped to the clicked project and offers per-session actions to resume it in the project's local tabs or in a freshly created worktree. diff --git a/.changeset/agent-manager-terminal-render-performance.md b/.changeset/agent-manager-terminal-render-performance.md new file mode 100644 index 0000000000..b3cc4d10e1 --- /dev/null +++ b/.changeset/agent-manager-terminal-render-performance.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use the DOM renderer for Agent Manager terminals to avoid WebGL context failures, while batching output and pausing hidden-terminal rendering diff --git a/.changeset/calm-agent-manager-streams.md b/.changeset/calm-agent-manager-streams.md new file mode 100644 index 0000000000..2d16ef00e4 --- /dev/null +++ b/.changeset/calm-agent-manager-streams.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Reduce duplicate event processing across VS Code when multiple sessions run concurrently. diff --git a/.changeset/explicit-agent-manager-provider.md b/.changeset/explicit-agent-manager-provider.md new file mode 100644 index 0000000000..31ed86c6ab --- /dev/null +++ b/.changeset/explicit-agent-manager-provider.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Allow Agent Manager task model overrides to specify an explicit provider when resolving model names. diff --git a/.changeset/fast-agent-manager-diffs.md b/.changeset/fast-agent-manager-diffs.md new file mode 100644 index 0000000000..8a51a2f6c9 --- /dev/null +++ b/.changeset/fast-agent-manager-diffs.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Load Agent Manager worktree diffs faster and keep warmed reviews visible when switching worktrees. diff --git a/.changeset/fix-background-promotion-edge-cases.md b/.changeset/fix-background-promotion-edge-cases.md new file mode 100644 index 0000000000..25df1e2f7c --- /dev/null +++ b/.changeset/fix-background-promotion-edge-cases.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": patch +"kilo-code": patch +--- + +Prevent stale subagent cards from showing background promotion and respect the background-subagent capability when promoting running tasks. diff --git a/.changeset/fix-cli-startup-followup.md b/.changeset/fix-cli-startup-followup.md new file mode 100644 index 0000000000..23d9ac25a0 --- /dev/null +++ b/.changeset/fix-cli-startup-followup.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix CLI help disposal and shell completion after startup optimization. diff --git a/.changeset/fix-sync-filter-lifecycle.md b/.changeset/fix-sync-filter-lifecycle.md new file mode 100644 index 0000000000..47964a30b1 --- /dev/null +++ b/.changeset/fix-sync-filter-lifecycle.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent duplicate-event tracking from suppressing delayed sync events after reconnects or high event bursts. diff --git a/.changeset/individual-background-subagent-promotion.md b/.changeset/individual-background-subagent-promotion.md new file mode 100644 index 0000000000..2f9046bba1 --- /dev/null +++ b/.changeset/individual-background-subagent-promotion.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Allow each foreground subagent to continue in the background independently, including when several subagents run in parallel. diff --git a/.changeset/isolated-reasoning-streams.md b/.changeset/isolated-reasoning-streams.md new file mode 100644 index 0000000000..10d21a5345 --- /dev/null +++ b/.changeset/isolated-reasoning-streams.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent duplicate reasoning and response text while subagent sessions are open. diff --git a/.changeset/jetbrains-worktree-safety.md b/.changeset/jetbrains-worktree-safety.md new file mode 100644 index 0000000000..40e619f1b0 --- /dev/null +++ b/.changeset/jetbrains-worktree-safety.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Keep JetBrains Agent Manager worktrees in the main repository storage, prevent nested worktree deletion from removing child worktrees, and show a clear missing-folder error for deleted workspaces. diff --git a/.changeset/quick-clis-rest.md b/.changeset/quick-clis-rest.md new file mode 100644 index 0000000000..d7b3d48509 --- /dev/null +++ b/.changeset/quick-clis-rest.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Improve CLI cold and warm startup time. diff --git a/.changeset/quiet-editor-location-watchers.md b/.changeset/quiet-editor-location-watchers.md new file mode 100644 index 0000000000..ed22ebbe10 --- /dev/null +++ b/.changeset/quiet-editor-location-watchers.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Prevent runaway memory growth in long-running editor servers by sharing project services across file, terminal, reference, agent, and session routes. diff --git a/.changeset/quiet-manual-interruptions.md b/.changeset/quiet-manual-interruptions.md new file mode 100644 index 0000000000..07927ead3e --- /dev/null +++ b/.changeset/quiet-manual-interruptions.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Stop manually aborted turns without briefly showing an interruption warning. diff --git a/.changeset/remove-model-reset.md b/.changeset/remove-model-reset.md new file mode 100644 index 0000000000..3646929dca --- /dev/null +++ b/.changeset/remove-model-reset.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Remove the model reset button from the chat and worktree prompt controls. diff --git a/.changeset/sidebar-scroll-preservation.md b/.changeset/sidebar-scroll-preservation.md new file mode 100644 index 0000000000..26c4cb0e3d --- /dev/null +++ b/.changeset/sidebar-scroll-preservation.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Preserve the Agent Manager sidebar scroll position when worktrees are deleted. diff --git a/.changeset/smooth-streaming-transcript.md b/.changeset/smooth-streaming-transcript.md new file mode 100644 index 0000000000..68d740d664 --- /dev/null +++ b/.changeset/smooth-streaming-transcript.md @@ -0,0 +1,6 @@ +--- +"@kilocode/kilo-ui": patch +"kilo-code": patch +--- + +Keep the session transcript glued to its bottom while a response streams, so text, tool cards, reasoning, and message actions no longer twitch as they update. diff --git a/.changeset/stable-editor-context-prompt-prefix.md b/.changeset/stable-editor-context-prompt-prefix.md new file mode 100644 index 0000000000..322da1e6a9 --- /dev/null +++ b/.changeset/stable-editor-context-prompt-prefix.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Stop moving editor context between user messages so providers with prefix caching, including local models, can reuse the conversation across turns. diff --git a/.changeset/steady-location-keys.md b/.changeset/steady-location-keys.md new file mode 100644 index 0000000000..1607aba1a2 --- /dev/null +++ b/.changeset/steady-location-keys.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Keep file route location services on the same cache key as workspace-aware server routes. diff --git a/.changeset/terminal-replay-close-code.md b/.changeset/terminal-replay-close-code.md new file mode 100644 index 0000000000..2be3e869de --- /dev/null +++ b/.changeset/terminal-replay-close-code.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use a browser-valid close code when Agent Manager terminal replay exceeds its buffer limit diff --git a/.changeset/update-bun-1-4.md b/.changeset/update-bun-1-4.md new file mode 100644 index 0000000000..f2f71b5a70 --- /dev/null +++ b/.changeset/update-bun-1-4.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Build the CLI with Bun 1.4 to reduce compiled binary size and build time. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 69612910fe..895870a494 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ The Kilo Community is [on Discord](https://kilo.ai/discord). ## Prerequisites -- **Bun 1.3.14+** — required for all packages. +- **Bun 1.4.0+** — required for all packages. - **Java 21** — required by the JetBrains plugin. The root `bun turbo typecheck` and `bun turbo test:ci` commands include `@kilocode/kilo-jetbrains` and will fail without Java 21. The preferred way to install Java is via [SDKMAN](https://sdkman.io/install): @@ -41,7 +41,7 @@ The Kilo Community is [on Discord](https://kilo.ai/discord). ## Developing Kilo CLI -- **Requirements:** Bun 1.3.14+, Java 21 (see [Prerequisites](#prerequisites) above) +- **Requirements:** Bun 1.4.0+, Java 21 (see [Prerequisites](#prerequisites) above) - Install dependencies and start the CLI from the repo root: ```bash diff --git a/bun.lock b/bun.lock index 229bc772e3..31cb7c0978 100644 --- a/bun.lock +++ b/bun.lock @@ -1000,25 +1000,25 @@ }, }, "trustedDependencies": [ - "esbuild", - "protobufjs", "web-tree-sitter", + "esbuild", "tree-sitter-bash", + "protobufjs", ], "patchedDependencies": { - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "virtua@0.49.1": "patches/virtua@0.49.1.patch", + "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", "@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch", - "pacote@21.5.1": "patches/pacote@21.5.1.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", + "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", + "pacote@21.5.1": "patches/pacote@21.5.1.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "solid-js@1.9.12": "patches/solid-js@1.9.12.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", - "virtua@0.49.1": "patches/virtua@0.49.1.patch", - "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", - "mammoth@1.12.0": "patches/mammoth@1.12.0.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", }, "overrides": { "@effect/platform-node-shared": "4.0.0-beta.74", @@ -1069,7 +1069,7 @@ "@tanstack/solid-virtual": "3.13.32", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "@types/cross-spawn": "6.0.6", "@types/luxon": "3.7.1", "@types/node": "24.12.4", @@ -2536,7 +2536,7 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], + "@types/bun": ["@types/bun@1.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], "@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="], @@ -2972,7 +2972,7 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], + "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], diff --git a/bunfig.toml b/bunfig.toml index 61bb6e0fdb..2c85f59748 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Keep Kilo's longer supply-chain quarantine while allowing packages that must track coordinated releases. minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours) -minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] +minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "@types/bun", "bun-types"] # kilocode_change [test] root = "./do-not-run-tests-from-root" diff --git a/nix/bun.nix b/nix/bun.nix index a880895349..f9bc254337 100644 --- a/nix/bun.nix +++ b/nix/bun.nix @@ -14,19 +14,19 @@ let sources = { "aarch64-linux" = { name = "bun-linux-aarch64"; - hash = "sha256-on/7Y6gxA3WDbg1vZorhf6jY0YuIw3yCHGUzGXOhmjs="; + hash = "sha256-SxozLuhhmD65O8/m93D/+U4+MbLDiL2uo8jtNeWO7Q4="; }; "x86_64-linux" = { name = "bun-linux-x64"; - hash = "sha256-lR7iruhV8IWVruxiJSJqKY0/6oOj3NZGXAnLzN9+hI8="; + hash = "sha256-LQP7X7g6yLVnrKCigbLOGhoZ1Ij1bClo2Iw/Jekv5FI="; }; "aarch64-darwin" = { name = "bun-darwin-aarch64"; - hash = "sha256-2LliIYKK1vl6x6wKt+lYcjQa92MAHogD6CZ2UsJlJiA="; + hash = "sha256-xmnpf2Fk4cluBwF0jbmN+ndJKQjL2DlMdVcTSnNd44E="; }; "x86_64-darwin" = { name = "bun-darwin-x64"; - hash = "sha256-QYPfM3RiPlurMVxUfPoJdFM81FfYa3O2OfeoeXTNZjM="; + hash = "sha256-HQIRuPHcmRGCNEaHrRXnLuhvFUhFpff6R3mUzTQd2bA="; }; }; source = diff --git a/nix/hashes.json b/nix/hashes.json index 254761e671..9415b2d921 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-9riExKYPZOz7RqMyrtEMiJyxbtBHnCRkwoBrx825t6k=", - "aarch64-linux": "sha256-mCu+gs0rZGv2PRQlojZR+g3TnRXjMdiN0ORrnUwFbRU=", - "aarch64-darwin": "sha256-boQX3rpYs2vgRzIgVwEeyAKJ8b3MzbBbrq8GL5YkKE8=", - "x86_64-darwin": "sha256-flbEqrgSGWDHfuTeVTv3iV8wYCeDHpPeqkj5Mq5WXy0=" + "x86_64-linux": "sha256-EZPg35MrIg7sK0kfqwrhWyGMF4YOVK2yObGbvIXJ1zk=", + "aarch64-linux": "sha256-DHSeWLho1CV+SY2TMBeBKbMCOhWEd0VTGDnlMMZw+i4=", + "aarch64-darwin": "sha256-zMEmZ3ox3aXX/Q4L4vwYL45RUbiD8Jpq3DlEhVA44YU=", + "x86_64-darwin": "sha256-nGjMFQ98tn7ay4zBt+hqqUFWr80c0SV0qmJJ1lLA1l0=" } } diff --git a/package.json b/package.json index efbc37aeb8..efddc08e6d 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.3.14", + "packageManager": "bun@1.4.0", "scripts": { "dev": "KILO_CLIENT=cli bun run --cwd packages/opencode --conditions=node src/index.ts", "dev:stats": "bun sst shell --stage=production -- bun run --cwd packages/stats/app dev", @@ -35,7 +35,7 @@ "@effect/platform-node": "4.0.0-beta.83", "@anthropic-ai/sandbox-runtime": "0.0.63", "@npmcli/arborist": "9.4.0", - "@types/bun": "1.3.14", + "@types/bun": "1.4.0", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@opentui/core": "0.4.5", diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile index 8c93e45502..ddfe934f20 100644 --- a/packages/containers/bun-node/Dockerfile +++ b/packages/containers/bun-node/Dockerfile @@ -6,7 +6,7 @@ FROM ${REGISTRY}/build/base:24.04 SHELL ["/bin/bash", "-lc"] ARG NODE_VERSION=24.4.0 -ARG BUN_VERSION=1.3.14 +ARG BUN_VERSION=1.4.0 ENV BUN_INSTALL=/opt/bun ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/packages/core/src/aisdk.ts b/packages/core/src/aisdk.ts index b604dac664..bc0280036b 100644 --- a/packages/core/src/aisdk.ts +++ b/packages/core/src/aisdk.ts @@ -35,7 +35,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { const id = setTimeout(() => { const err = new Error("SSE read timed out") ctl.abort(err) - void reader.cancel(err) + void reader.cancel(err).catch(() => undefined) // kilocode_change - handle Bun 1.4 cancellation rejection reject(err) }, ms) diff --git a/packages/kilo-docs/pages/automate/agent-manager.md b/packages/kilo-docs/pages/automate/agent-manager.md index a903d0bf90..8c4fa2c233 100644 --- a/packages/kilo-docs/pages/automate/agent-manager.md +++ b/packages/kilo-docs/pages/automate/agent-manager.md @@ -214,7 +214,7 @@ The tool supports two modes: | `worktree` | Creates one Agent Manager git worktree and session per task | | `local` | Creates Agent Manager sessions in the current workspace without git worktree isolation | -Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. Prompted tasks inherit the model and reasoning variant used by the chat turn that starts them. A task can override that selection with a `model` (by name, e.g. `Claude Opus 4.1`) when you explicitly request a different model, or with one of the current model's reasoning `variant` values when you request a different variant. Agent Manager resolves the provider for a model override, preferring the provider used by the current turn and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted to force a specific provider. Prepared sessions without an initial prompt use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions. +Each request can include 1-20 tasks. Each task must include at least one of `prompt`, `name`, or `branchName`. Prompted tasks inherit the model and reasoning variant used by the chat turn that starts them. A task can override that selection with a `model` (by name, e.g. `Claude Opus 4.1`) when you explicitly request a different model, or with one of the current model's reasoning `variant` values when you request a different variant. Add `provider` beside `model` to force a model-name match to one of the listed provider IDs. Agent Manager resolves the provider for a model override when `provider` is omitted, preferring the provider used by the current turn and falling back to the Kilo Gateway; a qualified `provider/model` ID is also accepted. Prepared sessions without an initial prompt use the normal model defaults. Use `versions: true` only when the tasks are alternate versions of the same work to compare; otherwise, multiple tasks start as independent sessions. The companion `agent_manager_models` tool searches models and their supported reasoning variants on demand. Results are grouped by model name (with the offering providers listed for reference) and limited to 20 per call, so the full catalog is never added to the conversation context. @@ -324,6 +324,8 @@ The project and worktree context owns document tabs, loaded content, and comment Each session has a dedicated terminal rooted in the session's worktree directory. Press `Cmd+/` (macOS) / `Ctrl+/` (Windows/Linux) to focus the terminal for the active session. If the embedded terminal is already visible but the prompt has focus, the same shortcut focuses the terminal without hiding it. Press it again while the terminal has focus to hide the panel. +When you use `@terminal` in an Agent Manager prompt, Kilo captures the focused terminal for the selected session or worktree. This includes embedded **Run** and **Setup** tabs. Terminal context is limited to 500 lines or 50,000 characters; longer output is truncated. + ### Choosing the Terminal Destination The toolbar's terminal button is a split button: click it to open a terminal, or use its dropdown to choose where terminals open: diff --git a/packages/kilo-docs/pages/automate/extending/shell-integration.md b/packages/kilo-docs/pages/automate/extending/shell-integration.md index ed9296a1ef..52f1d4fcc0 100644 --- a/packages/kilo-docs/pages/automate/extending/shell-integration.md +++ b/packages/kilo-docs/pages/automate/extending/shell-integration.md @@ -42,13 +42,13 @@ The CLI automatically detects the appropriate shell for your platform using `She ## Agent Manager Terminals (VS Code Extension) -When using the Kilo Code VS Code extension with the Agent Manager, each agent session gets its own dedicated VS Code terminal. +When using the Kilo Code VS Code extension with the Agent Manager, each agent session gets its own terminal. Depending on the terminal destination, this is either a dedicated VS Code integrated terminal or an embedded terminal in the Agent Manager panel. ### Per-Session Terminals -- Each session creates a terminal named **`Agent: {branch}`**, where `{branch}` is the git branch or worktree the session is working in +- Each session creates a terminal named **`Agent: {branch}`**, where `{branch}` is the git branch or worktree the session is working in, when you use a VS Code integrated terminal - The terminal's working directory is automatically set to the session's worktree directory -- Terminals are standard VS Code integrated terminals — you can interact with them directly +- VS Code terminal destinations are standard integrated terminals. The Agent Manager panel destination uses embedded terminals in the Agent Manager layout. ### Keyboard Shortcuts @@ -59,7 +59,7 @@ When using the Kilo Code VS Code extension with the Agent Manager, each agent se ### Terminal Context Menu Actions -Right-click in an Agent Manager terminal to access these actions: +Right-click in a VS Code integrated Agent Manager terminal to access these actions: - **Add Terminal Content to Context** — sends the terminal's visible output to the agent as context - **Fix This Command** — asks the agent to diagnose and fix the last failed command diff --git a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md index d8a0080369..18d4bc4825 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/chat-interface.md @@ -67,7 +67,7 @@ Open your terminal and run `kilo` to launch the interactive terminal interface ( **Providing context:** -The extension automatically passes context from your editor, including your open tabs and active file. You can type `@` in the chat input to get file and terminal autocomplete suggestions — use `@filename` to attach a file or `@terminal` to include your active terminal output. You can also mention file paths naturally in your message (e.g., "update src/utils.ts to add a helper function"). The agent can also discover files on its own using its built-in tools. +The extension automatically passes context from your editor, including your open tabs and active file. You can type `@` in the chat input to get file and terminal autocomplete suggestions. Use `@filename` to attach a file. In the sidebar, `@terminal` includes the active VS Code terminal output. In Agent Manager, it includes the focused terminal for the selected session or worktree, including embedded **Run** and **Setup** tabs. You can also mention file paths naturally in your message (e.g., "update src/utils.ts to add a helper function"). The agent can also discover files on its own using its built-in tools. **Exporting local transcripts:** diff --git a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md index a11e0ddf54..2a88ed2de4 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/context-mentions.md @@ -23,12 +23,14 @@ Type `@` in the chat input to get autocomplete suggestions. You can mention: | Mention | Description | Example | |---|---|---| | **File** | Attach a file's contents to your message | `@src/utils.ts` | -| **Terminal** | Include your active VS Code terminal output | `@terminal` | +| **Terminal** | In the sidebar, include the active VS Code terminal output. In Agent Manager, include the focused terminal for the selected session or worktree, including embedded **Run** and **Setup** tabs. | `@terminal` | | **Git Changes** | Attach uncommitted working-tree diffs and new files | `@git-changes` | | **Past chats** | Add a previous session's chat history as context | `@` → **Past chats** → pick a session | Selecting a suggestion inserts the mention and highlights it in the input. File contents, terminal output, and git changes are attached as context when you send the message. +Terminal context is limited to 500 lines or 50,000 characters. Longer output is truncated. If no terminal content is available, Kilo reports that terminal content is unavailable instead of attaching an empty file. + ### Referencing Past Chats Choosing **Past chats** from the `@` menu opens a searchable picker of sessions in the current workspace and related Git worktrees, including Agent Manager worktrees, ordered by recency. Search matches session titles and worktree names. Selecting a session inserts a highlighted mention token; when you send the message, that session's current chat history is added as context so the agent can build on the earlier conversation. Clicking the mention token opens the referenced session. diff --git a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md index 1ef3526d95..fae75f4aa3 100644 --- a/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md +++ b/packages/kilo-docs/pages/code-with-ai/agents/model-selection.md @@ -29,7 +29,7 @@ While the specifics change constantly, some principles stay consistent: - Press `Shift+Tab` in the prompt input to cycle to the next reasoning effort variant, wrapping after the last one. This works in the sidebar chat, the Agent Manager prompt, and the New Worktree dialog, and the variant selector tooltip shows the shortcut on hover. To keep `Shift+Tab` for keyboard focus navigation instead, disable the `kilo-code.new.chat.shiftTabCyclesVariant` setting (also available under **Settings → Display**). - Set per-agent defaults and a global default in the **Settings** panel (Models tab), or directly in the `kilo.jsonc` config file. - **Model precedence:** Session override → Last picked per agent → Per-agent config → Global config → [Auto Free](/docs/code-with-ai/agents/auto-model#tiers) (note: Auto Free may route to providers that log prompts — see the Auto Model page for details). -- The model selector remembers the last model you picked for each agent — switching agents restores your previous choice. A manual pick always beats config settings; use the **reset button** (visible when your active model differs from config) to go back to the config default. +- The model selector remembers the last model you picked for each agent, so switching agents restores your previous choice. A manual pick always beats config settings. {% /tab %} {% tab label="CLI" %} diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md index 3f4b6f5b34..e264507d77 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/vscode/index.md @@ -35,6 +35,28 @@ Key features include: - **Sub-Agent Viewer** — Read-only panels for viewing child agent sessions - **Legacy Migration** — Automatic migration wizard for VSCode extension settings +## Background agents + +When Kilo starts an agent in the background, a collapsible status strip appears in the chat header. The strip stays visible while the transcript scrolls, so you can check background work without searching the conversation. Click the strip to expand it and see each agent. + +Each agent shows one of these states: + +| State | Meaning | +|---|---| +| **Running** | The agent is still working. | +| **Done** | The agent completed successfully. | +| **Cancelled** | The agent was stopped before it completed. | +| **Error** | The agent stopped because it encountered an error. | + +An agent can also show **Needs input** when it is waiting for a permission decision or an answer to a question. Open the agent row to inspect its read-only transcript and handle the request in the child-agent view. In the sidebar and Kilo tab, the transcript opens in a read-only editor tab. In Agent Manager, it opens in the right-hand inspector. + +Use these controls in the expanded strip: + +- **Stop** cancels a running agent and its child session. +- **Dismiss** hides one finished agent from the strip. It does not delete the transcript or the agent record. +- **Clear finished** hides all agents that are no longer running. +- **Continue in background** appears when a foreground subagent is running. It detaches that work so the parent session can continue while the subagent runs in the background. + ## Shared Settings Settings apply across extension surfaces, including the sidebar and Agent Manager. The standalone CLI uses the same `~/.config/kilo/kilo.jsonc` (global) and `./kilo.jsonc` (project) files when used directly. diff --git a/packages/kilo-docs/pages/contributing/development-environment.md b/packages/kilo-docs/pages/contributing/development-environment.md index e2f77c6cc9..a7c0c6830d 100644 --- a/packages/kilo-docs/pages/contributing/development-environment.md +++ b/packages/kilo-docs/pages/contributing/development-environment.md @@ -16,7 +16,7 @@ This document will help you set up your development environment and understand h Before you begin, make sure you have the following installed: 1. **Git** - For version control -2. **Bun 1.3.14+** - Required for installing dependencies and running scripts +2. **Bun 1.4.0+** - Required for installing dependencies and running scripts 3. **Visual Studio Code** - Our recommended IDE for development 4. **Java 21** - Required only when running JetBrains plugin checks or repo-level checks that include `@kilocode/kilo-jetbrains` diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png index 9f4f07d373..f451d7b61a 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/agentmanager/multi-project-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:00088eaafbe9b65c5baf0eb1ce185a95da3e8492bf3c77a940f20d329ff93046 -size 39409 +oid sha256:490320ae56acbbc5ea7283d72fb8dd4b93a28ce3f1ec9678ce8ff3ea1bab953e +size 34649 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png index 203aa7fd0e..6014d515ee 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/chat-view-readable-420-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bbe086983757d02e90c26f3798a6a8e3883ac57e009bebdde0852836684c7595 -size 32553 +oid sha256:208d1aed2d0a3a3b5397d1a11155871f6bd8c01ee8772b81fb45875c36d43a17 +size 32340 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png index f00d7b3ef9..ca23518fe5 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/message-list-layout-correction-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d866d08bc0d18ee2abc4c2bd246f2a958107ca1cb1771cb61e37aa2597504ed8 -size 28266 +oid sha256:e50f52681c7c255f4f65bea146dd4adbbb8a5d3a0027777406e42572c58378f4 +size 29904 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png index b291c21175..34d77548c9 100644 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-sidebar-chromium-linux.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c2c8b3e4ded1bde02fe2c72764d16c4e056a91e9fa935c9925b8095bfaf9a748 -size 27272 +oid sha256:7403709acf8593873394ba15f23a50b56b2008e7d99440df503089a3f21b2fe6 +size 30279 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-200-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-200-chromium-linux.png deleted file mode 100644 index c15fb4d2e9..0000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-200-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:f347005b2fb066a836b865b236d4ca27e5a8a09dc80c7df6683520f000bdf4c3 -size 5280 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-420-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-420-chromium-linux.png deleted file mode 100644 index f0b4b4b229..0000000000 --- a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/prompt-input/with-model-override-420-chromium-linux.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ecc3761d4315beb12e69af19d391ecdede2e6d71586ee3a7bd6f46c32013c4c1 -size 5546 diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt index 4abe1975d6..15bfc06642 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorkspaceRpcApiImpl.kt @@ -508,6 +508,10 @@ class KiloWorkspaceRpcApiImpl internal constructor( status = KiloWorkspaceStatusDto.UNSUPPORTED, error = state.reason, ) + is KiloWorkspaceState.Missing -> KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.MISSING, + error = state.path, + ) is KiloWorkspaceState.Error -> KiloWorkspaceStateDto( status = KiloWorkspaceStatusDto.ERROR, error = state.message, diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 80c0e642f9..7c783ea59c 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -83,9 +83,10 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val res = runGit(base, "worktree", "list", "--porcelain") if (!res.ok) return@withContext WorktreeListDto() val items = managedWorktrees(parseWorktreeList(res.stdout)) - val store = worktreeNameStore(items) - val state = store?.let { syncWorktreeState(it, worktreePaths(items)) } ?: WorktreeState() - val named = overlayWorktreeNames(items, state.names) + val alive = items.filter { it.main || Files.isDirectory(Path.of(it.path)) } + val store = worktreeNameStore(alive) + val state = store?.let { syncWorktreeState(it, worktreePaths(alive)) } ?: WorktreeState() + val named = overlayWorktreeNames(alive, state.names) WorktreeListDto(orderWorktrees(named, state.worktreeOrder)) } @@ -276,6 +277,14 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return Path.of(lines[0]).normalize() != Path.of(lines[1]).normalize() } + /** Main working tree for the repo containing [base]; falls back to [base] when git fails. */ + private fun mainWorktree(base: Path): Path { + val res = runGit(base, "worktree", "list", "--porcelain") + if (!res.ok) return base + val main = parseWorktreeList(res.stdout).firstOrNull { it.main } ?: return base + return Path.of(main.path).normalize() + } + override suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto = withContext(Dispatchers.IO) { val base = Path.of(directory).normalize() @@ -312,7 +321,12 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { /** Runs `git worktree add` under `/.kilo/worktrees/` and records list bookkeeping. */ private fun addWorktree(base: Path, branch: String, existing: Boolean, baseRef: String?): CreateWorktreeResultDto { - val dir = base.resolve(".kilo").resolve("worktrees").resolve(branch.replace('/', '-')) + val root = mainWorktree(base) + val storage = root.resolve(".kilo").resolve("worktrees").normalize() + val parts = branch.split('/') + if (parts.any { it.isBlank() || it == "." || it == ".." }) return CreateWorktreeResultDto(error = "Invalid branch name") + val dir = storage.resolve(branch.replace('/', '-')).normalize() + if (dir.parent != storage) return CreateWorktreeResultDto(error = "Invalid branch name") Files.createDirectories(dir.parent) val args = buildList { addAll(listOf("worktree", "add")) @@ -327,7 +341,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } } LOG.info("worktree add requested: branch=$branch existing=$existing base=${baseRef ?: "(current)"} dir=$dir") - val res = runGit(base, *args.toTypedArray()) + val res = add(base, args) if (!res.ok) { LOG.warn("worktree add failed: branch=$branch exit=${res.exit} stderr=${res.stderr.trim()}") return CreateWorktreeResultDto(error = res.stderr.ifBlank { "git worktree add failed" }) @@ -347,15 +361,42 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val base = Path.of(directory).normalize() LOG.info("worktree remove requested: path=$path branch=${branch ?: "(none)"} force=$force base=$base") val list = runGit(base, "worktree", "list", "--porcelain") - val store = (if (list.ok) worktreeNameStore(managedWorktrees(parseWorktreeList(list.stdout))) else null) - ?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE) + if (!list.ok) return@withContext RemoveWorktreeResultDto(error = list.stderr.ifBlank { "git worktree list failed" }) + val all = parseWorktreeList(list.stdout) + val items = managedWorktrees(all) + val main = all.firstOrNull { it.main } + val storage = main?.let { Path.of(it.path).normalize().resolve(".kilo").resolve("worktrees").normalize() } + val target = all.firstOrNull { + val item = Path.of(it.path).normalize() + !it.main && samePath(it.path, path) && item.parent == storage + } + ?: return@withContext RemoveWorktreeResultDto(error = "Refusing to remove unmanaged worktree: $path") + // Compare canonical (symlink-resolved) paths: on macOS the temp/repo root is a symlink + // (/var -> /private/var), so a raw startsWith against normalized porcelain paths would miss + // a live child and let `git worktree remove --force` delete it recursively. + val root = realPath(path) + val nested = all.filter { + !it.prunable && Files.isDirectory(Path.of(it.path)) && !samePath(it.path, path) && realPath(it.path).startsWith(root) + } + if (nested.isNotEmpty()) { + val names = nested.joinToString("\n") { it.path } + return@withContext RemoveWorktreeResultDto(error = "Delete nested worktrees first:\n$names") + } + val store = worktreeNameStore(items) ?: base.resolve(".kilo").resolve(WORKTREE_NAMES_FILE) // Force means the user accepted removing a locked worktree; unlock first so the plain // remove succeeds. Unlock fails harmlessly when the tree isn't actually locked. if (force) { - val unlock = runGit(base, "worktree", "unlock", path) + val unlock = runGit(base, "worktree", "unlock", target.path) if (!unlock.ok) LOG.info("worktree unlock skipped: path=$path exit=${unlock.exit} stderr=${unlock.stderr.trim()}") } - val res = runGit(base, "worktree", "remove", "--force", path) + // Only skip git's own removal when the checkout directory is actually gone. Git also flags a + // worktree prunable when its admin metadata is stale while the files remain; those must still + // be deleted so a later create of the same slug is not blocked by leftovers. + val res = if (!Files.isDirectory(Path.of(target.path))) { + GitResult(0, "", "") + } else { + runGit(base, "worktree", "remove", "--force", target.path) + } if (!res.ok) { val locked = res.stderr.contains("locked working tree", ignoreCase = true) LOG.warn("worktree remove failed: path=$path locked=$locked exit=${res.exit} stderr=${res.stderr.trim()}") @@ -370,7 +411,11 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { if (!del.ok) LOG.warn("worktree branch delete failed: branch=$it exit=${del.exit} stderr=${del.stderr.trim()}") } LOG.info("worktree removed: path=$path branch=${branch ?: "(none)"}") - removeWorktreeState(store, path) + removeWorktreeState(store, target.path) + val prune = runGit(base, "worktree", "prune") + if (!prune.ok) LOG.warn("worktree prune failed: exit=${prune.exit} stderr=${prune.stderr.trim()}") + runCatching { service().workspaces.remove(target.path) } + .onFailure { err -> LOG.info("workspace cache eviction skipped: path=${target.path} message=${err.message}") } RemoveWorktreeResultDto(ok = true) } @@ -471,6 +516,20 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } } + private fun add(base: Path, args: List): GitResult { + val first = runGit(base, *args.toTypedArray()) + if (first.ok || !stale(first.stderr)) return first + val prune = runGit(base, "worktree", "prune") + if (!prune.ok) LOG.warn("worktree prune before retry failed: exit=${prune.exit} stderr=${prune.stderr.trim()}") + return runGit(base, *args.toTypedArray()) + } + + private fun stale(text: String): Boolean { + return text.contains("is already checked out", ignoreCase = true) || + text.contains("already used by worktree", ignoreCase = true) || + text.contains("missing but already registered worktree", ignoreCase = true) + } + private suspend fun parallel(items: List, block: suspend (T) -> R): List = coroutineScope { val sem = Semaphore(4) items.map { item -> async { sem.withPermit { block(item) } } }.map { it.await() } @@ -637,16 +696,18 @@ internal fun parseWorktreeList(raw: String): List { var branch = "(detached)" var locked = false var lockReason: String? = null + var prunable = false var first = true fun flush() { val p = path ?: return val name = p.substringAfterLast('/').ifBlank { p } - out.add(WorktreeDto(p, name, branch, p, main = first, locked = locked, lockReason = lockReason)) + out.add(WorktreeDto(p, name, branch, p, main = first, locked = locked, lockReason = lockReason, prunable = prunable)) first = false path = null branch = "(detached)" locked = false lockReason = null + prunable = false } for (line in raw.lines()) { when { @@ -656,6 +717,7 @@ internal fun parseWorktreeList(raw: String): List { locked = true lockReason = line.removePrefix("locked").trim().takeIf { it.isNotEmpty() } } + line == "prunable" || line.startsWith("prunable ") -> prunable = true line.isBlank() -> flush() } } @@ -669,8 +731,9 @@ internal fun managedWorktrees(items: List): List { val storage = root.resolve(".kilo").resolve("worktrees").normalize() return items.filter { item -> if (item.main) return@filter true + if (item.prunable) return@filter false val path = Path.of(item.path).normalize() - path.startsWith(storage) && path != storage + path.parent == storage } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt index cd700fc209..c35d1e17a0 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspace.kt @@ -28,6 +28,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import java.nio.file.Files +import java.nio.file.Path import java.util.concurrent.atomic.AtomicReference /** @@ -77,6 +79,11 @@ class KiloBackendWorkspace( _state.value = KiloWorkspaceState.Unsupported(reason) return@launch } + if (!Files.isDirectory(Path.of(directory))) { + log.info("Workspace directory is missing: $directory") + _state.value = KiloWorkspaceState.Missing(directory) + return@launch + } val progress = AtomicReference(KiloWorkspaceLoadProgress()) _state.value = KiloWorkspaceState.Loading(progress.get()) diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt index 93ec913349..d7f5da9b11 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceManager.kt @@ -8,6 +8,8 @@ import ai.kilocode.jetbrains.api.client.DefaultApi import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.SharedFlow import okhttp3.OkHttpClient +import java.nio.file.Files +import java.nio.file.Path import java.util.concurrent.ConcurrentHashMap /** @@ -74,8 +76,26 @@ class KiloBackendWorkspaceManager( } } - /** Remove a workspace (e.g. when a worktree is deleted). */ + /** + * Remove any cached workspace whose directory resolves to the same real path as [dir]. + * Callers pass git porcelain paths, while workspaces are often keyed by the resolved + * (`toRealPath`) path or the IDE base path, so an exact-string match would miss the entry + * and leave a deleted worktree cached as Ready — still producing backend errors. + */ fun remove(dir: String) { - workspaces.remove(dir)?.stop() + val target = canonical(dir) + workspaces.keys.filter { canonical(it) == target }.forEach { key -> + log.info("Removing cached workspace for $key") + workspaces.remove(key)?.stop() + } + } + + /** Resolve symlinks on the parent so `/var/...` and `/private/var/...` compare equal even after the leaf is deleted. */ + private fun canonical(dir: String): String { + val path = Path.of(dir).normalize() + val parent = path.parent ?: return path.toString() + val name = path.fileName ?: return path.toString() + val root = runCatching { if (Files.exists(parent)) parent.toRealPath() else parent }.getOrDefault(parent) + return root.resolve(name).toString() } } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt index c9a543b1ef..239ba178c8 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/workspace/KiloWorkspaceState.kt @@ -20,6 +20,7 @@ sealed class KiloWorkspaceState { val skills: List, ) : KiloWorkspaceState() data class Unsupported(val reason: String) : KiloWorkspaceState() + data class Missing(val path: String) : KiloWorkspaceState() data class Error(val message: String, val errors: List = emptyList()) : KiloWorkspaceState() } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index 58a5e6ed52..dbaa975e97 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -80,6 +80,26 @@ class KiloWorktreeRpcApiImplTest { assertEquals("Air Agent worktree", list[1].lockReason) } + @Test + fun `parseWorktreeList captures the prunable flag`() { + val raw = """ + worktree /repo + HEAD 1111111111111111111111111111111111111111 + branch refs/heads/main + + worktree /repo/.kilo/worktrees/hyper-video + HEAD 2222222222222222222222222222222222222222 + branch refs/heads/hyper-video + prunable gitdir file points to non-existent location + + """.trimIndent() + + val list = parseWorktreeList(raw) + + assertFalse(list[0].prunable, "main tree is not prunable") + assertTrue(list[1].prunable, "second tree should be flagged prunable") + } + @Test fun `managedWorktrees keeps only agent manager worktrees`() { val raw = """ @@ -124,6 +144,33 @@ class KiloWorktreeRpcApiImplTest { assertEquals(listOf("/repo"), list.map { it.path }) } + @Test + fun `managedWorktrees rejects nested and prunable worktrees`() { + val raw = """ + worktree /repo + HEAD 1111111111111111111111111111111111111111 + branch refs/heads/main + + worktree /repo/.kilo/worktrees/feature-x + HEAD 2222222222222222222222222222222222222222 + branch refs/heads/feature/x + + worktree /repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested + HEAD 3333333333333333333333333333333333333333 + branch refs/heads/nested + + worktree /repo/.kilo/worktrees/dead + HEAD 4444444444444444444444444444444444444444 + branch refs/heads/dead + prunable gitdir file points to non-existent location + + """.trimIndent() + + val list = managedWorktrees(parseWorktreeList(raw)) + + assertEquals(listOf("/repo", "/repo/.kilo/worktrees/feature-x"), list.map { it.path }) + } + @Test fun `classifyGhError detects missing and unauthorized gh states`() { assertEquals(GhAvailability.UNAUTH, classifyGhError("You are not logged into any GitHub hosts. Run gh auth login to authenticate.")) @@ -232,6 +279,44 @@ class KiloWorktreeRpcApiImplTest { assertFalse(after.any { it.branch == "feature/x" }, "removed worktree should be gone") } + @Test + fun `create from inside linked worktree uses main worktree storage`() = runBlocking { + initRepo() + val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + + val result = api.create(first.path, CreateWorktreeRequestDto("feature/y")) + val created = assertNotNull(result.worktree, "create failed: ${result.error}") + + assertEquals(repo.resolve(".kilo").resolve("worktrees").resolve("feature-y").toRealPath().toString(), created.path) + assertFalse( + Files.exists(Path.of(first.path).resolve(".kilo").resolve("worktrees").resolve("feature-y")), + "creating from a linked worktree must not nest storage inside it", + ) + } + + @Test + fun `create rejects a branch slug that escapes storage`() = runBlocking { + initRepo() + + val result = api.create(repo.toString(), CreateWorktreeRequestDto("../escape")) + + assertNull(result.worktree) + assertEquals("Invalid branch name", result.error) + assertFalse(Files.exists(repo.resolve(".kilo").resolve("escape"))) + } + + @Test + fun `create succeeds after pruning a deleted checked out branch`() = runBlocking { + initRepo() + val first = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + delete(Path.of(first.path)) + + val result = api.create(repo.toString(), CreateWorktreeRequestDto("feature/x", existingBranch = true)) + + val created = assertNotNull(result.worktree, "create should prune stale metadata and retry: ${result.error}") + assertTrue(Files.isDirectory(Path.of(created.path))) + } + @Test fun `create records newest worktree first so reload keeps it on top`() = runBlocking { initRepo() @@ -362,6 +447,84 @@ class KiloWorktreeRpcApiImplTest { assertTrue(result.error != null, "failure should carry an error message") } + @Test + fun `remove refuses a path outside managed storage`() = runBlocking { + initRepo() + val outside = repo.resolve("outside") + Files.createDirectories(outside) + + val result = api.remove(repo.toString(), outside.toString(), null) + + assertFalse(result.ok) + assertTrue(result.error?.contains("Refusing") == true) + assertTrue(Files.isDirectory(outside), "unmanaged directory must not be touched") + } + + @Test + fun `remove refuses a worktree containing a live nested worktree`() = runBlocking { + initRepo() + val parent = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val nested = assertNotNull(api.create(parent.path, CreateWorktreeRequestDto("feature/y")).worktree) + val old = Path.of(parent.path).resolve(".kilo").resolve("worktrees").resolve("nested") + Files.createDirectories(old.parent) + git(parent.path, "worktree", "move", nested.path, old.toString()) + + val result = api.remove(repo.toString(), parent.path, parent.branch) + + assertFalse(result.ok) + assertTrue(result.error?.contains(old.toString()) == true, "error should name the blocker: ${result.error}") + assertTrue(Files.isDirectory(Path.of(parent.path))) + assertTrue(Files.isDirectory(old)) + } + + @Test + fun `remove succeeds when nested worktree directory is already gone`() = runBlocking { + initRepo() + val parent = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("feature/x")).worktree) + val nested = assertNotNull(api.create(parent.path, CreateWorktreeRequestDto("feature/y")).worktree) + val old = Path.of(parent.path).resolve(".kilo").resolve("worktrees").resolve("nested") + Files.createDirectories(old.parent) + git(parent.path, "worktree", "move", nested.path, old.toString()) + delete(old) + + val result = api.remove(repo.toString(), parent.path, parent.branch) + + assertTrue(result.ok, "remove should succeed despite dead nested metadata: ${result.error}") + assertFalse(Files.exists(Path.of(parent.path))) + } + + @Test + fun `remove prunes dangling metadata on success`() = runBlocking { + initRepo() + val dead = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("dead")).worktree) + delete(Path.of(dead.path)) + val live = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("live")).worktree) + + val result = api.remove(repo.toString(), live.path, live.branch) + + assertTrue(result.ok, "remove should succeed: ${result.error}") + val out = output(repo, "worktree", "list", "--porcelain") + assertFalse(out.contains(dead.path), "remove should prune unrelated dangling worktree metadata") + } + + @Test + fun `list drops missing worktrees and reconciles stored state`() = runBlocking { + initRepo() + val live = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("live")).worktree) + val dead = assertNotNull(api.create(repo.toString(), CreateWorktreeRequestDto("dead")).worktree) + assertNotNull(api.rename(repo.toString(), live.path, "Live").worktree) + assertNotNull(api.rename(repo.toString(), dead.path, "Dead").worktree) + delete(Path.of(dead.path)) + + val listed = api.list(repo.toString()).worktrees + + assertTrue(listed.any { it.path == live.path }) + assertFalse(listed.any { it.path == dead.path }) + val state = readWorktreeState(repo.resolve(".kilo").resolve("jetbrains.json")) + assertEquals(mapOf(live.path to "Live"), state.names) + assertEquals(listOf(live.path), state.worktreeOrder) + } + @Test fun `listBranches returns local branches and the current one`() = runBlocking { initRepo() @@ -595,6 +758,17 @@ class KiloWorktreeRpcApiImplTest { assertEquals(0, out.exitCode, "git ${args.joinToString(" ")} failed: ${out.stderr}") } + private fun git(dir: String, vararg args: String) { + git(Path.of(dir), *args) + } + + private fun output(dir: Path, vararg args: String): String { + val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile()) + val out = CapturingProcessHandler(cmd).runProcess(30_000) + assertEquals(0, out.exitCode, "git ${args.joinToString(" ")} failed: ${out.stderr}") + return out.stdout + } + private fun delete(dir: Path) { if (!Files.exists(dir)) return Files.walk(dir).use { paths -> diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt index b3358b033b..93227e5254 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/workspace/KiloBackendWorkspaceTest.kt @@ -25,6 +25,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeoutOrNull +import java.nio.file.Files +import java.nio.file.Path import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals @@ -39,6 +41,8 @@ class KiloBackendWorkspaceTest { private val log = TestLog() private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val apps = mutableListOf() + private val root: Path = Files.createTempDirectory("kilo-backend-workspace") + private val project: Path = Files.createDirectories(root.resolve("project")) @AfterTest fun tearDown() { @@ -48,6 +52,7 @@ class KiloBackendWorkspaceTest { scope.cancel() mock.close() withTimeout(10_000) { scope.coroutineContext[Job]?.join() } + delete(root) } } @@ -69,9 +74,11 @@ class KiloBackendWorkspaceTest { private suspend fun ready(app: KiloBackendAppService): KiloBackendWorkspace { connect(app) - return app.workspaces.get("/test/project") + return app.workspaces.get(project.toString()) } + private fun dir(name: String): String = Files.createDirectories(root.resolve(name)).toString() + private suspend fun loaded(ws: KiloBackendWorkspace) { withTimeout(15_000) { ws.state.first { it is KiloWorkspaceState.Ready } @@ -112,8 +119,9 @@ class KiloBackendWorkspaceTest { val app = setup() connect(app) - val ws1 = app.workspaces.get("/test") - val ws2 = app.workspaces.get("/test") + val path = dir("same") + val ws1 = app.workspaces.get(path) + val ws2 = app.workspaces.get(path) // LLM note: get() starts background loading; settle it so teardown is not racing active HTTP calls in CI. loaded(ws1) assertTrue(ws1 === ws2) @@ -124,14 +132,16 @@ class KiloBackendWorkspaceTest { val app = setup() connect(app) - val ws1 = app.workspaces.get("/project-a") - val ws2 = app.workspaces.get("/project-b") + val first = dir("project-a") + val second = dir("project-b") + val ws1 = app.workspaces.get(first) + val ws2 = app.workspaces.get(second) // LLM note: get() starts background loading; settle both loads before the scope-cancelling teardown. loaded(ws1) loaded(ws2) assertTrue(ws1 !== ws2) - assertEquals("/project-a", ws1.directory) - assertEquals("/project-b", ws2.directory) + assertEquals(first, ws1.directory) + assertEquals(second, ws2.directory) } @Test @@ -150,7 +160,7 @@ class KiloBackendWorkspaceTest { // Manager should throw since app is disconnected assertFailsWith { - app.workspaces.get("/test/project") + app.workspaces.get(project.toString()) } } @@ -187,7 +197,7 @@ class KiloBackendWorkspaceTest { connect(app) // get() creates workspace and starts loading immediately - val ws = app.workspaces.get("/test") + val ws = app.workspaces.get(dir("plain")) withTimeout(15_000) { ws.state.first { it is KiloWorkspaceState.Ready } @@ -211,7 +221,7 @@ class KiloBackendWorkspaceTest { val err = ws.state.value as KiloWorkspaceState.Error assertTrue(err.message.contains("providers")) assertTrue(err.errors.any { it.resource == "providers" }) - assertTrue(log.messages.any { it.contains("Workspace error [/test/project]: Failed to load:") && it.contains("providers") }) + assertTrue(log.messages.any { it.contains("Workspace error [${project}]: Failed to load:") && it.contains("providers") }) } @Test @@ -298,6 +308,26 @@ class KiloBackendWorkspaceTest { assertEquals(0, mock.requestCount("/agent")) } + @Test + fun `missing directory transitions to Missing without fetching workspace data`() = runBlocking { + val app = setup() + connect(app) + mock.resetCounts() + val dir = Files.createTempDirectory("kilo-missing-workspace") + Files.delete(dir) + val ws = app.workspaces.get(dir.toString()) + + val state = withTimeout(15_000) { + ws.state.first { it is KiloWorkspaceState.Missing } + } as KiloWorkspaceState.Missing + + assertEquals(dir.toString(), state.path) + assertEquals(0, mock.requestCount("/agent")) + assertEquals(0, mock.requestCount("/provider")) + assertEquals(0, mock.requestCount("/command")) + assertEquals(0, mock.requestCount("/skill")) + } + @Test fun `commands failure transitions to Error`() = runBlocking { mock.commandsStatus = 500 @@ -446,7 +476,7 @@ class KiloBackendWorkspaceTest { @Test fun `workspace exposes sessions for its directory`() = runBlocking { mock.sessions = """[ - {"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":1,"updated":1}} + {"id":"ses_1","slug":"s","projectID":"p","directory":"${project}","title":"T","version":"1","time":{"created":1,"updated":1}} ]""" val app = setup() val ws = ready(app) @@ -460,7 +490,7 @@ class KiloBackendWorkspaceTest { @Test fun `workspace maps missing session timestamps to zero`() = runBlocking { mock.sessions = """[ - {"id":"ses_1","slug":"s","projectID":"p","directory":"/test/project","title":"T","version":"1","time":{"created":null,"updated":null}} + {"id":"ses_1","slug":"s","projectID":"p","directory":"${project}","title":"T","version":"1","time":{"created":null,"updated":null}} ]""" val app = setup() val ws = ready(app) @@ -473,14 +503,14 @@ class KiloBackendWorkspaceTest { @Test fun `workspace creates session in its directory`() = runBlocking { - mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"/test/project","title":"New","version":"1","time":{"created":1,"updated":1}}""" + mock.sessionCreate = """{"id":"ses_new","slug":"n","projectID":"p","directory":"${project}","title":"New","version":"1","time":{"created":1,"updated":1}}""" val app = setup() val ws = ready(app) loaded(ws) val session = ws.createSession() assertEquals("ses_new", session.id) - assertEquals("/test/project", session.directory) + assertEquals(project.toString(), session.directory) } // ------ Concurrency tests ------ @@ -498,7 +528,7 @@ class KiloBackendWorkspaceTest { try { val results = (1..10).map { async(Dispatchers.Default) { - manager.get("/same/dir") + manager.get(dir("same-concurrent")) } }.awaitAll() @@ -572,7 +602,7 @@ class KiloBackendWorkspaceTest { ) withTimeout(15_000) { reload.await() } - val ws = app.workspaces.get("/test/project") + val ws = app.workspaces.get(project.toString()) assertTrue(ws !== initial) val state = withTimeout(15_000) { ws.state.first { @@ -646,4 +676,11 @@ class KiloBackendWorkspaceTest { {"name":"test-skill","description":"A test skill","location":"file:///test","content":"# Test"} ]""".trimIndent() } + + private fun delete(dir: Path) { + if (!Files.exists(dir)) return + Files.walk(dir).use { paths -> + paths.sorted(Comparator.reverseOrder()).forEach { Files.deleteIfExists(it) } + } + } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 6a7737d022..7d42ce9455 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -2414,6 +2414,14 @@ class SessionController( ) } + if (workspace.status == KiloWorkspaceStatusDto.MISSING) { + return SessionControllerEvent.ConnectionChanged.ShowError( + KiloBundle.message("session.connection.missing"), + KiloBundle.message("session.connection.missing.detail", workspace.error ?: directory), + "workspace", + ) + } + if (app.status == KiloAppStatusDto.READY && workspace.status == KiloWorkspaceStatusDto.READY && app.warnings.isNotEmpty()) { return SessionControllerEvent.ConnectionChanged.ShowWarning( summary(app.warnings.size), diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index aeff0f994a..a12c21fb71 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -13,6 +13,8 @@ session.connection.downloading.version=Downloading Kilo Core v{0} ({1})… {2}% session.connection.error.app=Connection failed session.connection.error.workspace=Workspace loading failed session.connection.error.unknown=Unknown error +session.connection.missing=Workspace folder missing +session.connection.missing.detail=Kilo can''t load this session because the workspace folder no longer exists: {0} session.connection.retry=Try again session.connection.unsupported=Workspace not supported session.connection.unsupported.devcontainer=Kilo runs on your host machine, so it can't reach the files inside this Dev Container. diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 81ad5a8296..9c47e660c4 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -24,6 +24,7 @@ import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers import ai.kilocode.client.testing.fire +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.ui.list.ActiveListBadge import ai.kilocode.client.ui.list.ActiveListItem import ai.kilocode.client.ui.list.ActiveListMetrics @@ -73,6 +74,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + installBrowser() coroutines = TestCoroutines() rpc = FakeWorktreeRpcApi() service = KiloWorktreeService(coroutines.scope, rpc) diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index 1c8de530f0..d798260f49 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -214,6 +214,25 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertTrue(failures.first().locked) } + fun `test refused nested remove keeps the row and surfaces the error`() { + val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x") + rpc.listed += item + rpc.removeResult = { _, _, _ -> RemoveWorktreeResultDto(error = "Delete nested worktrees first:\n/repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested") } + val controller = controller() + controller.reload() + flush() + + val failures = mutableListOf() + controller.remove(controller.model.getElementAt(0), onFailure = { failures.add(it) }) + flush() + + assertEquals(1, controller.model.size) + assertEquals("feature/x", controller.model.getElementAt(0).branch) + assertNull(controller.progress(item.id)) + assertEquals(listOf(false), rpc.removeForces.toList()) + assertEquals("Delete nested worktrees first:\n/repo/.kilo/worktrees/feature-x/.kilo/worktrees/nested", failures.single().error) + } + fun `test force remove passes the force flag and drops the row on success`() { val item = WorktreeDto("/repo/.kilo/worktrees/feature-x", "feature-x", "feature/x", "/repo/.kilo/worktrees/feature-x", locked = true) rpc.listed += item diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt index 65282cabf0..b878a7ba3e 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhBannerTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability import com.intellij.openapi.application.ApplicationManager @@ -22,6 +23,7 @@ class GhBannerTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + installBrowser() coroutines = TestCoroutines() rpc = FakeWorktreeRpcApi() timers = TestUiTimers() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt index c5f185a231..309614052c 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/GhStatusCoordinatorTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.testing.FakeWorktreeRpcApi import ai.kilocode.client.testing.TestCoroutines import ai.kilocode.client.testing.pumpEdt import ai.kilocode.client.testing.TestUiTimers +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.util.edtWait import ai.kilocode.rpc.dto.GhAvailability import com.intellij.openapi.application.ApplicationManager @@ -21,6 +22,7 @@ class GhStatusCoordinatorTest : BasePlatformTestCase() { override fun setUp() { super.setUp() + installBrowser() coroutines = TestCoroutines() rpc = FakeWorktreeRpcApi() timers = TestUiTimers() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt index e64333062a..8d930b6077 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/ConnectionDelayTest.kt @@ -193,6 +193,30 @@ class ConnectionDelayTest : SessionControllerTestBase() { ) } + fun `test missing workspace status shows missing folder message`() { + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) + projectRpc.state.value = workspaceReady() + val m = controller(displayMs = 50) + val events = collect(m) + flush() + events.clear() + + projectRpc.state.value = KiloWorkspaceStateDto( + status = KiloWorkspaceStatusDto.MISSING, + error = "/repo/.kilo/worktrees/deleted", + ) + pause(80) + + val event = events.filterIsInstance().single() + assertEquals("Workspace folder missing", event.summary) + assertEquals( + "Kilo can't load this session because the workspace folder no longer exists: /repo/.kilo/worktrees/deleted", + event.detail, + ) + assertEquals("workspace", event.source) + assertFalse(event.detail.orEmpty().contains("JetBrains Gateway")) + } + fun `test ready hides visible delayed connection banner immediately`() { appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY) projectRpc.state.value = workspaceReady() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt index 69911d9496..aed6ce37bc 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/settings/providers/ProvidersSettingsUiTest.kt @@ -4,6 +4,7 @@ import ai.kilocode.client.util.edtWait import ai.kilocode.client.app.KiloProviderService import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.testing.FakeProviderRpcApi +import ai.kilocode.client.testing.installBrowser import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ActiveListActionCell import ai.kilocode.client.ui.list.ActiveListConfig @@ -985,6 +986,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { fun `test provider oauth auto response shows device auth panel`() { val callback = CompletableDeferred() + val browser = installBrowser() val rpc = installProvider( ProviderSettingsDto( providers = listOf(provider("openai", "OpenAI")), @@ -1016,6 +1018,7 @@ class ProvidersSettingsUiTest : BasePlatformTestCase() { assertTrue(t, t.contains("Open Browser")) assertTrue(t, t.contains("Cancel")) assertEquals("https://auth.openai.com/device", fieldsByName(panel, "kilo.provider.oauth.url").single().text) + assertEquals(listOf("https://auth.openai.com/device"), browser.urls) val qr = components(panel).filterIsInstance().single { it.name == "kilo.provider.oauth.qr" } assertNotNull(qr.icon) } diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeBrowserLauncher.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeBrowserLauncher.kt new file mode 100644 index 0000000000..8870f98427 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/testing/FakeBrowserLauncher.kt @@ -0,0 +1,37 @@ +package ai.kilocode.client.testing + +import com.intellij.ide.browsers.BrowserLauncher +import com.intellij.ide.browsers.WebBrowser +import com.intellij.openapi.application.ApplicationManager +import com.intellij.openapi.project.Project +import com.intellij.testFramework.fixtures.BasePlatformTestCase +import com.intellij.testFramework.replaceService +import java.nio.file.Path + +class FakeBrowserLauncher : BrowserLauncher() { + val urls = mutableListOf() + val files = mutableListOf() + + override fun open(url: String) { + urls.add(url) + } + + @Suppress("DEPRECATION") + override fun browse(file: java.io.File) { + files.add(file.toPath()) + } + + override fun browse(file: Path) { + files.add(file) + } + + override fun browse(url: String, browser: WebBrowser?, project: Project?) { + urls.add(url) + } +} + +fun BasePlatformTestCase.installBrowser(): FakeBrowserLauncher { + val fake = FakeBrowserLauncher() + ApplicationManager.getApplication().replaceService(BrowserLauncher::class.java, fake, testRootDisposable) + return fake +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt index 487951a112..aaf238e2f6 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/KiloWorkspaceStateDto.kt @@ -8,6 +8,7 @@ enum class KiloWorkspaceStatusDto { LOADING, READY, UNSUPPORTED, + MISSING, ERROR, } diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt index b3a98d6d58..54ee736293 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/WorktreeDto.kt @@ -11,6 +11,7 @@ data class WorktreeDto( val main: Boolean = false, // primary working tree — not deletable val locked: Boolean = false, // git worktree lock — blocks a plain remove val lockReason: String? = null, // optional reason recorded when the tree was locked + val prunable: Boolean = false, // git marks metadata stale because the directory is gone ) @Serializable diff --git a/packages/kilo-ui/src/components/icon.tsx b/packages/kilo-ui/src/components/icon.tsx index f58623ade7..5a263ec877 100644 --- a/packages/kilo-ui/src/components/icon.tsx +++ b/packages/kilo-ui/src/components/icon.tsx @@ -70,6 +70,10 @@ const icons: Record = { viewBox: "0 0 24 24", path: ``, }, + local: { + viewBox: "0 0 20 20", + path: ``, + }, } type Name = keyof typeof icons diff --git a/packages/kilo-ui/src/components/message-part.css b/packages/kilo-ui/src/components/message-part.css index 3d9d9d6312..1edeff46e1 100644 --- a/packages/kilo-ui/src/components/message-part.css +++ b/packages/kilo-ui/src/components/message-part.css @@ -119,7 +119,6 @@ color: var(--text-base); } } - } [data-slot="message-part-title-filename"] { @@ -155,7 +154,6 @@ direction: rtl; text-align: left; } - } /* Task tool child-session tool list (v1.0.25 style) */ @@ -828,6 +826,30 @@ html[data-theme="kilo-vscode"] [data-component="reasoning-part"] { } } +/* The shared collapsible turns overflow visible when expanded, which let the + reasoning markdown spill below its box for a frame while the height changed. + Clip it at the box instead, and give the auto-collapse at the end of a + reasoning block a real animation, so the transcript slides instead of + jumping when a tall block closes. */ +[data-component="reasoning-part"] [data-slot="collapsible-content"][data-expanded] { + overflow: clip; +} + +[data-component="reasoning-part"] [data-slot="collapsible-content"][data-closed] { + overflow: clip; + animation: kilo-reasoning-close 180ms ease-out; +} + +@keyframes kilo-reasoning-close { + from { + height: var(--kb-collapsible-content-height); + } + + to { + height: 0; + } +} + @media (prefers-reduced-motion: reduce) { [data-component="reasoning-part"] [data-slot="collapsible-content"][data-expanded], [data-component="reasoning-part"] [data-slot="collapsible-content"][data-closed] { diff --git a/packages/kilo-ui/src/components/message-part.tsx b/packages/kilo-ui/src/components/message-part.tsx index 6b01b15649..61cc5dbd77 100644 --- a/packages/kilo-ui/src/components/message-part.tsx +++ b/packages/kilo-ui/src/components/message-part.tsx @@ -163,6 +163,7 @@ export interface MessagePartProps { working?: boolean feedback?: MessageFeedbackControls throughput?: JSX.Element + readonly?: boolean } export type PartComponent = Component @@ -1044,6 +1045,7 @@ export function Part(props: MessagePartProps) { working={props.working} feedback={props.feedback} throughput={props.throughput} + readonly={props.readonly} /> ) @@ -1068,6 +1070,7 @@ export interface ToolProps { locked?: boolean animate?: boolean reveal?: boolean + readonly?: boolean } export type ToolComponent = Component @@ -1267,6 +1270,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { forceOpen={props.forceOpen} animate reveal={props.animate} + readonly={props.readonly} /> ) } @@ -1344,6 +1348,7 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { forceOpenFile={props.forceOpenFile} animate reveal={props.animate} + readonly={props.readonly} /> diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx index e793f16693..1033c5ad4f 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx @@ -12,6 +12,7 @@ mock.module("@solid-primitives/resize-observer", () => ({ const originalElement = globalThis.Element const originalNode = globalThis.Node const originalWheelEvent = globalThis.WheelEvent +const originalMutationObserver = globalThis.MutationObserver type Listener = { callback: (event: Event) => void @@ -113,9 +114,25 @@ class FakeKeyboardEvent { ) {} } +const mutators: (() => void)[] = [] + +class FakeMutationObserver { + constructor(readonly callback: () => void) { + mutators.push(callback) + } + + observe() {} + + disconnect() { + const at = mutators.indexOf(this.callback) + if (at >= 0) mutators.splice(at, 1) + } +} + globalThis.Element = FakeElement as unknown as typeof Element globalThis.Node = FakeElement as unknown as typeof Node globalThis.WheelEvent = FakeWheelEvent as unknown as typeof WheelEvent +globalThis.MutationObserver = FakeMutationObserver as unknown as typeof MutationObserver const { createAutoScroll } = await import("./create-auto-scroll") @@ -133,6 +150,8 @@ function setup(options?: { doc?: FakeDocument; interacted?: () => void; working? root.scroll.scrollRef(el as unknown as HTMLElement) root.scroll.contentRef(new FakeElement() as unknown as HTMLElement) + const mutate = () => mutators.forEach((callback) => callback()) + const resize = (index?: number) => { if (index !== undefined) { observers[index]?.() @@ -141,11 +160,12 @@ function setup(options?: { doc?: FakeDocument; interacted?: () => void; working? observers.forEach((callback) => callback()) } - return { ...root, doc, el, resize } + return { ...root, doc, el, resize, mutate } } beforeEach(() => { observers.length = 0 + mutators.length = 0 }) afterAll(() => { @@ -155,6 +175,8 @@ afterAll(() => { else Reflect.deleteProperty(globalThis, "Node") if (originalWheelEvent) globalThis.WheelEvent = originalWheelEvent else Reflect.deleteProperty(globalThis, "WheelEvent") + if (originalMutationObserver) globalThis.MutationObserver = originalMutationObserver + else Reflect.deleteProperty(globalThis, "MutationObserver") }) describe("createAutoScroll non-scrollable layouts", () => { @@ -289,16 +311,59 @@ describe("createAutoScroll non-scrollable layouts", () => { ctx.el.scrollTop = 800 ctx.scroll.handleScroll() + // A tool card that shrinks and recovers inside one frame makes the browser + // clamp the pin away without changing the final content size, so no resize + // entry follows and the pin has to be restored from the scroll event. ctx.el.scrollTop = 760 ctx.scroll.handleScroll() expect(ctx.scroll.userScrolled()).toBe(false) + expect(ctx.el.scrollTop).toBe(1000) + ctx.dispose() + }) - ctx.el.scrollHeight = 1100 - ctx.resize(0) + test("pins streamed content when it is added, before any resize entry", () => { + const ctx = setup({ working: true }) + ctx.el.scrollHeight = 1000 + ctx.el.clientHeight = 200 + ctx.el.scrollTop = 800 + + // The resize entry for this growth only arrives after the frame has laid out + // and painted, so the mutation itself has to pin the view. + ctx.el.scrollHeight = 1080 + ctx.mutate() expect(ctx.scroll.userScrolled()).toBe(false) - expect(ctx.el.scrollTop).toBe(1100) + expect(ctx.el.scrollTop).toBe(1080) + ctx.dispose() + }) + + test("ignores content mutations while the user reads earlier output", () => { + const ctx = setup({ working: true }) + ctx.el.scrollHeight = 1000 + ctx.el.clientHeight = 200 + ctx.el.scrollTop = 400 + ctx.scroll.pause() + + ctx.el.scrollHeight = 1080 + ctx.mutate() + + expect(ctx.el.scrollTop).toBe(400) + ctx.dispose() + }) + + test("leaves an idle transcript where a layout clamp put it", () => { + const ctx = setup() + ctx.el.scrollHeight = 1000 + ctx.el.clientHeight = 200 + ctx.el.scrollTop = 800 + ctx.scroll.handleScroll() + + ctx.el.scrollTop = 704 + ctx.scroll.handleScroll() + + expect(ctx.scroll.userScrolled()).toBe(false) + expect(ctx.el.scrollTop).toBe(704) ctx.dispose() }) diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx index 3e8d83dd6c..0a79023e0b 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -25,6 +25,7 @@ export function createAutoScroll(options: AutoScrollOptions) { let settling = false let settleTimer: ReturnType | undefined let cleanup: (() => void) | undefined + let watcher: MutationObserver | undefined const [store, setStore] = createStore({ contentRef: undefined as HTMLElement | undefined, @@ -111,7 +112,15 @@ export function createAutoScroll(options: AutoScrollOptions) { // Virtualizer and layout corrections can move the viewport without // changing content height. Only an input event should pause auto-follow. - if (!store.userScrolled && !input && !userActivity.isRecent()) return + if (!store.userScrolled && !input && !userActivity.isRecent()) { + // A tool card that swaps views shrinks the transcript and recovers inside + // the same frame. The shrink makes the browser clamp the pin away, and + // because the final content size is unchanged no resize entry follows, so + // the correction has to happen here or the transcript stays parked below + // its bottom until the next content update. + if (active()) bottom() + return + } stop() } @@ -135,6 +144,18 @@ export function createAutoScroll(options: AutoScrollOptions) { follow() } + // Content mutations are pinned while they are still queued, before the frame + // lays out and paints. A ResizeObserver entry arrives after that layout, so + // waiting for it lets the browser paint one frame with the new content hanging + // below the viewport, which reads as the transcript twitching as it streams. + const onContentMutate = () => { + if (!scroll) return + if (store.userScrolled || userActivity.isRecent()) return + if (!canScroll(scroll)) return + + follow() + } + const onViewportResize = () => { if (!scroll) return if (!canScroll(scroll)) return @@ -193,6 +214,18 @@ export function createAutoScroll(options: AutoScrollOptions) { el.style.overflowAnchor = store.userScrolled ? "auto" : "none" } + const setContent = (el: HTMLElement | undefined) => { + watcher?.disconnect() + watcher = undefined + + setStore("contentRef", el) + + if (!el || typeof MutationObserver !== "function") return + + watcher = new MutationObserver(onContentMutate) + watcher.observe(el, { childList: true, subtree: true, characterData: true }) + } + const setScroll = (el: HTMLElement | undefined) => { if (cleanup) { cleanup() @@ -210,6 +243,8 @@ export function createAutoScroll(options: AutoScrollOptions) { onCleanup(() => { if (settleTimer) clearTimeout(settleTimer) + watcher?.disconnect() + watcher = undefined if (cleanup) cleanup() }) @@ -219,7 +254,7 @@ export function createAutoScroll(options: AutoScrollOptions) { return { scrollRef: setScroll, - contentRef: (el: HTMLElement | undefined) => setStore("contentRef", el), + contentRef: setContent, handleScroll, pause, resume, diff --git a/packages/kilo-vscode/docs/mercury-next-edit-testing.html b/packages/kilo-vscode/docs/mercury-next-edit-testing.html index 5772426f25..599d30a4ba 100644 --- a/packages/kilo-vscode/docs/mercury-next-edit-testing.html +++ b/packages/kilo-vscode/docs/mercury-next-edit-testing.html @@ -276,7 +276,7 @@
  • VSCode ≥ 1.105.1 (matches kilocode's engines.vscode)
  • - Bun ≥ 1.3.14 (the build script checks the version) — install via + Bun ≥ 1.4.0 (the build script checks the version) — install via brew install bun or bun.sh
  • GitHub CLI (gh) — optional but makes the PR checkout one command
  • diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 5b938177b2..e6f1a336c3 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -1311,7 +1311,6 @@ }, "dependencies": { "@anthropic-ai/sdk": "^0.39.0", - "fuzzysort": "3.1.0", "@kilocode/kilo-gateway": "workspace:*", "@kilocode/kilo-i18n": "workspace:*", "@kilocode/kilo-indexing": "workspace:*", @@ -1332,6 +1331,7 @@ "diff": "8.0.4", "fastest-levenshtein": "^1.0.16", "friendly-words": "1.3.1", + "fuzzysort": "3.1.0", "ignore": "^7.0.3", "js-tiktoken": "^1.0.18", "lru-cache": "^11.0.2", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index a7c385d8bf..206972ce9e 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -163,7 +163,7 @@ import type { StoredProviderKey } from "./provider-actions" import { AnacondaDesktopBridge } from "./anaconda-desktop/bridge" import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models" import type { Agent } from "@kilocode/sdk/v2/client" -import { configFeatures } from "./features" +import { configFeatures, serverFeatures } from "./features" import { fetchSnapshot } from "./kilo-provider/config-snapshot" import { createAutoApproveBridge } from "./kilo-provider/auto-approve" import type { KiloProviderOptions } from "./kilo-provider/options" @@ -1043,7 +1043,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper modelUsage: (msg) => handleModelUsageMessage(msg, this.extensionContext, (value) => this.postMessage(value)), backgroundJobs: (sessionID, requestID) => this.fetchAndSendBackgroundJobs(sessionID, requestID), cancelBackgroundJob: (jobID, sessionID, requestID) => this.cancelBackgroundJob(jobID, sessionID, requestID), - backgroundSubagents: (sessionID) => this.backgroundSubagents(sessionID), + promoteBackgroundJob: (jobID, sessionID) => this.promoteBackgroundJob(jobID, sessionID), }) ) { return @@ -2937,16 +2937,16 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper } } - private async backgroundSubagents(sessionID: string): Promise { + private async promoteBackgroundJob(jobID: string, sessionID: string): Promise { const client = this.client if (!client || this.connectionState !== "connected") return try { - await client.experimental.session.background( - { sessionID, directory: this.getWorkspaceDirectory(sessionID) }, + await client.kilocode.backgroundJob.promote( + { jobID, directory: this.getWorkspaceDirectory(sessionID) }, { throwOnError: true }, ) } catch (error) { - console.error("[Kilo New] KiloProvider: Failed to background subagents:", error) + console.error("[Kilo New] KiloProvider: Failed to promote background job:", error) } } @@ -3394,6 +3394,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper const global = snapshot.targets.global.raw as Config const projectConfig = bindings.project ? (snapshot.targets.project.raw as Config) : undefined this.cachedGlobalConfig = global + const features = configFeatures(snapshot.effective, await serverFeatures(this.client, dir)) this.cachedConfigMessage = { type: "configLoaded", config: snapshot.effective, @@ -3401,7 +3402,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper projectConfig, bindings, settings: this.configSettings(), - features: configFeatures(snapshot.effective), + features, } this.postMessage({ type: "configUpdated", @@ -3410,7 +3411,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper projectConfig, bindings, settings: this.configSettings(), - features: configFeatures(snapshot.effective), + features, }) await Promise.all([ refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(), @@ -4126,7 +4127,15 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper this.cancelRetry(sid) const client = this.client if (!client) return Promise.resolve(false) - return this.aborts.stop(client, sid, this.getWorkspaceDirectory(sid)) + const directory = this.getWorkspaceDirectory(sid) + const dirs = this.aborts.directories(sid, directory) + const ids = new Map(dirs.map((dir) => [dir, this.connectionService.beginExplicitAbort(sid, dir)])) + return this.aborts.stop(client, sid, directory, dirs).then((result) => { + for (const attempt of result.attempts) { + this.connectionService.finishExplicitAbort(sid, attempt.dir, ids.get(attempt.dir)!, attempt.aborted) + } + return result.complete + }) } private async handleAbort(sessionID?: string): Promise { @@ -4134,7 +4143,6 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper if (!sid || !(await this.stopSession(sid))) return this.sessionStatusMap.set(sid, "idle") this.streams.flush(sid) - this.postMessage({ type: "sessionTurnClosed", sessionID: sid, reason: "interrupted" }) this.postMessage({ type: "sessionStatus", sessionID: sid, status: "idle" }) } diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 99eab29e3e..6fd983ae94 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -74,7 +74,7 @@ import type { ProjectContext } from "./project/context" import { ProjectContexts } from "./project/contexts" import { hydrateExpanded } from "./project/hydrate" import { createMultiVersion, type MultiVersionHost } from "./provider-multi-version" -import { handleProjectMessage, type ProjectMessageDeps } from "./project/messages" +import { handleProjectMessage, routeProjectSession, type ProjectMessageDeps } from "./project/messages" import { createProjectWiring, type ProjectWiring } from "./project/wiring" import { ProjectScope } from "./project/scope" import type { AgentManagerOutMessage, AgentManagerInMessage } from "./types" @@ -197,6 +197,7 @@ export class AgentManagerProvider implements Disposable { pushState: (ctx) => this.pushState(ctx), changed: () => this.onWorkspaceChanged(), selected: (target) => this.postToWebview({ type: "agentManager.selectionActivated", target }), + routeSession: (pid, sid, dir, gen) => routeProjectSession(this.panel?.sessions, pid, sid, dir, gen), }) this.registry = wiring.registry this.contexts = wiring.contexts @@ -212,7 +213,7 @@ export class AgentManagerProvider implements Disposable { log: (msg) => this.log(msg), }) const local = createLocalDiff(this.gitOps, (...args) => this.log(...args)) - this.diffCatalog = new DiffSourceCatalog(this.connectionService) + this.diffCatalog = new DiffSourceCatalog(this.connectionService, local) this.diffs = new WorktreeDiffController({ getState: () => this.getStateManager(), getRoot: () => this.getRoot(), @@ -323,9 +324,8 @@ export class AgentManagerProvider implements Disposable { } const info = ev.properties?.info const dir = info?.directory - // Session events from sync or older backends can lack time/directory; a - // throw here would escape into the SSE dispatch loop and starve the other - // listeners (there is no per-listener error isolation). + // Session events from sync or older backends can lack time/directory; a throw + // would escape into the SSE dispatch loop and starve the other listeners. if (!info?.time || !dir || (info.parentID !== undefined && info.parentID !== null)) return const ctx = this.contexts.byDirectory(dir) if (!ctx || ctx.lifecycle !== "ready") return @@ -1804,7 +1804,7 @@ export class AgentManagerProvider implements Disposable { await continueInWorktree( { root, - getClient: () => this.connectionService.getClient(), + connection: this.connectionService, createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts), runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id), cleanupWorktree: async (id) => { diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index 8b14d97277..e31ebc00c7 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -44,6 +44,7 @@ interface ExecOptions { env?: NodeJS.ProcessEnv stdin?: string timeout?: number + signal?: AbortSignal } export interface ExecResult { @@ -594,12 +595,12 @@ export class GitOps { * 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 { + execGit(args: string[], cwd: string, options?: { stdin?: string; signal?: AbortSignal }): Promise { return this.exec(args, cwd, options) } - execGitBuffer(args: string[], cwd: string): Promise { - return this.execBuffer(args, cwd) + execGitBuffer(args: string[], cwd: string, options?: { signal?: AbortSignal }): Promise { + return this.execBuffer(args, cwd, options) } private async exec(args: string[], cwd: string, options?: ExecOptions): Promise { @@ -616,7 +617,7 @@ export class GitOps { return { code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" } } const invoke = () => this.invoke(cmd, args, cwd, options) - return this.semaphore ? this.semaphore.run(invoke) : invoke() + return this.semaphore ? this.semaphore.run(invoke, options?.signal) : invoke() } private executable(): Promise { @@ -642,7 +643,7 @@ export class GitOps { } private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise { - if (this.controller.signal.aborted) { + if (this.controller.signal.aborted || options?.signal?.aborted) { return Promise.resolve({ code: 1, stdout: Buffer.alloc(0), stderr: "GitOps disposed" }) } @@ -664,6 +665,7 @@ export class GitOps { : undefined this.controller.signal.addEventListener("abort", abort, { once: true }) + options?.signal?.addEventListener("abort", abort, { once: true }) child.stdout?.on("data", (chunk: Buffer) => out.push(chunk)) child.stderr?.on("data", (chunk: Buffer) => err.push(chunk)) @@ -673,6 +675,7 @@ export class GitOps { child.on("close", (code) => { if (timeout) clearTimeout(timeout) this.controller.signal.removeEventListener("abort", abort) + options?.signal?.removeEventListener("abort", abort) resolve({ code: code ?? 1, stdout: Buffer.concat(out), diff --git a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts index 77a51d5031..676a8cbfa6 100644 --- a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts +++ b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts @@ -8,7 +8,10 @@ import { recordForkHandoff } from "./fork-handoff" export interface ContinueContext { root: string - getClient: () => KiloClient + connection: { + getClient: () => KiloClient + runExplicitAbort: (sessionId: string, directory: string, action: () => Promise) => Promise + } createWorktreeOnDisk: (opts: { baseBranch: string; baseRef: string }) => Promise<{ worktree: { id: string } result: CreateWorktreeResult @@ -30,10 +33,13 @@ export type StepResult = { ok: true; value: T } | { ok: false; error: string /** Abort a running session. Best-effort — failures are logged but not fatal. */ export async function abortSession(ctx: ContinueContext, sessionId: string): Promise { try { - const client = ctx.getClient() - await client.session.abort({ sessionID: sessionId }).catch((err) => { - ctx.log("Session abort failed (may already be idle):", getErrorMessage(err)) - }) + await ctx.connection + .runExplicitAbort(sessionId, ctx.root, async () => { + await ctx.connection.getClient().session.abort({ sessionID: sessionId }, { throwOnError: true }) + }) + .catch((err) => { + ctx.log("Session abort failed (may already be idle):", getErrorMessage(err)) + }) } catch (err) { ctx.log("Client not available for abort, continuing:", getErrorMessage(err)) } @@ -96,7 +102,7 @@ async function rollback( export async function forkSession(ctx: ContinueContext, sessionId: string, dir: string): Promise> { let client: KiloClient try { - client = ctx.getClient() + client = ctx.connection.getClient() } catch (err) { ctx.log("Client not available for session fork:", getErrorMessage(err)) return { ok: false, error: "Not connected to CLI backend" } diff --git a/packages/kilo-vscode/src/agent-manager/local-diff.ts b/packages/kilo-vscode/src/agent-manager/local-diff.ts index e643e86627..c142bef0ee 100644 --- a/packages/kilo-vscode/src/agent-manager/local-diff.ts +++ b/packages/kilo-vscode/src/agent-manager/local-diff.ts @@ -117,26 +117,29 @@ async function ancestor(git: GitOps, dir: string, base: string, log?: Log): Prom 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 +function counts(value: string) { + const result = new Map() + for (const line of value.trim().split("\n")) { + if (!line || line.startsWith(":")) 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, { + const file = parts.slice(2).join("\t") + if (!file) continue + result.set(file, { additions: add === "-" ? 0 : parseInt(add || "0", 10) || 0, deletions: del === "-" ? 0 : parseInt(del || "0", 10) || 0, binary: add === "-" || del === "-", }) } - return map + return result +} + +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) + return counts(result.code === 0 ? result.stdout : "") } async function statStamp(dir: string, file: string): Promise { @@ -144,7 +147,22 @@ async function statStamp(dir: string, file: string): Promise { if (!full) return `missing:${file}` const stat = await fs.lstat(full).catch(() => undefined) if (!stat) return `missing:${file}` - return `${stat.size}:${stat.mtimeMs}` + return `${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}:${stat.ino ?? 0}` +} + +async function detailReads(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) { + return Promise.all([ + readBefore(git, dir, anc, meta.file, meta.status, signal), + readAfter(dir, meta.file, meta.status), + meta.tracked ? unifiedPatch(git, dir, anc, meta.file, signal) : Promise.resolve(""), + ]) +} + +async function sizes(git: GitOps, dir: string, anc: string, meta: Meta, signal?: AbortSignal) { + return Promise.all([ + meta.status === "added" ? 0 : blobSize(git, dir, anc, meta.file, signal), + meta.status === "deleted" ? 0 : fileSize(dir, meta.file), + ]) } async function lineCount(file: string): Promise { @@ -166,28 +184,28 @@ function statusFromCode(code: string): Status { } 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() }) + const [tracked, untracked] = await Promise.all([ + git.execGit(["-c", "core.quotepath=false", "diff", "--raw", "--numstat", "--no-renames", anc], dir), + git.execGit(["ls-files", "--others", "--exclude-standard"], dir), + ]) + if (tracked.code !== 0) { + log?.("git diff --raw --numstat failed", { code: tracked.code, stderr: tracked.stderr.trim() }) return [] } - const counts = await numstat(git, dir, anc) const result: Meta[] = [] const seen = new Set() + const stats = counts(tracked.stdout) - for (const line of nameStatus.stdout.trim().split("\n")) { - if (!line) continue + for (const line of tracked.stdout.trim().split("\n")) { + if (!line.startsWith(":")) continue const parts = line.split("\t") - const code = parts[0] + const code = parts[0]?.split(" ").at(-1) 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, binary: false } + const stat = stats.get(file) ?? { additions: 0, deletions: 0, binary: false } result.push({ file, additions: stat.additions, @@ -201,7 +219,6 @@ async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise }>() + const generations = new Map() + const details = new Map() + const pending = new Map }>() + let bytes = 0 + + const forget = (id: string) => { + const value = details.get(id) + if (!value) return + bytes -= value.bytes + details.delete(id) + } + + const remember = (id: string, value: WorktreeDiffEntry, stamp: string) => { + const size = [value.before, value.after, value.patch, value.image?.before?.data, value.image?.after?.data].reduce( + (sum, value) => sum + Buffer.byteLength(value ?? ""), + 0, + ) + const current = details.get(id) + if (current) bytes -= current.bytes + details.delete(id) + details.set(id, { value, bytes: size, stamp }) + bytes += size + while (details.size > 128 || bytes > 64 * 1024 * 1024) { + const key = details.keys().next().value! + bytes -= details.get(key)!.bytes + details.delete(key) + } + } return { summary: async (dir: string, base: string): Promise => { const id = `${dir}\0${base}` + const generation = (generations.get(id) ?? 0) + 1 + generations.set(id, generation) const anc = await ancestor(git, dir, base, log) if (!anc) { - states.delete(id) + if (generations.get(id) === generation) states.delete(id) return [] } const items = await list(git, dir, anc, log) + if (generations.get(id) !== generation) return items.map(summarize) states.delete(id) states.set(id, { anc, metas: new Map(items.map((item) => [item.file, item])) }) if (states.size > 8) states.delete(states.keys().next().value!) return items.map(summarize) }, - file: async (dir: string, base: string, file: string): Promise => { + file: async (dir: string, base: string, file: string, signal?: AbortSignal): Promise => { const state = states.get(`${dir}\0${base}`) if (!state) return diffFile(git, dir, base, file, log) const meta = state.metas.get(file) if (!meta) return null - return materialize(git, dir, state.anc, meta, log) + const id = `${dir}\0${base}\0${state.anc}\0${file}\0${meta.tracked}\0${meta.status}\0${meta.additions}\0${meta.deletions}\0${meta.binary}\0${meta.stamp}` + const cached = details.get(id) + if (cached) { + if (cached.stamp === meta.stamp) { + remember(id, cached.value, meta.stamp) + return cached.value + } + forget(id) + } + const current = pending.get(id) + if (current && !current.signal?.aborted) return current.work + const work = materialize(git, dir, state.anc, meta, log, signal) + pending.set(id, { signal, work }) + work.then( + (value) => { + if (pending.get(id)?.work !== work) return + pending.delete(id) + if (value.image?.before?.error === "unreadable" || value.image?.after?.error === "unreadable") return + remember(id, value, meta.stamp) + }, + () => { + if (pending.get(id)?.work === work) pending.delete(id) + }, + ) + return work }, } } @@ -343,9 +415,9 @@ async function detailMeta(git: GitOps, dir: string, anc: string, file: string): } } -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 +async function blobSize(git: GitOps, dir: string, anc: string, file: string, signal?: AbortSignal): Promise { + const result = await git.execGit(["cat-file", "-s", `${anc}:${file}`], dir, { signal }) + if (result.code !== 0) throw new Error(`Could not read base blob for ${file}`) return parseInt(result.stdout.trim(), 10) || 0 } @@ -356,8 +428,14 @@ async function fileSize(dir: string, file: string): Promise { return stat?.size ?? 0 } -async function readBlob(git: GitOps, dir: string, ref: string, file: string): Promise { - const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir) +async function readBlob( + git: GitOps, + dir: string, + ref: string, + file: string, + signal?: AbortSignal, +): Promise { + const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir, { signal }) return result.code === 0 ? result.stdout : undefined } @@ -369,29 +447,47 @@ async function readFile(dir: string, file: string): Promise return readImageFile(full) } -async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise { +async function readBefore( + git: GitOps, + dir: string, + anc: string, + file: string, + status: Status, + signal?: AbortSignal, +): Promise { if (status === "added") return "" - const result = await git.execGit(["show", `${anc}:${file}`], dir) - return result.code === 0 ? result.stdout : "" + const result = await git.execGit(["show", `${anc}:${file}`], dir, { signal }) + if (result.code !== 0) throw new Error(`Could not read base file for ${file}`) + return result.stdout } async function readAfter(dir: string, file: string, status: Status): Promise { if (status === "deleted") return "" const full = resolveInside(dir, file) - if (!full) return "" + if (!full) throw new Error(`Could not resolve working file for ${file}`) const stat = await fs.lstat(full).catch(() => undefined) - if (!stat) return "" + if (!stat) throw new Error(`Could not read working file for ${file}`) if (stat.isSymbolicLink()) return fs.readlink(full).catch(() => "") - if (!stat.isFile()) return "" - return fs.readFile(full, "utf-8").catch(() => "") + if (!stat.isFile()) throw new Error(`Working path is not a file: ${file}`) + return fs.readFile(full, "utf-8").catch(() => { + throw new Error(`Could not read working file for ${file}`) + }) } -async function unifiedPatch(git: GitOps, dir: string, anc: string, file: string): Promise { +async function unifiedPatch( + git: GitOps, + dir: string, + anc: string, + file: string, + signal?: AbortSignal, +): Promise { const result = await git.execGit( ["-c", "core.quotepath=false", "diff", "--no-ext-diff", "--no-renames", anc, "--", file], dir, + { signal }, ) - return result.code === 0 ? result.stdout : "" + if (result.code !== 0) throw new Error(`Could not create diff for ${file}`) + return result.stdout } function linesOf(text: string): number { @@ -418,17 +514,27 @@ export async function diffFile( return materialize(git, dir, anc, meta, log) } -async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, log?: Log): Promise { +async function materialize( + git: GitOps, + dir: string, + anc: string, + meta: Meta, + log?: Log, + signal?: AbortSignal, +): Promise { const mime = imageMime(meta.file) if (meta.binary && !mime) return summarize(meta) - const beforeBytes = meta.status === "added" ? 0 : await blobSize(git, dir, anc, meta.file) - const afterBytes = meta.status === "deleted" ? 0 : await fileSize(dir, meta.file) + const [beforeBytes, afterBytes] = await sizes(git, dir, anc, meta, signal) + if (signal?.aborted) throw new Error("Diff detail aborted") if (mime) { const image = await loadImage( meta.file, - meta.status === "added" ? undefined : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file) }, + meta.status === "added" + ? undefined + : { bytes: beforeBytes, read: () => readBlob(git, dir, anc, meta.file, signal) }, meta.status === "deleted" ? undefined : { bytes: afterBytes, read: () => readFile(dir, meta.file) }, ) + if (signal?.aborted) throw new Error("Diff detail aborted") return { ...summarize(meta), summarized: false, image } } // Cheap size probe before materializing content — protects the extension @@ -444,9 +550,9 @@ async function materialize(git: GitOps, dir: string, anc: string, meta: Meta, lo 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 [before, after, tracked] = await detailReads(git, dir, anc, meta, signal) + if (signal?.aborted) throw new Error("Diff detail aborted") + const patch = meta.tracked ? tracked : buildUntrackedPatch(meta.file, after) const additions = meta.status === "added" && meta.additions === 0 && !meta.tracked ? linesOf(after) : meta.additions return { file: meta.file, diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index c9bf7ef77d..d3fa3bb80b 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -12,7 +12,25 @@ import type { ProjectRegistry } from "./registry" import type { ProjectContext, ProjectInitResult } from "./context" import type { ProjectContexts } from "./contexts" import { projectIdFor, resolveProjectRoot, samePath } from "./paths" -import type { SidebarTarget } from "./route" +import type { SidebarTarget, SessionRef } from "./route" + +/** Route one session to a directory inside a project via the shared session provider. */ +export function routeProjectSession( + sessions: + | { + setSessionDirectory(id: string, directory: string): void + registerSessionRoute?(ref: SessionRef, directory: string, generation: number): void + } + | undefined, + projectId: string, + sessionId: string, + directory: string, + generation: number, +): void { + if (!sessions) return + sessions.setSessionDirectory(sessionId, directory) + sessions.registerSessionRoute?.({ projectId, sessionId }, directory, generation) +} export interface ProjectMessageDeps { registry: ProjectRegistry @@ -27,6 +45,8 @@ export interface ProjectMessageDeps { expand: (ctx: ProjectContext) => void /** Push the current project snapshots to the webview. */ push: () => void + /** Push one project's managed state to the webview. */ + pushState?: (ctx: ProjectContext) => void /** Acknowledge an atomically validated sidebar selection. */ selected: (target: SidebarTarget) => void /** Show a user-facing error. */ @@ -35,6 +55,8 @@ export interface ProjectMessageDeps { openSettings: (tab?: string, projectId?: string) => void /** Ensure a context's repository state is ready (no-op once initialized). */ ready: (ctx: ProjectContext) => Promise + /** Route one session to a directory inside a project (session override + project route). */ + routeSession?: (projectId: string, sessionId: string, directory: string, generation: number) => void log: (...args: unknown[]) => void } @@ -64,6 +86,11 @@ export async function handleProjectMessage(m: AgentManagerInMessage, deps: Proje await activateSelection(m.target, deps, m.restore === true) return true } + if (m.type === "agentManager.openSessionLocally") { + if (!m.projectId) return false + await openSessionLocally(m.projectId, m.sessionId, deps) + return true + } if (m.type === "agentManager.rememberTarget") { rememberTarget(m.projectId, m.target, deps) return true @@ -106,6 +133,36 @@ async function activateSelection(requested: SidebarTarget, deps: ProjectMessageD finish(target, deps) } +/** + * Move a worktree-bound session back to the project root and open it in the + * project's local tabs. Fall back to local gracefully when the worktree is + * already gone (the session may be live only). + */ +async function openSessionLocally(projectId: string, sessionId: string, deps: ProjectMessageDeps): Promise { + if (disabled(deps)) return + const ctx = deps.contexts.resolve(projectId) + if (!ctx || !deps.contexts.usable(projectId)) { + deps.error("The project is unavailable. Check that the repository still exists.") + return + } + const result = await deps.ready(ctx) + if (!result.current || !result.ok) { + deps.error("The project is not ready yet. Expand it before selecting a worktree or session.") + deps.push() + return + } + const state = ctx.peekState() + if (!state?.getSession(sessionId) && !ctx.hasLiveSession(sessionId)) { + deps.log(`openSessionLocally: unknown session ${sessionId}`) + return + } + state?.moveSession(sessionId, null) + deps.routeSession?.(projectId, sessionId, ctx.root, ctx.generation) + deps.pushState?.(ctx) + deps.push() + finish({ projectId, kind: "session", sessionId }, deps) +} + /** Commit the active project, persist the target, and acknowledge the selection. */ function finish(target: SidebarTarget, deps: ProjectMessageDeps): void { const previous = deps.contexts.active()?.id diff --git a/packages/kilo-vscode/src/agent-manager/project/state-gate.ts b/packages/kilo-vscode/src/agent-manager/project/state-gate.ts index 46aa539225..876ca9b127 100644 --- a/packages/kilo-vscode/src/agent-manager/project/state-gate.ts +++ b/packages/kilo-vscode/src/agent-manager/project/state-gate.ts @@ -9,6 +9,7 @@ export const STATE_GATED = new Set([ "agentManager.deleteWorktree", "agentManager.removeStaleWorktree", "agentManager.openLocally", + "agentManager.openSessionLocally", "agentManager.addSessionToWorktree", "agentManager.closeSession", "agentManager.persistSession", diff --git a/packages/kilo-vscode/src/agent-manager/project/wiring.ts b/packages/kilo-vscode/src/agent-manager/project/wiring.ts index 56dfb5602e..59a47ed011 100644 --- a/packages/kilo-vscode/src/agent-manager/project/wiring.ts +++ b/packages/kilo-vscode/src/agent-manager/project/wiring.ts @@ -44,6 +44,8 @@ export function createProjectWiring(opts: { changed: () => void /** Acknowledge an atomically validated sidebar selection. */ selected: (target: import("./route").SidebarTarget) => void + /** Route one session to a directory inside a project (override + project route). */ + routeSession?: (projectId: string, sessionId: string, directory: string, generation: number) => void }): ProjectWiring { const registry = new ProjectRegistry( { read: () => opts.host.readProjects(), write: (value) => opts.host.writeProjects(value) }, @@ -65,7 +67,9 @@ export function createProjectWiring(opts: { expand: opts.expand, ready: opts.ready, push: opts.push, + pushState: opts.pushState, selected: opts.selected, + routeSession: opts.routeSession, error: (message) => opts.host.showError(message), openSettings: (tab, projectId) => opts.host.openSettings(tab, projectId), log: opts.log, diff --git a/packages/kilo-vscode/src/agent-manager/semaphore.ts b/packages/kilo-vscode/src/agent-manager/semaphore.ts index 37f3ab9cdd..9006c7bf67 100644 --- a/packages/kilo-vscode/src/agent-manager/semaphore.ts +++ b/packages/kilo-vscode/src/agent-manager/semaphore.ts @@ -7,12 +7,12 @@ */ export class Semaphore { private running = 0 - private readonly pending: (() => void)[] = [] + private readonly pending: { resolve: () => void; abort?: () => void }[] = [] constructor(private readonly limit: number) {} - async run(fn: () => Promise): Promise { - await this.acquire() + async run(fn: () => Promise, signal?: AbortSignal): Promise { + await this.acquire(signal) try { return await fn() } finally { @@ -20,22 +20,32 @@ export class Semaphore { } } - private acquire(): Promise { + private acquire(signal?: AbortSignal): Promise { + if (signal?.aborted) return Promise.reject(signal.reason) if (this.running < this.limit) { this.running++ return Promise.resolve() } - return new Promise((resolve) => { - this.pending.push(() => { - this.running++ - resolve() - }) + return new Promise((resolve, reject) => { + const item: { resolve: () => void; abort: () => void } = { + resolve: () => { + signal?.removeEventListener("abort", item.abort) + this.running++ + resolve() + }, + abort: () => { + const index = this.pending.indexOf(item) + if (index !== -1) this.pending.splice(index, 1) + reject(signal?.reason) + }, + } + signal?.addEventListener("abort", item.abort, { once: true }) + this.pending.push(item) }) } private release(): void { this.running-- - const next = this.pending.shift() - if (next) next() + this.pending.shift()?.resolve() } } diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index a7fb639515..7f540b663a 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -575,6 +575,13 @@ interface AddSessionToWorktreeIn { sessionId?: string } +/** Move a session back to the project root and open it in the local tabs. */ +interface OpenSessionLocallyIn { + type: "agentManager.openSessionLocally" + projectId?: string + sessionId: string +} + interface CloseSessionIn { type: "agentManager.closeSession" sessionId: string @@ -1081,6 +1088,7 @@ export type AgentManagerInMessage = | RemoveStaleWorktreeIn | PromoteSessionIn | OpenLocallyIn + | OpenSessionLocallyIn | AddSessionToWorktreeIn | CloseSessionIn | PersistSessionIn diff --git a/packages/kilo-vscode/src/diff/SourceController.ts b/packages/kilo-vscode/src/diff/SourceController.ts index 11a51ed344..8b7aa57d21 100644 --- a/packages/kilo-vscode/src/diff/SourceController.ts +++ b/packages/kilo-vscode/src/diff/SourceController.ts @@ -67,6 +67,7 @@ export class SourceController { private interval: ReturnType | undefined private lastHash: string | undefined private epoch = 0 + private readonly fetches = new Map>() constructor( private readonly build: (id: string, ctx: PanelContext) => DiffSource, @@ -91,6 +92,7 @@ export class SourceController { stop(): void { this.epoch++ this.stopPolling() + this.fetches.clear() this.active?.dispose?.() this.active = undefined this.activeId = undefined @@ -117,7 +119,7 @@ export class SourceController { if (opts.fetch === false) return - const keepPolling = await this.runFetch(source, epoch, true) + const keepPolling = await this.fetch(source, epoch, true) // Prevents the polling interval from starting after teardown or swap. if (this.epoch !== epoch || this.activeId !== id) return if (opts.poll !== false && keepPolling) this.startPolling(source, epoch) @@ -151,7 +153,7 @@ export class SourceController { // Push fresh diffs immediately after a successful revert so the webview // doesn't have to wait for the next polling tick. if (result.ok && this.epoch === epoch && this.active === source) { - await this.runFetch(source, epoch, false) + await this.fetch(source, epoch, true, true) } } @@ -160,7 +162,7 @@ export class SourceController { const source = this.active if (!source) return const epoch = this.epoch - await this.runFetch(source, epoch, true) + await this.fetch(source, epoch, true, true) } /** @@ -180,6 +182,13 @@ export class SourceController { this.send(this.messages.diffFile(source, file, null)) return } + // Yield once so a worktree switch can advance the epoch before queued + // detail work enters the shared Git semaphore. + await new Promise((resolve) => setTimeout(resolve, 0)) + if (this.epoch !== epoch || this.active !== source) { + this.send(this.messages.diffFile(source, file, null)) + return + } const diff = await source.fetchFile(file).catch(() => null) // Discard stale content after disposal/swap, but still complete the request // so consumers can clear per-file loading state. @@ -239,10 +248,15 @@ export class SourceController { private startPolling(source: DiffSource, epoch: number): void { this.stopPolling() + let busy = false this.interval = setInterval(async () => { + if (busy) return + busy = true // Self-cancel when the tick reports the source is done - const keep = await this.runFetch(source, epoch, false) - if (!keep) this.stopPolling() + const keep = await this.fetch(source, epoch, false).finally(() => { + busy = false + }) + if (!keep && this.epoch === epoch && this.active === source) this.stopPolling() }, DIFF_POLL_INTERVAL_MS) } @@ -252,4 +266,22 @@ export class SourceController { this.interval = undefined } } + + private fetch(source: DiffSource, epoch: number, initial: boolean, force = false): Promise { + const current = this.fetches.get(source) + if (current && !force) return current + if (current) { + return current.then(() => { + if (this.epoch !== epoch || this.active !== source) return false + return this.fetch(source, epoch, initial) + }) + } + const work = this.runFetch(source, epoch, initial) + this.fetches.set(source, work) + const clear = () => { + if (this.fetches.get(source) === work) this.fetches.delete(source) + } + void work.finally(clear).catch(() => undefined) + return work + } } diff --git a/packages/kilo-vscode/src/diff/sources/catalog.ts b/packages/kilo-vscode/src/diff/sources/catalog.ts index 8eb2d0ffdf..1cfb414a8c 100644 --- a/packages/kilo-vscode/src/diff/sources/catalog.ts +++ b/packages/kilo-vscode/src/diff/sources/catalog.ts @@ -18,6 +18,12 @@ import { import { TURN_PREFIX, createTurnDiffSource, type TurnDiffFetch } from "./turn" import { STAGED_DESCRIPTOR, STAGED_SOURCE_ID, createStagedDiffSource } from "./staged" import { UNSTAGED_DESCRIPTOR, UNSTAGED_SOURCE_ID, createUnstagedDiffSource } from "./unstaged" +import type { WorktreeDiffEntry } from "../../agent-manager/types" + +export interface LocalDiffSource { + summary: (dir: string, base: string) => Promise + file: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise +} export interface WorkspaceBranchesResult { branches: BranchListItem[] @@ -68,7 +74,10 @@ export class DiffSourceCatalog implements vscode.Disposable { private branchGit: GitOps | undefined private branchOutput: vscode.OutputChannel | undefined - constructor(private readonly connection: KiloConnectionService) {} + constructor( + private readonly connection: KiloConnectionService, + private readonly local?: LocalDiffSource, + ) {} listAvailable(ctx: PanelContext): DiffSourceDescriptor[] { if (ctx.hidePicker) return [] @@ -96,6 +105,8 @@ export class DiffSourceCatalog implements vscode.Disposable { ...opts, baseBranchOverride: ctx.baseBranchOverride, baseBranch: ctx.baseBranch, + summary: this.local?.summary, + file: this.local?.file, }) } diff --git a/packages/kilo-vscode/src/diff/sources/worktree.ts b/packages/kilo-vscode/src/diff/sources/worktree.ts index 89e750a1c8..4aa8b690f1 100644 --- a/packages/kilo-vscode/src/diff/sources/worktree.ts +++ b/packages/kilo-vscode/src/diff/sources/worktree.ts @@ -45,6 +45,8 @@ export interface WorktreeDiffSourceOptions { /** Shared GitOps / log so sources don't each spawn their own channel. */ git?: GitOps log?: (...args: unknown[]) => void + summary?: (dir: string, base: string) => Promise + file?: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise } /** @@ -57,6 +59,7 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): const output = opts.git ? undefined : vscode.window.createOutputChannel("Kilo Diff: Workspace") const log = opts.log ?? ((...args: unknown[]) => appendOutput(output!, "WorktreeDiffSource", ...args)) const git = opts.git ?? new GitOps({ log }) + const controller = new AbortController() const root = (): string | undefined => { const dir = opts.dir?.() @@ -101,7 +104,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): } const status: StatusResolver = async (current, file) => { - const entry = await diffFile(git, current.directory, current.baseBranch, file, log) + const entry = opts.file + ? await opts.file(current.directory, current.baseBranch, file) + : await diffFile(git, current.directory, current.baseBranch, file, log) return entry?.status } @@ -112,7 +117,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): const current = await resolveTarget() if (!current) return { diffs: [] } - const entries = await diffSummary(git, current.directory, current.baseBranch, log) + const entries = opts.summary + ? await opts.summary(current.directory, current.baseBranch) + : await diffSummary(git, current.directory, current.baseBranch, log) const diffs = entries.map(toDiffFile) log(`Diff: ${diffs.length} file(s)`) return { diffs } @@ -124,7 +131,9 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): if (!current) return null try { - const entry = await diffFile(git, current.directory, current.baseBranch, file, log) + const entry = opts.file + ? await opts.file(current.directory, current.baseBranch, file, controller.signal) + : await diffFile(git, current.directory, current.baseBranch, file, log) if (!entry) return null return toDiffFile(entry) } catch (err) { @@ -152,6 +161,7 @@ export function createWorktreeDiffSource(opts: WorktreeDiffSourceOptions = {}): // owned by the caller. if (!opts.git) git.dispose() output?.dispose() + controller.abort() target = undefined }, } diff --git a/packages/kilo-vscode/src/features.ts b/packages/kilo-vscode/src/features.ts index 0b0b727528..c2d3a1f38f 100644 --- a/packages/kilo-vscode/src/features.ts +++ b/packages/kilo-vscode/src/features.ts @@ -1,4 +1,5 @@ import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" +import type { KiloClient } from "@kilocode/sdk/v2" type PluginSpec = string | [string, Record] @@ -9,11 +10,24 @@ type ConfigLike = { export type Features = { indexing: boolean sandboxControls: boolean + backgroundSubagents: boolean } -export function configFeatures(config?: ConfigLike | null): Features { +export function configFeatures(config?: ConfigLike | null, backgroundSubagents = false): Features { return { indexing: hasIndexingPlugin(config?.plugin ?? []), sandboxControls: process.platform !== "win32", + backgroundSubagents, + } +} + +export async function serverFeatures(client: Pick, dir: string) { + if (!client.experimental?.capabilities?.get) return false + try { + const { data } = await client.experimental.capabilities.get({ directory: dir }, { throwOnError: true }) + return data?.backgroundSubagents === true + } catch (error) { + console.warn("[Kilo New] Failed to fetch server capabilities:", error) + return false } } diff --git a/packages/kilo-vscode/src/kilo-provider/abort.ts b/packages/kilo-vscode/src/kilo-provider/abort.ts index 7bb355b5b2..15f7c7f48a 100644 --- a/packages/kilo-vscode/src/kilo-provider/abort.ts +++ b/packages/kilo-vscode/src/kilo-provider/abort.ts @@ -27,20 +27,27 @@ export class SessionAbort { this.observe(sessionID, status, dir) } - async stop(client: KiloClient, sessionID: string, fallback: string) { - const known = this.active.has(sessionID) + directories(sessionID: string, fallback: string) { const dirs = [...(this.active.get(sessionID) ?? [])] if (!dirs.some((dir) => sameDirectory(dir, fallback))) dirs.push(fallback) + return dirs + } + + async stop(client: KiloClient, sessionID: string, fallback: string, dirs = this.directories(sessionID, fallback)) { + const known = this.active.has(sessionID) const results = await Promise.allSettled(dirs.map((dir) => abortSession({ client, sessionID, dir }))) const failures = results.flatMap((result, index) => result.status === "rejected" ? [{ dir: dirs[index], error: result.reason }] : [], ) if (failures.length > 0) { console.error("[Kilo New] KiloProvider: Failed to abort session in one or more directories:", failures) - return false + return { + complete: false, + attempts: results.map((result, index) => ({ dir: dirs[index], aborted: result.status === "fulfilled" })), + } } if (known) this.active.delete(sessionID) - return known + return { complete: known, attempts: dirs.map((dir) => ({ dir, aborted: true })) } } dispose(dir: string) { diff --git a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts index 90ca1ac6fc..088c8b3c20 100644 --- a/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts +++ b/packages/kilo-vscode/src/kilo-provider/config-snapshot.ts @@ -1,15 +1,16 @@ import type { KiloClient } from "@kilocode/sdk/v2/client" -import { configFeatures } from "../features" +import { configFeatures, serverFeatures } from "../features" import { retry } from "../services/cli-backend/retry" import type { ConfigTarget } from "./config-bindings" -type Client = Pick +type Client = Pick type Settings = { maxCost: number; languageCommitMessage: string; multiProject: boolean } export async function fetchSnapshot(client: Client, dir: string, settings: () => Settings) { - const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([ + const [{ data: config }, { data: global }, { data: overlay }, capabilities] = await Promise.all([ retry(() => client.config.get({ directory: dir }, { throwOnError: true })), client.global.config.get({ throwOnError: true }), client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }), + retry(() => serverFeatures(client, dir)), ]) return { config, @@ -17,6 +18,6 @@ export async function fetchSnapshot(client: Client, dir: string, settings: () => targets: overlay?.targets as { global: ConfigTarget; project: ConfigTarget } | undefined, collections: overlay?.collections, settings: settings(), - features: configFeatures(config), + features: configFeatures(config, capabilities), } } diff --git a/packages/kilo-vscode/src/kilo-provider/early-message.ts b/packages/kilo-vscode/src/kilo-provider/early-message.ts index 87321e5176..04c5cdb0a5 100644 --- a/packages/kilo-vscode/src/kilo-provider/early-message.ts +++ b/packages/kilo-vscode/src/kilo-provider/early-message.ts @@ -23,7 +23,7 @@ type Ctx = { modelUsage: (message: ModelUsageMessage) => Promise backgroundJobs: (sessionID: string, requestID: string) => Promise cancelBackgroundJob: (jobID: string, sessionID: string, requestID: string) => Promise - backgroundSubagents: (sessionID: string) => Promise + promoteBackgroundJob: (jobID: string, sessionID: string) => Promise } async function routeBackgroundMessage( @@ -46,8 +46,10 @@ async function routeBackgroundMessage( } return true } - if (message.type === "backgroundSubagents") { - if (typeof message.sessionID === "string") await ctx.backgroundSubagents(message.sessionID) + if (message.type === "promoteBackgroundJob") { + if (typeof message.jobID === "string" && typeof message.sessionID === "string") { + await ctx.promoteBackgroundJob(message.jobID, message.sessionID) + } return true } return undefined diff --git a/packages/kilo-vscode/src/kilo-provider/model-state.ts b/packages/kilo-vscode/src/kilo-provider/model-state.ts index c97775bb2d..655370aa1b 100644 --- a/packages/kilo-vscode/src/kilo-provider/model-state.ts +++ b/packages/kilo-vscode/src/kilo-provider/model-state.ts @@ -72,13 +72,6 @@ export async function handleMessage( await write(client, "model", model) return true } - if (type === "clearModelSelection") { - const data = await read(client) - const model = validateModelSelections(data.model) - delete model[message.agent as string] - await write(client, "model", model) - return true - } if (type === "requestModelSelections") { const data = await read(client) const selections = validateModelSelections(data.model) diff --git a/packages/kilo-vscode/src/provider-actions.ts b/packages/kilo-vscode/src/provider-actions.ts index 742380b560..9d67b2797f 100644 --- a/packages/kilo-vscode/src/provider-actions.ts +++ b/packages/kilo-vscode/src/provider-actions.ts @@ -10,7 +10,7 @@ import { withCustomProviderDeletions, } from "./shared/custom-provider" import { isCustomProviderPackage, KILO_AUTO, KILO_PROVIDER_ID, parseModelString } from "./shared/provider-model" -import { configFeatures } from "./features" +import { configFeatures, serverFeatures } from "./features" /** * Compute the default model selection from CLI config, VS Code settings, or hardcoded fallback. @@ -240,7 +240,7 @@ async function refreshConfig(ctx: ActionContext, setCachedConfig: SetCachedConfi ctx.client.global.config.get({ throwOnError: true }), ]) if (!config) return - const features = configFeatures(config) + const features = configFeatures(config, await serverFeatures(ctx.client, ctx.workspaceDir)) setCachedConfig({ type: "configLoaded", config, globalConfig: global, features }) ctx.postMessage({ type: "configUpdated", config, globalConfig: global, features }) } @@ -464,9 +464,10 @@ export async function saveCustomProvider( const merged = await ctx.client.config.get({ directory: ctx.workspaceDir }, { throwOnError: true }) const config = merged.data ?? updated - const msg = { type: "configLoaded", config, globalConfig: updated, features: configFeatures(config) } + const features = configFeatures(config, await serverFeatures(ctx.client, ctx.workspaceDir)) + const msg = { type: "configLoaded", config, globalConfig: updated, features } setCachedConfig(msg) - ctx.postMessage({ type: "configUpdated", config, globalConfig: updated, features: configFeatures(config) }) + ctx.postMessage({ type: "configUpdated", config, globalConfig: updated, features }) const auth = resolveCustomProviderAuth(apiKey, apiKeyChanged) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts index 262b2d64bf..9ec1e5ccf4 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import * as vscode from "vscode" import { KiloConnectionService } from "./connection-service" +import type { SSEPayload } from "./sdk-sse-adapter" function state(value: boolean) { return { @@ -39,6 +40,74 @@ describe("KiloConnectionService clients", () => { }) }) +describe("KiloConnectionService explicit aborts", () => { + const close = { + id: "event-close", + type: "session.turn.close", + properties: { sessionID: "session", reason: "interrupted" }, + } as SSEPayload + const status = { + type: "session.status", + properties: { sessionID: "session", status: { type: "busy" } }, + } as SSEPayload + + test("suppresses a successful explicit abort for every subscriber", () => { + const service = new KiloConnectionService({} as any) + const raw: SSEPayload[] = [] + const first: SSEPayload[] = [] + const second: SSEPayload[] = [] + service.onEvent((event) => raw.push(event)) + service.onEventFiltered( + () => true, + (event) => first.push(event), + ) + service.onEventFiltered( + () => true, + (event) => second.push(event), + ) + ;(service as any).broadcast(status, "/repo") + raw.length = 0 + first.length = 0 + second.length = 0 + + const id = service.beginExplicitAbort("session", "/repo") + ;(service as any).broadcast(close, "/repo") + service.finishExplicitAbort("session", "/repo", id, true) + + expect(first).toEqual([]) + expect(second).toEqual([]) + expect(raw).toEqual([close]) + }) + + test("replays a failed explicit abort for every subscriber", () => { + const service = new KiloConnectionService({} as any) + const raw: SSEPayload[] = [] + const first: SSEPayload[] = [] + const second: SSEPayload[] = [] + service.onEvent((event) => raw.push(event)) + service.onEventFiltered( + () => true, + (event) => first.push(event), + ) + service.onEventFiltered( + () => true, + (event) => second.push(event), + ) + ;(service as any).broadcast(status, "/repo") + raw.length = 0 + first.length = 0 + second.length = 0 + + const id = service.beginExplicitAbort("session", "/repo") + ;(service as any).broadcast(close, "/repo") + service.finishExplicitAbort("session", "/repo", id, false) + + expect(first).toEqual([close]) + expect(second).toEqual([close]) + expect(raw).toEqual([close]) + }) +}) + describe("KiloConnectionService viewed sessions", () => { test("keeps Agent Manager sessions when sidebar visibility changes during a flush", async () => { const service = new KiloConnectionService({} as any) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts index d202eeedba..73585d1534 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-service.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-service.ts @@ -3,8 +3,9 @@ import { ServerManager } from "./server-manager" import { createKiloClient, type KiloClient } from "@kilocode/sdk/v2/client" import { SdkSSEAdapter, type SSEPayload } from "./sdk-sse-adapter" import type { ServerConfig } from "./types" -import { resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" +import { createDuplicateEventFilter, resolveEventSessionId as resolveEventSessionIdPure } from "./connection-utils" import { SandboxPreference } from "../sandbox-preference" +import { ExplicitAbortState } from "./explicit-abort" export type ConnectionState = "connecting" | "connected" | "disconnected" | "error" type SSEEventListener = (event: SSEPayload, directory?: string) => void @@ -96,6 +97,8 @@ export class KiloConnectionService { private remoteService: import("../RemoteStatusService").RemoteStatusService | null = null private readonly eventListeners: Set = new Set() + private readonly filteredListeners = new Set<{ filter: SSEEventFilter; listener: SSEEventListener }>() + private readonly explicitAborts = new ExplicitAbortState() private readonly stateListeners: Set = new Set() private readonly notificationDismissListeners: Set = new Set() private readonly languageChangeListeners: Set = new Set() @@ -276,13 +279,34 @@ export class KiloConnectionService { * Subscribe to SSE events with a filter. The filter runs for every incoming SSE event. */ onEventFiltered(filter: SSEEventFilter, listener: SSEEventListener): () => void { - const wrapped: SSEEventListener = (event, directory) => { - if (!filter(event, directory)) { - return - } - listener(event, directory) + const entry = { filter, listener } + this.filteredListeners.add(entry) + return () => { + this.filteredListeners.delete(entry) } - return this.onEvent(wrapped) + } + + beginExplicitAbort(sessionID: string, directory: string): number | undefined { + return this.explicitAborts.begin(sessionID, directory) + } + + finishExplicitAbort(sessionID: string, directory: string, id: number | undefined, stopped: boolean): void { + for (const item of this.explicitAborts.finish(sessionID, directory, id, stopped)) + this.broadcastFiltered(item.event, item.directory) + } + + async runExplicitAbort(sessionID: string, directory: string, action: () => Promise): Promise { + const id = this.beginExplicitAbort(sessionID, directory) + return action().then( + (result) => { + this.finishExplicitAbort(sessionID, directory, id, true) + return result + }, + (error) => { + this.finishExplicitAbort(sessionID, directory, id, false) + throw error + }, + ) } /** @@ -305,6 +329,7 @@ export class KiloConnectionService { * id after external (CLI/TUI/cascade) deletes arrive via SSE. */ pruneSession(sessionId: string): void { + this.explicitAborts.remove(sessionId) for (const [mid, sid] of this.messageSessionIdsByMessageId) { if (sid === sessionId) this.messageSessionIdsByMessageId.delete(mid) } @@ -681,6 +706,8 @@ export class KiloConnectionService { this.sseClient?.dispose() this.serverManager.dispose() this.eventListeners.clear() + this.filteredListeners.clear() + this.explicitAborts.clear() this.stateListeners.clear() this.notificationDismissListeners.clear() this.profileChangeListeners.clear() @@ -780,6 +807,7 @@ export class KiloConnectionService { this.stopHealthPoll() this.stopCheckin() const sse = this.sseClient + this.explicitAborts.clear() this.sseClient = null sse?.disconnect() this.client = null @@ -820,6 +848,7 @@ export class KiloConnectionService { }, }) const sse = new SdkSSEAdapter(client) + const duplicateEvent = createDuplicateEventFilter() this.client = client this.sseClient = sse @@ -837,11 +866,9 @@ export class KiloConnectionService { // Wire SSE events → broadcast to all registered listeners sse.onEvent((event, directory) => { if (this.sseClient !== sse) return - this.handlePermissionEvent(event, directory) - this.handleQuestionEvent(event, directory) - for (const listener of this.eventListeners) { - listener(event, directory) - } + // EventV2Bridge also emits these durable compatibility envelopes after their normal live events. + if (duplicateEvent(event)) return + this.broadcast(event, directory) }) sse.onError((error) => { @@ -887,6 +914,20 @@ export class KiloConnectionService { this.startHealthPoll(config.baseUrl, config.password) } + private broadcast(event: SSEPayload, directory?: string): void { + this.handlePermissionEvent(event, directory) + this.handleQuestionEvent(event, directory) + for (const listener of this.eventListeners) listener(event, directory) + if (!this.explicitAborts.event(event, directory)) return + this.broadcastFiltered(event, directory) + } + + private broadcastFiltered(event: SSEPayload, directory?: string): void { + for (const entry of this.filteredListeners) { + if (entry.filter(event, directory)) entry.listener(event, directory) + } + } + private startCheckin(): void { this.stopCheckin() this.checkinTimer = setInterval(() => this.flushViewed(), 60_000) diff --git a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts index f41c9a8b16..8a25d85274 100644 --- a/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/connection-utils.ts @@ -4,6 +4,33 @@ export type { SSEPayload } from "./sdk-sse-adapter" type SyncPayload = Extract type TransientPayload = Exclude +const duplicateSyncEvents = new Set([ + "message.updated.1", + "message.removed.1", + "message.part.updated.1", + "message.part.removed.1", + "session.created.1", + "session.deleted.1", +]) + +const duplicateLiveEvents = new Set([...duplicateSyncEvents].map((name) => name.slice(0, -2))) +const DUPLICATE_EVENT_LIMIT = 1024 + +export function createDuplicateEventFilter() { + const seen = new Set() + return (event: SSEPayload): boolean => { + if (event.type === "sync") { + return duplicateSyncEvents.has(event.name) && seen.delete(event.id) + } + + if (duplicateLiveEvents.has(event.type)) { + if (seen.size >= DUPLICATE_EVENT_LIMIT) seen.delete(seen.values().next().value!) + seen.add(event.id) + } + return false + } +} + /** * Pure session ID resolution for SSE events. * The lookupMessageSessionId callback remains part of the public resolver contract for diff --git a/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts b/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts new file mode 100644 index 0000000000..b86e9121a2 --- /dev/null +++ b/packages/kilo-vscode/src/services/cli-backend/explicit-abort.ts @@ -0,0 +1,112 @@ +import path from "node:path" +import type { SSEPayload } from "./sdk-sse-adapter" + +type Buffered = { event: SSEPayload; directory?: string } +type State = { attempts: Set; stopped: boolean; buffered: Buffered[]; generation: number; idle: boolean } + +export class ExplicitAbortState { + private readonly active = new Set() + private readonly states = new Map() + private readonly generations = new Map() + private next = 0 + + begin(sessionID: string, directory: string): number | undefined { + const key = scope(sessionID, directory) + if (!this.active.has(key)) return + const id = ++this.next + const state = this.states.get(key) ?? { + attempts: new Set(), + stopped: false, + buffered: [], + generation: this.generations.get(key) ?? 0, + idle: false, + } + state.attempts.add(id) + this.states.set(key, state) + return id + } + + finish(sessionID: string, directory: string, id: number | undefined, stopped: boolean): Buffered[] { + if (id === undefined) return [] + const key = scope(sessionID, directory) + const state = this.states.get(key) + if (!state || !state.attempts.delete(id)) return [] + if (stopped) { + state.stopped = true + state.buffered = [] + return [] + } + if (state.stopped || state.attempts.size > 0) return [] + this.states.delete(key) + return state.buffered + } + + event(event: SSEPayload, directory?: string): boolean { + if (event.type === "session.status" && directory) return this.status(event, directory) + if (event.type === "session.turn.open") return this.open(event.properties.sessionID, directory) + if (event.type !== "session.turn.close") return true + const keys = this.keys(event.properties.sessionID, directory).filter((key) => this.states.has(key)) + if (keys.length !== 1) return true + const key = keys[0] + const state = this.states.get(key) + if (!state) return true + if (state.generation !== (this.generations.get(key) ?? 0) || event.properties.reason !== "interrupted") { + this.states.delete(key) + return true + } + if (state.stopped) return false + if (state.attempts.size === 0) { + this.states.delete(key) + return true + } + state.buffered.push({ event, directory }) + return false + } + + private status(event: Extract, directory: string) { + const key = scope(event.properties.sessionID, directory) + const state = this.states.get(key) + if (event.properties.status.type === "idle") { + this.active.delete(key) + if (state) state.idle = true + return true + } + this.active.add(key) + if (state?.idle) this.states.delete(key) + return true + } + + private open(sessionID: string, directory?: string) { + for (const key of this.keys(sessionID, directory)) { + this.generations.set(key, (this.generations.get(key) ?? 0) + 1) + this.states.delete(key) + } + return true + } + + clear() { + this.active.clear() + this.states.clear() + this.generations.clear() + } + + remove(sessionID: string) { + for (const key of this.keys(sessionID)) { + this.active.delete(key) + this.states.delete(key) + this.generations.delete(key) + } + } + + private keys(sessionID: string, directory?: string): string[] { + if (directory) return [scope(sessionID, directory)] + const prefix = `${sessionID}\0` + return [...new Set([...this.active, ...this.states.keys(), ...this.generations.keys()])].filter((key) => + key.startsWith(prefix), + ) + } +} + +function scope(sessionID: string, directory: string) { + return `${sessionID}\0${path.resolve(directory)}` +} diff --git a/packages/kilo-vscode/tests/unit/abort.test.ts b/packages/kilo-vscode/tests/unit/abort.test.ts index e971a1211b..af80429270 100644 --- a/packages/kilo-vscode/tests/unit/abort.test.ts +++ b/packages/kilo-vscode/tests/unit/abort.test.ts @@ -20,7 +20,13 @@ describe("SessionAbort", () => { const aborts = new SessionAbort() aborts.observe("session_1", "busy", "/repo") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(true) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toEqual({ + complete: true, + attempts: [ + { dir: "/repo", aborted: true }, + { dir: "/repo/worktree", aborted: true }, + ], + }) expect(calls).toEqual([ { type: "abort", @@ -41,7 +47,10 @@ describe("SessionAbort", () => { aborts.observe("session_1", "busy", "/repo") aborts.observe("session_1", "idle", "/repo") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toBe(false) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree")).toEqual({ + complete: false, + attempts: [{ dir: "/repo/worktree", aborted: true }], + }) expect(calls).toEqual([ { type: "abort", @@ -56,9 +65,23 @@ describe("SessionAbort", () => { const aborts = new SessionAbort() aborts.observe("session_1", "busy", "/repo/worktree") - expect(await aborts.stop(client(calls), "session_1", "/repo/worktree/.")).toBe(true) + expect(await aborts.stop(client(calls), "session_1", "/repo/worktree/.")).toEqual({ + complete: true, + attempts: [{ dir: "/repo/worktree", aborted: true }], + }) expect(calls).toHaveLength(1) }) + + it("reports a failed HTTP abort separately from ownership", async () => { + const calls: unknown[] = [] + const aborts = new SessionAbort() + aborts.observe("session_1", "busy", "/repo") + + expect(await aborts.stop(client(calls, true), "session_1", "/repo")).toEqual({ + complete: false, + attempts: [{ dir: "/repo", aborted: false }], + }) + }) }) describe("abortSession", () => { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-ambient-setup.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-ambient-setup.test.ts index b7fff2af69..003d780626 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-ambient-setup.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-ambient-setup.test.ts @@ -1,7 +1,12 @@ import { describe, expect, it } from "bun:test" import { createRoot, createSignal } from "solid-js" import { LOCAL } from "../../webview-ui/agent-manager/navigate" -import { ambientDecision, createAmbientSetup, showTerminalStack } from "../../webview-ui/agent-manager/terminal/ambient" +import { + ambientDecision, + createAmbientSetup, + keepTerminalStack, + showTerminalStack, +} from "../../webview-ui/agent-manager/terminal/ambient" import { createTerminalState } from "../../webview-ui/agent-manager/terminal/state" describe("showTerminalStack", () => { @@ -33,6 +38,14 @@ describe("showTerminalStack", () => { }) }) +describe("keepTerminalStack", () => { + it("keeps live terminals mounted under history", () => { + expect(keepTerminalStack(true, "wt-1", false, 1)).toBe(true) + expect(keepTerminalStack(true, null, true, 1)).toBe(true) + expect(keepTerminalStack(true, "wt-1", false, 0)).toBe(false) + }) +}) + describe("ambientDecision", () => { it("waits while setup is still running", () => { expect(ambientDecision(undefined, "wt-1", "wt-1")).toBe("wait") diff --git a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts index fff186c189..249efbaee6 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-arch.test.ts @@ -24,11 +24,13 @@ const TSX_FILES = [ path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx"), path.join(ROOT, "webview-ui/agent-manager/SubagentPanel.tsx"), path.join(ROOT, "webview-ui/agent-manager/EditPreviewPanel.tsx"), - path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"), + path.join(ROOT, "webview-ui/agent-manager/SessionRowActions.tsx"), path.join(ROOT, "webview-ui/agent-manager/NewWorktreeDialog.tsx"), path.join(ROOT, "webview-ui/agent-manager/ProjectSelect.tsx"), path.join(ROOT, "webview-ui/agent-manager/sortable-tab.tsx"), path.join(ROOT, "webview-ui/agent-manager/DiffPanel.tsx"), + path.join(ROOT, "webview-ui/agent-manager/DiffPanelCache.tsx"), + path.join(ROOT, "webview-ui/agent-manager/review-composers.ts"), path.join(ROOT, "webview-ui/documents/DocumentPanel.tsx"), path.join(ROOT, "webview-ui/diff-viewer/FullScreenDiffView.tsx"), path.join(ROOT, "webview-ui/diff-viewer/ImageDiffView.tsx"), @@ -456,9 +458,9 @@ describe("Agent Manager Worktree Actions", () => { }) it("does not attribute the new-worktree shortcut to session promotion", () => { - const source = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/UnassignedSessionsSection.tsx"), "utf-8") + const source = fs.readFileSync(path.join(ROOT, "webview-ui/agent-manager/SessionRowActions.tsx"), "utf-8") - expect(source).toContain('() +let id = 0 + +function schedule(fn: FrameRequestCallback) { + const next = ++id + frames.set(next, fn) + return next +} + +function cancel(id: number) { + frames.delete(id) +} + +function preserver(active: () => string | null | undefined = () => undefined) { + return createSidebarScrollPreserver(active, window.document, schedule, cancel) +} + +afterEach(() => { + window.document.body.innerHTML = "" + frames.clear() + id = 0 +}) + +function list(cls = "am-worktree-list") { + const el = window.document.createElement("div") + el.className = cls + Object.defineProperty(el, "scrollTop", { configurable: true, value: 0, writable: true }) + window.document.body.append(el) + return el +} + +function flush() { + for (let i = 0; i < 2; i++) { + const next = frames.entries().next().value + if (!next) return + frames.delete(next[0]) + next[1](0) + } +} + +describe("Agent Manager sidebar scroll preservation", () => { + it("restores the scroll offset after the state update has rendered", () => { + const el = list() + el.scrollTop = 240 + const preserve = preserver() + + preserve(() => { + el.scrollTop = 0 + }) + + expect(el.scrollTop).toBe(0) + flush() + expect(el.scrollTop).toBe(240) + }) + + it("tracks project and worktree scroll owners independently", () => { + const projects = list("am-projects-list") + const worktrees = list() + projects.scrollTop = 120 + worktrees.scrollTop = 80 + const preserve = preserver() + + preserve(() => { + projects.scrollTop = 0 + worktrees.scrollTop = 0 + }) + flush() + + expect(projects.scrollTop).toBe(120) + expect(worktrees.scrollTop).toBe(80) + }) + + it("does not override intentional selection scrolling", () => { + const el = list() + el.scrollTop = 240 + const preserve = preserver() + + preserve(() => { + el.scrollTop = 140 + }) + flush() + + expect(el.scrollTop).toBe(140) + }) + + it("does not restore when the selected worktree changes during the update", () => { + const el = list() + let selected = "first" + el.scrollTop = 240 + const preserve = preserver(() => selected) + + preserve(() => { + el.scrollTop = 0 + selected = "second" + }) + flush() + + expect(el.scrollTop).toBe(0) + }) + + it("does not restore when selection changes before the delayed frame", () => { + const el = list() + let selected = "first" + el.scrollTop = 240 + const preserve = preserver(() => selected) + + preserve(() => { + el.scrollTop = 0 + }) + selected = "second" + flush() + + expect(el.scrollTop).toBe(0) + }) + + it("keeps intentional scrolling from the top of the list", () => { + const el = list() + const preserve = preserver() + + preserve(() => { + el.scrollTop = 180 + }) + flush() + + expect(el.scrollTop).toBe(180) + }) + + it("cancels stale restores when a newer state arrives", () => { + const el = list() + const preserve = preserver() + el.scrollTop = 120 + + preserve(() => { + el.scrollTop = 0 + }) + el.scrollTop = 210 + preserve(() => { + el.scrollTop = 0 + }) + flush() + + expect(el.scrollTop).toBe(210) + expect(frames.size).toBe(0) + }) + + it("does not restore a container that was removed by the update", () => { + const el = list() + el.scrollTop = 160 + const preserve = preserver() + + preserve(() => { + el.remove() + el.scrollTop = 0 + }) + flush() + + expect(el.isConnected).toBe(false) + expect(el.scrollTop).toBe(0) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts index c9d0fdeebc..9f9220edf5 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-layout.test.ts @@ -15,6 +15,7 @@ const terminal = readFileSync( resolve(import.meta.dir, "../../webview-ui/agent-manager/terminal/TerminalTab.tsx"), "utf8", ) +const pkg = readFileSync(resolve(import.meta.dir, "../../package.json"), "utf8") test("xterm owns the padding used by FitAddon", () => { const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1] @@ -74,6 +75,21 @@ test("does not refit hidden terminal buffers during resize", () => { expect(callback!.indexOf("if (!props.active) return")).toBeLessThan(callback!.indexOf("fit.fit()")) }) +test("uses the scalable DOM renderer for concurrent terminals", () => { + expect(terminal).not.toContain("WebglAddon") + expect(pkg).not.toContain("@xterm/addon-webgl") +}) + +test("orders local terminal status lines through the output batcher", () => { + expect(terminal).toContain("const writeLine =") + expect(terminal).not.toContain("term.writeln(") +}) + +test("uses a browser-valid close code when replay overflows", () => { + expect(terminal).not.toContain("close(1009,") + expect(terminal).toContain('close(4009, "terminal replay exceeded limit")') +}) + test("keeps raw PTY line endings and initializes Unicode widths before attaching", () => { expect(terminal).toContain("convertEol: false") expect(terminal).toContain('term.unicode.activeVersion = "15-graphemes"') @@ -85,7 +101,17 @@ test("keeps raw PTY line endings and initializes Unicode widths before attaching test("fits and forces the initial PTY dimensions before socket attach", () => { expect(terminal).toContain("const syncSize = (force = false)") expect(terminal).toContain("if (props.active) syncSize(true)") - expect(terminal.indexOf("fitNow()\n open(props.wsUrl)")).toBeGreaterThan(-1) + expect(terminal.indexOf("fitNow()\n if (!ws) open(props.wsUrl)")).toBeGreaterThan(-1) +}) + +test("keeps terminal sockets mounted while history is open", () => { + expect(css).toContain(".am-detail-stack-hidden") + expect(css).toMatch(/\.am-detail-stack-hidden[^}]*top: 36px/s) + expect(css).toMatch(/\.am-detail-stack-hidden[^}]*transform: translate\(-100vw, 0\)/s) +}) + +test("moves a closed side panel outside xterm's intersection area", () => { + expect(css).toMatch(/\.am-side-host-hidden[^}]*transform: translate\(-100vw, 0\)/s) }) test("re-sends dimensions when an optimistic terminal receives its PTY", () => { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts index 2ab5221ae2..b2c66f99e9 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-terminal-replay.test.ts @@ -1,5 +1,132 @@ import { describe, expect, it } from "bun:test" -import { createInputBuffer, createReplayGate } from "../../webview-ui/agent-manager/terminal/replay" +import { createInputBuffer, createReplayGate, createWriteBatcher } from "../../webview-ui/agent-manager/terminal/replay" + +describe("Agent Manager terminal write batcher", () => { + const harness = (opts: { maxBytes?: number; stallFrames?: boolean } = {}) => { + const writes: Array = [] + const cbs: Array<() => void> = [] + const pending = new Map void>() + const watchdog: Array<() => void> = [] + let next = 0 + const batcher = createWriteBatcher( + (data, callback) => { + writes.push(data) + cbs.push(callback ?? (() => undefined)) + }, + opts.stallFrames + ? () => 0xffff + : (callback) => { + const id = ++next + pending.set(id, callback) + return id + }, + (id) => pending.delete(id), + (callback) => { + watchdog.push(callback) + return watchdog.length + }, + () => undefined, + opts.maxBytes ?? 1024 * 1024, + ) + return { + batcher, + writes, + cbs, + run: () => { + const frames = [...pending.values()] + pending.clear() + for (const frame of frames) frame() + }, + triggerWatchdog: () => watchdog.splice(0).forEach((frame) => frame()), + } + } + + it("joins one frame of text chunks into a single write", () => { + const h = harness() + h.batcher.write("a") + h.batcher.write("b") + h.batcher.write("c") + expect(h.writes).toEqual([]) + h.run() + expect(h.writes).toEqual(["abc"]) + }) + + it("keeps local status output after pending PTY output", () => { + const h = harness() + h.batcher.write("last output") + h.batcher.write("\r\n[terminal ended]\r\n") + h.run() + expect(h.writes).toEqual(["last output\r\n[terminal ended]\r\n"]) + }) + + it("coalesces many frames into separate writes", () => { + const h = harness() + h.batcher.write("1") + h.run() + h.batcher.write("2") + h.run() + expect(h.writes).toEqual(["1", "2"]) + }) + + it("merges binary control frames with text into one byte write", () => { + const h = harness() + h.batcher.write("txt") + h.batcher.write(new Uint8Array([1, 2])) + h.run() + expect(h.writes).toHaveLength(2) + expect(h.writes[0]).toBe("txt") + expect(Array.from(h.writes[1] as Uint8Array)).toEqual([1, 2]) + }) + + it("fires chunk callbacks after the batch write completes", () => { + const h = harness() + let done: string | undefined + h.batcher.write("a", () => (done = "first")) + h.batcher.write("b", () => (done = "second")) + h.run() + expect(h.cbs).toHaveLength(1) + h.cbs[0]!() + expect(done).toBe("second") + }) + + it("keeps the replay boundary sentinel callback after batch parse", () => { + const h = harness() + let boundary = false + h.batcher.write("replay") + h.batcher.write("", () => (boundary = true)) + h.run() + expect(h.writes).toEqual(["replay"]) + h.cbs[0]!() + expect(boundary).toBe(true) + }) + + it("cancel drops pending chunks and stops the scheduled flush", () => { + const h = harness() + h.batcher.write("a") + h.batcher.cancel() + h.run() + expect(h.writes).toEqual([]) + h.batcher.write("b") + h.run() + expect(h.writes).toEqual(["b"]) + }) + + it("flushes immediately when the byte cap is exceeded", () => { + const h = harness({ maxBytes: 4 }) + h.batcher.write("ab") + expect(h.writes).toEqual([]) + h.batcher.write("cd") + expect(h.writes).toEqual(["abcd"]) + }) + + it("watchdog drains the batch when animation frames stall", () => { + const h = harness({ stallFrames: true }) + h.batcher.write("x") + expect(h.writes).toEqual([]) + h.triggerWatchdog() + expect(h.writes).toEqual(["x"]) + }) +}) describe("Agent Manager terminal input buffer", () => { it("sends parser replies first while preserving user input order", () => { @@ -19,6 +146,22 @@ describe("Agent Manager terminal input buffer", () => { expect(input.take()).toBe("bcde2345") }) + + it("clears buffered input after a failed replay", () => { + const input = createInputBuffer() + input.add("command\r") + input.add("reply", true) + input.clear() + expect(input.take()).toBe("") + }) + + it("does not flush input when replay exceeds its limit", () => { + let flushed = 0 + const gate = createReplayGate({ write: () => undefined, flush: () => flushed++ }) + gate.attach(false) + expect(gate.output("x".repeat(8 * 1024 * 1024 + 1))).toBe(false) + expect(flushed).toBe(0) + }) }) describe("Agent Manager terminal replay gate", () => { diff --git a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts index 5d2e5bc3e1..4965ca1bf1 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-worktree-diffs.test.ts @@ -34,7 +34,7 @@ describe("createWorktreeDiffs", () => { it("stores full diffs per session", () => { withDiffs((diffs) => { diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] }) - expect(diffs.diffDatas()["s1"]).toHaveLength(1) + expect(diffs.diffDatas()["single\0s1"]).toHaveLength(1) }) }) @@ -56,7 +56,7 @@ describe("createWorktreeDiffs", () => { file: "a.ts", diff: diff("a.ts", 9), }) - expect(diffs.diffDatas()["s1"]![0]!.additions).toBe(9) + expect(diffs.diffDatas()["single\0s1"]![0]!.additions).toBe(9) expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0) }) }) @@ -65,11 +65,22 @@ describe("createWorktreeDiffs", () => { withDiffs((diffs) => { diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: true }) expect(diffs.diffLoading()).toBe(true) + expect(diffs.diffLoadingFor(() => "s1")).toBe(true) + diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [] }) + expect(diffs.diffLoadingFor(() => "s1")).toBe(false) diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: false }) expect(diffs.diffLoading()).toBe(false) }) }) + it("keeps loading isolated to its composite diff id", () => { + withDiffs((diffs) => { + diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1#branch", loading: true }) + expect(diffs.diffLoadingFor(() => "s1#branch")).toBe(true) + expect(diffs.diffLoadingFor(() => "s2#branch")).toBe(false) + }) + }) + it("requestDiffFile marks a file pending, posts once, and ignores repeats", () => { withDiffs((diffs, sent) => { diffs.requestDiffFile("s1", "a.ts") diff --git a/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts index 7e7e746022..171df5b67d 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-selection.test.ts @@ -19,6 +19,7 @@ function fakeState(persisted?: { current?: unknown }) { return { getWorktree: (id: string) => (id === "wt1" ? { path: "/repo/prj-extra/wt1" } : undefined), getSession: (id: string) => (id === "sess1" ? {} : undefined), + moveSession: () => {}, getActiveTarget: () => store.current, setActiveTarget: (target: unknown) => { store.current = target @@ -172,6 +173,27 @@ describe("activateSelection — cross-project selection", () => { expect(calls.error).toEqual([]) }) + it("pushes moved-session state before acknowledging local activation", async () => { + const { contexts, deps, calls, extra } = setup() + const ctx = contexts.expand(extra)! + ctx.stateManager() + await ctx.ensureReady(async () => ({ ok: true, refsFixed: 0 })) + contexts.activate(extra) + + const order: string[] = [] + deps.push = () => order.push("projects") + deps.pushState = () => order.push("state") + deps.selected = () => order.push("selected") + + await handleProjectMessage( + { type: "agentManager.openSessionLocally", projectId: extra, sessionId: "sess1" } as never, + deps, + ) + + expect(order).toEqual(["state", "projects", "projects", "selected"]) + expect(calls.error).toEqual([]) + }) + it("restores the persisted target when the selection asks for it", async () => { const persisted = { current: undefined as unknown } const { contexts, deps, calls, extra } = setup({ state: () => fakeState(persisted) }) diff --git a/packages/kilo-vscode/tests/unit/background-agents.test.ts b/packages/kilo-vscode/tests/unit/background-agents.test.ts index e83d7c1243..567e663b40 100644 --- a/packages/kilo-vscode/tests/unit/background-agents.test.ts +++ b/packages/kilo-vscode/tests/unit/background-agents.test.ts @@ -2,9 +2,10 @@ import { describe, expect, it } from "bun:test" import { backgroundAgents, backgroundJobAgents, - foregroundAgent, showBackgroundAgent, } from "../../webview-ui/src/components/chat/background-agents" +import { childForeground, showChildPromotion } from "../../webview-ui/src/components/chat/task-tool-state" +import { latestTaskPart } from "../../webview-ui/src/context/session-utils" import type { BackgroundJobInfo, PermissionRequest, @@ -74,6 +75,37 @@ describe("backgroundAgents", () => { expect(backgroundAgents([taskPart({ child: "ses_child" })], { ses_child: busy })).toEqual([]) }) + it("identifies each parallel foreground child independently", () => { + const status = { ses_a: busy, ses_b: busy } + + expect(childForeground("ses_a", {}, {}, status, true)).toBe(true) + expect(childForeground("ses_b", {}, {}, status, true)).toBe(true) + expect(childForeground("ses_a", { background: true }, {}, status, true)).toBe(false) + expect(childForeground("ses_b", {}, { background: true }, status, true)).toBe(false) + expect(childForeground("ses_a", {}, {}, { ses_a: idle }, true)).toBe(false) + expect( + childForeground("ses_a", {}, {}, { ses_a: { type: "retry", attempt: 1, message: "retry", next: 1 } }, true), + ).toBe(true) + expect(childForeground(undefined, {}, {}, status, true)).toBe(false) + expect(childForeground("ses_a", {}, {}, status, false)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, true, false, true)).toBe(true) + expect(showChildPromotion("ses_a", {}, {}, status, true, true, true)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, false, false, true)).toBe(false) + expect(showChildPromotion("ses_a", {}, {}, status, undefined, false, true)).toBe(false) + }) + + it("only promotes the latest task part for a resumed child", () => { + const parts = [ + taskPart({ id: "part_old", child: "ses_a" }), + taskPart({ id: "part_new", child: "ses_a" }), + taskPart({ id: "part_other", child: "ses_b" }), + ] + + expect(latestTaskPart("part_old", "ses_a", parts)).toBe(false) + expect(latestTaskPart("part_new", "ses_a", parts)).toBe(true) + expect(latestTaskPart("part_other", "ses_b", parts)).toBe(true) + }) + it("ignores agents whose session is no longer working", () => { const tools = [taskPart({ child: "ses_child", background: true })] @@ -262,13 +294,4 @@ describe("backgroundAgents", () => { expect(showBackgroundAgent(agent, hidden)).toBe(true) expect(showBackgroundAgent({ ...agent, status: "completed" }, hidden)).toBe(false) }) - - it("finds a running foreground child that can be promoted", () => { - const tools = [ - taskPart({ id: "part_background", child: "child_background", background: true }), - taskPart({ id: "part_foreground", child: "child_foreground", background: false }), - ] - - expect(foregroundAgent(tools, { child_background: busy, child_foreground: busy })).toBe("child_foreground") - }) }) diff --git a/packages/kilo-vscode/tests/unit/connection-utils.test.ts b/packages/kilo-vscode/tests/unit/connection-utils.test.ts index bd9ab80435..3a31216509 100644 --- a/packages/kilo-vscode/tests/unit/connection-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/connection-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { resolveEventSessionId } from "../../src/services/cli-backend/connection-utils" +import { createDuplicateEventFilter, resolveEventSessionId } from "../../src/services/cli-backend/connection-utils" import type { SSEPayload as Payload } from "../../src/services/cli-backend/sdk-sse-adapter" const noLookup = (_: string) => undefined @@ -172,3 +172,191 @@ describe("resolveEventSessionId", () => { expect(resolveEventSessionId(event, noLookup)).toBeUndefined() }) }) + +describe("createDuplicateEventFilter", () => { + it("drops a compatibility envelope only after its live event", () => { + const filter = createDuplicateEventFilter() + const live = { + id: "e13", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + } satisfies Payload + expect(filter(live)).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "e13", + seq: 5, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + }) + + it("keeps replay-only compatibility envelopes", () => { + const filter = createDuplicateEventFilter() + expect( + filter( + sync({ + type: "sync", + name: "session.next.model.switched.1", + id: "e14", + seq: 6, + aggregateID: "s6", + data: { sessionID: "s6", messageID: "m1", model: { id: "test", providerID: "kilo" } }, + }), + ), + ).toBe(false) + }) + + it("keeps session updates because the provider consumes their sync metadata", () => { + const filter = createDuplicateEventFilter() + const live = { + id: "e15", + type: "session.updated", + properties: { sessionID: "s6", info: { id: "s6", time: { created: 0, updated: 1 } } }, + } as Payload + expect(filter(live)).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "session.updated.1", + id: "e15", + seq: 7, + aggregateID: "s6", + data: { sessionID: "s6", info: { id: "s6", time: { created: 0, updated: 1 } } }, + }), + ), + ).toBe(false) + }) + + it("continues tracking new live events after the cap is reached", () => { + const filter = createDuplicateEventFilter() + for (let index = 0; index < 1024; index++) { + expect( + filter({ + id: `live-${index}`, + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + } + + expect( + filter({ + id: "live-1024", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-1024", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-0", + seq: 8, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "live-1024", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + }) + + it("forwards delayed envelopes for evicted IDs", () => { + const filter = createDuplicateEventFilter() + for (let index = 0; index < 1024; index++) { + expect( + filter({ + id: `pending-${index}`, + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + } + + expect( + filter({ + id: "overflow", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + }), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "pending-0", + seq: 8, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + expect( + filter( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "pending-1023", + seq: 9, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(true) + }) + + it("does not carry duplicate IDs between connections", () => { + const first = createDuplicateEventFilter() + const second = createDuplicateEventFilter() + const live = { + id: "connection-event", + type: "message.part.updated", + properties: { sessionID: "s6", part, delta: "x" }, + } satisfies Payload + + expect(first(live)).toBe(false) + expect( + second( + sync({ + type: "sync", + name: "message.part.updated.1", + id: "connection-event", + seq: 11, + aggregateID: "s6", + data: { sessionID: "s6", part, time: 0 }, + }), + ), + ).toBe(false) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts index 86d341b938..52c4e33748 100644 --- a/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts +++ b/packages/kilo-vscode/tests/unit/continue-in-worktree.test.ts @@ -72,8 +72,11 @@ function result(path: string): CreateWorktreeResult { function ctx(overrides: Partial = {}): ContinueContext { return { root: "/tmp/test", - getClient: () => { - throw new Error("no client") + connection: { + getClient: () => { + throw new Error("no client") + }, + runExplicitAbort: async (_sessionId, _directory, action) => action(), }, createWorktreeOnDisk: async () => null, runSetupScript: async () => {}, @@ -98,10 +101,12 @@ describe("continue-in-worktree steps", () => { it("does not throw when abort rejects", async () => { const c = ctx({ - getClient: () => - ({ - session: { abort: () => Promise.reject(new Error("fail")) }, - }) as never, + connection: { + getClient: () => ({ session: { abort: async () => undefined } }) as never, + runExplicitAbort: async () => { + throw new Error("fail") + }, + }, }) await abortSession(c, "session-1") }) @@ -109,15 +114,17 @@ describe("continue-in-worktree steps", () => { it("calls abort on the client", async () => { let called = false const c = ctx({ - getClient: () => - ({ - session: { - abort: () => { - called = true - return Promise.resolve() + connection: { + getClient: () => + ({ + session: { + abort: async () => { + called = true + }, }, - }, - }) as never, + }) as never, + runExplicitAbort: async (_sessionId, _directory, action) => action(), + }, }) await abortSession(c, "session-1") expect(called).toBe(true) @@ -134,10 +141,13 @@ describe("continue-in-worktree steps", () => { it("returns error when fork rejects", async () => { const c = ctx({ - getClient: () => - ({ - session: { fork: () => Promise.reject(new Error("fork failed")) }, - }) as never, + connection: { + getClient: () => + ({ + session: { fork: () => Promise.reject(new Error("fork failed")) }, + }) as never, + runExplicitAbort: async (_s, _d, action) => action(), + }, }) const res = await forkSession(c, "session-1", "/tmp/wt") expect(res.ok).toBe(false) @@ -148,10 +158,13 @@ describe("continue-in-worktree steps", () => { const forked = session("forked-1") const promptAsync = mock(async () => ({})) const c = ctx({ - getClient: () => - ({ - session: { fork: () => Promise.resolve({ data: forked }), promptAsync }, - }) as never, + connection: { + getClient: () => + ({ + session: { fork: () => Promise.resolve({ data: forked }), promptAsync }, + }) as never, + runExplicitAbort: async (_s, _d, action) => action(), + }, }) const res = await forkSession(c, "session-1", "/tmp/wt") expect(res.ok).toBe(true) @@ -215,7 +228,7 @@ describe("continueInWorktree", () => { let created: CreateWorktreeResult | undefined const c = ctx({ root, - getClient: () => api, + connection: { getClient: () => api, runExplicitAbort: async (_s, _d, action) => action() }, createWorktreeOnDisk: async (opts) => { const value = await manager.createWorktree(opts) created = value @@ -246,7 +259,7 @@ describe("continueInWorktree", () => { let created: CreateWorktreeResult | undefined const c = ctx({ root, - getClient: () => client(), + connection: { getClient: () => client(), runExplicitAbort: async (_s, _d, action) => action() }, createWorktreeOnDisk: async (opts) => { const value = await manager.createWorktree(opts) created = value diff --git a/packages/kilo-vscode/tests/unit/early-message.test.ts b/packages/kilo-vscode/tests/unit/early-message.test.ts index 1709d03b2e..cad4c42b43 100644 --- a/packages/kilo-vscode/tests/unit/early-message.test.ts +++ b/packages/kilo-vscode/tests/unit/early-message.test.ts @@ -76,4 +76,16 @@ describe("routeEarlyMessage background jobs", () => { ).toBe(true) expect(calls).toEqual([["ses_child", "ses_parent", "request-2"]]) }) + + it("forwards promotion for one child through its owning parent session", async () => { + const calls: unknown[] = [] + const ctx = { + promoteBackgroundJob: async (jobID: string, sessionID: string) => calls.push([jobID, sessionID]), + } as Ctx + + expect( + await routeEarlyMessage({ type: "promoteBackgroundJob", jobID: "ses_child_a", sessionID: "ses_parent" }, ctx), + ).toBe(true) + expect(calls).toEqual([["ses_child_a", "ses_parent"]]) + }) }) diff --git a/packages/kilo-vscode/tests/unit/explicit-abort.test.ts b/packages/kilo-vscode/tests/unit/explicit-abort.test.ts new file mode 100644 index 0000000000..6894478709 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/explicit-abort.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from "bun:test" +import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter" +import { ExplicitAbortState } from "../../src/services/cli-backend/explicit-abort" + +const open = (sessionID = "session") => + ({ id: "event-open", type: "session.turn.open", properties: { sessionID } }) as SSEPayload + +const status = (type: "idle" | "busy", sessionID = "session") => + ({ type: "session.status", properties: { sessionID, status: { type } } }) as SSEPayload + +const close = (reason: "completed" | "interrupted", sessionID = "session") => + ({ id: `event-${reason}`, type: "session.turn.close", properties: { sessionID, reason } }) as SSEPayload + +describe("explicit abort state", () => { + it("does not suppress an unexpected interruption", () => { + const state = new ExplicitAbortState() + + expect(state.event(close("interrupted"))).toBe(true) + }) + + it("drops an interrupted close after an explicit abort succeeds", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(false) + expect(state.finish("session", "/repo", id, true)).toEqual([]) + }) + + it("drops an interrupted close that arrives after abort success", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + + expect(state.event(close("interrupted"), "/repo")).toBe(false) + }) + + it("replays an interrupted close when the abort fails", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + const event = close("interrupted") + state.event(event, "/repo") + + expect(state.finish("session", "/repo", id, false)).toEqual([{ event, directory: "/repo" }]) + }) + + it("never suppresses a completed close", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + state.begin("session", "/repo") + + expect(state.event(close("completed"))).toBe(true) + }) + + it("waits for concurrent abort attempts before replaying", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const first = state.begin("session", "/repo") + const second = state.begin("session", "/repo") + state.event(close("interrupted"), "/repo") + + expect(state.finish("session", "/repo", first, false)).toEqual([]) + expect(state.finish("session", "/repo", second, true)).toEqual([]) + }) + + it("allows a later real interruption in the same session", () => { + const state = new ExplicitAbortState() + state.event(open(), "/repo") + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + expect(state.event(close("interrupted"), "/repo")).toBe(false) + state.event(status("idle"), "/repo") + state.event(open(), "/repo") + state.event(status("busy"), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("clears a pending abort when a new turn opens", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.event(open(), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + expect(state.finish("session", "/repo", id, true)).toEqual([]) + }) + + it("isolates identical session ids by directory", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo/a") + const id = state.begin("session", "/repo/a") + state.finish("session", "/repo/a", id, true) + + expect(state.event(close("interrupted"), "/repo/b")).toBe(true) + expect(state.event(close("interrupted"), "/repo/a")).toBe(false) + }) + + it("does not mark an already idle session", () => { + const state = new ExplicitAbortState() + state.event(status("idle"), "/repo") + + expect(state.begin("session", "/repo")).toBeUndefined() + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("clears suppression on a later busy status without turn-open", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.finish("session", "/repo", id, true) + state.event(status("idle"), "/repo") + state.event(status("busy"), "/repo") + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) + + it("does not carry a pending abort into a new busy turn", () => { + const state = new ExplicitAbortState() + state.event(status("busy"), "/repo") + const id = state.begin("session", "/repo") + state.event(status("idle"), "/repo") + state.event(status("busy"), "/repo") + state.finish("session", "/repo", id, true) + + expect(state.event(close("interrupted"), "/repo")).toBe(true) + }) +}) diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index 05c768c93a..c90aa3a7c0 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -667,6 +667,20 @@ describe("GitOps", () => { }) }) + it("kills an in-flight exec when its request signal aborts", async () => { + await withRepo(async (cwd) => { + const git = new GitOps({ log: () => undefined, binary: async () => process.execPath }) + const ctl = new AbortController() + const pending = git.execGit(["-e", "setTimeout(() => {}, 5000)"], cwd, { signal: ctl.signal }) + await sleep(25) + ctl.abort() + + const result = await pending + expect(result.code).not.toBe(0) + git.dispose() + }) + }) + it("is safe to call multiple times", () => { const git = ops(async () => "ok") git.dispose() diff --git a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts index ad6551e802..9e9e43d257 100644 --- a/packages/kilo-vscode/tests/unit/indexing-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/indexing-utils.test.ts @@ -121,6 +121,11 @@ describe("indexing SSE mapping", () => { }) describe("indexing feature detection", () => { + it("keeps background subagent capability disabled unless the server reports it", () => { + expect(configFeatures().backgroundSubagents).toBe(false) + expect(configFeatures({}, true).backgroundSubagents).toBe(true) + }) + it("enables indexing settings when the indexing plugin is present", () => { expect(configFeatures({ plugin: ["kilo-indexing"] }).indexing).toBe(true) }) diff --git a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts index 82621a7e46..ebb3ad1833 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-indexing-refresh.test.ts @@ -87,6 +87,11 @@ function createConnection() { return { data: snapshot } }, }, + experimental: { + capabilities: { + get: async () => ({ data: { backgroundSubagents: true } }), + }, + }, } return { 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 index 58de510b24..24be2aaf25 100644 --- a/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/kilo-provider-load-messages.test.ts @@ -191,6 +191,8 @@ function createConnection(client: ReturnType) { }, connect: async () => {}, getClient: () => client, + beginExplicitAbort: () => 1 as number | undefined, + finishExplicitAbort: () => undefined, onEventFiltered: () => () => undefined, onStateChange: (_l: (s: State) => void) => () => undefined, onNotificationDismissed: () => () => undefined, @@ -1238,7 +1240,7 @@ describe("KiloProvider.handleLoadMessages / slim payload", () => { expect(client.aborted).toContainEqual({ sessionID: "s1", directory: "/repo" }) expect(sent).toContainEqual({ type: "sessionCostAlertResolved", sessionID: "s1", limit: 1 }) - expect(sent).toContainEqual({ type: "sessionTurnClosed", sessionID: "s1", reason: "interrupted" }) + expect(sent).not.toContainEqual({ type: "sessionTurnClosed", sessionID: "s1", reason: "interrupted" }) }) it("strips transcript-only metadata before posting messages to the webview", async () => { diff --git a/packages/kilo-vscode/tests/unit/local-diff.test.ts b/packages/kilo-vscode/tests/unit/local-diff.test.ts index 68f8e8f89b..595d0bf349 100644 --- a/packages/kilo-vscode/tests/unit/local-diff.test.ts +++ b/packages/kilo-vscode/tests/unit/local-diff.test.ts @@ -352,6 +352,52 @@ describe("diffFile", () => { }) }) + it("reuses cached detail while the summary stamp is unchanged", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n") + const local = createLocalDiff(git()) + await local.summary(dir, base) + + const first = await local.file(dir, base, "seed.txt") + const second = await local.file(dir, base, "seed.txt") + + expect(second).toBe(first) + }) + }) + + it("does not cache detail that is aborted before Git completes", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\ncached\n") + const local = createLocalDiff(git()) + await local.summary(dir, base) + + const ctl = new AbortController() + const pending = local.file(dir, base, "seed.txt", ctl.signal) + ctl.abort() + await expect(pending).rejects.toThrow() + + const result = await local.file(dir, base, "seed.txt") + expect(result?.after).toBe("seed\ncached\n") + }) + }) + + it("invalidates cached detail after the summary stamp changes", async () => { + await withRepo(async (dir, base) => { + await fs.writeFile(path.join(dir, "seed.txt"), "seed\nfirst\n") + const local = createLocalDiff(git()) + await local.summary(dir, base) + const first = await local.file(dir, base, "seed.txt") + + await new Promise((resolve) => setTimeout(resolve, 5)) + await fs.writeFile(path.join(dir, "seed.txt"), "seed\nsecond value\n") + await local.summary(dir, base) + const second = await local.file(dir, base, "seed.txt") + + expect(second).not.toBe(first) + expect(second?.after).toBe("seed\nsecond value\n") + }) + }) + it("does not materialize binary detail from a cached summary", async () => { await withRepo(async (dir, base) => { await fs.writeFile(path.join(dir, "tone.wav"), Buffer.from([0x52, 0x49, 0x46, 0x46, 0x00, 0x01, 0x02, 0x03])) diff --git a/packages/kilo-vscode/tests/unit/navigate.test.ts b/packages/kilo-vscode/tests/unit/navigate.test.ts index 7a08ba6021..6d0a29cee3 100644 --- a/packages/kilo-vscode/tests/unit/navigate.test.ts +++ b/packages/kilo-vscode/tests/unit/navigate.test.ts @@ -5,13 +5,11 @@ import { validateLocalSession, adjacentHint, canOpenRootSession, - filterUnassignedSessions, remoteSessions, buildProjectNavOrder, resolveProjectNav, localNavId, worktreeNavId, - sessionNavId, type ProjectNavInput, LOCAL, } from "../../webview-ui/agent-manager/navigate" @@ -203,105 +201,6 @@ describe("adjacentHint", () => { }) }) -describe("filterUnassignedSessions", () => { - const at = (day: number) => `2026-01-${String(day).padStart(2, "0")}T00:00:00.000Z` - const info = (id: string, day: number, parentID: string | null = null) => ({ - id, - createdAt: at(day), - parentID, - }) - - it("filters sparse session updates until ancestry is known", () => { - const result = filterUnassignedSessions([{ id: "unknown", createdAt: at(1) }], new Set(), new Set()) - - expect(result).toEqual([]) - }) - - it("keeps root sessions with null parent IDs", () => { - const result = filterUnassignedSessions([info("root", 1, null)], new Set(), new Set()) - - expect(result.map((s) => s.id)).toEqual(["root"]) - }) - - it("filters child sessions with parent IDs", () => { - const result = filterUnassignedSessions( - [info("parent", 2), info("child", 3, "parent"), info("orphan", 4, "missing")], - new Set(), - new Set(), - ) - - expect(result.map((s) => s.id)).toEqual(["parent"]) - }) - - it("filters string parent IDs even when they are empty", () => { - const result = filterUnassignedSessions([info("blank", 2, ""), info("root", 1)], new Set(), new Set()) - - expect(result.map((s) => s.id)).toEqual(["root"]) - }) - - it("filters worktree sessions while keeping other roots", () => { - const result = filterUnassignedSessions( - [info("root", 1), info("worktree", 3), info("other", 2)], - new Set(["worktree"]), - new Set(), - ) - - expect(result.map((s) => s.id)).toEqual(["other", "root"]) - }) - - it("filters local tab sessions while keeping other roots", () => { - const result = filterUnassignedSessions( - [info("root", 1), info("local", 3), info("other", 2)], - new Set(), - new Set(["local"]), - ) - - expect(result.map((s) => s.id)).toEqual(["other", "root"]) - }) - - it("applies child, worktree, and local filters before sorting", () => { - const result = filterUnassignedSessions( - [info("old-root", 1), info("child", 6, "old-root"), info("worktree", 5), info("local", 4), info("new-root", 3)], - new Set(["worktree"]), - new Set(["local"]), - ) - - expect(result.map((s) => s.id)).toEqual(["new-root", "old-root"]) - }) - - it("returns an empty list when every session is filtered", () => { - const result = filterUnassignedSessions( - [info("child", 3, "root"), info("worktree", 2), info("local", 1)], - new Set(["worktree"]), - new Set(["local"]), - ) - - expect(result).toEqual([]) - }) - - it("does not mutate the input order", () => { - const sessions = [info("old", 1), info("new", 3), info("mid", 2)] - - filterUnassignedSessions(sessions, new Set(), new Set()) - - expect(sessions.map((s) => s.id)).toEqual(["old", "new", "mid"]) - }) - - it("preserves session objects and extra fields", () => { - const root = { ...info("root", 1), title: "Existing session" } - const result = filterUnassignedSessions([root], new Set(), new Set()) - - expect(result[0]).toBe(root) - expect(result[0]?.title).toBe("Existing session") - }) - - it("keeps a parent root when its child is filtered", () => { - const result = filterUnassignedSessions([info("root", 1), info("child", 2, "root")], new Set(), new Set()) - - expect(result.map((s) => s.id)).toEqual(["root"]) - }) -}) - describe("canOpenRootSession", () => { const sessions = [{ id: "root", parentID: null }, { id: "child", parentID: "root" }, { id: "sparse" }] @@ -338,17 +237,10 @@ describe("remoteSessions", () => { }) describe("buildProjectNavOrder", () => { - const project = ( - p: Omit & { sessionsCollapsed?: boolean }, - ): ProjectNavInput => ({ - ...p, - sessionsCollapsed: p.sessionsCollapsed ?? false, - }) - - it("builds A Local -> A worktree -> B Local -> B worktree -> B session across expanded projects", () => { + it("builds A Local -> A worktree -> B Local -> B worktree across expanded projects", () => { const order = buildProjectNavOrder([ - project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }), - project({ id: "B", expanded: true, worktrees: [{ id: "bw1" }], sections: [], unassigned: [{ id: "bs1" }] }), + { id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [] }, + { id: "B", expanded: true, worktrees: [{ id: "bw1" }], sections: [] }, ]) expect(order.map((e) => e.id)).toEqual([ @@ -356,33 +248,27 @@ describe("buildProjectNavOrder", () => { worktreeNavId("A", "aw1"), localNavId("B"), worktreeNavId("B", "bw1"), - sessionNavId("B", "bs1"), ]) expect(order.map((e) => e.target)).toEqual([ { projectId: "A", kind: "local" }, { projectId: "A", kind: "worktree", worktreeId: "aw1" }, { projectId: "B", kind: "local" }, { projectId: "B", kind: "worktree", worktreeId: "bw1" }, - { projectId: "B", kind: "session", sessionId: "bs1" }, ]) }) - it("uses project-qualified composite ids, never raw worktree/session ids", () => { - const order = buildProjectNavOrder([ - project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [{ id: "as1" }] }), - ]) + it("uses project-qualified composite ids, never raw worktree ids", () => { + const order = buildProjectNavOrder([{ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [] }]) const ids = order.map((e) => e.id) expect(ids).not.toContain("aw1") - expect(ids).not.toContain("as1") expect(ids).toContain("A:local") expect(ids).toContain("A:wt:aw1") - expect(ids).toContain("A:sess:as1") }) it("excludes collapsed projects entirely", () => { const order = buildProjectNavOrder([ - project({ id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }), - project({ id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [], unassigned: [{ id: "cs1" }] }), + { id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [] }, + { id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [] }, ]) expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1")]) expect(order.some((e) => e.target.kind === "worktree" && e.target.worktreeId === "cw1")).toBe(false) @@ -390,33 +276,31 @@ describe("buildProjectNavOrder", () => { it("excludes worktrees inside collapsed sections but keeps ungrouped ones", () => { const order = buildProjectNavOrder([ - project({ + { id: "A", expanded: true, worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }], sections: [{ id: "s1", collapsed: true }], - unassigned: [], - }), + }, ]) expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw2")]) }) it("renders ungrouped worktrees before section members (matching the project body)", () => { const order = buildProjectNavOrder([ - project({ + { id: "A", expanded: true, worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }], sections: [{ id: "s1", collapsed: false }], - unassigned: [], - }), + }, ]) expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw2"), worktreeNavId("A", "aw1")]) }) it("follows persisted top-level section order and worktree order", () => { const order = buildProjectNavOrder([ - project({ + { id: "A", expanded: true, worktrees: [{ id: "aw1", sectionId: "s1" }, { id: "aw2" }, { id: "aw3", sectionId: "s2" }], @@ -425,8 +309,7 @@ describe("buildProjectNavOrder", () => { { id: "s1", collapsed: false }, { id: "s2", collapsed: false }, ], - unassigned: [], - }), + }, ]) expect(order.map((e) => e.id)).toEqual([ @@ -439,14 +322,13 @@ describe("buildProjectNavOrder", () => { it("keeps multi-version worktrees adjacent", () => { const order = buildProjectNavOrder([ - project({ + { id: "A", expanded: true, worktrees: [{ id: "aw1", groupId: "g" }, { id: "aw2" }, { id: "aw3", groupId: "g" }], worktreeOrder: ["aw1", "aw2", "aw3"], sections: [], - unassigned: [], - }), + }, ]) expect(order.map((e) => e.id)).toEqual([ @@ -459,7 +341,7 @@ describe("buildProjectNavOrder", () => { it("matches raw ungrouped order when sections are present", () => { const order = buildProjectNavOrder([ - project({ + { id: "A", expanded: true, worktrees: [ @@ -470,8 +352,7 @@ describe("buildProjectNavOrder", () => { ], worktreeOrder: ["aw1", "aw2", "aw3", "s1", "aw4"], sections: [{ id: "s1", collapsed: false }], - unassigned: [], - }), + }, ]) expect(order.map((e) => e.id)).toEqual([ @@ -483,49 +364,26 @@ describe("buildProjectNavOrder", () => { ]) }) - it("excludes unassigned sessions when the sessions section is collapsed", () => { - const order = buildProjectNavOrder([ - project({ - id: "A", - expanded: true, - worktrees: [{ id: "aw1" }], - sections: [], - sessionsCollapsed: true, - unassigned: [{ id: "as1" }, { id: "as2" }], - }), - ]) - expect(order.map((e) => e.id)).toEqual([localNavId("A"), worktreeNavId("A", "aw1")]) - }) - it("returns an empty order when every project is collapsed", () => { - const order = buildProjectNavOrder([ - project({ id: "A", expanded: false, worktrees: [{ id: "aw1" }], sections: [], unassigned: [] }), - ]) + const order = buildProjectNavOrder([{ id: "A", expanded: false, worktrees: [{ id: "aw1" }], sections: [] }]) expect(order).toEqual([]) }) }) describe("resolveProjectNav", () => { - // A Local -> A worktree -> B Local -> B worktree -> B session + // A Local -> A worktree -> B Local -> B worktree const inputs: ProjectNavInput[] = [ - { id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [], sessionsCollapsed: false, unassigned: [] }, - { - id: "B", - expanded: true, - worktrees: [{ id: "bw1" }], - sections: [], - sessionsCollapsed: false, - unassigned: [{ id: "bs1" }], - }, + { id: "A", expanded: true, worktrees: [{ id: "aw1" }], sections: [] }, + { id: "B", expanded: true, worktrees: [{ id: "bw1" }], sections: [] }, ] const order = buildProjectNavOrder(inputs) // Collapsed project C must not appear in the order const withCollapsed = buildProjectNavOrder([ ...inputs, - { id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [], sessionsCollapsed: false, unassigned: [] }, + { id: "C", expanded: false, worktrees: [{ id: "cw1" }], sections: [] }, ]) - it("walks forward A Local -> A worktree -> B Local -> B worktree -> B session", () => { + it("walks forward A Local -> A worktree -> B Local -> B worktree", () => { let current: string | undefined = undefined const trail: string[] = [] for (let i = 0; i < 6; i++) { @@ -534,17 +392,11 @@ describe("resolveProjectNav", () => { current = entry.id trail.push(entry.id) } - expect(trail).toEqual([ - localNavId("A"), - worktreeNavId("A", "aw1"), - localNavId("B"), - worktreeNavId("B", "bw1"), - sessionNavId("B", "bs1"), - ]) + expect(trail).toEqual([localNavId("A"), worktreeNavId("A", "aw1"), localNavId("B"), worktreeNavId("B", "bw1")]) }) - it("walks in reverse B session -> B worktree -> B Local -> A worktree -> A Local", () => { - let current: string | undefined = sessionNavId("B", "bs1") + it("walks in reverse B worktree -> B Local -> A worktree -> A Local", () => { + let current: string | undefined = worktreeNavId("B", "bw1") const trail: string[] = [current] for (let i = 0; i < 6; i++) { const entry = resolveProjectNav("up", current, order) @@ -552,13 +404,7 @@ describe("resolveProjectNav", () => { current = entry.id trail.push(current) } - expect(trail).toEqual([ - sessionNavId("B", "bs1"), - worktreeNavId("B", "bw1"), - localNavId("B"), - worktreeNavId("A", "aw1"), - localNavId("A"), - ]) + expect(trail).toEqual([worktreeNavId("B", "bw1"), localNavId("B"), worktreeNavId("A", "aw1"), localNavId("A")]) }) it("returns undefined at the top boundary (up from first)", () => { @@ -566,12 +412,12 @@ describe("resolveProjectNav", () => { }) it("returns undefined at the bottom boundary (down from last)", () => { - expect(resolveProjectNav("down", sessionNavId("B", "bs1"), order)).toBeUndefined() + expect(resolveProjectNav("down", worktreeNavId("B", "bw1"), order)).toBeUndefined() }) it("does not wrap around", () => { expect(resolveProjectNav("up", localNavId("A"), order)).toBeUndefined() - expect(resolveProjectNav("down", sessionNavId("B", "bs1"), order)).toBeUndefined() + expect(resolveProjectNav("down", worktreeNavId("B", "bw1"), order)).toBeUndefined() }) it("treats an unknown current as before-first (down -> first, up -> undefined)", () => { @@ -592,7 +438,7 @@ describe("resolveProjectNav", () => { it("collapsed project C is excluded from the order", () => { expect(withCollapsed.length).toBe(order.length) expect(withCollapsed.some((e) => e.id === worktreeNavId("C", "cw1"))).toBe(false) - // Forward walk still ends at B session, never reaching C + // Forward walk still ends at B worktree, never reaching C let current: string | undefined = undefined let last: string | undefined for (let i = 0; i < 10; i++) { @@ -601,7 +447,7 @@ describe("resolveProjectNav", () => { current = entry.id last = entry.id } - expect(last).toBe(sessionNavId("B", "bs1")) + expect(last).toBe(worktreeNavId("B", "bw1")) }) }) @@ -636,25 +482,15 @@ describe("createProjectNav", () => { worktrees: { id: string; sectionId?: string }[], sessions: { id: string; worktreeId: string | null }[], sections: { id: string; collapsed: boolean }[] = [], - sessionsCollapsed = false, ): AgentManagerStateMessage => ({ type: "agentManager.state", worktrees: worktrees as never, sessions: sessions as never, sections, - sessionsCollapsed, }) as AgentManagerStateMessage - const session = (id: string): ProjectSessionInfo => ({ - id, - title: id, - createdAt: "", - updatedAt: "", - worktreeId: null, - }) - - // A (expanded): worktree aw1. B (expanded): worktree bw1 + unassigned bs1. + // A (expanded): worktree aw1. B (expanded): worktree bw1. // C (collapsed): worktree cw1 — must never be reached. const projects = () => [project("A", true), project("B", true), project("C", false)] const states = () => ({ @@ -662,7 +498,6 @@ describe("createProjectNav", () => { B: state([{ id: "bw1" }], [{ id: "bs1", worktreeId: null }]), C: state([{ id: "cw1" }], []), }) - const sessions = () => ({ A: [], B: [session("bs1")], C: [] }) const run = ( fn: (nav: ReturnType) => void, @@ -683,7 +518,6 @@ describe("createProjectNav", () => { focus, projects, states, - sessions, activeProjectId: () => activeProjectId, selection: () => selection, currentSessionID: () => currentSessionID, @@ -724,16 +558,19 @@ describe("createProjectNav", () => { return posted } - it("multi-project step traverses A Local -> A worktree -> B Local -> B worktree -> B session", () => { + it("multi-project step traverses A Local -> A worktree -> B Local -> B worktree", () => { expect(stepOnce("down", LOCAL, "A", undefined)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" }) expect(stepOnce("down", "aw1", "A", undefined)).toEqual({ projectId: "B", kind: "local" }) expect(stepOnce("down", LOCAL, "B", undefined)).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" }) - expect(stepOnce("down", "bw1", "B", undefined)).toEqual({ projectId: "B", kind: "session", sessionId: "bs1" }) - expect(stepOnce("down", null, "B", "bs1")).toBeUndefined() + expect(stepOnce("down", "bw1", "B", undefined)).toBeUndefined() }) - it("multi-project step reverses B session -> B worktree -> B Local -> A worktree -> A Local", () => { - expect(stepOnce("up", null, "B", "bs1")).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" }) + it("multi-project step treats a local session tab as B Local", () => { + expect(stepOnce("down", null, "B", "bs1")).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" }) + expect(stepOnce("up", null, "B", "bs1")).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" }) + }) + + it("multi-project step reverses B worktree -> B Local -> A worktree -> A Local", () => { expect(stepOnce("up", "bw1", "B", undefined)).toEqual({ projectId: "B", kind: "local" }) expect(stepOnce("up", LOCAL, "B", undefined)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" }) expect(stepOnce("up", "aw1", "A", undefined)).toEqual({ projectId: "A", kind: "local" }) @@ -741,14 +578,13 @@ describe("createProjectNav", () => { }) it("collapsed project C is never reached via jump", () => { - // Global order: A:local(0), A:aw1(1), B:local(2), B:bw1(3), B:bs1(4). + // Global order: A:local(0), A:aw1(1), B:local(2), B:bw1(3). expect(jumpOnce(0)).toEqual({ projectId: "A", kind: "local" }) expect(jumpOnce(1)).toEqual({ projectId: "A", kind: "worktree", worktreeId: "aw1" }) expect(jumpOnce(2)).toEqual({ projectId: "B", kind: "local" }) expect(jumpOnce(3)).toEqual({ projectId: "B", kind: "worktree", worktreeId: "bw1" }) - expect(jumpOnce(4)).toEqual({ projectId: "B", kind: "session", sessionId: "bs1" }) // Past the end — and C's collapsed worktree is never reachable by any index. - expect(jumpOnce(5)).toBeUndefined() + expect(jumpOnce(4)).toBeUndefined() expect(jumpOnce(99)).toBeUndefined() }) diff --git a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts index 55c251d07d..63767afb9a 100644 --- a/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts +++ b/packages/kilo-vscode/tests/unit/sandboxing-settings.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import { configFeatures } from "../../src/features" import { visible } from "../../webview-ui/src/components/settings/sandboxing" -const features = { indexing: false, sandboxControls: false } +const features = { indexing: false, sandboxControls: false, backgroundSubagents: false } const platform = Object.getOwnPropertyDescriptor(process, "platform") function setPlatform(value: string) { diff --git a/packages/kilo-vscode/tests/unit/section-helpers.test.ts b/packages/kilo-vscode/tests/unit/section-helpers.test.ts index 62f3e02cb1..04dadf5871 100644 --- a/packages/kilo-vscode/tests/unit/section-helpers.test.ts +++ b/packages/kilo-vscode/tests/unit/section-helpers.test.ts @@ -174,7 +174,7 @@ describe("buildSidebarOrder", () => { it("returns LOCAL + all sorted worktrees when no sections exist", () => { const sorted = [wt("a"), wt("b"), wt("c")] const items = buildTopLevelItems([], [], sorted, []) - const result = buildSidebarOrder(items, sorted, [], () => [], []) + const result = buildSidebarOrder(items, sorted, [], () => []) expect(result).toEqual([ { type: "local", id: "local" }, { type: "wt", id: "a" }, @@ -191,7 +191,7 @@ describe("buildSidebarOrder", () => { const sorted = [w1, w2, w3] const items = buildTopLevelItems([s1], [w3], sorted, ["s1", "w3"]) const members = (id: string) => (id === "s1" ? [w1, w2] : []) - const result = buildSidebarOrder(items, sorted, [s1], members, []) + const result = buildSidebarOrder(items, sorted, [s1], members) expect(result).toEqual([ { type: "local", id: "local" }, { type: "wt", id: "w3" }, @@ -207,7 +207,7 @@ describe("buildSidebarOrder", () => { const sorted = [w1, w2] const items = buildTopLevelItems([s1], [w2], sorted, ["s1", "w2"]) const members = (id: string) => (id === "s1" ? [w1] : []) - const result = buildSidebarOrder(items, sorted, [s1], members, []) + const result = buildSidebarOrder(items, sorted, [s1], members) expect(result).toEqual([ { type: "local", id: "local" }, { type: "wt", id: "w2" }, @@ -227,20 +227,17 @@ describe("buildSidebarOrder", () => { if (id === "s2") return [w3] return [] } - const result = buildSidebarOrder(items, sorted, [s1, s2], members, []) + const result = buildSidebarOrder(items, sorted, [s1, s2], members) expect(result.map((r) => r.id)).toEqual(["local", "w2", "w1", "w3"]) }) - it("appends unassigned sessions after worktrees", () => { + it("appends nothing after worktrees (sessions live in the history view)", () => { const sorted = [wt("a")] const items = buildTopLevelItems([], [], sorted, []) - const sessions = [{ id: "sess1" }, { id: "sess2" }] - const result = buildSidebarOrder(items, sorted, [], () => [], sessions) + const result = buildSidebarOrder(items, sorted, [], () => []) expect(result).toEqual([ { type: "local", id: "local" }, { type: "wt", id: "a" }, - { type: "session", id: "sess1" }, - { type: "session", id: "sess2" }, ]) }) }) diff --git a/packages/kilo-vscode/tests/unit/semaphore.test.ts b/packages/kilo-vscode/tests/unit/semaphore.test.ts index 697ba146b2..dc64b8b0e5 100644 --- a/packages/kilo-vscode/tests/unit/semaphore.test.ts +++ b/packages/kilo-vscode/tests/unit/semaphore.test.ts @@ -69,6 +69,22 @@ describe("Semaphore", () => { expect(order).toEqual([1, 2, 3]) }) + it("removes an aborted task from the pending queue", async () => { + const sem = new Semaphore(1) + let release: () => void = () => {} + const first = sem.run(() => new Promise((resolve) => (release = resolve))) + const controller = new AbortController() + const aborted = sem.run(async () => "aborted", controller.signal) + const next = sem.run(async () => "next") + + controller.abort(new Error("cancelled")) + await expect(aborted).rejects.toThrow("cancelled") + release() + + expect(await next).toBe("next") + await first + }) + it("allows full concurrency when limit exceeds task count", async () => { const sem = new Semaphore(10) let running = 0 diff --git a/packages/kilo-vscode/tests/unit/session-model-store.test.ts b/packages/kilo-vscode/tests/unit/session-model-store.test.ts index 0c99900dcb..c83f761658 100644 --- a/packages/kilo-vscode/tests/unit/session-model-store.test.ts +++ b/packages/kilo-vscode/tests/unit/session-model-store.test.ts @@ -234,53 +234,6 @@ describe("per-mode model memory", () => { expect(Object.keys(result.sessionOverrides)).toHaveLength(0) }) - it("clearing both session override and per-mode selection falls back to config default", () => { - let store = emptyStore() - // Simulate mode model set in config (getModeModel returns claude). - const configured: ResolveEnv = { - ...env(), - getModeModel: (name) => (name === "code" ? claude : null), - } - - // User picked gpt globally for "code" - let result = applyModel(store, "code", gpt, undefined) - store = { ...store, ...result } - - // User then overrode the session with claude - result = applyModel(store, "code", claude, "session-a") - store = { ...store, ...result } - - // Simulate clearModelOverride: clear both session override and per-mode selection - const reset: ModelStore = { - ...store, - sessionOverrides: {}, - modelSelections: { ...store.modelSelections, code: null }, - } - - // Should fall through to the configured per-mode model (claude from config) - expect(getSelected(reset, configured, "session-a", "code")).toEqual(claude) - }) - - it("clearing only session override but not per-mode selection leaves persisted pick visible", () => { - let store = emptyStore() - const e = env() - - // User picked claude globally for "code" - const result = applyModel(store, "code", claude, undefined) - store = { ...store, ...result } - - // User then overrode the session with gpt - const r2 = applyModel(store, "code", gpt, "session-a") - store = { ...store, ...r2 } - - // Simulate OLD behaviour: only clear session override, leave modelSelections intact - const partial: ModelStore = { ...store, sessionOverrides: {} } - - // The persisted per-mode selection (claude) is still returned — this is - // why the reset appeared to "do nothing" when config also resolved to claude. - expect(getSelected(partial, e, "session-a", "code")).toEqual(claude) - }) - it("switching from plan to implementation uses implementation config after clearing stale memory", () => { let store = emptyStore() const configured: ResolveEnv = { diff --git a/packages/kilo-vscode/tests/unit/session-parts.test.ts b/packages/kilo-vscode/tests/unit/session-parts.test.ts index 16cebaf7ac..1c951eb8da 100644 --- a/packages/kilo-vscode/tests/unit/session-parts.test.ts +++ b/packages/kilo-vscode/tests/unit/session-parts.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "bun:test" -import { mergeOptimisticPart, mergeParts, sameParts } from "../../webview-ui/src/context/session-parts" +import { createStore, produce } from "solid-js/store" +import { isolate, mergeOptimisticPart, mergeParts, sameParts } from "../../webview-ui/src/context/session-parts" import type { Part } from "../../webview-ui/src/types/messages" function text(id: string, value: string, time: { start?: number; end?: number } = {}): Part { @@ -20,6 +21,39 @@ function value(parts: Part[], id: string) { return part.text } +describe("isolate", () => { + it("keeps shared reasoning snapshots independent across session stores", () => { + const shared = { + id: "r1", + messageID: "m1", + type: "reasoning", + text: "Thinking", + time: { start: 1 }, + } satisfies Part + const [first, setFirst] = createStore({ parts: [shared].map(isolate) }) + const [second, setSecond] = createStore({ parts: [shared].map(isolate) }) + + setFirst( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "reasoning") part.text += " once" + }), + ) + setSecond( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "reasoning") part.text += " once" + }), + ) + + expect(first.parts[0]?.type === "reasoning" && first.parts[0].text).toBe("Thinking once") + expect(second.parts[0]?.type === "reasoning" && second.parts[0].text).toBe("Thinking once") + expect(shared.text).toBe("Thinking") + }) +}) + describe("mergeParts", () => { it("keeps a final streamed tail part created after the reconcile snapshot started", () => { const parts = mergeParts( @@ -120,6 +154,31 @@ describe("mergeOptimisticPart", () => { expect(result.parts.map((part) => part.id)).toEqual(["client-text", "server-file"]) expect(result.replaced).toBe("client-file") }) + + it("keeps streamed deltas independent across session stores", () => { + const shared = text("server", "start") + const [first, setFirst] = createStore({ parts: mergeOptimisticPart([], new Set(), shared).parts }) + const [second, setSecond] = createStore({ parts: mergeOptimisticPart([], new Set(), shared).parts }) + + setFirst( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "text") part.text += " chunk" + }), + ) + setSecond( + "parts", + produce((parts) => { + const part = parts[0] + if (part?.type === "text") part.text += " chunk" + }), + ) + + expect(value(first.parts, "server")).toBe("start chunk") + expect(value(second.parts, "server")).toBe("start chunk") + expect(value([shared], "server")).toBe("start") + }) }) describe("sameParts", () => { diff --git a/packages/kilo-vscode/tests/unit/source-controller.test.ts b/packages/kilo-vscode/tests/unit/source-controller.test.ts index 3c14910e70..c7517fde75 100644 --- a/packages/kilo-vscode/tests/unit/source-controller.test.ts +++ b/packages/kilo-vscode/tests/unit/source-controller.test.ts @@ -346,6 +346,36 @@ describe("SourceController.requestFile", () => { controller.stop() }) + it("does not start queued detail work after the source changes", async () => { + let details = 0 + const workspace: DiffSource = { + descriptor: WORKSPACE_DESC, + async fetch() { + return { diffs: [] } + }, + async fetchFile() { + details++ + return null + }, + } + const session: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + return { diffs: [] } + }, + } + const { controller } = make({ workspace, "session:s1": session }) + + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + await controller.activate("workspace") + const request = controller.requestFile("foo.ts") + await controller.activate("session:s1") + await request + + expect(details).toBe(0) + controller.stop() + }) + it("posts null when a pending fetchFile result is invalidated by stop", async () => { let release: () => void = () => {} const workspace: DiffSource = { @@ -459,4 +489,29 @@ describe("SourceController.refresh", () => { controller.stop() }) + + it("runs a forced refresh after an in-flight fetch", async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let fetches = 0 + const source: DiffSource = { + descriptor: SESSION_DESC, + async fetch() { + fetches++ + await gate + return { diffs: [] } + }, + } + const { controller } = make({ "session:s1": source }) + controller.setContext({ workspaceRoot: "/repo", sessionId: "s1" }) + const activation = controller.activate("session:s1", { poll: false }) + const refresh = controller.refresh() + release() + await Promise.all([activation, refresh]) + + expect(fetches).toBe(2) + controller.stop() + }) }) diff --git a/packages/kilo-vscode/tests/unit/transcript-rows.test.ts b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts index 6889aab495..8225595b60 100644 --- a/packages/kilo-vscode/tests/unit/transcript-rows.test.ts +++ b/packages/kilo-vscode/tests/unit/transcript-rows.test.ts @@ -149,6 +149,23 @@ describe("transcriptRows", () => { expect(rows.filter((row) => row.type === "assistant").map((row) => row.copy)).toEqual(["p1", "p1"]) }) + it("keeps historical copy rows while hiding the live turn copy row", () => { + const u1 = user("u1") + const a1 = assistant("a1", "u1") + const u2 = user("u2") + const a2 = assistant("a2", "u2") + const rows = transcriptRows( + messageTurns([u1, a1, u2, a2]), + lookup({ a1: [part("p1", "a1")], a2: [part("p2", "a2")] }), + { live: new Set(["u2"]) }, + ) + + expect(rows.filter((row) => row.type === "assistant").map((row) => ({ turn: row.turn, copy: row.copy }))).toEqual([ + { turn: "u1", copy: "p1" }, + { turn: "u2", copy: undefined }, + ]) + }) + it("keeps compaction replies ordered under the compacted turn and respects revert turns", () => { const u1 = user("u1") const a1 = assistant("a1", "u1") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index ff426db114..cd8fea4548 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -47,6 +47,7 @@ import type { TerminalDestination, TerminalFont, } from "../src/types/messages" +import { historyRowActions as historyRowActionsFactory } from "./history-actions" import { readFontSize } from "../src/font-size" import { IndexingProvider } from "../src/context/indexing" import {} from "@thisbeyond/solid-dnd" @@ -120,7 +121,6 @@ import { isKnownRootSession, nextSelectionAfterDelete, adjacentHint, - filterUnassignedSessions, focusChatSearch, LOCAL, } from "./navigate" @@ -154,7 +154,7 @@ import { createSideTerminal, createAmbientSetup, hasSetupTerminal, - showTerminalStack, + keepTerminalStack, readSavedDestination, resolveRunScriptRequest, resolveVscodeTerminalRequest, @@ -162,14 +162,14 @@ import { import { createEmbeddedTerminalReader } from "./terminal/output" import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering" import { useTabScroll } from "./tab-scroll" -import { DiffPanel } from "./DiffPanel" +import { DiffPanelCache } from "./DiffPanelCache" import { PRPanelHost } from "./pr/PRPanelHost" import { createRevertFile } from "./revert-file" import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView" import { createApplyToLocal } from "./apply-to-local" -import { createWorktreeDiffs, wireDiffId } from "./worktree-diffs" +import { createWorktreeDiffs, diffDataKey, wireDiffId } from "./worktree-diffs" import type { ReviewComment } from "../diff-viewer/review-comments" -import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations" +import { createReviewComposers } from "./review-composers" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" import { createSidebarSearch, type SidebarSearchItem } from "./sidebar-search" import { randomColor } from "./section-colors" @@ -186,8 +186,6 @@ import { sortWorktrees, type TopLevelItem, } from "./section-helpers" -import {} from "./section-dnd" -import {} from "./constrain-drag-x" import { mergeWorktreeDiffs } from "../diff-viewer/diff-state" import { DiffScopeControls } from "../diff-viewer/DiffScopeControls" import { scopeCapabilities } from "./diff-scope-state" @@ -215,6 +213,7 @@ import { defaultBase as projectDefaultBase } from "./project/default-base" import "./agent-manager.css" import "./agent-manager-review.css" import { cycleAgent as cycle } from "../src/context/session-agent" +import { createSidebarScrollPreserver } from "./sidebar-scroll" const REVIEW_TAB_ID = "review" interface SetupState { active: boolean @@ -291,14 +290,6 @@ const AgentManagerContent: Component = () => { const evictLocal = (sid: string) => setLocalSessionIDs((prev) => (prev.includes(sid) ? prev.filter((id) => id !== sid) : prev)) const [sidebarWidth, setSidebarWidth] = createSignal(persisted?.sidebarWidth ?? DEFAULT_SIDEBAR_WIDTH) - const sessionsCollapsed = () => registry.active().sessionsCollapsed() ?? true - const setSessionsCollapsed = (v: Parameters>[0]) => - registry.active().setSessionsCollapsed(typeof v === "function" ? v(sessionsCollapsed()) : v) - const toggleSessions = () => { - const collapsed = !sessionsCollapsed() - setSessionsCollapsed(collapsed) - vscode.postMessage({ type: "agentManager.setSessionsCollapsed", collapsed }) - } const sidebar = createSidebarCollapse(vscode, { initial: persisted?.sidebarCollapsed }) const sidebarCollapsed = sidebar.collapsed const expandSidebar = sidebar.expand @@ -308,7 +299,29 @@ const AgentManagerContent: Component = () => { let sidebarRaf: number | undefined let pendingSidebarWidth: number | undefined const [history, setHistory] = createSignal(false) + const [historyProject, setHistoryProject] = createSignal() + const [historySwitches, setHistorySwitches] = createSignal([]) + const closeHistory = () => { + setHistory(false) + setHistoryProject(undefined) + setHistorySwitches([]) + } + const openHistory = (pid?: string) => { + const scoped = pid !== undefined && multiProject() + if (scoped && (currentProjectId() !== pid || historySwitches().length > 0)) + setHistorySwitches((prev) => (prev.includes(pid) ? prev : [...prev, pid])) + setHistoryProject(scoped ? pid : undefined) + setHistory(true) + if (scoped) { + // Activate the target so the session store and pick routing use that project. + vscode.postMessage({ + type: "agentManager.activateSelection", + target: { projectId: pid, kind: "local" }, + } as never) + } + } const [sidePanel, setSidePanel] = createSignal(null) + const [diffMounted, setDiffMounted] = createSignal(false) const diffOpen = () => sidePanel() === SidePanel.Diff const prOpen = () => sidePanel() === SidePanel.PR const activePR = createMemo(() => { @@ -319,6 +332,7 @@ const AgentManagerContent: Component = () => { return { pr, selected, wt: worktrees().find((w) => w.id === selected) } }) const diffs = createWorktreeDiffs(vscode, activeProjectId) + createEffect(on(activeProjectId, diffs.reset, { defer: true })) const diffDatas = diffs.diffDatas const diffLoading = diffs.diffLoading const setDiffLoading = diffs.setDiffLoading @@ -326,11 +340,12 @@ const AgentManagerContent: Component = () => { const [panelWidth, setPanelWidth] = createSignal(clampPanelWidth(persisted?.sidePanelWidth, window.innerWidth)) const resizeSide = createPanelResize(setPanelWidth, () => window.innerWidth) const showSideTerminal = () => { - setHistory(false) + closeHistory() setReviewActive(false) setSidePanel(SidePanel.Terminal) } - const reviewComposer = createReviewComposer() + const composers = createReviewComposers(currentProjectId) + createEffect(on(activeProjectId, (_next, previous) => previous && composers.clearProject(previous), { defer: true })) const reviewState = createReviewState() const reviewOpenByContext = reviewState.open const setReviewOpenByContext = reviewState.setOpen @@ -349,7 +364,7 @@ const AgentManagerContent: Component = () => { currentProjectId, () => sidePanel() === SidePanel.Documents, () => { - setHistory(false) + closeHistory() setReviewActive(false) setSidePanel(SidePanel.Documents) }, @@ -364,7 +379,7 @@ const AgentManagerContent: Component = () => { sync: (id, parentID) => session.syncSession(id, parentID, "inspector"), unsync: (id) => session.unsyncSession(id, "inspector"), show: () => { - setHistory(false) + closeHistory() setReviewActive(false) setSidePanel(SidePanel.Subagents) }, @@ -524,7 +539,6 @@ const AgentManagerContent: Component = () => { setPendingDelete(null) } createEffect(on(selection, () => cancelPendingDelete(), { defer: true })) - createEffect(on(selection, () => clearReviewComposer(reviewComposer), { defer: true })) createEffect( on( selection, @@ -548,16 +562,6 @@ const AgentManagerContent: Component = () => { if (sel === null) return setReviewOpenForContext(sel, open) } - const reviewComments = createMemo(() => { - const sel = selection() - if (sel === null) return [] as ReviewComment[] - return readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", sel) - }) - const setReviewCommentsForSelection = (comments: ReviewComment[]) => { - const sel = selection() - if (sel === null) return - setReviewCommentsByContext((prev) => setReviewComments(prev, currentProjectId() ?? "single", sel, comments)) - } const apply = createApplyToLocal({ vscode, dialog, @@ -578,7 +582,7 @@ const AgentManagerContent: Component = () => { } const openWindow = metrics.click("open_worktree_window", "tab_toolbar", openWorktreeDirectory) const togglePRPanel = () => { - setHistory(false) + closeHistory() if (reviewActive()) closeReviewTab() const opening = sidePanel() !== SidePanel.PR setSidePanel((prev) => (prev === SidePanel.PR ? null : SidePanel.PR)) @@ -718,6 +722,7 @@ const AgentManagerContent: Component = () => { }) createEffect(() => { const ids = new Set(worktrees().map((wt) => wt.id)) + composers.prune(ids) setReviewOpenByContext((prev) => { const next = pruneReviewState(prev, currentProjectId() ?? "single", ids) if (Object.keys(next).length === Object.keys(prev).length) return prev @@ -741,10 +746,6 @@ const AgentManagerContent: Component = () => { const localSet = createMemo(() => new Set(localSessionIDs())) - const unassignedSessions = createMemo(() => - filterUnassignedSessions(session.sessions(), worktreeSessionIds(), localSet()), - ) - const projectSessionsLive = createProjectSessionsLive({ base: projectLive.sessions, pid: currentProjectId, @@ -754,6 +755,15 @@ const AgentManagerContent: Component = () => { locals: localSet, }) + /** Session ids shown in the project-scoped history view (every session of the project). */ + const historySessionIds = createMemo(() => { + const pid = historyProject() + if (!pid || !multiProject()) return undefined + const sessions = projectSessionsLive()[pid] + if (!sessions) return new Set() + return new Set(sessions.filter(isKnownRootSession).map((s) => s.id)) + }) + const localSessions = createLocalSessions({ ids: localSessionIDs, sessions: () => { @@ -812,7 +822,9 @@ const AgentManagerContent: Component = () => { return false }) - const showDetailStack = createMemo(() => showTerminalStack(history(), selection(), contextEmpty())) + const showDetailStack = createMemo(() => + keepTerminalStack(history(), selection(), contextEmpty(), terms.all().length + terms.sides().length), + ) const overlay = createMemo((): SetupState | null => { const state = setup() @@ -913,14 +925,12 @@ const AgentManagerContent: Component = () => { /** Flat visual order of all visible sidebar items — used for navigation and shortcut assignment. */ const sidebarOrder = createMemo(() => - buildSidebarOrder(topLevelItems(), sortedWorktrees(), sections(), worktreesInSection, unassignedSessions()), + buildSidebarOrder(topLevelItems(), sortedWorktrees(), sections(), worktreesInSection), ) /** Map from sidebar item id → 1-based shortcut number (⌘1 for LOCAL, ⌘2 for first worktree, etc.) */ const shortcutMap = createMemo(() => buildShortcutMap(sidebarOrder())) const projectShortcutMap = createMemo(() => - buildShortcutMap( - buildProjectNavEntries(projectList(), projectStates(), projectLive.sessions()).map((entry) => ({ id: entry.id })), - ), + buildShortcutMap(buildProjectNavEntries(projectList(), projectStates()).map((entry) => ({ id: entry.id }))), ) const moveToSection = (ids: string[], sec: string | null) => @@ -939,18 +949,9 @@ const AgentManagerContent: Component = () => { const scrollIntoView = (el: HTMLElement) => el.scrollIntoView({ block: "nearest", behavior: "smooth" }) - const selectUnassigned = (id: string) => { - saveTabMemory() - setSelection(null) - setReviewActive(false) - session.selectSession(id) - requestChatFocus(true) - } - const focusSidebarItem = (item: { type: string; id: string }) => { if (item.type === "local") selectLocal() else if (item.type === "wt") selectWorktree(item.id) - else selectUnassigned(item.id) requestChatFocus(true) const el = document.querySelector(`[data-sidebar-id="${item.id}"]`) if (el instanceof HTMLElement) scrollIntoView(el) @@ -963,7 +964,6 @@ const AgentManagerContent: Component = () => { focus: focusSidebarItem, projects: projectList, states: projectStates, - sessions: projectLive.sessions, activeProjectId, selection, currentSessionID: session.currentSessionID, @@ -1027,7 +1027,7 @@ const AgentManagerContent: Component = () => { const current = managedSessions().find((entry) => entry.id === sid) if (current?.worktreeId) return focusManagedSession(current.worktreeId, sid) saveTabMemory() - setHistory(false) + closeHistory() setReviewActive(false) appendToTabOrder(sel, sid) evictLocal(sid) @@ -1037,7 +1037,7 @@ const AgentManagerContent: Component = () => { const focusManagedSession = (worktreeId: string, sid: string) => { selectWorktree(worktreeId) - setHistory(false) + closeHistory() session.selectSession(sid) requestChatFocus() return true @@ -1063,7 +1063,7 @@ const AgentManagerContent: Component = () => { const focusSidebarSearchItem = (item: SidebarSearchItem) => { if (item.section?.collapsed) vscode.postMessage({ type: "agentManager.toggleSectionCollapsed", sectionId: item.section.id }) - setHistory(false) + closeHistory() if (item.kind === "local") return selectLocal() if (item.kind === "worktree") return selectWorktree(item.worktreeId) if (item.location === "local") selectLocal() @@ -1090,7 +1090,6 @@ const AgentManagerContent: Component = () => { apply: (state) => applyActiveState(state), pruneLive: (ids) => projectLive.prune(ids), }) - const stateHandlers = createProjectStateHandlers({ setMulti: setMultiProject, setProjects: setProjectList, @@ -1106,7 +1105,7 @@ const AgentManagerContent: Component = () => { rename: setRenamingSection, font: (font) => font && setTerminalFont(font), }) - + const preserveSidebarScroll = createSidebarScrollPreserver(() => selection() ?? session.currentSessionID()) /** Apply the active-transition effects of a state payload (data already landed in the store). */ const applyActiveState = (state: AgentManagerStateMessage) => { const switched = applyProjectSwitch(state) @@ -1156,7 +1155,10 @@ const AgentManagerContent: Component = () => { first: () => undefined, close: () => setReviewActive(false), hide: () => setSidePanel(null), - history: () => setHistory(false), + history: () => + state.projectId && historySwitches().includes(state.projectId) + ? setHistorySwitches((prev) => prev.filter((id) => id !== state.projectId)) + : closeHistory(), reset: subagents.reset, }) } @@ -1172,7 +1174,7 @@ const AgentManagerContent: Component = () => { onMount(() => { const handler = (event: MessageEvent) => { const msg = event.data - if (msg?.type === "navigate" && msg.view === "history") return setHistory(true) + if (msg?.type === "navigate" && msg.view === "history") return openHistory() if (msg?.type !== "action") return if (msg.action === "sessionPrevious") projectNav.step("up") else if (msg.action === "sessionNext") projectNav.step("down") @@ -1477,7 +1479,7 @@ const AgentManagerContent: Component = () => { if (msg.type === "agentManager.focusContextRequested") focusCtl.report() if (msg.type === "agentManager.state" && msg.isGitRepo === false && !sessionsLoaded()) setSessionsLoaded(true) - if (msg.type === "agentManager.state") stateHandlers.state(msg) + if (msg.type === "agentManager.state") preserveSidebarScroll(() => stateHandlers.state(msg)) // When a multi-version progress update arrives, mark newly created worktrees as loading if ((msg as { type: string }).type === "agentManager.multiVersionProgress") { @@ -1500,18 +1502,11 @@ const AgentManagerContent: Component = () => { } } - // Set per-session model selection without clearing busy state. - // Used during Phase 1 of multi-version creation so the UI selector - // reflects the correct model as soon as the worktree appears. if ((msg as { type: string }).type === "agentManager.setSessionModel") { const ev = msg as { type: string; sessionId: string; providerID: string; modelID: string } session.setSessionModel(ev.sessionId, ev.providerID, ev.modelID) } - // Handle initial message send for multi-version sessions. - // The extension creates the worktrees/sessions, then asks the webview - // to send the prompt through the normal KiloProvider sendMessage path. - // Once the message is sent, clear the loading state for that worktree. if ((msg as { type: string }).type === "agentManager.sendInitialMessage") { const ev = msg as unknown as AgentManagerSendInitialMessage @@ -1580,7 +1575,9 @@ const AgentManagerContent: Component = () => { applyProjectSelection(msg, { active: (projectId) => activeProjectId() === projectId, applied: (projectId) => currentProjectId() === projectId, - managed: (projectId) => projectLive.sessions()[projectId] ?? projectStates()[projectId]?.sessions ?? [], + // Managed state is the placement authority; the live listing can briefly + // keep a stale worktree tag after a session moved back to local. + managed: (projectId) => projectStates()[projectId]?.sessions ?? projectLive.sessions()[projectId] ?? [], local: () => selectLocal(), worktree: (projectId, worktreeId) => selectWorktree(worktreeId), focusLocal: focusLocalSession, @@ -1660,6 +1657,17 @@ const AgentManagerContent: Component = () => { // The composite id (ctx#scope) the extension keys diff data by. const diffScopeId = review.id + const reviewComments = createMemo(() => { + const key = diffScopeId() + if (!key) return [] as ReviewComment[] + return readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", key) + }) + const setReviewCommentsForSelection = (comments: ReviewComment[]) => { + const key = diffScopeId() + if (!key) return + setReviewCommentsByContext((prev) => setReviewComments(prev, currentProjectId() ?? "single", key, comments)) + } + const diffScopeControls = (compact: boolean) => ( { tabFocus.restore() } - // Data for the review tab / side panel: keyed by the composite diff id - // (ctx#scope) the extension pushes, so each scope keeps its own file set and - // switching back to a fetched scope is instant. const reviewDiffs = createMemo(() => { const data = diffDatas() const key = diffScopeId() if (!key) return [] - return data[key] ?? [] + return data[diffDataKey(activeProjectId(), key)] ?? [] }) const diffSessionKey = createMemo(() => diffScopeId() ?? "") - // Source-level notice for the active composite id (e.g. snapshots disabled - // for the Session scope), shown as a banner instead of the empty state. const diffNotice = createMemo(() => { const key = diffScopeId() if (!key) return undefined - return diffNotices()[key] + return diffNotices()[diffDataKey(activeProjectId(), key)] }) const requestDiffFile = (file: string) => { @@ -1752,9 +1755,12 @@ const AgentManagerContent: Component = () => { } const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId)) + const diffLoadingForCurrent = createMemo(() => diffs.diffLoadingFor(diffScopeId)) const revertCtl = createRevertFile(diffScopeId, diffCtx, () => review.scope(), vscode, showToast, t, activeProjectId) + createEffect(() => diffOpen() && setDiffMounted(true)) + const handleShowKeyboardShortcuts = () => { const categories = buildShortcutCategories(kb(), t) dialog.show(() => ( @@ -1914,10 +1920,21 @@ const AgentManagerContent: Component = () => { vscode.postMessage({ type: "agentManager.openLocally", sessionId: sid }) } - const openUnassigned = (id: string) => { - metrics.track("open_session_locally", "unassigned_session_menu") - openLocally(id) - } + /** History row menu: start a session in a new worktree or back in the project's local tabs. */ + const historyRowActions = historyRowActionsFactory({ + t, + onPromote: (sessionId) => { + metrics.track("promote_session", "history_row") + closeHistory() + vscode.postMessage({ type: "agentManager.promoteSession", sessionId }) + }, + onLocal: (sessionId) => { + const pid = historyProject() + if (pid) vscode.postMessage({ type: "agentManager.openSessionLocally", projectId: pid, sessionId } as never) + else openLocally(sessionId) + closeHistory() + }, + }) const handleAddSession = () => { const sel = selection() @@ -2317,6 +2334,7 @@ const AgentManagerContent: Component = () => { t={t} onSearchRef={(ref) => (sidebarSearchMenu = ref)} onShortcuts={handleShowKeyboardShortcuts} + onHistory={openHistory} shortcutMap={projectShortcutMap} /> @@ -2330,8 +2348,6 @@ const AgentManagerContent: Component = () => { isLocalBusy={isLocalBusy} repoBranch={repoBranch} localStats={localStats} - sessionsCollapsed={sessionsCollapsed} - toggleSessions={toggleSessions} search={{ items: sidebarSearch.items, current: sidebarSearch.current }} bindings={kb} defaultBranch={repoDefaultBranch} @@ -2345,6 +2361,7 @@ const AgentManagerContent: Component = () => { onNewWorktree={showNewWorktreeDialog} onNewSection={newSection} onShortcuts={metrics.click("keyboard_shortcuts", "worktrees_header", handleShowKeyboardShortcuts)} + onHistory={() => openHistory()} projectId={activeProjectId()} sections={sections} sortedWorktrees={sortedWorktrees} @@ -2375,19 +2392,12 @@ const AgentManagerContent: Component = () => { confirmDeleteWorktree={confirmDeleteWorktree} handleDeleteWorktree={handleDeleteWorktree} confirmRemoveStaleWorktree={confirmRemoveStaleWorktree} - unassignedSessions={unassignedSessions} - selectUnassigned={selectUnassigned} - promoteSession={promoteSession} - openUnassigned={openUnassigned} track={metrics.click} />
    - {/* Tab bar — full version with tabs renders when a section is selected - and has tabs; otherwise a minimal version still renders so the - sidebar toggle button stays at a fixed position. */} { { if (addSessionToCurrentWorktree(id)) return - setHistory(false) + closeHistory() if (localSessionIDs().includes(id)) { saveTabMemory() session.selectSession(id) @@ -2483,28 +2493,23 @@ const AgentManagerContent: Component = () => { } openLocally(id) }} - onBack={() => setHistory(false)} - worktreeSessionIds={activeWorktreeSessionIds} + onBack={closeHistory} + worktreeSessionIds={historyProject() ? undefined : activeWorktreeSessionIds} + sessionIds={historySessionIds} + rowActions={historyRowActions} /> - {/* Terminal overlay is scoped to the main pane so it does not cover the tab bar or side panel. */} -
    - {/* Chat/terminal + side diff panel. Keep it mounted under the - review tab so live xterm canvases never leave the paint tree. */} +
    - {/* Keep terminal tabs mounted so output streams across worktree switches. */} {renderTerminalLayer({ state: terms, onFocusPrompt: focusCtl.focus, onFocusChange: focusCtl.report, })} - {/* Session-less context (e.g. a worktree mid-provisioning): the - empty state lives in the main pane so the side terminal - panel can render next to it. */}
    { } openLocally(id) }} - onShowHistory={() => setHistory(true)} + onShowHistory={() => openHistory()} onForkMessage={readOnly() ? undefined : handleForkSession} onForkSession={readOnly() ? undefined : handleForkSession} readonly={readOnly()} @@ -2599,11 +2604,9 @@ const AgentManagerContent: Component = () => {
    - {/* One inspector host for all right-side modes. It stays - mounted while a side terminal is alive — hidden via - .am-side-host-hidden (absolute + opacity), never - unmounted, so xterm render loops keep streaming. */} - 0 || subagents.tabs().length > 0}> + 0 || subagents.tabs().length > 0} + >
    { />
    - - metrics.track("send_review_comments", "side_review")} - onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))} - onExpand={ - selection() !== null - ? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" }) - : undefined - } - onRequestDiff={requestDiffFile} - onOpenFile={(file, line) => { - const id = diffCtx() - if (id) - vscode.postMessage({ type: "agentManager.openFile", sessionId: id, filePath: file, line }) - }} - onOpenDocument={documentInspector.open} - onRevertFile={metrics.use("revert_file", "side_review", revertCtl.revert)} - revertingFiles={revertCtl.reverting()} - activeTerminalId={terms.activeId()} - /> - + diffOpen() && !history() && !reviewActive()} + data={diffDatas} + loading={(key) => diffs.diffLoadingFor(() => key)} + loadingFiles={(key) => diffs.diffFileLoadingFor(() => key)} + notice={(key) => diffNotices()[diffDataKey(activeProjectId(), key)]} + comments={(key) => + readReviewComments(reviewCommentsByContext(), currentProjectId() ?? "single", key) + } + setComments={(key, comments) => + setReviewCommentsByContext((prev) => + setReviewComments(prev, currentProjectId() ?? "single", key, comments), + ) + } + composer={composers.get} + lead={() => diffScopeControls(true)} + canRevert={scopeCapabilities(review.scope()).revert} + diffStyle={diffStyle.style()} + onDiffStyleChange={setSharedDiffStyle} + markdownRender={markdown.render()} + onMarkdownRenderChange={markdown.update} + onSendClick={() => metrics.track("send_review_comments", "side_review")} + onClose={metrics.click("side_review_close", "side_review", () => setSidePanel(null))} + onExpand={ + selection() !== null + ? metrics.click("fullscreen_review", "side_review", openReviewTab, { action: "open" }) + : undefined + } + onRequestDiff={diffs.requestDiffFile} + onOpenFile={(ctx, file, line) => + vscode.postMessage({ type: "agentManager.openFile", sessionId: ctx, filePath: file, line }) + } + onOpenDocument={documentInspector.open} + onRevertFile={(key, ctx, file) => { + metrics.track("revert_file", "side_review") + revertCtl.revertFor(key, ctx, review.scope(), file) + }} + revertingFiles={revertCtl.revertingFor} + activeTerminalId={terms.activeId()} + contexts={() => new Set(worktrees().map((wt) => wt.id))} + onEvict={(key) => { + composers.drop(key) + diffs.drop(key) + }} + /> {
    { canComment={scopeCapabilities(review.scope()).comments} comments={reviewComments()} onCommentsChange={setReviewCommentsForSelection} - composer={reviewComposer} + composer={composers.get(`${activeProjectId() ?? "single"}\0${diffScopeId() ?? ""}`)} onSendAll={closeReviewTab} onSendClick={() => metrics.track("send_review_comments", "fullscreen_review")} diffStyle={diffStyle.style()} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx index 9513aacae6..4dbb6611ac 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanel.tsx @@ -1,4 +1,13 @@ -import { type Component, createSignal, createMemo, Show, createEffect, on, type JSXElement } from "solid-js" +import { + type Component, + createSignal, + createMemo, + Show, + createEffect, + createRenderEffect, + on, + type JSXElement, +} from "solid-js" import type { VirtualizerHandle } from "virtua/solid" import { Diff } from "@kilocode/kilo-ui/diff" import { Accordion } from "@kilocode/kilo-ui/accordion" @@ -8,7 +17,6 @@ import { DiffChanges } from "@kilocode/kilo-ui/diff-changes" import { Icon } from "@kilocode/kilo-ui/icon" import { Button } from "@kilocode/kilo-ui/button" import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import type { DiffLineAnnotation, AnnotationSide, SelectedLineRange } from "@pierre/diffs" import type { WorktreeFileDiff } from "../src/types/messages" @@ -74,6 +82,7 @@ const DIFF_NOTICE_KEYS: Record = { interface DiffPanelProps { diffs: WorktreeFileDiff[] loading: boolean + active?: boolean loadingFiles?: Set sessionId?: string sessionKey?: string @@ -159,6 +168,7 @@ export const DiffPanel: Component = (props) => { }, ), ) + const setOpen = (files: string[] | ((prev: string[]) => string[])) => { const key = props.sessionKey ?? "" const current = open() @@ -197,6 +207,20 @@ export const DiffPanel: Component = (props) => { // so pierre's annotation cache doesn't invalidate and destroy the textarea. let draftMeta: AnnotationMeta | null = composer().draft let editMeta: AnnotationMeta | null = composer().edit + createRenderEffect( + on( + () => props.active, + (active) => { + if (!active) return + const value = reviewComposerDraft(composer()) + const edit = reviewComposerEdit(composer()) + setDraft(value) + setEditing(edit) + draftMeta = composer().draft + editMeta = composer().edit + }, + ), + ) // Ref to the scrollable container — used to preserve scroll position when // annotation changes cause pierre to fully re-render diffs @@ -249,6 +273,7 @@ export const DiffPanel: Component = (props) => { on( () => props.sessionKey, () => { + if (props.active === false) return setDraft(null) draftMeta = null setEditing(null) @@ -264,7 +289,7 @@ export const DiffPanel: Component = (props) => { diffs: () => props.diffs, open, loading: () => props.loadingFiles, - send: () => props.onRequestDiff, + send: () => (props.active === false ? undefined : props.onRequestDiff), }) // --- CRUD --- @@ -327,6 +352,7 @@ export const DiffPanel: Component = (props) => { on( () => [props.diffs, comments()] as const, ([diffs, current]) => { + if (props.active === false) return const valid = sanitizeReviewComments(current, diffs) if (valid.length !== current.length) { setComments(valid) @@ -392,8 +418,10 @@ export const DiffPanel: Component = (props) => { const result = buildFileAnnotations(file, commentsByFile().get(file) ?? [], editing(), draft(), draftMeta, editMeta) draftMeta = result.draftMeta editMeta = result.editMeta - composer().draft = draft() ? draftMeta : null - composer().edit = editing() ? editMeta : null + if (props.active !== false) { + composer().draft = draft() ? draftMeta : null + composer().edit = editing() ? editMeta : null + } return result.annotations } @@ -553,7 +581,6 @@ export const DiffPanel: Component = (props) => {
    - {t("session.review.loadingChanges")}
    @@ -714,10 +741,7 @@ export const DiffPanel: Component = (props) => { fallback={
    Diff preview loads on demand.}> - <> - - Loading diff... - + Loading diff...
    } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx new file mode 100644 index 0000000000..0bf696f83c --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/DiffPanelCache.tsx @@ -0,0 +1,129 @@ +import { For, createEffect, createMemo, createSignal, type Accessor, type Component, type JSX } from "solid-js" +import type { WorktreeFileDiff } from "../src/types/messages" +import type { ReviewComment } from "../diff-viewer/review-comments" +import type { ReviewComposer } from "../diff-viewer/review-annotations" +import { DiffPanel } from "./DiffPanel" +import { diffDataKey } from "./worktree-diffs" + +const CACHE_SIZE = 4 + +interface Entry { + key: string + cacheKey: string + ctx: string + used: number +} + +interface Props { + current: Accessor + context: Accessor + project: Accessor + active: Accessor + onEvict?: (key: string) => void + contexts: Accessor> + data: Accessor> + loading: (key: string) => boolean + loadingFiles: (key: string) => Set + notice: (key: string) => string | undefined + comments: (ctx: string) => ReviewComment[] + setComments: (ctx: string, comments: ReviewComment[]) => void + composer: (key: string) => ReviewComposer + lead: () => JSX.Element + canRevert: boolean + diffStyle: "unified" | "split" + onDiffStyleChange: (style: "unified" | "split") => void + markdownRender: boolean + onMarkdownRenderChange: (render: boolean) => void + onSendClick: () => void + onClose: () => void + onExpand?: () => void + onRequestDiff: (key: string, file: string) => void + onOpenFile: (ctx: string, file: string, line?: number) => void + onOpenDocument: (file: string) => void + onRevertFile: (key: string, ctx: string, file: string) => void + revertingFiles: (key: string) => Set + activeTerminalId?: string +} + +export const DiffPanelCache: Component = (props) => { + const [entries, setEntries] = createSignal([]) + let used = 0 + + createEffect(() => { + const contexts = props.contexts() + setEntries((prev) => { + const next = prev.filter((entry) => entry.ctx === "local" || contexts.has(entry.ctx)) + for (const item of prev) if (!next.includes(item)) props.onEvict?.(item.cacheKey) + return next + }) + }) + + createEffect(() => { + if (!props.active()) return + const key = props.current() + const ctx = props.context() + const project = props.project() ?? "single" + if (!key || !ctx) return + const cacheKey = `${project}\0${key}` + setEntries((prev) => { + const prefix = `${project}\0` + const scoped = prev.filter((item) => item.cacheKey.startsWith(prefix)) + const current = scoped.find((item) => item.cacheKey === cacheKey) + if (current) { + current.used = ++used + for (const item of prev) if (!scoped.includes(item)) props.onEvict?.(item.cacheKey) + return scoped + } + const next = [...scoped, { key, cacheKey, ctx, used: ++used }] + if (next.length <= CACHE_SIZE) { + for (const item of prev) if (!next.includes(item)) props.onEvict?.(item.cacheKey) + return next + } + const oldest = next.reduce((entry, item) => (item.used < entry.used ? item : entry)) + const result = next.filter((item) => item !== oldest) + props.onEvict?.(oldest.cacheKey) + for (const item of prev) if (!result.includes(item)) props.onEvict?.(item.cacheKey) + return result + }) + }) + + return ( + + {(entry) => { + const active = createMemo( + () => props.active() && `${props.project() ?? "single"}\0${props.current()}` === entry.cacheKey, + ) + return ( +
    + props.setComments(entry.key, comments)} + composer={props.composer(entry.cacheKey)} + onSendClick={props.onSendClick} + onClose={props.onClose} + onExpand={props.onExpand} + onRequestDiff={(file) => props.onRequestDiff(entry.key, file)} + onOpenFile={(file, line) => props.onOpenFile(entry.ctx, file, line)} + onOpenDocument={props.onOpenDocument} + onRevertFile={(file) => props.onRevertFile(entry.key, entry.ctx, file)} + revertingFiles={props.revertingFiles(entry.key)} + activeTerminalId={props.activeTerminalId} + /> +
    + ) + }} +
    + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx index 69c9a2d8a7..b231e6f22c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/NewWorktreeDialog.tsx @@ -214,12 +214,6 @@ export const NewWorktreeDialog: Component<{ setVariant(session.variantForAgent(name, sel)) } - const resetModel = () => { - const sel = session.configModelForAgent(agent()) - setModel(sel) - setVariant(session.variantForAgent(agent(), sel)) - } - const cycle = (direction: 1 | -1) => { cycleAgent({ agents: session.agents(), @@ -252,14 +246,6 @@ export const NewWorktreeDialog: Component<{ return stored && list.includes(stored) ? stored : undefined }) - // True when the user has changed the model from the session/config default - const overridden = createMemo(() => { - const sel = model() - const cfg = session.configModelForAgent(agent()) - if (!sel || !cfg) return false - return sel.providerID !== cfg.providerID || sel.modelID !== cfg.modelID - }) - // Reset variant when model changes and stored variant is not in new list createEffect(() => { const list = variants() @@ -895,20 +881,6 @@ export const NewWorktreeDialog: Component<{ deferDismiss cycleHint={settings()["chat.shiftTabCyclesVariant"] !== false} /> - - - - -
    diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx index 93b42482a9..ee2c86e9be 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectList.tsx @@ -47,6 +47,7 @@ interface Props { t: LanguageContextValue["t"] onSearchRef: (ref: SidebarSearchMenuRef) => void onShortcuts: () => void + onHistory: (projectId: string) => void shortcutMap?: () => Map } @@ -202,6 +203,7 @@ export const ProjectList: Component = (props) => { }) } onRemove={(projectId) => vscode.postMessage({ type: "agentManager.removeProject", projectId })} + onHistory={props.onHistory} onExpand={(projectId, expanded) => vscode.postMessage({ type: "agentManager.setProjectExpanded", projectId, expanded }) } @@ -228,7 +230,6 @@ export const ProjectList: Component = (props) => { t={props.t} onSelectLocal={(projectId) => select({ projectId, kind: "local" })} onSelectWorktree={(projectId, worktreeId) => select({ projectId, kind: "worktree", worktreeId })} - onSelectSession={(projectId, sessionId) => select({ projectId, kind: "session", sessionId })} onNewWorktree={newWorktree} shortcutMap={props.shortcutMap} /> diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx index 176449c5e4..52741e1440 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectSidebarBody.tsx @@ -23,7 +23,6 @@ import { useVSCode } from "../src/context/vscode" import SectionHeader from "./SectionHeader" import { SidebarSectionHeader } from "./SidebarSectionHeader" import { WorktreeItem } from "./WorktreeItem" -import { UnassignedSessionsSection } from "./UnassignedSessionsSection" import { ProjectActions } from "./ProjectActions" import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton" import { applyTabOrder, firstOrderedTitle, reorderTabs } from "./tab-order" @@ -55,7 +54,6 @@ interface Props { t: LanguageContextValue["t"] onSelectLocal: (projectId: string) => void onSelectWorktree: (projectId: string, worktreeId: string) => void - onSelectSession: (projectId: string, sessionId: string) => void onNewWorktree: (projectId: string) => void shortcutMap?: () => Map } @@ -100,14 +98,11 @@ export const ProjectSidebarBody: Component = (props) => { const sections = () => store.sections() const worktrees = () => store.worktrees() const order = () => store.worktreeOrder() - const localSessions = () => sessions(null) const sorted = createMemo(() => sortWorktrees(worktrees(), order())) const members = (sectionId: string) => sorted().filter((wt) => wt.sectionId === sectionId) const ungrouped = createMemo(() => sorted().filter((wt) => !wt.sectionId)) const top = createMemo(() => buildTopLevelItems(sections(), ungrouped(), sorted(), order())) - const sidebarOrder = createMemo(() => - projectSidebarOrder(top(), sorted(), sections(), members, state()?.sessionsCollapsed ? [] : localSessions()), - ) + const sidebarOrder = createMemo(() => projectSidebarOrder(top(), sorted(), sections(), members)) const post = (message: Record) => vscode.postMessage({ ...message, projectId: props.project.id } as never) @@ -438,23 +433,6 @@ export const ProjectSidebarBody: Component = (props) => {
    - - props.sessions !== undefined && state() !== undefined} - collapsed={() => state()?.sessionsCollapsed === true} - disabled={state() === undefined} - active={() => undefined} - onToggle={() => { - const current = state() - if (!current) return - post({ type: "agentManager.setSessionsCollapsed", collapsed: !current.sessionsCollapsed }) - }} - onSelect={(sessionId) => props.onSelectSession(props.project.id, sessionId)} - onPromote={(sessionId) => post({ type: "agentManager.promoteSession", sessionId })} - onOpen={(sessionId) => post({ type: "agentManager.openLocally", sessionId })} - sidebarId={(sessionId) => `${props.project.id}:sess:${sessionId}`} - />
    ) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx index ee75e92802..9e07e8ad79 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/ProjectsSection.tsx @@ -14,6 +14,7 @@ interface ProjectsSectionProps { onSelect: (id: string) => void onRemove: (id: string) => void onExpand: (id: string, expanded: boolean) => void + onHistory: (id: string) => void count: (id: string) => number | undefined tools?: JSX.Element body: (project: AgentProjectSnapshot) => JSX.Element @@ -69,18 +70,30 @@ export const ProjectsSection: Component = (props) => ( } actions={ - +
    { event.stopPropagation() - props.onRemove(project().id) + props.onHistory(project().id) }} /> - + + { + event.stopPropagation() + props.onRemove(project().id) + }} + /> + +
    } onToggle={() => { if (project().missing) return diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SessionRowActions.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SessionRowActions.tsx new file mode 100644 index 0000000000..a40d6d23a3 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/SessionRowActions.tsx @@ -0,0 +1,40 @@ +/** @jsxImportSource solid-js */ + +import type { Component } from "solid-js" +import { IconButton } from "@kilocode/kilo-ui/icon-button" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" +import type { LanguageContextValue } from "../src/context/language" + +interface Props { + t: LanguageContextValue["t"] + /** Move the session into a freshly created worktree. */ + onWorktree: () => void + /** Move the session back to the project root and open it in the local tabs. */ + onLocal: () => void +} + +/** Direct hover actions for sessions in the Agent Manager history view. */ +export const SessionRowActions: Component = (props) => ( + <> + + + + + + + +) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx index f1ab0bbeb8..5e5e8ded6d 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/SidebarBody.tsx @@ -15,7 +15,6 @@ import type { PRStatus, RunStatus, SectionState, - SessionInfo, WorktreeGitStats, WorktreeState, } from "../src/types/messages" @@ -31,7 +30,6 @@ import SectionHeader from "./SectionHeader" import { SidebarSectionHeader } from "./SidebarSectionHeader" import { WorktreeItem } from "./WorktreeItem" import { WorktreeSectionActions } from "./WorktreeSectionActions" -import { UnassignedSessionsSection } from "./UnassignedSessionsSection" import { StatsSkeleton, WorktreeSkeleton } from "./Skeleton" import type { SidebarSearchMenuRef } from "./SidebarSearchMenu" @@ -48,8 +46,6 @@ export interface SidebarBodyProps { isLocalBusy: () => boolean repoBranch: () => string | undefined localStats: () => LocalGitStats | undefined - sessionsCollapsed: () => boolean - toggleSessions: () => void search: { items: () => SidebarSearchItem[]; current: () => SidebarSearchItem | undefined } bindings: () => Record defaultBranch: () => string @@ -63,6 +59,7 @@ export interface SidebarBodyProps { onNewWorktree: () => void onNewSection: () => void onShortcuts: () => void + onHistory: () => void sections: () => SectionState[] sortedWorktrees: () => WorktreeState[] worktrees: () => WorktreeState[] @@ -92,10 +89,6 @@ export interface SidebarBodyProps { confirmDeleteWorktree: (id: string) => void handleDeleteWorktree: (id: string, e: MouseEvent) => void confirmRemoveStaleWorktree: (id: string) => void - unassignedSessions: () => SessionInfo[] - selectUnassigned: (id: string) => void - promoteSession: (id: string) => void - openUnassigned: (id: string) => void track: (event: string, source: string, action: () => void) => () => void } @@ -184,7 +177,7 @@ export const SidebarBody: Component = (props) => { {/* WORKTREES section */} -
    +
    {props.t("agentManager.section.worktrees")}} @@ -203,6 +196,7 @@ export const SidebarBody: Component = (props) => { onNew={props.onNewWorktree} onSection={props.onNewSection} onShortcuts={props.onShortcuts} + onHistory={props.onHistory} onSettings={() => vscode.postMessage({ type: "openSettingsPanel", tab: "agentManager", projectId: props.projectId }) } @@ -448,17 +442,6 @@ export const SidebarBody: Component = (props) => {
    - - (props.selection() === null ? props.currentSessionID() : undefined)} - onToggle={props.toggleSessions} - onSelect={props.selectUnassigned} - onPromote={props.promoteSession} - onOpen={props.openUnassigned} - /> ) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/Skeleton.tsx b/packages/kilo-vscode/webview-ui/agent-manager/Skeleton.tsx index a2d89eb932..9cbeb90a33 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/Skeleton.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/Skeleton.tsx @@ -3,8 +3,6 @@ import { For, type Component } from "solid-js" /** Staggered widths so stacked placeholder rows read as a list, not a striped block. */ const BRANCH = ["62%", "44%", "70%", "52%"] const SUB = ["38%", "30%", "46%", "34%"] -const TITLE = ["70%", "55%", "65%", "48%"] - const rows = (count: number) => Array.from({ length: count }, (_, index) => index) /** Offset each row's pulse so any row count keeps the wave effect. */ @@ -33,23 +31,6 @@ export const WorktreeSkeleton: Component<{ count?: number }> = (props) => (
    ) -/** Placeholder session rows shown until a project's session list arrives. */ -export const SessionSkeleton: Component<{ count?: number }> = (props) => ( -
    - - {(index) => ( -
    -
    -
    -
    - )} - -
    -) - /** Placeholder for the two-line git stats column on a local or worktree row. */ export const StatsSkeleton: Component = () => (
    diff --git a/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx b/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx deleted file mode 100644 index 433699569f..0000000000 --- a/packages/kilo-vscode/webview-ui/agent-manager/UnassignedSessionsSection.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import { For, Show, type Accessor, type Component } from "solid-js" -import { ContextMenu } from "@kilocode/kilo-ui/context-menu" -import { Icon } from "@kilocode/kilo-ui/icon" -import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { Tooltip } from "@kilocode/kilo-ui/tooltip" -import type { SessionInfo } from "../src/types/messages" -import { useLanguage } from "../src/context/language" -import { formatRelativeDate } from "../src/utils/date" -import { SidebarSectionHeader } from "./SidebarSectionHeader" -import { SessionSkeleton } from "./Skeleton" - -interface Props { - sessions: Accessor - loaded: Accessor - collapsed: Accessor - disabled?: boolean - active: Accessor - onToggle: () => void - onSelect: (id: string) => void - onPromote: (id: string) => void - onOpen: (id: string) => void - sidebarId?: (id: string) => string -} - -export const UnassignedSessionsSection: Component = (props) => { - const { t } = useLanguage() - - const promote = (id: string, event: MouseEvent) => { - event.stopPropagation() - props.onPromote(id) - } - - return ( -
    - {t("agentManager.section.sessions")}} - onToggle={props.onToggle} - /> - -
    - }> - - {(session) => ( - - - - - - - props.onPromote(session.id)}> - - {t("agentManager.session.openInWorktree")} - - props.onOpen(session.id)}> - - {t("agentManager.session.openLocally")} - - - - - )} - - -
    -
    -
    - ) -} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx index e84d3a369f..e0241e3908 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/WorktreeSectionActions.tsx @@ -5,7 +5,7 @@ import { Show } from "solid-js" import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu" import { Icon } from "@kilocode/kilo-ui/icon" import { IconButton } from "@kilocode/kilo-ui/icon-button" -import { TooltipKeybind } from "@kilocode/kilo-ui/tooltip" +import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip" import type { LanguageContextValue } from "../src/context/language" import { parseBindingTokens } from "./keybind-tokens" import { SidebarSearchMenu, type SidebarSearchMenuRef } from "./SidebarSearchMenu" @@ -26,6 +26,7 @@ interface WorktreeSectionActionsProps { onSection: () => void onShortcuts: () => void onSettings: () => void + onHistory: () => void } export const WorktreeSectionActions: Component = (props) => ( @@ -107,6 +108,15 @@ export const WorktreeSectionActions: Component = (p onClick={props.onShortcuts} /> + + + [data-component="resize-handle"]::after { background: var(--surface-interactive-base); } @@ -2803,6 +2733,14 @@ body.am-wt-dragging-active * { min-width: 250px; } +/* Per-project row actions (history, remove) */ + +.am-project-actions-row { + display: flex; + align-items: center; + gap: 2px; +} + /* Fixed-width slot so menu items with and without a check mark align. */ .am-menu-check { display: flex; @@ -3666,31 +3604,6 @@ body.am-wt-dragging-active * { animation: am-skeleton-pulse 1.5s ease-in-out infinite; } -/* Session skeleton — matches .am-item layout */ -.am-skeleton-session { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 6px 10px; -} - -.am-skeleton-session-title { - height: 13px; - border-radius: 3px; - background: var(--text-base); - animation: am-skeleton-pulse 1.5s ease-in-out infinite; -} - -.am-skeleton-session-time { - height: 10px; - width: 52px; - border-radius: 3px; - background: var(--text-base); - animation: am-skeleton-pulse 1.5s ease-in-out infinite; - flex-shrink: 0; -} - /* Tab switcher for New/Import tabs in the dialog */ .am-tab-switcher { @@ -4624,14 +4537,12 @@ body.vscode-high-contrast-light { /* Sidebar items */ .am-local-item, - .am-worktree-item, - .am-item { + .am-worktree-item { border: 1px solid var(--vscode-contrastBorder, transparent); } .am-local-item-active, - .am-worktree-item-active, - .am-item-active { + .am-worktree-item-active { border-color: var(--vscode-contrastBorder, transparent); } @@ -4808,7 +4719,6 @@ body.vscode-high-contrast-light { /* Focus states */ .am-local-item:focus-visible, .am-worktree-item:focus-visible, - .am-item:focus-visible, .am-tab:focus-visible, .am-worktree-create:focus-visible, .am-nv-pill:focus-visible, @@ -4878,12 +4788,18 @@ body.vscode-high-contrast-light { /* * Stacking layout for xterm terminal tabs. * - * The hydration invariant (see the render helpers under `terminal/`) requires that - * xterm canvases never leave the paint tree. Both the layer and each - * terminal slot therefore use absolute-positioning + opacity for - * visibility — NEVER `display: none`, because that would detach the - * subtree and stop xterm's internal rAF render loop, causing the - * "press Enter to see content" bug on worktree switches. + * Terminals are never unmounted once mounted; inactive slots hide one + * viewport to the left while keeping their layout box. xterm 6's render + * service observes the screen element and pauses hidden terminals' + * render loops (rAF and model updates), then replays a full + * refresh when a slot becomes visible again — the activation fit + + * refresh in the TerminalTab component is insurance on top of that. The + * historical `display: none` avoidance was for the "press Enter to see + * content" bug, caused by older xterm versions not resuming after a + * paint-tree detach; it also stops FitAddon from measuring, so hidden + * content wraps wrong. Keeping the box fixes wrap and still lets the + * render observer pause, saving the full-rate render cost for output no + * one sees (such as background setup scripts). * * Stack: position:relative container hosting the main pane, review, * and side panel. Sits below the tab bar inside `.am-detail`. @@ -4891,7 +4807,7 @@ body.vscode-high-contrast-light { * `pointer-events: none` when no terminal is active, letting * clicks fall through to the chat behind it. * Slots: absolute-positioned and stacked inside the layer; only the - * active one is opaque and receives pointer events. + * active one is displayed and receives pointer events. */ .am-detail-stack { @@ -4903,6 +4819,17 @@ body.vscode-high-contrast-light { flex-direction: column; } +.am-detail-stack-hidden { + position: absolute; + top: 36px; + right: 0; + bottom: 0; + left: 0; + opacity: 0; + pointer-events: none; + transform: translate(-100vw, 0); +} + .am-terminal-layer { position: absolute; inset: 0; @@ -4921,17 +4848,28 @@ body.vscode-high-contrast-light { } .am-terminal-slot { + /* Hidden slots stay in layout but are translated one viewport to the + left, so xterm's render observer sees no intersection and pauses the + render loop (rAF loop and model updates), then replays a + full refresh when the slot slides back in. Keeping the layout box + (unlike display:none) lets FitAddon measure the real panel size + while hidden — background-created terminals such as setup scripts + wrap their output at the panel's width from the start instead of + the 80 column default. */ position: absolute; inset: 0; display: flex; + flex-direction: column; min-height: 0; min-width: 0; opacity: 0; pointer-events: none; + transform: translate(-100vw, 0); z-index: 0; } .am-terminal-slot-visible { + transform: none; opacity: 1; pointer-events: auto; z-index: 1; @@ -4939,9 +4877,9 @@ body.vscode-high-contrast-light { /* Side terminal panel — lives inside .am-diff-panel-wrapper next to the diff and PR panels. Header reuses .am-diff-header metrics so the - chrome does not shift when switching inspector modes. Visibility is - opacity-only: the xterm render loop dies if the subtree leaves the - paint tree. */ + chrome does not shift when switching inspector modes. The panel is + opacity-toggled, its terminal slots hide by off-screen translate + (per-slot render pause, see .am-terminal-slot above). */ .am-side-terminal { position: absolute; inset: 0; @@ -5066,6 +5004,7 @@ body.vscode-high-contrast-light { bottom: 0; opacity: 0; pointer-events: none; + transform: translate(-100vw, 0); } /* Subagent inspector panel. It remains mounted while another inspector mode diff --git a/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx index d57c3a39c5..5fee2b5b3f 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/apply-to-local.tsx @@ -15,6 +15,7 @@ import { showToast } from "@kilocode/kilo-ui/toast" import { groupApplyConflicts } from "./apply-conflicts" import { ApplyDialog } from "./ApplyDialog" import { composeDiffId } from "./diff-scope-state" +import { diffDataKey } from "./worktree-diffs" import type { tracker } from "./telemetry" import type { useDialog } from "@kilocode/kilo-ui/context/dialog" import type { useLanguage } from "../src/context/language" @@ -74,7 +75,7 @@ export function createApplyToLocal(opts: ApplyToLocalOptions) { const applyDiffs = createMemo(() => { const key = applyDiffKey() if (!key) return [] as WorktreeFileDiff[] - return diffDatas()[key] ?? ([] as WorktreeFileDiff[]) + return diffDatas()[diffDataKey(opts.projectId?.(), key)] ?? ([] as WorktreeFileDiff[]) }) const applyStateForTarget = createMemo(() => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/history-actions.tsx b/packages/kilo-vscode/webview-ui/agent-manager/history-actions.tsx new file mode 100644 index 0000000000..f8d747749d --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/history-actions.tsx @@ -0,0 +1,20 @@ +/** @jsxImportSource solid-js */ + +import type { JSX } from "solid-js" +import type { LanguageContextValue } from "../src/context/language" +import type { SessionInfo } from "../src/types/messages" +import { SessionRowActions } from "./SessionRowActions" + +/** + * Per-row actions for the Agent Manager sessions view. + * `onLocal` and `onPromote` close over the app's scoped-project handlers. + */ +export function historyRowActions(opts: { + t: LanguageContextValue["t"] + onPromote: (sessionId: string) => void + onLocal: (sessionId: string) => void +}): (entry: SessionInfo) => JSX.Element { + return (entry) => ( + opts.onPromote(entry.id)} onLocal={() => opts.onLocal(entry.id)} /> + ) +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts index 8596666cf9..68f4df7282 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/navigate.ts @@ -25,16 +25,6 @@ export function canOpenRootSession(id: string, sessions: Pick( - sessions: T[], - worktree: Set, - local: Set, -): T[] { - return [...sessions] - .filter((s) => isKnownRootSession(s) && !worktree.has(s.id) && !local.has(s.id)) - .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()) -} - export function resolveNavigation(direction: "up" | "down", current: string | undefined, ids: string[]): NavResult { // Determine current position: -1 = local, 0..N-1 = session index if (!current) { @@ -166,22 +156,18 @@ export interface ProjectNavInput { /** Persisted top-level order containing worktree and section IDs. */ worktreeOrder?: string[] sections: { id: string; collapsed: boolean }[] - sessionsCollapsed: boolean - /** Visible unassigned (root, no worktree) sessions in render order. */ - unassigned: { id: string }[] } export const localNavId = (projectId: string) => `${projectId}:local` export const worktreeNavId = (projectId: string, worktreeId: string) => `${projectId}:wt:${worktreeId}` -export const sessionNavId = (projectId: string, sessionId: string) => `${projectId}:sess:${sessionId}` /** * Build one global visual order across expanded projects. * * For each expanded project (in input order): Local, then ungrouped worktrees, - * then members of each non-collapsed section in top-level order, then visible - * unassigned sessions. This matches `buildTopLevelItems` and the project body. - * Collapsed projects contribute nothing. + * then members of each non-collapsed section in top-level order. This matches + * `buildTopLevelItems` and the project body. Collapsed projects contribute + * nothing. */ export function buildProjectNavOrder(projects: ProjectNavInput[]): NavEntry[] { const order: NavEntry[] = [] @@ -211,11 +197,6 @@ export function buildProjectNavOrder(projects: ProjectNavInput[]): NavEntry[] { } } } - if (!p.sessionsCollapsed) { - for (const s of p.unassigned) { - order.push({ id: sessionNavId(pid, s.id), target: { projectId: pid, kind: "session", sessionId: s.id } }) - } - } } return order } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project-nav.ts b/packages/kilo-vscode/webview-ui/agent-manager/project-nav.ts index 2ff2fdbe84..56ef29a051 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project-nav.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project-nav.ts @@ -6,24 +6,26 @@ import { resolveProjectNav, localNavId, worktreeNavId, - sessionNavId, type NavEntry, type NavTarget, LOCAL, } from "./navigate" import type { SidebarItem } from "./section-helpers" -import type { AgentManagerStateMessage, AgentProjectSnapshot, ProjectSessionInfo } from "../src/types/messages" +import type { AgentManagerStateMessage, AgentProjectSnapshot } from "../src/types/messages" /** * Sidebar keyboard-nav controller for the Agent Manager. * * Handles previous/next (⌘⌥↑/↓) and numeric-shortcut (⌘1-9) navigation across * the sidebar. In multi-project mode it builds one global visual order across - * every expanded project — Local, ungrouped worktrees, then section members, - * and visible unassigned sessions — using stable project-qualified composite - * ids, and activates each target with a single atomic - * `agentManager.activateSelection` dispatch. In single-project mode it keeps - * the legacy in-process traversal over the active project's flat sidebar order. + * every expanded project — Local, ungrouped worktrees, then section members — + * using stable project-qualified composite ids, and activates each target with + * a single atomic `agentManager.activateSelection` dispatch. In single-project + * mode it keeps the legacy in-process traversal over the active project's flat + * sidebar order. + * + * Sessions are reachable through the history view, not the tree, so they are + * not part of the nav order. * * The pure order/resolution logic lives in {@link navigate.ts} so it stays * solid/DOM-free and unit-testable; this module owns the reactive wiring and @@ -31,13 +33,12 @@ import type { AgentManagerStateMessage, AgentProjectSnapshot, ProjectSessionInfo */ export interface ProjectNavDeps { multiProject: Accessor - /** Legacy flat sidebar order for single-project mode (LOCAL, worktrees, sessions). */ + /** Legacy flat sidebar order for single-project mode (LOCAL, worktrees). */ sidebarOrder: Accessor /** Legacy in-process activator for single-project mode. */ focus: (item: SidebarItem) => void projects: Accessor states: Accessor> - sessions: Accessor> activeProjectId: Accessor selection: Accessor currentSessionID: Accessor @@ -52,13 +53,12 @@ export interface ProjectNav { export function buildProjectNavEntries( projects: AgentProjectSnapshot[], states: Record, - sessions: Record, ): NavEntry[] { return buildProjectNavOrder( projects.map((p) => { const st = states[p.id] if (!st) { - return { id: p.id, expanded: false, worktrees: [], sections: [], sessionsCollapsed: false, unassigned: [] } + return { id: p.id, expanded: false, worktrees: [], sections: [] } } return { id: p.id, @@ -66,8 +66,6 @@ export function buildProjectNavEntries( worktrees: (st.worktrees ?? []).map((w) => ({ id: w.id, sectionId: w.sectionId, groupId: w.groupId })), worktreeOrder: st.worktreeOrder, sections: (st.sections ?? []).map((s) => ({ id: s.id, collapsed: s.collapsed })), - sessionsCollapsed: st.sessionsCollapsed === true, - unassigned: (sessions[p.id] ?? []).filter((s) => s.worktreeId === null).map((s) => ({ id: s.id })), } }), ) @@ -77,7 +75,7 @@ export function buildProjectNavEntries( export const navSelector = (target: NavTarget): string => { if (target.kind === "local") return `[data-sidebar-id="${target.projectId}:local"]` if (target.kind === "worktree") return `[data-sidebar-id="${target.projectId}:${target.worktreeId}"]` - return `[data-sidebar-id="${sessionNavId(target.projectId, target.sessionId)}"]` + return `[data-sidebar-id="${target.projectId}:sess:${target.sessionId}"]` } /** @@ -97,9 +95,7 @@ export function createProjectNav( ): ProjectNav { const projectOrder = createMemo((): NavEntry[] => { if (!deps.multiProject()) return [] - const states = deps.states() - const live = deps.sessions() - return buildProjectNavEntries(deps.projects(), states, live) + return buildProjectNavEntries(deps.projects(), deps.states()) }) const currentId = createMemo((): string | undefined => { @@ -109,10 +105,8 @@ export function createProjectNav( const sel = deps.selection() if (sel === LOCAL) return localNavId(pid) if (typeof sel === "string") return worktreeNavId(pid, sel) - if (sel === null) { - const sid = deps.currentSessionID() - if (sid) return sessionNavId(pid, sid) - } + // A null selection with an open local session is "on local" for nav. + if (sel === null && deps.currentSessionID()) return localNavId(pid) return undefined }) diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts index ce0aa9e51c..158b91cb54 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/review-state.ts @@ -50,8 +50,9 @@ export function pruneReviewState( ): Record { return Object.fromEntries( Object.entries(values).filter(([key]) => { - const [owner, context] = key.split(":") - return owner !== project || context === "local" || contexts.has(context) + const [owner, value] = key.split(":") + const context = value?.split("#", 1)[0] + return owner !== project || context === "local" || (context !== undefined && contexts.has(context)) }), ) } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts index 3f2a528518..1b3605fae4 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/revert-file.ts @@ -21,38 +21,45 @@ export function createRevertFile( projectId?: Accessor, ) { const [files, setFiles] = createSignal>>({}) + const key = (project: string | undefined, scope: string) => `${project ?? "single"}\0${scope}` const reverting = createMemo(() => { const id = diffScopeId() if (!id) return new Set() - return files()[id] ?? new Set() + return files()[key(projectId?.(), id)] ?? new Set() }) - function revert(file: string) { - const id = diffScopeId() - const context = ctx() + const revertingFor = (id: string) => files()[key(projectId?.(), id)] ?? new Set() + + function revertFor(id: string | undefined, context: string | undefined, source: string, file: string) { if (!id || !context) return + const data = key(projectId?.(), id) setFiles((prev) => { - const set = new Set(prev[id] ?? []) + const set = new Set(prev[data] ?? []) set.add(file) - return { ...prev, [id]: set } + return { ...prev, [data]: set } }) vscode.postMessage({ type: "agentManager.revertWorktreeFile", projectId: projectId?.(), sessionId: context, file, - scope: scope(), + scope: source, }) } + function revert(file: string) { + revertFor(diffScopeId(), ctx(), scope(), file) + } + function onResult(ev: AgentManagerRevertWorktreeFileResultMessage) { + const data = key(ev.projectId, ev.sessionId) setFiles((prev) => { - const set = new Set(prev[ev.sessionId] ?? []) + const set = new Set(prev[data] ?? []) set.delete(ev.file) const next = { ...prev } - if (set.size === 0) delete next[ev.sessionId] - else next[ev.sessionId] = set + if (set.size === 0) delete next[data] + else next[data] = set return next }) if (ev.status === "success") { @@ -62,5 +69,5 @@ export function createRevertFile( } } - return { reverting, revert, onResult } + return { reverting, revertingFor, revert, revertFor, onResult } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts new file mode 100644 index 0000000000..a82f0781be --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/review-composers.ts @@ -0,0 +1,42 @@ +import type { Accessor } from "solid-js" +import { createReviewComposer, type ReviewComposer } from "../diff-viewer/review-annotations" + +export function createReviewComposers(project: Accessor) { + const values = new Map() + + const get = (key: string) => { + const current = values.get(key) + if (current) return current + const next = createReviewComposer() + values.set(key, next) + return next + } + + const clear = (ctx: string | null) => { + if (!ctx) return + const prefix = `${project() ?? "single"}\0${ctx}` + for (const key of values.keys()) { + if (key === prefix || key.startsWith(`${prefix}#`)) values.delete(key) + } + } + + const drop = (key: string) => values.delete(key) + + const clearProject = (id: string) => { + const prefix = `${id}\0` + for (const key of values.keys()) { + if (key.startsWith(prefix)) values.delete(key) + } + } + + const prune = (contexts: Set) => { + const prefix = `${project() ?? "single"}\0` + for (const key of values.keys()) { + if (!key.startsWith(prefix)) continue + const ctx = key.slice(prefix.length).split("#", 1)[0] + if (ctx !== "local" && !contexts.has(ctx)) values.delete(key) + } + } + + return { get, clear, drop, clearProject, prune } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts index fba922c053..b27cd849b8 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/section-helpers.ts @@ -111,14 +111,13 @@ export function buildTopLevelItems( /** * Build the flat visual order of all sidebar items matching what the user sees. * LOCAL is always first, then worktrees in visual order (ungrouped first, then sections, - * skipping collapsed sections), then unassigned sessions. + * skipping collapsed sections). Sessions are reachable through the history view, not the tree. */ export function buildSidebarOrder( items: TopLevelItem[], sorted: WorktreeState[], sections: SectionState[], members: (id: string) => WorktreeState[], - sessions: { id: string }[], ): SidebarItem[] { const result: SidebarItem[] = [{ type: "local", id: "local" }] if (sections.length > 0) { @@ -138,9 +137,6 @@ export function buildSidebarOrder( result.push({ type: "wt", id: wt.id }) } } - for (const s of sessions) { - result.push({ type: "session", id: s.id }) - } return result } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts new file mode 100644 index 0000000000..7c81604c29 --- /dev/null +++ b/packages/kilo-vscode/webview-ui/agent-manager/sidebar-scroll.ts @@ -0,0 +1,38 @@ +type Entry = { + el: HTMLElement + top: number +} + +export function createSidebarScrollPreserver( + active: () => string | null | undefined = () => undefined, + root: ParentNode = document, + schedule: typeof requestAnimationFrame = requestAnimationFrame, + cancel: typeof cancelAnimationFrame = cancelAnimationFrame, +) { + let frame: number | undefined + let inner: number | undefined + + return (fn: () => void): void => { + if (frame !== undefined) cancel(frame) + if (inner !== undefined) cancel(inner) + + const prior = active() + const scrolls: Entry[] = [...root.querySelectorAll(".am-worktree-list, .am-projects-list")].map( + (el) => ({ + el, + top: el.scrollTop, + }), + ) + fn() + frame = schedule(() => { + frame = undefined + inner = schedule(() => { + inner = undefined + if (active() !== prior) return + for (const item of scrolls) { + if (item.el.isConnected && item.top > 0 && item.el.scrollTop === 0) item.el.scrollTop = item.top + } + }) + }) + } +} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx index a158d43131..8084d56ea2 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/SideTerminalPanel.tsx @@ -5,8 +5,14 @@ * panels, so every mode uses the same persisted resize width. The tab row is * the shared inspector strip used by subagents as well. * - * Visibility is opacity-based, never unmount: the xterm render loop dies when - * its subtree leaves the paint tree (see `render.tsx`). + * Hidden slots are translated off-screen while keeping their layout + * box, never unmounted: xterm keeps its buffer, socket, and parser + * alive, while xterm's own render observer (IntersectionObserver on the + * screen element) pauses the render loop for hidden slots and replays a + * full refresh when a slot becomes visible again. Keeping the box means + * FitAddon can measure the panel (correct wrapping) even while hidden. + * `TerminalTab` still does an explicit fit + refresh on activation as + * insurance (see `render.tsx`). */ import type { Accessor, Component } from "solid-js" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx index a0b805c097..a58e421de6 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/TerminalTab.tsx @@ -21,7 +21,7 @@ import { useVSCode } from "../../src/context/vscode" import { useLanguage } from "../../src/context/language" import { formatReviewCommentsMarkdown } from "../../src/utils/review-comment-markdown" import type { ScriptTerminalStatus, TerminalFont } from "./state" -import { createInputBuffer, createReplayGate } from "./replay" +import { createInputBuffer, createReplayGate, createWriteBatcher } from "./replay" import { registerTerminalOutput, unregisterTerminalOutput } from "./output" interface Props { @@ -32,13 +32,12 @@ interface Props { font: TerminalFont /** Whether this terminal is currently the focused tab. * - * The xterm subtree always stays in the paint tree (see the layer / - * slot CSS in `terminal/render.tsx` and `agent-manager.css`), so we - * do NOT rely on this prop to rescue the canvas after a hypothetical - * `display: none` detach — the layout is designed so that never - * happens. It's used only to auto-focus on activation and to force - * an xterm re-paint when the slot transitions back to visible after - * sitting behind an occluding layer. */ + * Inactive slots are translated off-screen (see the layer / slot CSS + * in `terminal/render.tsx` and `agent-manager.css`): xterm's render + * observer pauses invisible terminals and resumes them with a full + * refresh on activation. This prop drives that activation repaint + * plus auto-focus on activation — xterm's own resume is primary, the + * explicit fit + refresh here is insurance. */ active: boolean /** Side terminals only repaint on activation; focus is restored explicitly * when that context's remembered focus owner is the terminal. */ @@ -168,6 +167,12 @@ export const TerminalTab: Component = (props) => { const fit = new FitAddon() term.loadAddon(fit) term.open(host) + // Keep xterm's DOM renderer. Every mounted WebGL addon owns a scarce + // browser context, including when xterm pauses an off-screen terminal. + // Chromium can evict a live renderer once enough Agent Manager terminals + // exist, and xterm 6.0's WebGL addon also has unresolved shared-atlas + // corruption. Frame batching below removes the per-message render churn + // without making terminal correctness depend on GPU resources. registerTerminalOutput(props.terminalId, () => { const buffer = term.buffer.active return Array.from( @@ -221,6 +226,8 @@ export const TerminalTab: Component = (props) => { let socketEnded = false let frame: number | undefined let deferred: number | undefined + const batcher = createWriteBatcher((data, callback) => term.write(data, callback)) + const writeLine = (data: string) => batcher.write(`${data}\r\n`) // The failure line must not depend on event ordering: the stream can // close before the exited snapshot lands (fast failures), or stay open // when a background child outlives the script. Write it exactly once, @@ -232,12 +239,12 @@ export const TerminalTab: Component = (props) => { if (status?.kind !== "setup") return if (status.state === "failed") { failureWritten = true - term.writeln(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailed")}]\x1b[0m`) + writeLine(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailed")}]\x1b[0m`) return } if (status.state === "exited" && status.exitCode !== 0) { failureWritten = true - term.writeln(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailedCode")} ${status.exitCode ?? "?"}]\x1b[0m`) + writeLine(`\r\n\x1b[31m[${t("agentManager.terminal.setupFailedCode")} ${status.exitCode ?? "?"}]\x1b[0m`) } } createEffect(() => { @@ -297,7 +304,7 @@ export const TerminalTab: Component = (props) => { }, 100) } const replay = createReplayGate({ - write: (data, callback) => term.write(data, callback), + write: (data, callback) => batcher.write(data, callback), flush: () => flush(true), }) const disposeKey = term.onKey(markUser) @@ -306,6 +313,7 @@ export const TerminalTab: Component = (props) => { } const open = (url: string) => { if (closed || !url) return + if (ws && ws.readyState !== WebSocket.CLOSING && ws.readyState !== WebSocket.CLOSED) return replay.attach(disconnected) const next = new WebSocket(url) next.binaryType = "arraybuffer" @@ -324,20 +332,28 @@ export const TerminalTab: Component = (props) => { if (closed || ws !== next) return streamed = true if (typeof event.data === "string") { - replay.output(event.data) + if (!replay.output(event.data)) { + input.clear() + next.close(4009, "terminal replay exceeded limit") + return + } scheduleFlush() return } if (event.data instanceof ArrayBuffer) { const bytes = new Uint8Array(event.data) if (replay.frame(bytes)) return - replay.output(bytes) + if (!replay.output(bytes)) { + input.clear() + next.close(4009, "terminal replay exceeded limit") + return + } scheduleFlush() } } next.onerror = () => { if (closed || ws !== next) return - term.writeln(`\r\n\x1b[90m[${t("agentManager.terminal.connectionError")}]\x1b[0m`) + writeLine(`\r\n\x1b[90m[${t("agentManager.terminal.connectionError")}]\x1b[0m`) } next.onclose = () => { if (closed || ws !== next) return @@ -357,7 +373,7 @@ export const TerminalTab: Component = (props) => { restartRequested = false } const key = props.restartable ? "agentManager.terminal.endedRestartable" : "agentManager.terminal.ended" - term.writeln(`\r\n\x1b[90m[${t(key)}]\x1b[0m`) + writeLine(`\r\n\x1b[90m[${t(key)}]\x1b[0m`) } } const disposeData = term.onData(send) @@ -425,8 +441,6 @@ export const TerminalTab: Component = (props) => { return } clearTimeout(resizeTimer) - if (readyTimer) clearTimeout(readyTimer) - if (fallbackTimer) clearTimeout(fallbackTimer) resizeTimer = setTimeout(syncSize, RESIZE_DEBOUNCE_MS) }) ro.observe(host) @@ -437,27 +451,27 @@ export const TerminalTab: Component = (props) => { frame = undefined if (closed) return fitNow() - open(props.wsUrl) + if (!ws) open(props.wsUrl) deferred = requestAnimationFrame(loadAddons) }) // ---- Repaint recovery ---- // - // Every xterm canvas stays mounted in the paint tree (stacking CSS - // guarantees this), but browsers still deprioritise canvases that - // aren't visibly contributing pixels: after another terminal is - // opened on top, or after the window loses focus, the canvas keeps - // its last painted bitmap frozen while xterm's internal buffer goes - // on updating. When we flip the slot back to opacity:1 the canvas - // shows that stale frame until something kicks xterm's render loop - // — historically "press Enter to wake it up". Forcing a - // `fit + refresh(0, rows-1)` once per activation reclaims the paint - // priority; from then on the browser keeps the canvas live. + // Inactive xterm slots slide off-screen with their layout box + // intact, so their canvases are not composed while hidden but + // FitAddon keeps measuring. xterm's render observer pauses hidden + // terminals and replays a full refresh when they slide back in, but + // browsers still defer some canvas/render work: forcing a + // `fit + refresh(0, rows-1)` once per activation reclaims paint + // priority immediately; from then on the renderer keeps the canvas + // live. Historically the missing insurance step here was "press + // Enter to wake it up". // // Focus is opt-in per repaint (`shouldFocus`): repaints triggered by // resizes or font changes must not yank the cursor out of the chat // input, only explicit activation / focus requests may. let pendingFrame: number | null = null + let repaintTimer: ReturnType | undefined let shouldFocus = false const isRenderable = () => { if (!host.isConnected) return false @@ -466,6 +480,8 @@ export const TerminalTab: Component = (props) => { } const runRepaint = () => { pendingFrame = null + clearTimeout(repaintTimer) + repaintTimer = undefined if (!props.active) return if (!isRenderable()) return try { @@ -483,6 +499,11 @@ export const TerminalTab: Component = (props) => { shouldFocus ||= focus if (pendingFrame !== null) return pendingFrame = requestAnimationFrame(runRepaint) + repaintTimer = setTimeout(() => { + if (pendingFrame === null) return + cancelAnimationFrame(pendingFrame) + runRepaint() + }, 250) } const fontSub = vscode.onMessage((message) => { if (message.type === "appendReviewCommentsToTerminal") { @@ -565,12 +586,12 @@ export const TerminalTab: Component = (props) => { const ownsFocus = () => host.contains(document.activeElement) const onVisibilityChange = () => { if (document.hidden) return - if (!props.active || !ownsFocus()) return - scheduleRepaint(true) + if (!props.active) return + scheduleRepaint(ownsFocus()) } const onWindowFocus = () => { - if (!props.active || !ownsFocus()) return - scheduleRepaint(true) + if (!props.active) return + scheduleRepaint(ownsFocus()) } document.addEventListener("visibilitychange", onVisibilityChange) window.addEventListener("focus", onWindowFocus) @@ -590,8 +611,11 @@ export const TerminalTab: Component = (props) => { onCleanup(() => { closed = true + batcher.cancel() + replay.cancel() unregisterTerminalOutput(props.terminalId) if (pendingFrame !== null) cancelAnimationFrame(pendingFrame) + clearTimeout(repaintTimer) if (frame !== undefined) cancelAnimationFrame(frame) if (deferred !== undefined) cancelAnimationFrame(deferred) document.removeEventListener("visibilitychange", onVisibilityChange) @@ -605,6 +629,8 @@ export const TerminalTab: Component = (props) => { fontSub() themeObserver.disconnect() clearTimeout(resizeTimer) + clearTimeout(readyTimer) + clearTimeout(fallbackTimer) ro.disconnect() disposeData.dispose() disposeBinary.dispose() diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts index c674c80d79..fbe218f06c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/ambient.ts @@ -27,11 +27,21 @@ export type AmbientDecision = "wait" | "hide" | "keep" /** The detail stack hosts chat, terminals, and the read-only banner. * It shows for any selected context (local/worktree) and for an * unassigned session, where `selection` is null but the context is not - * empty (a live session is showing). The history view is exclusive. */ + * empty (a live session is showing). Keep it mounted under history when + * terminals exist; the caller hides it without disposing xterm sockets. */ export function showTerminalStack(history: boolean, selection: string | null, contextEmpty: boolean): boolean { return !history && (selection !== null || !contextEmpty) } +export function keepTerminalStack( + history: boolean, + selection: string | null, + contextEmpty: boolean, + terminals: number, +): boolean { + return showTerminalStack(history, selection, contextEmpty) || terminals > 0 +} + /** Setup output owns progress/error presentation when its terminal exists. */ export function hasSetupTerminal(selection: string | null, terminals: TerminalTabStateWithContext[]): boolean { return selection !== null && terminals.some((term) => term.contextKey === selection && term.kind === "setup") diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts index 3cc82946a4..fc9b8e526e 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/index.ts @@ -26,4 +26,4 @@ export { TerminalDestinationButton } from "./TerminalDestinationButton" export { createSideTerminal, readSavedDestination, resolveRunScriptRequest, resolveVscodeTerminalRequest } from "./side" export { TerminalTab } from "./TerminalTab" export { SortableTerminalTab } from "./SortableTerminalTab" -export { createAmbientSetup, hasSetupTerminal, showTerminalStack } from "./ambient" +export { createAmbientSetup, hasSetupTerminal, keepTerminalStack, showTerminalStack } from "./ambient" diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx index 4ffab1208c..f90303936c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/render.tsx @@ -70,22 +70,27 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element { * * ## Invariant * - * **Once an xterm instance is mounted, its DOM subtree must never leave - * the browser's paint tree.** `display: none` on any ancestor detaches - * the subtree: xterm's internal `requestAnimationFrame` render loop - * stops, the canvas goes stale, and no amount of `term.refresh()` can - * reliably restart the loop fast enough on reattachment — that's the - * "press Enter to see content" bug users hit when switching worktrees. + * **Once an xterm instance is mounted, its DOM subtree is only ever + * hidden, never unmounted.** Inactive slots keep their layout box but + * are translated one viewport off-screen; xterm 6's render service + * observes the screen element and pauses the render loop (rAF and model + * updates) for non-intersecting terminals, then replays a + * full refresh when the slot slides back in. Keeping the box (unlike + * `display: none`) also lets FitAddon measure the real panel size while + * hidden, so background-created terminals such as setup scripts wrap + * output at the panel's width from the start. Hiding via opacity instead + * — the historical workaround for the "press Enter to see content" bug, + * where reattachment left a stale canvas — kept the render loop running + * at full rate for output no one sees. `TerminalTab`'s activation + * repaint (fit + refresh) remains as insurance on top of xterm's own + * resume. * * ## Design * * Both the outer layer and each individual terminal slot are * `position: absolute; inset: 0` — stacked on top of the chat area and - * on top of each other. Visibility is controlled purely via CSS classes - * that toggle `opacity`, `pointer-events`, and `z-index`. Elements with - * `opacity: 0` stay in the paint tree (unlike `display: none`), so - * xterm's render loop keeps firing and every canvas stays composed and - * ready for instant reveal. + * on top of each other. The layer controls whether a terminal is visible + * at all; slots control which terminal of a context is shown. * * The layer is mounted under `` only when at least one terminal * exists; that boundary never flips under a live xterm, since removing @@ -134,12 +139,13 @@ export function renderTerminalLayer(props: { /** * Render the side-panel terminal layer inside the right-hand inspector. * - * Same paint-tree invariant as `renderTerminalLayer`: every side - * terminal stays mounted, visibility is toggled via `opacity` / - * `pointer-events` / `inert` only. The layer is scoped to - * `contextKey` — side terminals from other contexts stay composed in - * the background and never refit — and within a context only the - * active strip tab's terminal is shown. + * Same invariant as `renderTerminalLayer`: every side terminal stays + * mounted, inactive slots are translated off-screen so xterm's render + * observer pauses them while FitAddon keeps measuring, and only the + * visible tab's slot is shown. The layer is scoped to `contextKey` — + * side terminals from other + * contexts stay paused in the background and never refit — and within a + * context only the active strip tab's terminal is shown. */ export function renderSideTerminalLayer(props: { state: TerminalStateControls diff --git a/packages/kilo-vscode/webview-ui/agent-manager/terminal/replay.ts b/packages/kilo-vscode/webview-ui/agent-manager/terminal/replay.ts index f953a41ffc..acfba3ef23 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/terminal/replay.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/terminal/replay.ts @@ -5,6 +5,21 @@ interface ReplayGateDeps { flush(): void } +const encoder = new TextEncoder() +const decoder = new TextDecoder() + +export function byteLength(data: string | Uint8Array) { + return typeof data === "string" ? encoder.encode(data).byteLength : data.byteLength +} + +function tail(data: string, limit: number) { + const bytes = encoder.encode(data) + if (bytes.byteLength <= limit) return data + let start = bytes.byteLength - limit + while (start < bytes.byteLength && (bytes[start]! & 0xc0) === 0x80) start++ + return decoder.decode(bytes.subarray(start)) +} + /** Keep terminal protocol replies ahead of user input without reordering the * user's bytes when both arrive while initial replay is being parsed. */ export function createInputBuffer(limit = 256 * 1024) { @@ -14,11 +29,11 @@ export function createInputBuffer(limit = 256 * 1024) { const add = (data: string, reply = false) => { if (reply) { replies += data - if (replies.length > limit) replies = replies.slice(-limit) + replies = tail(replies, limit) return } input += data - if (input.length > limit) input = input.slice(-limit) + input = tail(input, limit) } const take = () => { @@ -28,7 +43,116 @@ export function createInputBuffer(limit = 256 * 1024) { return data } - return { add, take } + const clear = () => { + replies = "" + input = "" + } + + return { add, clear, take } +} + +/** + * Coalesce per-message PTY chunks into one xterm write per animation + * frame. xterm parses every `write()` call with its own scope and + * schedules a render cycle per dirty buffer; at sustained streaming + * rates (one WebSocket message per line of output) that multiplies + * parse runs and render schedules. One write per frame keeps the parser + * busy once instead of once per message; output latency stays under one + * frame. Callbacks attached to individual chunks fire after the batch + * that contained them finishes parsing, preserving replay-gate ordering. + * + * Two safety valves keep the batch bounded: a watchdog flushes via a + * timer when animation frames stop (background or minimized windows + * throttle rAF), and a byte cap flushes immediately so a burst never + * accumulates anywhere near xterm's discard watermark. + */ +export function createWriteBatcher( + write: (data: string | Uint8Array, callback?: () => void) => void, + schedule: (callback: () => void) => number = (callback) => requestAnimationFrame(callback), + unschedule: (handle: number) => void = (handle) => cancelAnimationFrame(handle), + delay: (callback: () => void, ms: number) => ReturnType = (callback, ms) => + setTimeout(callback, ms), + clearDelay: (handle: ReturnType) => void = (handle) => clearTimeout(handle), + maxBytes: number = 512 * 1024, +) { + let chunks: Array = [] + let callbacks: Array<() => void> = [] + let pendingBytes = 0 + let scheduled = false + let raf: number | undefined + let watchdog: ReturnType | undefined + + const drain = () => { + if (raf !== undefined) unschedule(raf) + if (watchdog !== undefined) clearDelay(watchdog) + raf = undefined + watchdog = undefined + scheduled = false + const data = chunks + const cbs = callbacks + chunks = [] + callbacks = [] + pendingBytes = 0 + if (data.length === 0 && cbs.length === 0) return + const groups: Array = [] + for (const chunk of data) { + const prior = groups.at(-1) + if (typeof chunk === "string") { + if (typeof prior === "string") groups[groups.length - 1] = prior + chunk + else groups.push(chunk) + continue + } + if (!(prior instanceof Uint8Array)) { + groups.push(chunk) + continue + } + const merged = new Uint8Array(prior.byteLength + chunk.byteLength) + merged.set(prior) + merged.set(chunk, prior.byteLength) + groups[groups.length - 1] = merged + } + if (groups.length === 0) groups.push("") + const complete = () => { + for (const cb of cbs) cb() + } + for (let index = 0; index < groups.length; index++) { + write(groups[index]!, index === groups.length - 1 ? complete : undefined) + } + } + + const kick = () => { + if (scheduled) return + scheduled = true + raf = schedule(drain) + watchdog = delay(drain, 250) + } + + const writeChunk = (data: string | Uint8Array, callback?: () => void) => { + if ((typeof data === "string" ? data.length : data.byteLength) === 0 && !callback) return + const bytes = byteLength(data) + if (pendingBytes > 0 && pendingBytes + bytes > maxBytes) drain() + chunks.push(data) + pendingBytes += bytes + if (callback) callbacks.push(callback) + if (pendingBytes >= maxBytes) { + drain() + return + } + kick() + } + + const cancel = () => { + if (raf !== undefined) unschedule(raf) + if (watchdog !== undefined) clearDelay(watchdog) + raf = undefined + watchdog = undefined + scheduled = false + chunks = [] + callbacks = [] + pendingBytes = 0 + } + + return { write: writeChunk, cancel } } /** @@ -45,7 +169,12 @@ export function createReplayGate(deps: ReplayGateDeps) { let boundary = false let draining = false let serial = 0 - let pending: Array = [] + let pending: Array<{ data: string | Uint8Array; callback?: () => void }> = [] + let bytes = 0 + // The server retains at most 2 Mi UTF-16 code units. Eight MiB covers + // their maximum UTF-8 expansion while still staying far below xterm's + // 50 MiB discard watermark if a boundary frame never arrives. + const limit = 8 * 1024 * 1024 const attach = (reconnecting: boolean) => { serial++ @@ -53,14 +182,24 @@ export function createReplayGate(deps: ReplayGateDeps) { boundary = false draining = false pending = [] + bytes = 0 } - const output = (data: string | Uint8Array) => { + const output = (data: string | Uint8Array, callback?: () => void) => { if (blocked && !boundary) { - pending.push(data) - return + bytes += byteLength(data) + if (bytes > limit) { + serial++ + blocked = false + pending = [] + bytes = 0 + return false + } + pending.push({ data, callback }) + return true } - deps.write(data) + deps.write(data, callback) + return true } const frame = (data: Uint8Array) => { @@ -74,8 +213,9 @@ export function createReplayGate(deps: ReplayGateDeps) { // parser-generated replies in its separate priority buffer meanwhile. draining = true const current = serial - for (const chunk of pending) deps.write(chunk) + for (const chunk of pending) deps.write(chunk.data, chunk.callback) pending = [] + bytes = 0 deps.write("", () => { if (serial !== current) return draining = false @@ -86,5 +226,14 @@ export function createReplayGate(deps: ReplayGateDeps) { return true } - return { attach, blocked: () => blocked, draining: () => draining, frame, output } + const cancel = () => { + serial++ + blocked = false + boundary = false + draining = false + pending = [] + bytes = 0 + } + + return { attach, blocked: () => blocked, cancel, draining: () => draining, frame, output } } diff --git a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts index 60ba7cb599..350ba0ea5c 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/worktree-diffs.ts @@ -29,15 +29,43 @@ export function wireDiffId(id: string) { return { sessionId: ctx, scope, diffSessionId: sessionId } } +export function diffDataKey(project: string | undefined, id: string): string { + return `${project ?? "single"}\0${id}` +} + export function createWorktreeDiffs( vscode: ReturnType, project: () => string | undefined = () => undefined, ) { const [diffDatas, setDiffDatas] = createSignal>({}) - const [diffLoading, setDiffLoading] = createSignal(false) + const [diffLoadings, setDiffLoadings] = createSignal>({}) + const diffLoading = () => Object.keys(diffLoadings()).length > 0 const [diffNotices, setDiffNotices] = createSignal>({}) const [diffFileLoading, setDiffFileLoading] = createSignal>>({}) + const key = (id: string) => diffDataKey(project(), id) + + const reset = () => { + setDiffDatas({}) + setDiffLoadings({}) + setDiffNotices({}) + setDiffFileLoading({}) + } + + const drop = (id: string) => { + const data = id.includes("\0") ? id : key(id) + const remove = >(prev: T): T => { + if (!(data in prev)) return prev + const next = { ...prev } + delete next[data] + return next + } + setDiffDatas(remove) + setDiffLoadings(remove) + setDiffNotices(remove) + setDiffFileLoading(remove) + } + const setDiffFilePending = (sessionId: string, file: string, value: boolean) => { setDiffFileLoading((prev) => { const session = prev[sessionId] ?? {} @@ -66,20 +94,21 @@ export function createWorktreeDiffs( /** Lazily load a single file's full diff for the given composite diff id. */ const requestDiffFile = (id: string, file: string) => { - if (diffFileLoading()[id]?.[file]) return - setDiffFilePending(id, file, true) + const data = key(id) + if (diffFileLoading()[data]?.[file]) return + setDiffFilePending(data, file, true) vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", projectId: project(), file, ...wireDiffId(id) }) } /** Files the backend flagged as stale in a merged update need a fresh fetch. */ - const refreshStaleDiffs = (id: string, files: Set) => { - const loading = diffFileLoading()[id] ?? {} + const refreshStaleDiffs = (id: string, files: Set, data = key(id), owner = project()) => { + const loading = diffFileLoading()[data] ?? {} for (const file of files) { if (loading[file]) continue - setDiffFilePending(id, file, true) + setDiffFilePending(data, file, true) vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", - projectId: project(), + projectId: owner, file, ...wireDiffId(id), }) @@ -90,53 +119,79 @@ export function createWorktreeDiffs( const diffFileLoadingFor = (sessionId: Accessor) => { const id = sessionId() if (!id) return new Set() - return new Set(Object.keys(diffFileLoading()[id] ?? {})) + return new Set(Object.keys(diffFileLoading()[key(id)] ?? {})) + } + + /** Initial summary loading for one composite diff id. Cached results stay visible while refreshing. */ + const diffLoadingFor = (sessionId: Accessor) => { + const id = sessionId() + if (!id) return false + const data = key(id) + return diffLoadings()[data] === true && !(data in diffDatas()) } // Backend messages. const onWorktreeDiff = (ev: AgentManagerWorktreeDiffMessage) => { + const data = diffDataKey(ev.projectId, ev.sessionId) let staleFiles: Set | undefined setDiffDatas((prev) => { - const existing = prev[ev.sessionId] + const existing = prev[data] const merged = existing ? mergeWorktreeDiffs(existing, ev.diffs) : { diffs: ev.diffs, stale: new Set() } staleFiles = merged.stale const next = merged.diffs if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev - return { ...prev, [ev.sessionId]: next } + return { ...prev, [data]: next } }) - if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles) + if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles, data, ev.projectId) } const onWorktreeDiffFile = (ev: AgentManagerWorktreeDiffFileMessage) => { + const data = diffDataKey(ev.projectId, ev.sessionId) if (ev.diff) { setDiffDatas((prev) => { - const existing = prev[ev.sessionId] ?? [] + const existing = prev[data] ?? [] const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item)) - return { ...prev, [ev.sessionId]: next } + return { ...prev, [data]: next } }) - setDiffFilePending(ev.sessionId, ev.diff.file, false) + setDiffFilePending(data, ev.diff.file, false) return } - setDiffFilePending(ev.sessionId, ev.file, false) + setDiffFilePending(data, ev.file, false) } const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => { - setDiffLoading(ev.loading) + const data = diffDataKey(ev.projectId, ev.sessionId) + // One source is active per project. Replacing the map on start also clears + // an interrupted source whose stale completion is intentionally discarded. + if (ev.loading) { + setDiffLoadings({ [data]: true }) + return + } + setDiffLoadings((prev) => { + if (!prev[data]) return prev + const next = { ...prev } + delete next[data] + return next + }) } const onWorktreeDiffNotice = (ev: AgentManagerWorktreeDiffNoticeMessage) => { - setDiffNotices((prev) => ({ ...prev, [ev.sessionId]: ev.notice })) + setDiffNotices((prev) => ({ ...prev, [diffDataKey(ev.projectId, ev.sessionId)]: ev.notice })) } return { diffDatas, diffLoading, - setDiffLoading, + setDiffLoading: (loading: boolean) => setDiffLoadings(loading ? diffLoadings() : {}), diffNotices, requestDiffFile, refreshStaleDiffs, diffFileLoadingFor, + diffLoadingFor, + diffDataKey, + drop, + reset, onWorktreeDiff, onWorktreeDiffFile, onWorktreeDiffLoading, diff --git a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts index 904ceca13f..8d51779566 100644 --- a/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts +++ b/packages/kilo-vscode/webview-ui/diff-viewer/diff-requests.ts @@ -13,6 +13,7 @@ interface DiffRequestOptions { export function createDiffRequests(opts: DiffRequestOptions) { const requested = new Map() + let active = false createEffect( on( @@ -36,8 +37,17 @@ export function createDiffRequests(opts: DiffRequestOptions) { createEffect( on( - () => [opts.open(), opts.diffs(), opts.loading()] as const, + () => [opts.open(), opts.diffs(), opts.loading(), opts.send()] as const, ([open, diffs]) => { + if (!opts.send()) { + requested.clear() + active = false + return + } + if (!active) { + requested.clear() + active = true + } const files = new Set(open) for (const file of requested.keys()) { if (!files.has(file)) requested.delete(file) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx index ce9b9f25e7..807e386681 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/AssistantMessage.tsx @@ -114,6 +114,7 @@ interface AssistantMessageProps { forceOpenFile?: string /** Part behind the currently hovered/focused task-timeline bar, if any. */ highlight?: () => TimelineHighlight | undefined + readonly?: boolean } type ToolStateProps = { @@ -339,6 +340,7 @@ export const AssistantMessage: Component = (props) => { reasoningAutoCollapse={display.reasoningAutoCollapse()} feedback={props.feedback} throughput={throughputEl()} + readonly={props.readonly} animate={ part.type === "tool" && ((part as unknown as ToolPart).state?.status === "pending" || diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx index a3c4c972b0..8764d7f78d 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/BackgroundAgents.tsx @@ -19,13 +19,7 @@ import { useLanguage } from "../../context/language" import { useSession } from "../../context/session" import { useVSCode } from "../../context/vscode" import { useWorktreeMode } from "../../context/worktree-mode" -import { - backgroundAgents, - backgroundJobAgents, - foregroundAgent, - showBackgroundAgent, - type BackgroundAgent, -} from "./background-agents" +import { backgroundAgents, backgroundJobAgents, showBackgroundAgent, type BackgroundAgent } from "./background-agents" import { openSubagent } from "./open-subagent" export const BackgroundAgents: Component<{ readonly?: boolean }> = (props) => { @@ -96,15 +90,9 @@ export const BackgroundAgents: Component<{ readonly?: boolean }> = (props) => { }) const visible = createMemo(() => agents().filter((agent) => showBackgroundAgent(agent, hidden()))) - const foreground = createMemo(() => { - const id = session.currentSessionID() - return id ? foregroundAgent(session.getSessionToolParts(id), session.allStatusMap()) : undefined - }) - const summary = createMemo(() => { const running = visible().filter((agent) => agent.status === "running").length const total = visible().length - if (total === 0 && foreground()) return language.t("task.backgroundAgents.foreground") if (total === 1 && running === 1) return language.t("task.backgroundAgents.running.one") if (running === total) return language.t("task.backgroundAgents.running.many", { count: String(total) }) return language.t("task.backgroundAgents.summary", { running: String(running), total: String(total) }) @@ -142,11 +130,6 @@ export const BackgroundAgents: Component<{ readonly?: boolean }> = (props) => { vscode.postMessage({ type: "cancelBackgroundJob", jobID: agent.jobID, sessionID: id, requestID: pending }) } - const background = () => { - const id = session.currentSessionID() - if (id) vscode.postMessage({ type: "backgroundSubagents", sessionID: id }) - } - const hideFinished = () => setHidden( new Set( @@ -157,7 +140,7 @@ export const BackgroundAgents: Component<{ readonly?: boolean }> = (props) => { ) return ( - 0 || (!props.readonly && foreground())}> + 0}>
    0}> @@ -193,16 +176,6 @@ export const BackgroundAgents: Component<{ readonly?: boolean }> = (props) => { /> - - - agent.status !== "running")}> - -
    diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx index 0f7cfb2247..568245bcc6 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/TaskToolExpanded.tsx @@ -13,20 +13,23 @@ import { BasicTool, initialOpen } from "@kilocode/kilo-ui/basic-tool" import { Icon } from "@kilocode/kilo-ui/icon" import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Markdown } from "@kilocode/kilo-ui/markdown" +import { Tooltip } from "@kilocode/kilo-ui/tooltip" import { useLanguage } from "../../context/language" import { useI18n } from "@kilocode/kilo-ui/context/i18n" import { createAutoScroll } from "@kilocode/kilo-ui/hooks" import { useSession } from "../../context/session" import { useVSCode } from "../../context/vscode" import { useWorktreeMode } from "../../context/worktree-mode" -import { childID } from "../../context/session-utils" +import { childID, latestTaskPart } from "../../context/session-utils" +import { useConfig } from "../../context/config" import { openSubagent } from "./open-subagent" -import { taskResult, taskRunning, taskVisible } from "./task-tool-state" +import { showChildPromotion, taskResult, taskRunning, taskVisible } from "./task-tool-state" const TaskToolRenderer: Component = (props) => { const i18n = useI18n() const language = useLanguage() const session = useSession() + const { features } = useConfig() const vscode = useVSCode() const worktree = useWorktreeMode() @@ -38,6 +41,22 @@ const TaskToolRenderer: Component = (props) => { state: { metadata: props.metadata as { sessionId?: string } }, }) + const promotable = createMemo(() => + showChildPromotion( + childSessionId(), + props.partMetadata as Record | undefined, + props.metadata as Record | undefined, + session.allStatusMap(), + features().backgroundSubagents, + props.readonly, + latestTaskPart( + props.partID, + childSessionId(), + session.currentSessionID() ? session.getSessionToolParts(session.currentSessionID()!) : [], + ), + ), + ) + const running = createMemo(() => taskRunning(props.status)) // BasicTool's forceOpen effect only fires onOpenChange on a false->true // transition — a virtualized remount that starts with forceOpen already @@ -134,6 +153,13 @@ const TaskToolRenderer: Component = (props) => { }) } + const background = (e: MouseEvent) => { + e.stopPropagation() + const id = session.currentSessionID() + const child = childSessionId() + if (id && child) vscode.postMessage({ type: "promoteBackgroundJob", jobID: child, sessionID: id }) + } + const trigger = () => (
    @@ -150,6 +176,17 @@ const TaskToolRenderer: Component = (props) => {
    + + + + + = (props) => { @@ -92,6 +93,7 @@ export const TranscriptRowView: Component = (props) => { forceOpenPartID={props.activeSearchPartID} forceOpenFile={props.activeSearchPartFile} highlight={props.highlight} + readonly={props.readonly} feedback={{ enabled: feedback.telemetryEnabled(), rating: feedback.getRating(row().message.id), diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/background-agents.ts b/packages/kilo-vscode/webview-ui/src/components/chat/background-agents.ts index a547356e2a..bd80f95fe2 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/background-agents.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/background-agents.ts @@ -108,18 +108,3 @@ export function backgroundJobAgents( } }) } - -export function foregroundAgent(tools: ToolPart[], status: Record): string | undefined { - const latest = new Map() - for (const part of tools) { - if (part.tool !== "task") continue - const id = text(meta(part, "sessionId")) - if (id) latest.set(id, part) - } - for (const part of latest.values()) { - const id = text(meta(part, "sessionId")) - if (!id || meta(part, "background") === true || !working(status[id])) continue - return id - } - return undefined -} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/task-tool-state.ts b/packages/kilo-vscode/webview-ui/src/components/chat/task-tool-state.ts index f4079c5ef6..f54bbb72e1 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/task-tool-state.ts +++ b/packages/kilo-vscode/webview-ui/src/components/chat/task-tool-state.ts @@ -1,7 +1,33 @@ +import type { SessionStatusInfo } from "../../types/messages" + export function taskRunning(status: string | undefined) { return status === "pending" || status === "running" } +export function childForeground( + id: string | undefined, + part: Record | undefined, + state: Record | undefined, + status: Record, + latest: boolean, +) { + if (!id || !latest) return false + if (part?.background === true || state?.background === true) return false + return status[id]?.type === "busy" || status[id]?.type === "retry" +} + +export function showChildPromotion( + id: string | undefined, + part: Record | undefined, + state: Record | undefined, + status: Record, + enabled: boolean | undefined, + readonly: boolean | undefined, + latest: boolean, +) { + return enabled === true && !readonly && childForeground(id, part, state, status, latest) +} + export function taskVisible(open: boolean | undefined, id: string | undefined) { return open ? id : undefined } diff --git a/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx b/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx index 53a111ff2a..09978acddf 100644 --- a/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/history/HistoryView.tsx @@ -4,7 +4,7 @@ * Contains a source tab bar and an always-visible "Import session" button. */ -import { Component, Show, createEffect, createSignal, onCleanup, type Accessor } from "solid-js" +import { Component, Show, createEffect, createSignal, onCleanup, type Accessor, type JSX } from "solid-js" import { Button } from "@kilocode/kilo-ui/button" import { useDialog } from "@kilocode/kilo-ui/context/dialog" import { useLanguage } from "../../context/language" @@ -13,11 +13,16 @@ import { useLocalTabs } from "../../context/local-tabs" import { CloudImportDialog } from "../chat/CloudImportDialog" import SessionList from "./SessionList" import CloudSessionList from "./CloudSessionList" +import type { SessionInfo } from "../../types/messages" interface HistoryViewProps { onSelectSession: (id: string) => void onBack?: () => void worktreeSessionIds?: Accessor | undefined> + /** Filter the Local tab to these session ids. */ + sessionIds?: Accessor | undefined> + /** Extra per-row actions rendered in the Local tab. */ + rowActions?: (session: SessionInfo) => JSX.Element } type Source = "local" | "cloud" | "worktree" @@ -159,7 +164,13 @@ const HistoryView: Component = (props) => { aria-labelledby="history-tab-local" hidden={tab() !== "local"} > - {tab() === "local" && } + {tab() === "local" && ( + + )}
    void - sessionIds?: Accessor> + sessionIds?: Accessor | undefined> + /** Extra per-row actions rendered after rename/delete (e.g. Agent Manager menus). */ + rowActions?: (session: SessionInfo) => JSX.Element } const SessionList: Component = (props) => { @@ -159,6 +161,7 @@ const SessionList: Component = (props) => { aria-label={label(language.t("session.delete.title"), item)} onClick={(event) => confirmDelete(item, event.currentTarget)} /> + {props.rowActions?.(item)} } > diff --git a/packages/kilo-vscode/webview-ui/src/context/config.tsx b/packages/kilo-vscode/webview-ui/src/context/config.tsx index 53b3bfafc3..4fc09d1e8d 100644 --- a/packages/kilo-vscode/webview-ui/src/context/config.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/config.tsx @@ -88,7 +88,11 @@ export const ConfigProvider: ParentComponent = (props) => { const [projectConfig, setProjectConfig] = createSignal({}) const [collections, setCollections] = createSignal({}) const [settings, setSettings] = createSignal>({}) - const [features, setFeatures] = createSignal({ indexing: false, sandboxControls: false }) + const [features, setFeatures] = createSignal({ + indexing: false, + sandboxControls: false, + backgroundSubagents: false, + }) const [loading, setLoading] = createSignal(true) const [draft, setDraft] = createSignal>({}) const [globalDraft, setGlobalDraft] = createSignal>({}) diff --git a/packages/kilo-vscode/webview-ui/src/context/session-parts.ts b/packages/kilo-vscode/webview-ui/src/context/session-parts.ts index 883a2b7148..b025feabd7 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-parts.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-parts.ts @@ -1,5 +1,9 @@ import type { Part } from "../types/messages" +export function isolate(part: Part): Part { + return { ...part } +} + function stream(part: Part): part is Extract { return part.type === "text" || part.type === "reasoning" } @@ -27,10 +31,11 @@ export function sameParts(local: Part[] = [], snapshot: Part[] = []): boolean { export function mergeOptimisticPart(current: Part[], ids: ReadonlySet, part: Part) { const index = current.findIndex((item) => ids.has(item.id) && item.type === part.type) - if (index < 0) return { parts: [...current, part] } + const copy = isolate(part) + if (index < 0) return { parts: [...current, copy] } const old = current[index]! const next = current.slice() - next[index] = part + next[index] = copy return { parts: next, replaced: old.id } } diff --git a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts index b783496f7a..84212c2c4b 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session-utils.ts +++ b/packages/kilo-vscode/webview-ui/src/context/session-utils.ts @@ -105,6 +105,7 @@ type ToolState = { } type TaskPart = { + id?: string type: string tool?: string metadata?: { sessionId?: string } @@ -116,6 +117,11 @@ export function childID(part: TaskPart): string | undefined { return part.metadata?.sessionId ?? part.state?.metadata?.sessionId } +export function latestTaskPart(partID: string | undefined, child: string | undefined, parts: readonly TaskPart[]) { + if (!partID || !child) return false + return parts.findLast((part) => childID(part) === child)?.id === partID +} + function stringField(value: unknown): string | undefined { return typeof value === "string" ? value : undefined } diff --git a/packages/kilo-vscode/webview-ui/src/context/session.tsx b/packages/kilo-vscode/webview-ui/src/context/session.tsx index 6bf6779bc8..54841acdae 100644 --- a/packages/kilo-vscode/webview-ui/src/context/session.tsx +++ b/packages/kilo-vscode/webview-ui/src/context/session.tsx @@ -79,7 +79,7 @@ import { getAgentModel } from "./session-model-store" import { resolveMessagePrefs } from "./session-preferences" import { errorIDs, preserveSessionErrors, withoutResolvedSessionErrors } from "./session-errors" import { PartStash } from "./part-stash" -import { mergeOptimisticPart, mergeParts } from "./session-parts" +import { isolate, mergeOptimisticPart, mergeParts } from "./session-parts" import { mergeMessages, sameReconcileShape } from "./session-merge" import { state as todoState } from "./todo-revert" import { sessionVariantKeys, transferVariants, variantKey } from "./session-variant-store" @@ -190,12 +190,8 @@ interface SessionContextValue { // Model selection (global, extension-lifetime) selected: (sessionID?: string) => ModelSelection | null - configModel: (sessionID?: string) => ModelSelection | null modelForAgent: (agent: string) => ModelSelection | null - configModelForAgent: (agent: string) => ModelSelection | null selectModel: (providerID: string, modelID: string, sessionID?: string) => void - hasModelOverride: (sessionID?: string) => boolean - clearModelOverride: (sessionID?: string) => void // Cost and context usage for the current session costBreakdown: Accessor> @@ -736,7 +732,7 @@ export const SessionProvider: ParentComponent = (props) => { }) } - function clearModeModelSelection(agentName: string, persist = false) { + function clearModeModelSelection(agentName: string) { setUserSetAgents((prev) => { const next = { ...prev } delete next[agentName] @@ -748,7 +744,6 @@ export const SessionProvider: ParentComponent = (props) => { delete selections[agentName] }), ) - if (persist) vscode.postMessage({ type: "clearModelSelection", agent: agentName }) } function shouldClearModeModelSelection(agentName: string) { @@ -765,11 +760,6 @@ export const SessionProvider: ParentComponent = (props) => { }) } - function configModel(sessionID?: string): ModelSelection | null { - const agentName = agentForScope(sessionID) - return resolveModel(agentName) - } - function modelForAgent(agentName: string): ModelSelection | null { return getAgentModel( { @@ -790,36 +780,6 @@ export const SessionProvider: ParentComponent = (props) => { ) } - function configModelForAgent(agentName: string): ModelSelection | null { - return resolveModel(agentName) - } - - /** True when the active model differs from what the config dictates. */ - function hasModelOverride(sessionID?: string) { - const sel = selected(sessionID) - const cfg = configModel(sessionID) - if (!sel || !cfg) return false - return sel.providerID !== cfg.providerID || sel.modelID !== cfg.modelID - } - - /** Clear the per-mode model override, falling back to config default. */ - function clearModelOverride(sessionID?: string) { - const sid = sessionID ?? currentSessionID() - const agentName = sid ? agentForScope(sid) : selectedAgentName() - // Always clear the persisted per-mode model selection so the user's - // configured (or fallback) model becomes effective, not the last manual pick. - clearModeModelSelection(agentName, true) - if (sid) { - setStore( - "sessionOverrides", - produce((overrides) => { - delete overrides[sid] - }), - ) - hideErrors(sid) - } - } - // Handle agentsLoaded immediately (not in onMount) so we never miss // the initial push that arrives before the DOM mounts. This mirrors the // pattern used by ProviderProvider for providersLoaded. @@ -932,7 +892,7 @@ export const SessionProvider: ParentComponent = (props) => { onCleanup(variants.load()) // Load persisted per-mode model selections from model.json via extension host. - // Uses replace semantics so a reset (empty payload) clears old entries. + // Uses replace semantics so an empty payload clears old entries. const unsubSelections = vscode.onMessage((message: ExtensionMessage) => { if (message.type !== "modelSelectionsLoaded") return setStore("modelSelections", reconcile(message.selections)) @@ -1497,7 +1457,7 @@ export const SessionProvider: ParentComponent = (props) => { const cutoff = Math.max(0, messages.length - 15) for (let i = 0; i < messages.length; i++) { const msg = messages[i]! - const parts = msg.parts ?? [] + const parts = msg.parts?.map(isolate) ?? [] if (mode === "reconcile" && store.parts[msg.id] && !optimisticParts.has(msg.id)) { const merged = mergeParts(store.parts[msg.id], parts, input.since ?? Number.POSITIVE_INFINITY) setStore("parts", msg.id, reconcile(merged, { key: "id" })) @@ -1586,7 +1546,7 @@ export const SessionProvider: ParentComponent = (props) => { if (message.parts && message.parts.length > 0) { optimisticParts.delete(message.id) stash.remove(message.id) - setStore("parts", message.id, message.parts) + setStore("parts", message.id, message.parts.map(isolate)) } rebuildToolParts(message.sessionID, store.messages[message.sessionID] ?? []) } @@ -1664,7 +1624,7 @@ export const SessionProvider: ParentComponent = (props) => { } } else { // Add new part - list.push(part) + list.push(isolate(part)) } }), ) @@ -2107,7 +2067,7 @@ export const SessionProvider: ParentComponent = (props) => { setStore("messages", key, messages) for (const msg of messages) { if (msg.parts && msg.parts.length > 0) { - setStore("parts", msg.id, msg.parts) + setStore("parts", msg.id, msg.parts.map(isolate)) } } rebuildToolParts(key, messages) @@ -2994,12 +2954,8 @@ export const SessionProvider: ParentComponent = (props) => { scopedQuestions, scopedSuggestions, selected, - configModel, modelForAgent, - configModelForAgent, selectModel, - hasModelOverride, - clearModelOverride, costBreakdown, contextUsage, modelUsage, diff --git a/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts b/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts index 89ac134335..0d8939042c 100644 --- a/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts +++ b/packages/kilo-vscode/webview-ui/src/context/transcript-rows.ts @@ -108,7 +108,11 @@ function diffs(msg: Message) { return msg.summary.diffs ?? [] } -function copy(messages: Message[], getParts: (id: string) => Part[]) { +function copy(messages: Message[], getParts: (id: string) => Part[], live: boolean) { + // While the session streams, the last non-empty text part changes at every + // part boundary and the copy/feedback row would hop between parts (mount/ + // unmount churn next to the streamed text). Anchor it only once idle. + if (live) return undefined for (let i = messages.length - 1; i >= 0; i -= 1) { const parts = getParts(messages[i]!.id) for (let j = parts.length - 1; j >= 0; j -= 1) { @@ -138,7 +142,7 @@ export function transcriptRows( queued: opts.queued?.has(turn.id) === true, live: opts.live?.has(turn.id) === true, } - const copied = copy(turn.assistant, parts) + const copied = copy(turn.assistant, parts, meta.live) if (!turn.partial) { rows.push({ diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts index 527f49dc6d..69b73384ba 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ar.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ar.ts @@ -180,7 +180,6 @@ export const dict = { "prompt.action.autoApprove.disable": "تعطيل الموافقة التلقائية", "prompt.action.autoApprove.enabled": "الموافقة التلقائية مفعلة. ستتم الموافقة على طلبات الأذونات تلقائياً.", "prompt.action.autoApprove.disabled": "الموافقة التلقائية معطلة. انقر للموافقة على طلبات الأذونات تلقائياً.", - "prompt.action.resetModel": "إعادة تعيين النموذج إلى الافتراضي", "prompt.action.enhanceDescription": "زر «حسّن الموجه» يطوّر موجهك بإضافة سياق أو توضيح أو إعادة صياغة. جرّب اكتب موجه هنا ثم اضغط الزر مرة ثانية وشوف النتيجة.", "prompt.action.sandbox.enable": "تفعيل sandbox", @@ -1221,7 +1220,6 @@ export const dict = { "task.backgroundAgents.open": "فتح الوكيل الخلفي", "task.backgroundAgents.cancel": "إيقاف", "task.backgroundAgents.continueInBackground": "متابعة في الخلفية", - "task.backgroundAgents.foreground": "الوكيل الأمامي قيد التشغيل", "task.backgroundAgents.waiting": "وكيل خلفي يحتاج إلى إدخالك", "task.backgroundAgents.needsInput": "الإدخال مطلوب", "task.backgroundAgents.dismiss": "تجاهل", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/br.ts b/packages/kilo-vscode/webview-ui/src/i18n/br.ts index f50c4b3c0f..49e1e409db 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/br.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/br.ts @@ -186,7 +186,6 @@ export const dict = { "A aprovação automática está ativada. Solicitações de permissão serão aprovadas automaticamente.", "prompt.action.autoApprove.disabled": "A aprovação automática está desativada. Clique para aprovar solicitações de permissão automaticamente.", - "prompt.action.resetModel": "Redefinir modelo para o padrão", "prompt.action.enhanceDescription": "O botão 'Aprimorar prompt' ajuda a melhorar seu pedido fornecendo contexto adicional, esclarecimentos ou reformulações. Tente digitar um pedido aqui e clique no botão novamente para ver como funciona.", "prompt.action.indexing": "Configurações de indexação", @@ -1264,7 +1263,6 @@ export const dict = { "task.backgroundAgents.open": "Abrir agente em segundo plano", "task.backgroundAgents.cancel": "Parar", "task.backgroundAgents.continueInBackground": "Continuar em segundo plano", - "task.backgroundAgents.foreground": "O agente em primeiro plano está em execução", "task.backgroundAgents.waiting": "Um agente em segundo plano precisa da sua entrada", "task.backgroundAgents.needsInput": "Entrada necessária", "task.backgroundAgents.dismiss": "Dispensar", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts index b827e54aea..333f063bf6 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/bs.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/bs.ts @@ -187,7 +187,6 @@ export const dict = { "Automatsko odobravanje je uključeno. Zahtjevi za dozvole bit će automatski odobreni.", "prompt.action.autoApprove.disabled": "Automatsko odobravanje je isključeno. Kliknite za automatsko odobravanje zahtjeva za dozvole.", - "prompt.action.resetModel": "Resetuj model na zadani", "prompt.action.enhanceDescription": "Dugme 'Poboljšaj prompt' pomaže poboljšati vaš zahtjev pružajući dodatni kontekst, pojašnjenje ili preformulaciju. Pokušajte upisati zahtjev ovdje i ponovo kliknite na dugme da vidite kako funkcioniše.", "prompt.action.sandbox.enable": "Omogući sandbox", @@ -1255,7 +1254,6 @@ export const dict = { "task.backgroundAgents.open": "Otvori agenta u pozadini", "task.backgroundAgents.cancel": "Zaustavi", "task.backgroundAgents.continueInBackground": "Nastavi u pozadini", - "task.backgroundAgents.foreground": "Agent u prvom planu radi", "task.backgroundAgents.waiting": "Agent u pozadini treba vaš unos", "task.backgroundAgents.needsInput": "Potreban unos", "task.backgroundAgents.dismiss": "Odbaci", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/da.ts b/packages/kilo-vscode/webview-ui/src/i18n/da.ts index 678ecb251c..b38f118524 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/da.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/da.ts @@ -186,7 +186,6 @@ export const dict = { "Automatisk godkendelse er aktiveret. Tilladelsesanmodninger godkendes automatisk.", "prompt.action.autoApprove.disabled": "Automatisk godkendelse er deaktiveret. Klik for at godkende tilladelsesanmodninger automatisk.", - "prompt.action.resetModel": "Nulstil model til standard", "prompt.action.enhanceDescription": "Knappen 'Forbedr prompt' hjælper med at forbedre din forespørgsel ved at give ekstra kontekst, præcisering eller omformulering. Prøv at skrive en forespørgsel her og klik på knappen igen for at se hvordan det virker.", "prompt.action.sandbox.enable": "Aktivér sandbox", @@ -1250,7 +1249,6 @@ export const dict = { "task.backgroundAgents.open": "Åbn baggrundsagent", "task.backgroundAgents.cancel": "Stop", "task.backgroundAgents.continueInBackground": "Fortsæt i baggrunden", - "task.backgroundAgents.foreground": "Forgrundsagenten kører", "task.backgroundAgents.waiting": "En baggrundsagent har brug for dit input", "task.backgroundAgents.needsInput": "Input kræves", "task.backgroundAgents.dismiss": "Afvis", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/de.ts b/packages/kilo-vscode/webview-ui/src/i18n/de.ts index 06d8c8c22b..376b4b43eb 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/de.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/de.ts @@ -192,7 +192,6 @@ export const dict = { "Automatische Genehmigung ist aktiviert. Berechtigungsanfragen werden automatisch genehmigt.", "prompt.action.autoApprove.disabled": "Automatische Genehmigung ist deaktiviert. Klicken, um Berechtigungsanfragen automatisch zu genehmigen.", - "prompt.action.resetModel": "Modell auf Standard zurücksetzen", "prompt.action.enhanceDescription": "Die Schaltfläche 'Prompt verbessern' hilft, deine Anfrage durch zusätzlichen Kontext, Klarstellungen oder Umformulierungen zu verbessern. Versuche, hier eine Anfrage einzugeben und klicke erneut auf die Schaltfläche, um zu sehen, wie es funktioniert.", "prompt.action.sandbox.enable": "Sandbox aktivieren", @@ -1279,7 +1278,6 @@ export const dict = { "task.backgroundAgents.open": "Hintergrund-Agent öffnen", "task.backgroundAgents.cancel": "Stoppen", "task.backgroundAgents.continueInBackground": "Im Hintergrund fortsetzen", - "task.backgroundAgents.foreground": "Vordergrund-Agent läuft", "task.backgroundAgents.waiting": "Ein Hintergrund-Agent benötigt deine Eingabe", "task.backgroundAgents.needsInput": "Eingabe erforderlich", "task.backgroundAgents.dismiss": "Ausblenden", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/en.ts b/packages/kilo-vscode/webview-ui/src/i18n/en.ts index f1bd0dc779..03b2150f01 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/en.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/en.ts @@ -203,7 +203,6 @@ export const dict = { "prompt.action.sandbox.description.disabled": "Click to restrict filesystem writes and network access.", "prompt.action.sandbox.description.disabledNetworkAllowed": "Click to restrict filesystem writes. Network access remains allowed by your sandbox settings.", - "prompt.action.resetModel": "Reset model to default", "prompt.action.enhanceDescription": "The 'Enhance Prompt' button helps improve your prompt by providing additional context, clarification, or rephrasing. Try typing a prompt in here and clicking the button again to see how it works.", "speechToText.tooltip.start": "Start voice input with Kilo Gateway", @@ -1234,7 +1233,6 @@ export const dict = { "task.backgroundAgents.open": "Open background agent", "task.backgroundAgents.cancel": "Stop", "task.backgroundAgents.continueInBackground": "Continue in background", - "task.backgroundAgents.foreground": "Foreground subagent running", "task.backgroundAgents.waiting": "A background agent needs your input", "task.backgroundAgents.needsInput": "Needs input", "task.backgroundAgents.dismiss": "Dismiss", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/es.ts b/packages/kilo-vscode/webview-ui/src/i18n/es.ts index 49e52639c6..1229a5e73c 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/es.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/es.ts @@ -187,7 +187,6 @@ export const dict = { "La aprobación automática está activada. Las solicitudes de permisos se aprobarán automáticamente.", "prompt.action.autoApprove.disabled": "La aprobación automática está desactivada. Haz clic para aprobar solicitudes de permisos automáticamente.", - "prompt.action.resetModel": "Restablecer modelo al predeterminado", "prompt.action.enhanceDescription": "El botón 'Mejorar el mensaje' ayuda a mejorar tu petición proporcionando contexto adicional, aclaraciones o reformulaciones. Intenta escribir una petición aquí y haz clic en el botón nuevamente para ver cómo funciona.", "prompt.action.indexing": "Configuración de indexación", @@ -1266,7 +1265,6 @@ export const dict = { "task.backgroundAgents.open": "Abrir agente en segundo plano", "task.backgroundAgents.cancel": "Detener", "task.backgroundAgents.continueInBackground": "Continuar en segundo plano", - "task.backgroundAgents.foreground": "El agente en primer plano está ejecutándose", "task.backgroundAgents.waiting": "Un agente en segundo plano necesita tu entrada", "task.backgroundAgents.needsInput": "Necesita entrada", "task.backgroundAgents.dismiss": "Descartar", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts index 66f9ae5070..8da19e2b78 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fa.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fa.ts @@ -203,7 +203,6 @@ export const dict = { "prompt.action.sandbox.description.disabled": "برای محدود کردن نوشتن در سیستم فایل و دسترسی به شبکه کلیک کنید.", "prompt.action.sandbox.description.disabledNetworkAllowed": "برای محدود کردن نوشتن در سیستم فایل کلیک کنید. دسترسی به شبکه طبق تنظیمات sandbox شما مجاز است.", - "prompt.action.resetModel": "بازنشانی مدل به حالت پیش‌فرض", "prompt.action.enhanceDescription": "دکمه «بهبود پرامپت» با ارائه زمینه بیشتر، توضیح یا بازنویسی، به بهتر کردن پرامپت شما کمک می‌کند. یک پرامپت تایپ کنید و دوباره روی دکمه کلیک کنید تا نحوه عملکرد آن را ببینید.", "speechToText.tooltip.start": "شروع ورودی صوتی با Kilo Gateway", @@ -1246,7 +1245,6 @@ export const dict = { "task.backgroundAgents.open": "باز کردن عامل پس‌زمینه", "task.backgroundAgents.cancel": "توقف", "task.backgroundAgents.continueInBackground": "ادامه در پس‌زمینه", - "task.backgroundAgents.foreground": "عامل پیش‌زمینه در حال اجراست", "task.backgroundAgents.waiting": "یک عامل پس‌زمینه به ورودی شما نیاز دارد", "task.backgroundAgents.needsInput": "ورودی لازم است", "task.backgroundAgents.dismiss": "رد کردن", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts index f334381925..0fce1d864a 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/fr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/fr.ts @@ -187,7 +187,6 @@ export const dict = { "L'approbation automatique est activée. Les demandes d'autorisation seront approuvées automatiquement.", "prompt.action.autoApprove.disabled": "L'approbation automatique est désactivée. Cliquez pour approuver automatiquement les demandes d'autorisation.", - "prompt.action.resetModel": "Réinitialiser le modèle par défaut", "prompt.action.enhanceDescription": "Le bouton 'Améliorer la requête' aide à améliorer votre demande en fournissant un contexte supplémentaire, des clarifications ou des reformulations. Essayez de taper une demande ici et cliquez à nouveau sur le bouton pour voir comment cela fonctionne.", "prompt.action.indexing": "Paramètres d'indexation", @@ -1287,7 +1286,6 @@ export const dict = { "task.backgroundAgents.open": "Ouvrir l'agent en arrière-plan", "task.backgroundAgents.cancel": "Arrêter", "task.backgroundAgents.continueInBackground": "Continuer en arrière-plan", - "task.backgroundAgents.foreground": "L'agent au premier plan est actif", "task.backgroundAgents.waiting": "Un agent en arrière-plan attend votre saisie", "task.backgroundAgents.needsInput": "Saisie requise", "task.backgroundAgents.dismiss": "Ignorer", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/it.ts b/packages/kilo-vscode/webview-ui/src/i18n/it.ts index ddeccb4ad8..e679b00553 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/it.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/it.ts @@ -166,7 +166,6 @@ export const dict = { "L'approvazione automatica è abilitata. Le richieste di autorizzazione saranno approvate automaticamente.", "prompt.action.autoApprove.disabled": "L'approvazione automatica è disabilitata. Fai clic per approvare automaticamente le richieste di autorizzazione.", - "prompt.action.resetModel": "Ripristina modello predefinito", "prompt.action.enhanceDescription": "Il pulsante 'Migliora prompt' aiuta a migliorare il prompt aggiungendo contesto, chiarimenti o riformulazioni. Scrivi un prompt qui e fai di nuovo clic sul pulsante per vedere come funziona.", "prompt.toast.promptSendFailed.title": "Invio prompt non riuscito", @@ -1122,7 +1121,6 @@ export const dict = { "task.backgroundAgents.open": "Apri agente in background", "task.backgroundAgents.cancel": "Arresta", "task.backgroundAgents.continueInBackground": "Continua in background", - "task.backgroundAgents.foreground": "L'agente in primo piano è in esecuzione", "task.backgroundAgents.waiting": "Un agente in background richiede il tuo input", "task.backgroundAgents.needsInput": "Input richiesto", "task.backgroundAgents.dismiss": "Ignora", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts index 43ac30ffef..07ebc3c4c5 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ja.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ja.ts @@ -184,7 +184,6 @@ export const dict = { "prompt.action.autoApprove.disable": "自動承認を無効化", "prompt.action.autoApprove.enabled": "自動承認が有効です。権限リクエストは自動的に承認されます。", "prompt.action.autoApprove.disabled": "自動承認が無効です。クリックすると権限リクエストを自動的に承認します。", - "prompt.action.resetModel": "モデルをデフォルトにリセット", "prompt.action.enhanceDescription": "「プロンプトを強化」ボタンは、追加コンテキスト、説明、または言い換えを提供することで、リクエストを改善します。ここにリクエストを入力し、ボタンを再度クリックして動作を確認してください。", "prompt.action.indexing": "インデックス設定", @@ -1242,7 +1241,6 @@ export const dict = { "task.backgroundAgents.open": "バックグラウンドエージェントを開く", "task.backgroundAgents.cancel": "停止", "task.backgroundAgents.continueInBackground": "バックグラウンドで続行", - "task.backgroundAgents.foreground": "フォアグラウンドエージェントが実行中", "task.backgroundAgents.waiting": "バックグラウンドエージェントが入力を待っています", "task.backgroundAgents.needsInput": "入力が必要", "task.backgroundAgents.dismiss": "閉じる", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts index 1cdc1ba029..cd76691dfe 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ko.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ko.ts @@ -187,7 +187,6 @@ export const dict = { "prompt.action.autoApprove.disable": "자동 승인 사용 안 함", "prompt.action.autoApprove.enabled": "자동 승인이 켜져 있습니다. 권한 요청이 자동으로 승인됩니다.", "prompt.action.autoApprove.disabled": "자동 승인이 꺼져 있습니다. 클릭하면 권한 요청을 자동으로 승인합니다.", - "prompt.action.resetModel": "모델을 기본값으로 재설정", "prompt.action.enhanceDescription": "'프롬프트 향상' 버튼은 추가 컨텍스트, 명확화 또는 재구성을 제공하여 요청을 개선합니다. 여기에 요청을 입력한 다음 버튼을 다시 클릭하여 작동 방식을 확인해보세요.", "prompt.action.sandbox.enable": "샌드박스 활성화", @@ -1229,7 +1228,6 @@ export const dict = { "task.backgroundAgents.open": "백그라운드 에이전트 열기", "task.backgroundAgents.cancel": "중지", "task.backgroundAgents.continueInBackground": "백그라운드에서 계속", - "task.backgroundAgents.foreground": "포그라운드 에이전트 실행 중", "task.backgroundAgents.waiting": "백그라운드 에이전트에 입력이 필요합니다", "task.backgroundAgents.needsInput": "입력 필요", "task.backgroundAgents.dismiss": "닫기", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts index 33bcc6a81a..2716c88c01 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/nl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/nl.ts @@ -188,7 +188,6 @@ export const dict = { "Automatisch goedkeuren is ingeschakeld. Toestemmingsverzoeken worden automatisch goedgekeurd.", "prompt.action.autoApprove.disabled": "Automatisch goedkeuren is uitgeschakeld. Klik om toestemmingsverzoeken automatisch goed te keuren.", - "prompt.action.resetModel": "Model terugzetten naar standaard", "prompt.action.enhanceDescription": "De knop 'Prompt verbeteren' helpt je prompt te verbeteren door extra context, verduidelijking of herformulering te bieden. Typ hier een prompt en klik nogmaals op de knop om te zien hoe het werkt.", "prompt.action.sandbox.enable": "Sandbox inschakelen", @@ -1236,7 +1235,6 @@ export const dict = { "task.backgroundAgents.open": "Achtergrondagent openen", "task.backgroundAgents.cancel": "Stoppen", "task.backgroundAgents.continueInBackground": "Doorgaan op de achtergrond", - "task.backgroundAgents.foreground": "Voorgrondagent actief", "task.backgroundAgents.waiting": "Een achtergrondagent heeft je invoer nodig", "task.backgroundAgents.needsInput": "Invoer vereist", "task.backgroundAgents.dismiss": "Negeren", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/no.ts b/packages/kilo-vscode/webview-ui/src/i18n/no.ts index 9682777b47..70d7a8cbca 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/no.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/no.ts @@ -189,7 +189,6 @@ export const dict = { "Automatisk godkjenning er aktivert. Tillatelsesforespørsler godkjennes automatisk.", "prompt.action.autoApprove.disabled": "Automatisk godkjenning er deaktivert. Klikk for å godkjenne tillatelsesforespørsler automatisk.", - "prompt.action.resetModel": "Tilbakestill modell til standard", "prompt.action.indexing": "Indekseringsinnstillinger", "prompt.action.enhanceDescription": "Knappen 'Forbedre prompt' hjelper med å forbedre forespørselen din ved å gi ekstra kontekst, avklaring eller omformulering. Prøv å skrive en forespørsel her og klikk på knappen igjen for å se hvordan det fungerer.", @@ -1246,7 +1245,6 @@ export const dict = { "task.backgroundAgents.open": "Åpne bakgrunnsagent", "task.backgroundAgents.cancel": "Stopp", "task.backgroundAgents.continueInBackground": "Fortsett i bakgrunnen", - "task.backgroundAgents.foreground": "Forgrunnsagenten kjører", "task.backgroundAgents.waiting": "En bakgrunnsagent trenger innspill fra deg", "task.backgroundAgents.needsInput": "Innspill kreves", "task.backgroundAgents.dismiss": "Avvis", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts index fbfb49718b..940fdb7924 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/pl.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/pl.ts @@ -186,7 +186,6 @@ export const dict = { "Automatyczne zatwierdzanie jest włączone. Prośby o uprawnienia będą zatwierdzane automatycznie.", "prompt.action.autoApprove.disabled": "Automatyczne zatwierdzanie jest wyłączone. Kliknij, aby automatycznie zatwierdzać prośby o uprawnienia.", - "prompt.action.resetModel": "Zresetuj model do domyślnego", "prompt.action.enhanceDescription": "Przycisk 'Ulepsz podpowiedź' pomaga ulepszyć Twoją prośbę, dostarczając dodatkowy kontekst, wyjaśnienia lub przeformułowania. Spróbuj wpisać prośbę tutaj i kliknij przycisk ponownie, aby zobaczyć, jak to działa.", "prompt.action.indexing": "Ustawienia indeksowania", @@ -1255,7 +1254,6 @@ export const dict = { "task.backgroundAgents.open": "Otwórz agenta w tle", "task.backgroundAgents.cancel": "Zatrzymaj", "task.backgroundAgents.continueInBackground": "Kontynuuj w tle", - "task.backgroundAgents.foreground": "Agent pierwszoplanowy działa", "task.backgroundAgents.waiting": "Agent w tle potrzebuje danych wejściowych", "task.backgroundAgents.needsInput": "Wymagane dane wejściowe", "task.backgroundAgents.dismiss": "Odrzuć", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts index 83f079fa80..bf50538442 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/ru.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/ru.ts @@ -184,7 +184,6 @@ export const dict = { "prompt.action.autoApprove.enabled": "Автоодобрение включено. Запросы разрешений будут одобряться автоматически.", "prompt.action.autoApprove.disabled": "Автоодобрение отключено. Нажмите, чтобы автоматически одобрять запросы разрешений.", - "prompt.action.resetModel": "Сбросить модель на значение по умолчанию", "prompt.action.indexing": "Настройки индексации", "prompt.action.enhanceDescription": "Кнопка 'Улучшить запрос' помогает сделать ваш запрос лучше, предоставляя дополнительный контекст, уточнения или переформулировку. Попробуйте ввести запрос и снова нажать кнопку, чтобы увидеть, как это работает.", @@ -1249,7 +1248,6 @@ export const dict = { "task.backgroundAgents.open": "Открыть фонового агента", "task.backgroundAgents.cancel": "Остановить", "task.backgroundAgents.continueInBackground": "Продолжить в фоне", - "task.backgroundAgents.foreground": "Агент на переднем плане выполняется", "task.backgroundAgents.waiting": "Фоновому агенту требуется ваш ввод", "task.backgroundAgents.needsInput": "Требуется ввод", "task.backgroundAgents.dismiss": "Скрыть", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/th.ts b/packages/kilo-vscode/webview-ui/src/i18n/th.ts index 999677979a..fe7e687401 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/th.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/th.ts @@ -183,7 +183,6 @@ export const dict = { "prompt.action.autoApprove.disable": "ปิดใช้การอนุมัติอัตโนมัติ", "prompt.action.autoApprove.enabled": "เปิดใช้การอนุมัติอัตโนมัติแล้ว คำขอสิทธิ์จะได้รับการอนุมัติโดยอัตโนมัติ", "prompt.action.autoApprove.disabled": "ปิดใช้การอนุมัติอัตโนมัติแล้ว คลิกเพื่ออนุมัติคำขอสิทธิ์โดยอัตโนมัติ", - "prompt.action.resetModel": "รีเซ็ตโมเดลเป็นค่าเริ่มต้น", "prompt.action.enhanceDescription": "ปุ่ม 'ปรับปรุงพรอมต์' ช่วยปรับปรุงพรอมต์ของคุณโดยให้บริบทเพิ่มเติม ชี้แจง หรือเขียนใหม่ ลองพิมพ์พรอมต์ที่นี่และคลิกปุ่มอีกครั้งเพื่อดูว่ามันทำงานอย่างไร", "prompt.action.indexing": "การตั้งค่าการสร้างดัชนี", @@ -1226,7 +1225,6 @@ export const dict = { "task.backgroundAgents.open": "เปิดเอเจนต์เบื้องหลัง", "task.backgroundAgents.cancel": "หยุด", "task.backgroundAgents.continueInBackground": "ทำต่อในเบื้องหลัง", - "task.backgroundAgents.foreground": "เอเจนต์เบื้องหน้ากำลังทำงาน", "task.backgroundAgents.waiting": "เอเจนต์เบื้องหลังต้องการข้อมูลจากคุณ", "task.backgroundAgents.needsInput": "ต้องการข้อมูล", "task.backgroundAgents.dismiss": "ยกเลิก", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts index 16c46210ad..ac7fddbd19 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/tr.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/tr.ts @@ -185,7 +185,6 @@ export const dict = { "prompt.action.autoApprove.disable": "Otomatik onayı devre dışı bırak", "prompt.action.autoApprove.enabled": "Otomatik onay etkin. İzin istekleri otomatik olarak onaylanacak.", "prompt.action.autoApprove.disabled": "Otomatik onay devre dışı. İzin isteklerini otomatik onaylamak için tıklayın.", - "prompt.action.resetModel": "Modeli varsayılana sıfırla", "prompt.action.enhanceDescription": "'Komutu Geliştir' düğmesi, ek bağlam, açıklama veya yeniden ifadelendirme sağlayarak komutunuzu iyileştirmeye yardımcı olur. Buraya bir komut yazıp düğmeye tekrar tıklayarak nasıl çalıştığını görebilirsiniz.", "prompt.action.sandbox.enable": "Sandbox'ı etkinleştir", @@ -1223,7 +1222,6 @@ export const dict = { "task.backgroundAgents.open": "Arka plan ajanını aç", "task.backgroundAgents.cancel": "Durdur", "task.backgroundAgents.continueInBackground": "Arka planda devam et", - "task.backgroundAgents.foreground": "Ön plan ajanı çalışıyor", "task.backgroundAgents.waiting": "Bir arka plan ajanı girişinizi bekliyor", "task.backgroundAgents.needsInput": "Giriş gerekli", "task.backgroundAgents.dismiss": "Kapat", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts index 658c0a272b..bab5fba3d8 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/uk.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/uk.ts @@ -187,7 +187,6 @@ export const dict = { "prompt.action.autoApprove.enabled": "Автоматичне схвалення увімкнено. Запити дозволів схвалюватимуться автоматично.", "prompt.action.autoApprove.disabled": "Автоматичне схвалення вимкнено. Натисніть, щоб автоматично схвалювати запити дозволів.", - "prompt.action.resetModel": "Скинути модель до стандартної", "prompt.action.enhanceDescription": "Кнопка 'Покращити запит' допомагає вдосконалити ваш запит, надаючи додатковий контекст, уточнення або перефразування. Введіть запит тут і натисніть кнопку ще раз, щоб побачити, як це працює.", "prompt.action.sandbox.enable": "Увімкнути пісочницю", @@ -1222,7 +1221,6 @@ export const dict = { "task.backgroundAgents.open": "Відкрити фонового агента", "task.backgroundAgents.cancel": "Зупинити", "task.backgroundAgents.continueInBackground": "Продовжити у фоні", - "task.backgroundAgents.foreground": "Агент на передньому плані працює", "task.backgroundAgents.waiting": "Фоновому агенту потрібен ваш ввід", "task.backgroundAgents.needsInput": "Потрібен ввід", "task.backgroundAgents.dismiss": "Сховати", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts index f4f0768beb..ed70000dea 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zh.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zh.ts @@ -180,7 +180,6 @@ export const dict = { "prompt.action.send.recording": "转录并发送", "prompt.action.stop": "停止", "prompt.action.enhance": "优化提示词", - "prompt.action.resetModel": "重置模型为默认值", "prompt.action.enhanceDescription": "'增强提示'按钮通过提供额外上下文、澄清或重新表述来帮助改进您的请求。尝试在此处输入请求,然后再次点击按钮查看其工作原理。", "prompt.action.sandbox.enable": "启用沙盒", @@ -1185,7 +1184,6 @@ export const dict = { "task.backgroundAgents.open": "打开后台智能体", "task.backgroundAgents.cancel": "停止", "task.backgroundAgents.continueInBackground": "在后台继续", - "task.backgroundAgents.foreground": "前台智能体正在运行", "task.backgroundAgents.waiting": "后台智能体需要你的输入", "task.backgroundAgents.needsInput": "需要输入", "task.backgroundAgents.dismiss": "关闭", diff --git a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts index f89b1aa9ef..8c0b62d92b 100644 --- a/packages/kilo-vscode/webview-ui/src/i18n/zht.ts +++ b/packages/kilo-vscode/webview-ui/src/i18n/zht.ts @@ -179,7 +179,6 @@ export const dict = { "prompt.action.autoApprove.disable": "停用自動核准", "prompt.action.autoApprove.enabled": "自動核准已啟用。權限請求將自動獲准。", "prompt.action.autoApprove.disabled": "自動核准已停用。點擊以自動核准權限請求。", - "prompt.action.resetModel": "重置模型為預設值", "prompt.action.enhanceDescription": "「強化提示詞」按鈕可透過提供額外內容、說明或改寫來協助改善提示詞。試著在這裡輸入提示詞,再點選一次按鈕以了解其運作方式。", "prompt.action.sandbox.enable": "啟用沙盒", @@ -1189,7 +1188,6 @@ export const dict = { "task.backgroundAgents.open": "開啟背景 Agent", "task.backgroundAgents.cancel": "停止", "task.backgroundAgents.continueInBackground": "在背景繼續", - "task.backgroundAgents.foreground": "前景 Agent 執行中", "task.backgroundAgents.waiting": "背景 Agent 需要你的輸入", "task.backgroundAgents.needsInput": "需要輸入", "task.backgroundAgents.dismiss": "關閉", diff --git a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx index 02995df7b4..d682b3ab6b 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/StoryProviders.tsx @@ -230,10 +230,7 @@ export function mockSessionValue(overrides?: { scopedSuggestions: (sid?: string) => (sid ? suggestions.filter((item) => item.sessionID === sid) : suggestions), selected: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }), modelForAgent: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }), - configModelForAgent: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }), selectModel: noop, - hasModelOverride: () => false, - clearModelOverride: noop, costBreakdown: () => [], contextUsage: () => undefined, modelUsage: () => undefined, @@ -340,6 +337,7 @@ const ConfigWrapper: ParentComponent<{ return { indexing: props.features?.indexing ?? hasIndexingPlugin(config.plugin ?? []), sandboxControls: props.features?.sandboxControls ?? false, + backgroundSubagents: props.features?.backgroundSubagents ?? false, } }) diff --git a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx index 63f3b3dc5c..6c6b87899a 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/agent-manager.stories.tsx @@ -1550,6 +1550,7 @@ export const MultiProjectSidebar: Story = { t={t} onSearchRef={() => {}} onShortcuts={() => {}} + onHistory={() => {}} />
    diff --git a/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx index 1364714851..3f54314e6b 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/history.stories.tsx @@ -75,8 +75,6 @@ const WithSessions: ParentComponent<{ sessions?: typeof mockSessions }> = (props scopedQuestions: () => [] as any[], selected: () => ({ providerID: "kilo", modelID: "anthropic/claude-sonnet-4-6" }), selectModel: noop, - hasModelOverride: () => false, - clearModelOverride: noop, costBreakdown: () => [], contextUsage: () => undefined, agents: () => [{ name: "code", description: "Code mode", mode: "primary" as const }], diff --git a/packages/kilo-vscode/webview-ui/src/stories/prompt-input.stories.tsx b/packages/kilo-vscode/webview-ui/src/stories/prompt-input.stories.tsx index 479ce93bff..d1f982b6ff 100644 --- a/packages/kilo-vscode/webview-ui/src/stories/prompt-input.stories.tsx +++ b/packages/kilo-vscode/webview-ui/src/stories/prompt-input.stories.tsx @@ -27,11 +27,7 @@ const agents = [ { name: "architect", description: "Plan and design before implementation", mode: "primary" as const }, ] -const noop = () => {} - -const PromptProviders: ParentComponent<{ variants?: boolean; modelOverride?: boolean; training?: boolean }> = ( - props, -) => { +const PromptProviders: ParentComponent<{ variants?: boolean; training?: boolean }> = (props) => { const base = mockSessionValue({ status: "idle" }) const session = { ...base, @@ -39,8 +35,6 @@ const PromptProviders: ParentComponent<{ variants?: boolean; modelOverride?: boo selectedAgent: () => "code", variantList: () => (props.variants ? ["low", "medium", "high"] : []), currentVariant: () => (props.variants ? ("medium" as string | undefined) : undefined), - hasModelOverride: () => props.modelOverride ?? false, - clearModelOverride: noop, } return ( @@ -169,25 +163,3 @@ export const WithThinking200: Story = { ), } - -// --------------------------------------------------------------------------- -// Stories — model override active (reset button visible) -// --------------------------------------------------------------------------- - -export const WithModelOverride420: Story = { - name: "With model override — 420px", - render: () => ( - - - - ), -} - -export const WithModelOverride200: Story = { - name: "With model override — 200px", - render: () => ( - - - - ), -} 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 08cab9f94d..8d849ec4e8 100644 --- a/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css +++ b/packages/kilo-vscode/webview-ui/src/styles/chat-layout.css @@ -132,6 +132,15 @@ margin-inline: calc(var(--chat-scrollbar-width, 10px) / 2) calc(var(--chat-scrollbar-width, 10px) / -2); } +/* Streaming content reflows under a parked pointer, so without a cursor of its + own the transcript alternated between the text cursor over message text and + the default cursor over containers and gaps. The whole transcript is + selectable text, so it declares the text cursor once and every descendant + inherits it. Controls keep their own pointer cursor. */ +.message-list-content { + cursor: text; +} + .message-list-content-empty { display: flex; min-height: 100%; diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts index ccca142648..de4014161a 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/config.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/config.ts @@ -173,4 +173,5 @@ export interface Config { export interface FeatureFlags { indexing: boolean sandboxControls: boolean + backgroundSubagents: boolean } diff --git a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts index e383151aa0..e8ccea2d37 100644 --- a/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts +++ b/packages/kilo-vscode/webview-ui/src/types/messages/webview-messages.ts @@ -57,8 +57,9 @@ export interface CancelBackgroundJobMessage { requestID: string } -export interface BackgroundSubagentsMessage { - type: "backgroundSubagents" +export interface PromoteBackgroundJobMessage { + type: "promoteBackgroundJob" + jobID: string sessionID: string } @@ -697,6 +698,13 @@ export interface OpenLocallyRequest { sessionId: string } +// Move a worktree-bound session back to the project root and open it in the local tabs +export interface OpenSessionLocallyRequest { + type: "agentManager.openSessionLocally" + projectId?: string + sessionId: string +} + // Add a new session to an existing worktree export interface AddSessionToWorktreeRequest { type: "agentManager.addSessionToWorktree" @@ -1397,11 +1405,6 @@ export interface PersistModelSelectionRequest { modelID: string } -export interface ClearModelSelectionRequest { - type: "clearModelSelection" - agent: string -} - export interface RequestModelSelectionsMessage { type: "requestModelSelections" } @@ -1495,7 +1498,7 @@ export type WebviewMessage = | AbortRequest | RequestBackgroundJobsMessage | CancelBackgroundJobMessage - | BackgroundSubagentsMessage + | PromoteBackgroundJobMessage | RevertSessionRequest | UnrevertSessionRequest | DeleteMessageRequest @@ -1601,6 +1604,7 @@ export type WebviewMessage = | RemoveStaleWorktreeRequest | PromoteSessionRequest | OpenLocallyRequest + | OpenSessionLocallyRequest | AddSessionToWorktreeRequest | ForkSessionRequest | SidebarForkSessionRequest @@ -1711,7 +1715,6 @@ export type WebviewMessage = | ToggleFavoriteRequest | RequestFavoritesMessage | PersistModelSelectionRequest - | ClearModelSelectionRequest | RequestModelSelectionsMessage | ToggleRemoteMessage | SetRemoteEnabledMessage diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index d8cd761083..6de30f0c94 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -312,15 +312,8 @@ for (const item of targets) { // kilocode_change end format: "esm", minify: true, - // kilocode_change start - disable code-splitting to avoid a Bun 1.3.14 codegen bug. - // With splitting:true Bun emits cross-chunk re-exports like `import{vn as G9}` whose - // binding isn't top-level, so the compiled binary crashes at startup on the baseline - // target: "SyntaxError: Exported binding 'G9' needs to refer to a top-level declared - // variable." (Bun oven-sh/bun#25621, #5344, #7265; also opencode#23349). Fixed upstream - // in Bun#26089, post-1.3.14. Splitting only deduped shared code between the entrypoints; - // turning it off inlines per entrypoint and produces a valid binary. - splitting: false, - // kilocode_change end + // Bun 1.4 fixes the cross-chunk re-export codegen bug from Bun 1.3.14. + splitting: true, // kilocode_change compile: { autoloadBunfig: false, autoloadDotenv: false, diff --git a/packages/opencode/src/bun-compat.d.ts b/packages/opencode/src/bun-compat.d.ts new file mode 100644 index 0000000000..03de3f4fc9 --- /dev/null +++ b/packages/opencode/src/bun-compat.d.ts @@ -0,0 +1,12 @@ +// kilocode_change - new file +// Bun 1.4 narrows NodeJS.Process event overloads. +declare global { + namespace NodeJS { + interface Process { + on(event: string | symbol, listener: (...args: never[]) => void): this + off(event: string | symbol, listener: (...args: never[]) => void): this + } + } +} + +export {} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index f23744f03f..6fc98fe18f 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,37 +1,42 @@ import yargs from "yargs" import { hideBin } from "yargs/helpers" -import { RunCommand } from "./cli/cmd/run" -import { GenerateCommand } from "./cli/cmd/generate" // kilocode_change - upstream account console intentionally omitted; KiloCli registers `kilo console` for local settings -import { ProvidersCommand } from "./cli/cmd/providers" -import { AgentCommand } from "./cli/cmd/agent" -import { UpgradeCommand } from "./cli/cmd/upgrade" -import { UninstallCommand } from "./cli/cmd/uninstall" -import { ModelsCommand } from "./cli/cmd/models" import { UI } from "./cli/ui" +import { TuiThreadCommand } from "./cli/cmd/tui" // kilocode_change - yargs requires the default command builder eagerly import { InstallationVersion } from "@opencode-ai/core/installation/version" import { FormatError } from "./cli/error" -import { ServeCommand } from "./cli/cmd/serve" -import { DebugCommand } from "./cli/cmd/debug" -import { StatsCommand } from "./cli/cmd/stats" -import { McpCommand } from "./cli/cmd/mcp" -import { GithubCommand } from "./cli/cmd/github" -import { ExportCommand } from "./cli/cmd/export" -import { ImportCommand } from "./cli/cmd/import" -import { AttachCommand } from "./cli/cmd/attach" -import { TuiThreadCommand } from "./cli/cmd/tui" -import { AcpCommand } from "./cli/cmd/acp" import { EOL } from "os" // kilocode_change - upstream web command intentionally omitted; Kilo does not ship an embedded web UI -import { PrCommand } from "./cli/cmd/pr" -import { SessionCommand } from "./cli/cmd/session" -import { DbCommand } from "./cli/cmd/db" import { errorMessage } from "./util/error" -import { PluginCommand } from "./cli/cmd/plug" import { Heap } from "./cli/heap" import { KiloCli } from "@/kilocode/cli/setup" // kilocode_change import * as Log from "@opencode-ai/core/util/log" // kilocode_change import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process" // kilocode_change +// kilocode_change start - defer heavy command implementations until yargs selects them +import { + AcpCommand, + AgentCommand, + AttachCommand, + DbCommand, + DebugCommand, + ExportCommand, + GenerateCommand, + GithubCommand, + ImportCommand, + McpCommand, + ModelsCommand, + PluginCommand, + PrCommand, + ProvidersCommand, + RunCommand, + ServeCommand, + SessionCommand, + StatsCommand, + UninstallCommand, + UpgradeCommand, + waitForLazyCommands, +} from "@/kilocode/cli/lazy-commands" +// kilocode_change end const args = hideBin(process.argv) const metadata = ensureProcessMetadata("main") // kilocode_change - correlate logs across the CLI and TUI worker @@ -119,6 +124,7 @@ let cli = yargs(args) // kilocode_change // kilocode_change start - register Kilo-specific commands after the upstream chain cli = KiloCli.register(cli) +await waitForLazyCommands() // kilocode_change - yargs completion invokes builders synchronously cli = cli // kilocode_change end .fail((msg, err) => { diff --git a/packages/opencode/src/kilo-sessions/kilo-sessions.ts b/packages/opencode/src/kilo-sessions/kilo-sessions.ts index 571c8ead89..c7ebc30282 100644 --- a/packages/opencode/src/kilo-sessions/kilo-sessions.ts +++ b/packages/opencode/src/kilo-sessions/kilo-sessions.ts @@ -47,6 +47,7 @@ import { Snapshot } from "@/snapshot" import { cumulativeSessionDiff } from "@/kilocode/session-portability/cumulative-diff" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" // kilocode_change +import { KiloShutdown } from "@/kilocode/cli/shutdown" async function provide(input: { directory: string; fn: () => R }): Promise { const { provide } = await import("@/kilocode/instance") @@ -257,11 +258,17 @@ export namespace KiloSessions { () => ingest.drain(), (err) => log.warn("ingest drain failed", { err }), ) + KiloShutdown.register(drainIngest) export async function drainIngestForShutdown() { await drainIngest() } + /** @internal - lifecycle regression coverage */ + export function _queueIngestForTest(sessionId: string) { + return ingest.sync(sessionId, [{ type: "session_status", data: { status: "idle" } }]) + } + const remoteEnabled = process.env["KILO_REMOTE"] === "1" let remote: { conn: RemoteWS.Connection; sender: RemoteSender.Sender } | undefined let enabling: Promise | undefined diff --git a/packages/opencode/src/kilocode/cli/bootstrap-runtime.ts b/packages/opencode/src/kilocode/cli/bootstrap-runtime.ts new file mode 100644 index 0000000000..2fb98f93fe --- /dev/null +++ b/packages/opencode/src/kilocode/cli/bootstrap-runtime.ts @@ -0,0 +1,30 @@ +import { Account } from "@/account/account" +import { Auth } from "@/auth" +import { Config } from "@/config/config" +import { makeRuntime } from "@/effect/run-service" +import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" +import { Effect, Layer, Option } from "effect" + +const account = Layer.mock(Account.Service)({ + active: () => Effect.succeed(Option.none()), +}) +const config = makeRuntime(Config.Service, AppNodeBuilder.build(Config.node, [[Account.node, account]])) +const auth = makeRuntime(Auth.Service, Auth.defaultLayer) + +export namespace KiloCliBootstrapRuntime { + export function getGlobal() { + return config.runPromise((service) => service.getGlobal()) + } + + export function getAuth() { + return auth.runPromise((service) => service.get("kilo")) + } + + export function setAuth(info: Auth.Info) { + return auth.runPromise((service) => service.set("kilo", info)) + } + + export async function dispose() { + await Promise.all([config.dispose(), auth.dispose()]) + } +} diff --git a/packages/opencode/src/kilocode/cli/lazy-commands.ts b/packages/opencode/src/kilocode/cli/lazy-commands.ts new file mode 100644 index 0000000000..b917fab6d1 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/lazy-commands.ts @@ -0,0 +1,182 @@ +import type { Argv, ArgumentsCamelCase, CommandModule } from "yargs" + +type Load = () => Promise> +const completion = process.argv.includes("--get-yargs-completions") +const tasks: Promise[] = [] +let selected = false + +function build(command: CommandModule, args: Argv) { + if (!command.builder) return args as unknown as Argv + if (typeof command.builder === "function") return command.builder(args) + return args.options(command.builder) as unknown as Argv +} + +export function waitForLazyCommands() { + return Promise.all(tasks) +} + +export function hasLazyCommandSelection() { + return selected +} + +export function resetLazyCommandSelection() { + selected = false +} + +export function markLazyCommandSelection() { + selected = true +} + +export function lazy(input: { + command: string | readonly string[] + aliases?: string | readonly string[] + describe?: string | false + load: Load +}): CommandModule { + const state: { command?: CommandModule; task?: Promise> } = {} + const load = () => (state.task ??= input.load()) + if (completion) { + tasks.push( + load() + .then((command) => { + state.command = command + }) + .catch(() => undefined), + ) + } + return { + command: input.command, + aliases: input.aliases, + describe: input.describe, + builder: ((args: Argv) => { + markLazyCommandSelection() + if (state.command) return build(state.command, args) + return load().then((command) => build(command, args)) + }) as never, + async handler(args: ArgumentsCamelCase) { + const command = await load() + await command.handler(args) + }, + } +} + +export const AcpCommand = lazy({ + command: "acp", + describe: "start ACP (Agent Client Protocol) server", + load: async () => (await import("@/cli/cmd/acp")).AcpCommand, +}) + +export const AttachCommand = lazy({ + command: "attach ", + describe: "attach to a running kilo server", + load: async () => (await import("@/cli/cmd/attach")).AttachCommand, +}) + +export const RunCommand = lazy({ + command: "run [message..]", + describe: "run kilo with a message", + load: async () => (await import("@/cli/cmd/run")).RunCommand, +}) + +export const GenerateCommand = lazy({ + command: "generate", + load: async () => (await import("@/cli/cmd/generate")).GenerateCommand, +}) + +export const McpCommand = lazy({ + command: "mcp", + describe: "manage MCP (Model Context Protocol) servers", + load: async () => (await import("@/cli/cmd/mcp")).McpCommand, +}) + +export const DebugCommand = lazy({ + command: "debug", + describe: "debugging and troubleshooting tools", + load: async () => (await import("@/cli/cmd/debug")).DebugCommand, +}) + +export const ProvidersCommand = lazy({ + command: "auth", + aliases: ["providers"], + describe: "manage AI providers and credentials", + load: async () => (await import("@/cli/cmd/providers")).ProvidersCommand, +}) + +export const AgentCommand = lazy({ + command: "agent", + describe: "manage agents", + load: async () => (await import("@/cli/cmd/agent")).AgentCommand, +}) + +export const UpgradeCommand = lazy({ + command: "upgrade [target]", + describe: "upgrade kilo to the latest or a specific version", + load: async () => (await import("@/cli/cmd/upgrade")).UpgradeCommand, +}) + +export const UninstallCommand = lazy({ + command: "uninstall", + describe: "uninstall kilo and remove all related files", + load: async () => (await import("@/cli/cmd/uninstall")).UninstallCommand, +}) + +export const ServeCommand = lazy({ + command: "serve", + describe: "starts a headless kilo server", + load: async () => (await import("@/cli/cmd/serve")).ServeCommand, +}) + +export const ModelsCommand = lazy({ + command: "models [provider]", + describe: "list all available models", + load: async () => (await import("@/cli/cmd/models")).ModelsCommand, +}) + +export const StatsCommand = lazy({ + command: "stats", + describe: "show token usage and cost statistics", + load: async () => (await import("@/cli/cmd/stats")).StatsCommand, +}) + +export const ExportCommand = lazy({ + command: "export [sessionID]", + describe: "export session data as JSON", + load: async () => (await import("@/cli/cmd/export")).ExportCommand, +}) + +export const ImportCommand = lazy({ + command: "import ", + describe: "import session data from JSON file or URL", + load: async () => (await import("@/cli/cmd/import")).ImportCommand, +}) + +export const SessionCommand = lazy({ + command: "session", + describe: "manage sessions", + load: async () => (await import("@/cli/cmd/session")).SessionCommand, +}) + +export const GithubCommand = lazy({ + command: "github", + describe: "manage GitHub agent", + load: async () => (await import("@/cli/cmd/github")).GithubCommand, +}) + +export const PrCommand = lazy({ + command: "pr", + describe: "manage pull requests", + load: async () => (await import("@/cli/cmd/pr")).PrCommand, +}) + +export const PluginCommand = lazy({ + command: "plugin ", + aliases: ["plug"], + describe: "install plugin and update config", + load: async () => (await import("@/cli/cmd/plug")).PluginCommand, +}) + +export const DbCommand = lazy({ + command: "db", + describe: "database tools", + load: async () => (await import("@/cli/cmd/db")).DbCommand, +}) diff --git a/packages/opencode/src/kilocode/cli/lazy-kilo-commands.ts b/packages/opencode/src/kilocode/cli/lazy-kilo-commands.ts new file mode 100644 index 0000000000..d8e4ab4bc8 --- /dev/null +++ b/packages/opencode/src/kilocode/cli/lazy-kilo-commands.ts @@ -0,0 +1,67 @@ +import { lazy } from "@/kilocode/cli/lazy-commands" + +export const KiloConsoleCommand = lazy({ + command: "console", + describe: "open or stop the local Kilo Console (deprecated)", + load: async () => (await import("@/kilocode/cli/cmd/console")).KiloConsoleCommand, +}) + +export const CloudCommand = lazy({ + command: "cloud", + describe: "run Cloud Agent tasks", + load: async () => (await import("@/kilocode/cli/cmd/cloud")).CloudCommand, +}) + +export const RollCallCommand = lazy({ + command: "roll-call ", + describe: "batch-test text models matching a filter for connectivity and latency", + load: async () => (await import("@/kilocode/cli/cmd/roll-call")).RollCallCommand, +}) + +export const ProfileCommand = lazy({ + command: "profile", + describe: "show Kilo account profile", + load: async () => (await import("@/kilocode/cli/cmd/profile")).ProfileCommand, +}) + +export const RemoteCommand = lazy({ + command: "remote", + describe: "enable remote connection for real-time session relay", + load: async () => (await import("@/cli/cmd/remote")).RemoteCommand, +}) + +export const DaemonCommand = lazy({ + command: "daemon", + describe: "manage the local kilo daemon", + load: async () => (await import("@/kilocode/cli/cmd/daemon")).DaemonCommand, +}) + +export const ConfigCLICommand = lazy({ + command: "config", + describe: "configuration tools", + load: async () => (await import("@/cli/cmd/config")).ConfigCommand, +}) + +export const WorktreeCommand = lazy({ + command: "worktree", + describe: "manage git worktrees", + load: async () => (await import("@/kilocode/cli/cmd/worktree")).WorktreeCommand, +}) + +export const PtySmokeCommand = lazy({ + command: "__pty-smoke", + describe: false, + load: async () => (await import("@/kilocode/cli/cmd/pty-smoke")).PtySmokeCommand, +}) + +export const DevSetupCommand = lazy({ + command: "dev-setup", + describe: "install a `kilodev` shell alias for this checkout", + load: async () => (await import("@/kilocode/cli/dev-setup")).DevSetupCommand, +}) + +export const DevAliasCommand = lazy({ + command: "dev-alias [shell]", + describe: false, + load: async () => (await import("@/kilocode/cli/dev-setup")).DevAliasCommand, +}) diff --git a/packages/opencode/src/kilocode/cli/setup.ts b/packages/opencode/src/kilocode/cli/setup.ts index c434ab60ee..7557d3c88a 100644 --- a/packages/opencode/src/kilocode/cli/setup.ts +++ b/packages/opencode/src/kilocode/cli/setup.ts @@ -1,39 +1,26 @@ import type { Argv } from "yargs" +import type { Auth } from "@/auth" import * as Log from "@opencode-ai/core/util/log" import { InstallationBuildKind, InstallationVersion } from "@opencode-ai/core/installation/version" import { KiloShutdown } from "@/kilocode/cli/shutdown" import { createHelpCommand } from "@/kilocode/help-command" -import { KiloConsoleCommand } from "@/kilocode/cli/cmd/console" -import { CloudCommand } from "@/kilocode/cli/cmd/cloud" -import { RollCallCommand } from "@/kilocode/cli/cmd/roll-call" -import { ProfileCommand } from "@/kilocode/cli/cmd/profile" -import { DaemonCommand } from "@/kilocode/cli/cmd/daemon" -import { DevSetupCommand, DevAliasCommand } from "@/kilocode/cli/dev-setup" -import { RemoteCommand } from "@/cli/cmd/remote" -import { ConfigCommand as ConfigCLICommand } from "@/cli/cmd/config" -import { WorktreeCommand } from "@/kilocode/cli/cmd/worktree" -import { PtySmokeCommand } from "@/kilocode/cli/cmd/pty-smoke" +import { hasLazyCommandSelection } from "@/kilocode/cli/lazy-commands" +import { + CloudCommand, + ConfigCLICommand, + DaemonCommand, + DevAliasCommand, + DevSetupCommand, + KiloConsoleCommand, + ProfileCommand, + PtySmokeCommand, + RemoteCommand, + RollCallCommand, + WorktreeCommand, +} from "@/kilocode/cli/lazy-kilo-commands" const log = Log.create({ service: "kilocode.cli" }) -// Process-level ingest drain for non-TUI commands (`kilo run`, etc.). -// KiloCli.shutdown() runs KiloShutdown before disposeAllInstances — preserve that order. -// Registered at setup load time (not inside shutdown()) so the task is always present. -// Dynamic import keeps setup.ts's own static import graph unchanged: consumers that load -// setup.ts under partial module mocks (e.g. cli-shutdown tests whose @/auth mock omits -// OAUTH_DUMMY_KEY) would otherwise fail to link the provider/plugin chain. Dynamic import -// returns the same in-process module singleton, so the drained queue is the one that -// received events. Task try/catch covers dynamic-import failure outside the shared drain -// guard; the drain itself never rejects. -KiloShutdown.register(async () => { - try { - const { KiloSessions } = await import("@/kilo-sessions/kilo-sessions") - await KiloSessions.drainIngestForShutdown() - } catch (err) { - log.warn("ingest drain failed", { err }) - } -}) - // All Kilo-specific CLI customization lives here so the shared upstream entrypoint // (src/index.ts) only needs a handful of thin call-sites behind kilocode_change markers. // This keeps index.ts close to upstream and reduces merge conflicts on every sync. @@ -46,6 +33,11 @@ KiloShutdown.register(async () => { // top level, with implementation imports inside their handlers. export namespace KiloCli { let info = false + let narrow = false + + export function workerTui(opts: { [key: string]: unknown }) { + return !hasLazyCommandSelection() && opts.mini !== true && !opts.worktree + } // Register only the Kilo-specific commands. Upstream commands stay in index.ts's chain so // upstream merges that add or remove commands keep working without touching this file. @@ -78,6 +70,7 @@ export namespace KiloCli { export async function bootstrap(opts: { [key: string]: unknown }): Promise { info = opts.help === true || opts.version === true if (info) return + narrow = workerTui(opts) const { KiloLog } = await import("@/kilocode/log") await KiloLog.init() @@ -93,9 +86,11 @@ export namespace KiloCli { const { JsonMigration } = await import("@/kilocode/storage/json-migration") await JsonMigration.bootstrap() - const { AppRuntime } = await import("@/effect/app-runtime") - const { Config } = await import("@/config/config") - const cfg = await AppRuntime.runPromise(Config.Service.use((c) => c.getGlobal())) + const runtime = narrow ? await import("@/kilocode/cli/bootstrap-runtime") : undefined + const app = narrow ? undefined : await import("@/effect/app-runtime") + const cfg = runtime + ? await runtime.KiloCliBootstrapRuntime.getGlobal() + : await app!.AppRuntime.runPromise((await import("@/config/config")).Config.Service.use((c) => c.getGlobal())) const { Global } = await import("@opencode-ai/core/global") const { Telemetry } = await import("@kilocode/kilo-telemetry") @@ -105,16 +100,25 @@ export namespace KiloCli { enabled: cfg.experimental?.openTelemetry !== false, }) - const { Auth } = await import("@/auth") const { migrateLegacyKiloAuth } = gateway + const getAuth = async () => { + if (runtime) return runtime.KiloCliBootstrapRuntime.getAuth() + const { Auth } = await import("@/auth") + return app!.AppRuntime.runPromise(Auth.Service.use((s) => s.get("kilo"))) + } + const setAuth = async (auth: Auth.Info) => { + if (runtime) return runtime.KiloCliBootstrapRuntime.setAuth(auth) + const { Auth } = await import("@/auth") + return app!.AppRuntime.runPromise(Auth.Service.use((s) => s.set("kilo", auth))) + } // Migrate legacy Kilo CLI auth (~/.kilocode/cli/config.json) into auth.json if present. await migrateLegacyKiloAuth( - async () => (await AppRuntime.runPromise(Auth.Service.use((s) => s.get("kilo")))) !== undefined, - async (auth) => AppRuntime.runPromise(Auth.Service.use((s) => s.set("kilo", auth))), + async () => (await getAuth()) !== undefined, + setAuth, ) - const auth = await AppRuntime.runPromise(Auth.Service.use((s) => s.get("kilo"))) + const auth = await getAuth() if (auth) { const token = auth.type === "oauth" ? auth.access : auth.key const account = auth.type === "oauth" ? auth.accountId : undefined @@ -146,6 +150,11 @@ export namespace KiloCli { } } finally { await KiloShutdown.run() + if (narrow) { + const { KiloCliBootstrapRuntime } = await import("@/kilocode/cli/bootstrap-runtime") + await KiloCliBootstrapRuntime.dispose() + return + } const { InstanceRuntime } = await import("@/project/instance-runtime") await InstanceRuntime.disposeAllInstances() // safety net (no-op if already disposed) } diff --git a/packages/opencode/src/kilocode/config/default-plugins.ts b/packages/opencode/src/kilocode/config/default-plugins.ts index 1b4740b9d6..146246d449 100644 --- a/packages/opencode/src/kilocode/config/default-plugins.ts +++ b/packages/opencode/src/kilocode/config/default-plugins.ts @@ -1,16 +1,13 @@ -import { createRequire } from "module" import { ConfigPlugin } from "@/config/plugin" import { ConfigPluginV1 } from "@opencode-ai/core/v1/config/plugin" import { isIndexingPlugin } from "@kilocode/kilo-indexing/detect" import { ensureAtomicChatPlugin, isAtomicChatPlugin } from "@/kilocode/atomic-chat-feature" -import { ensureIndexingPlugin, resolveIndexingPlugin } from "@/kilocode/indexing-feature" +import { ensureIndexingPlugin, INDEXING_PLUGIN } from "@/kilocode/indexing-feature" type Log = { debug: (msg: string, data?: Record) => void } -const req = createRequire(import.meta.url) - export namespace KilocodeDefaultPlugins { export function apply( cfg: T, @@ -19,7 +16,7 @@ export namespace KilocodeDefaultPlugins { let plugins = cfg.plugin ?? [] if (!opts.disabled) { - plugins = ensureIndexingPlugin(plugins, resolveIndexingPlugin(req, opts.log)) + plugins = ensureIndexingPlugin(plugins, INDEXING_PLUGIN) plugins = ensureAtomicChatPlugin(plugins) } diff --git a/packages/opencode/src/kilocode/editor-context.ts b/packages/opencode/src/kilocode/editor-context.ts index ef7c077580..4350bddd4f 100644 --- a/packages/opencode/src/kilocode/editor-context.ts +++ b/packages/opencode/src/kilocode/editor-context.ts @@ -27,22 +27,14 @@ export function staticEnvLines(ctx?: EditorContext): string[] { * Build a per-message block from editor context. * These change frequently (user switches files/tabs) and belong in the * user message so the model always has fresh context. - * Always includes at least the current timestamp. + * Always includes at least the supplied message timestamp. */ -function timestamp(): string { - const now = new Date() - const offset = -now.getTimezoneOffset() - const sign = offset >= 0 ? "+" : "-" - const h = Math.floor(Math.abs(offset) / 60) - .toString() - .padStart(2, "0") - const m = (Math.abs(offset) % 60).toString().padStart(2, "0") - const pad = (n: number) => n.toString().padStart(2, "0") - return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}${sign}${h}:${m}` +function timestamp(now: Date): string { + return now.toISOString().replace(/\.\d+Z$/, "Z") } -export function environmentDetails(ctx?: EditorContext): string { - const lines: string[] = [`Current time: ${timestamp()}`] +export function environmentDetails(ctx?: EditorContext, now = new Date()): string { + const lines: string[] = [`Message time: ${timestamp(now)}`] if (ctx?.directory) { lines.push(`Working directory: ${ctx.directory}`) } diff --git a/packages/opencode/src/kilocode/help-command.ts b/packages/opencode/src/kilocode/help-command.ts index ecb5f90f14..fa92607751 100644 --- a/packages/opencode/src/kilocode/help-command.ts +++ b/packages/opencode/src/kilocode/help-command.ts @@ -1,13 +1,15 @@ import { cmd } from "../cli/cmd/cmd" import { generateHelp } from "./help" import type { Argv } from "yargs" +import { markLazyCommandSelection } from "@/kilocode/cli/lazy-commands" export function createHelpCommand(root?: () => Argv) { return cmd({ command: "help [command]", describe: "show full CLI reference", - builder: (yargs) => - yargs + builder: (yargs) => { + markLazyCommandSelection() + return yargs .positional("command", { describe: "command to show help for", type: "string", @@ -22,7 +24,8 @@ export function createHelpCommand(root?: () => Argv) { type: "string", choices: ["md", "text"] as const, default: "md" as const, - }), + }) + }, async handler(args) { if (!args.command && !args.all) { if (root) { diff --git a/packages/opencode/src/kilocode/indexing-feature.ts b/packages/opencode/src/kilocode/indexing-feature.ts index 3cb2e6042c..2dc74e2ba2 100644 --- a/packages/opencode/src/kilocode/indexing-feature.ts +++ b/packages/opencode/src/kilocode/indexing-feature.ts @@ -1,4 +1,3 @@ -import { pathToFileURL } from "url" import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect" export const INDEXING_PLUGIN = "@kilocode/kilo-indexing" @@ -11,29 +10,10 @@ type ConfigLike = { plugin?: readonly PluginSpec[] | null } -type Req = { - resolve: (id: string) => string -} - -type LogLike = { - debug: (msg: string, data?: Record) => void -} - export function indexingEnabled(config?: ConfigLike | null): boolean { return hasIndexingPlugin(config?.plugin ?? []) } -export function resolveIndexingPlugin(req: Req, log?: LogLike): string { - try { - const file = req.resolve(INDEXING_PLUGIN) - return pathToFileURL(file).href - } catch (err) { - const error = err instanceof Error ? err.message : String(err) - log?.debug("failed to resolve indexing plugin package, using package marker", { error }) - return INDEXING_PLUGIN - } -} - export function ensureIndexingPlugin(items: readonly PluginSpec[], plugin?: string): PluginSpec[] { const plugins = [...items] if (!plugin) return plugins diff --git a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts index 3c24d0f94c..27cb5ffa34 100644 --- a/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/groups/kilocode.ts @@ -80,6 +80,7 @@ export const KilocodePaths = { sessionModelUsage: `/session/:sessionID/model-usage`, backgroundJobs: `${root}/background-jobs`, backgroundJobCancel: `${root}/background-jobs/:jobID/cancel`, + backgroundJobPromote: `${root}/background-jobs/:jobID/promote`, } as const export const KilocodeApi = HttpApi.make("kilocode") @@ -272,6 +273,18 @@ export const KilocodeApi = HttpApi.make("kilocode") description: "Cancel one background subagent job and its session tree.", }), ), + HttpApiEndpoint.post("backgroundJobPromote", KilocodePaths.backgroundJobPromote, { + params: { jobID: Schema.String }, + query: WorkspaceRoutingQuery, + success: described(Schema.Boolean, "Background job promoted"), + error: HttpApiError.NotFound, + }).annotateMerge( + OpenApi.annotations({ + identifier: "kilocode.backgroundJob.promote", + summary: "Promote background job", + description: "Continue one foreground subagent in the background.", + }), + ), ) .annotateMerge( OpenApi.annotations({ diff --git a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts index 309e980fb4..1963357320 100644 --- a/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts +++ b/packages/opencode/src/kilocode/server/httpapi/handlers/kilocode.ts @@ -25,6 +25,7 @@ import { Skill } from "@/skill" import { BackgroundJob } from "@/background/job" import { SessionRunState } from "@/session/run-state" import { SessionID } from "@/session/schema" +import { RuntimeFlags } from "@/effect/runtime-flags" import { AgentManagerRejectPayload, AgentManagerReplyPayload, @@ -48,6 +49,7 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" const notebook = yield* Notebook.Service const background = yield* BackgroundJob.Service const runState = yield* SessionRunState.Service + const flags = yield* RuntimeFlags.Service const locations = yield* LocationServiceMap.Service // Location-scoped services, keyed by the request's directory and workspace. @@ -234,6 +236,16 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" return true }) + const backgroundJobPromote = Effect.fn("KilocodeHttpApi.backgroundJobPromote")(function* (ctx: { + params: { jobID: string } + }) { + if (!flags.experimentalBackgroundSubagents) return false + const job = yield* background.get(ctx.params.jobID) + if (!job) return yield* new HttpApiError.NotFound({}) + const promoted = yield* background.promote(ctx.params.jobID) + return promoted !== undefined + }) + return handlers .handle("heapSnapshot", heapSnapshot) .handle("commandFiles", commandFiles) @@ -251,5 +263,6 @@ export const kilocodeHandlers = HttpApiBuilder.group(InstanceHttpApi, "kilocode" .handle("sessionModelUsage", sessionModelUsage) .handle("backgroundJobs", backgroundJobs) .handle("backgroundJobCancel", backgroundJobCancel) + .handle("backgroundJobPromote", backgroundJobPromote) }), ) diff --git a/packages/opencode/src/kilocode/server/reference-reconciler.ts b/packages/opencode/src/kilocode/server/reference-reconciler.ts index 11ad901b69..65e5f8f5e0 100644 --- a/packages/opencode/src/kilocode/server/reference-reconciler.ts +++ b/packages/opencode/src/kilocode/server/reference-reconciler.ts @@ -3,7 +3,7 @@ import { InstanceRef } from "@/effect/instance-ref" import { isInterrupted } from "@/kilocode/effect/cause" import * as KiloReference from "@/kilocode/reference" import { InstanceStore } from "@/project/instance-store" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { PluginV2 } from "@opencode-ai/core/plugin" // kilocode_change import { ReferenceReconciler } from "@opencode-ai/server/kilocode/reference-reconciler" @@ -46,4 +46,4 @@ export const locations = Layer.effect( }), }) }), -).pipe(Layer.provide(locationServiceMapLayer)) +) diff --git a/packages/opencode/src/kilocode/session/prompt.ts b/packages/opencode/src/kilocode/session/prompt.ts index 4b997d2a5f..9b088be76b 100644 --- a/packages/opencode/src/kilocode/session/prompt.ts +++ b/packages/opencode/src/kilocode/session/prompt.ts @@ -270,7 +270,10 @@ export namespace KiloSessionPrompt { const taggedSession = PermissionProvenance.tagSession(session.permission ?? []) const ruleset = Permission.merge( taggedAgent, - guardPermissions({ agent: { name: agent.name, permission: taggedAgent }, session: { permission: taggedSession } }), + guardPermissions({ + agent: { name: agent.name, permission: taggedAgent }, + session: { permission: taggedSession }, + }), ) const outcome = yield* input.permission.ask({ ...input.request, ruleset, hardRuleset: hardPermissions({ agent }) }) if (outcome.manual) return { source: "manual" } satisfies PermissionProvenance.Approval @@ -278,13 +281,9 @@ export namespace KiloSessionPrompt { // kilocode_change end }) - /** - * Mutable cache for environment details, keyed by user message ID - * so it recomputes when a new user message arrives. - */ + /** Mutable per-turn cache for deterministic environment detail blocks. */ export interface EnvCache { - block?: string - user?: string + blocks?: Map } export function memoryToolEnabled(input: { ctx: MemoryPaths.Ctx }) { @@ -356,46 +355,54 @@ export namespace KiloSessionPrompt { } /** - * Ephemerally injects dynamic editor context (visible files, open tabs, etc.) - * into the last user message. Caches the result per user message ID so repeated - * loop iterations produce byte-identical messages (prompt caching). + * Reconstructs dynamic editor context on every user message without + * persisting synthetic prompt scaffolding. Using each message's creation + * time keeps historical blocks byte-identical, so later turns only append + * instead of moving the block and discarding the provider prompt cache. */ export function injectEditorContext(input: { msgs: MessageV2.WithParts[] - lastUser: MessageV2.User + session: Pick sessionID: SessionID cache: EnvCache }) { - if (input.cache.user !== input.lastUser.id) { - const ctx = (() => { - try { - return Instance.current - } catch { - return undefined - } - })() - input.cache.block = environmentDetails({ - ...input.lastUser.editorContext, - ...(ctx ? { directory: ctx.directory, worktree: ctx.worktree } : {}), - }) - input.cache.user = input.lastUser.id + const route = { + directory: input.session.directory, + worktree: path.resolve( + input.session.directory, + ...(input.session.path + ?.split("/") + .filter(Boolean) + .map(() => "..") ?? []), + ), } - if (!input.cache.block) return - const idx = input.msgs.findLastIndex((m) => m.info.role === "user") - if (idx === -1) return - input.msgs[idx] = { - ...input.msgs[idx], - parts: [ - ...input.msgs[idx].parts, - { - id: PartID.make(Identifier.ascending("part")), - sessionID: input.sessionID, - messageID: input.msgs[idx].info.id, - type: "text", - text: input.cache.block, - synthetic: true, - } satisfies MessageV2.TextPart, - ], + input.cache.blocks ??= new Map() + for (const msg of input.msgs) { + if (msg.info.role !== "user") continue + if ( + msg.parts.some( + (part) => part.type === "text" && part.synthetic && part.text.startsWith(""), + ) + ) + continue + const block = + input.cache.blocks.get(msg.info.id) ?? + environmentDetails( + { + ...route, + ...msg.info.editorContext, + }, + new Date(msg.info.time.created), + ) + input.cache.blocks.set(msg.info.id, block) + msg.parts.push({ + id: PartID.make(Identifier.ascending("part")), + sessionID: input.sessionID, + messageID: msg.info.id, + type: "text", + text: block, + synthetic: true, + } satisfies MessageV2.TextPart) } } diff --git a/packages/opencode/src/kilocode/storage/json-migration.ts b/packages/opencode/src/kilocode/storage/json-migration.ts index 431c13caf5..cbc5aa3986 100644 --- a/packages/opencode/src/kilocode/storage/json-migration.ts +++ b/packages/opencode/src/kilocode/storage/json-migration.ts @@ -73,7 +73,10 @@ export async function bootstrap() { const marker = Database.path() if (marker === ":memory:") return const pending = marker + ".json-migration" - if ((await Filesystem.exists(marker)) && !(await Filesystem.exists(pending))) return + const [database, retry] = await Promise.all([Filesystem.exists(marker), Filesystem.exists(pending)]) + if (database && !retry) return + const storage = path.join(Global.Path.data, "storage") + if (!database && !retry && !(await Filesystem.exists(storage))) return await Filesystem.write(pending, "1") const tty = process.stderr.isTTY diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.ts b/packages/opencode/src/kilocode/tool/agent-manager-models.ts index 613489a1c8..d70d697db1 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager-models.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.ts @@ -89,7 +89,7 @@ export const AgentManagerModelsTool = Tool.define< offset, total: matches.length, nextOffset, - hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Agent Manager picks the provider, preferring the one used by the current turn.", + hint: "Pass a model name (or one of its providers/IDs) as the agent_manager task `model`. Add the task `provider` to force one of the listed providers; otherwise Agent Manager prefers the provider used by the current turn.", }), metadata: { count: models.length, total: matches.length }, } diff --git a/packages/opencode/src/kilocode/tool/agent-manager-models.txt b/packages/opencode/src/kilocode/tool/agent-manager-models.txt index 8c73797bde..8571d6045d 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager-models.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager-models.txt @@ -1,5 +1,5 @@ Search the models available to Agent Manager sessions and inspect their reasoning variants. -Use this tool before `agent_manager` when you need to pick a model or reasoning effort. Results are grouped by model, not by provider, because you select a model and Agent Manager chooses the provider for you. With no arguments it returns the top available models (capped at 20); pass `query` to search by model name or ID, and `offset` to page further. The query is matched leniently: it is case-insensitive, ignores spacing and punctuation, and is order-independent, so `opus claude`, `glm5.2`, and `gpt5` all work. You do not need the exact model name. +Use this tool before `agent_manager` when you need to pick a model or reasoning effort. Results are grouped by model, not by provider, and list every provider that offers each model so you can constrain the provider when needed. With no arguments it returns the top available models (capped at 20); pass `query` to search by model name or ID, and `offset` to page further. The query is matched leniently: it is case-insensitive, ignores spacing and punctuation, and is order-independent, so `opus claude`, `glm5.2`, and `gpt5` all work. You do not need the exact model name. -Each result includes the model name, its reasoning variant names, and the providers that offer it (informational only). Pass the model name back as the `agent_manager` task `model`. Agent Manager resolves the provider automatically, preferring the provider used by the current turn and falling back to the Kilo Gateway, so you do not need to choose a provider yourself. +Each result includes the model name, its reasoning variant names, and the providers that offer it. Pass the model name back as the `agent_manager` task `model`; pass one of the listed provider IDs as the task `provider` when the provider must be explicit. When `provider` is omitted, Agent Manager resolves it automatically, preferring the one used by the current turn and falling back to the Kilo Gateway. diff --git a/packages/opencode/src/kilocode/tool/agent-manager.ts b/packages/opencode/src/kilocode/tool/agent-manager.ts index 8166f8302e..630f08200f 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.ts +++ b/packages/opencode/src/kilocode/tool/agent-manager.ts @@ -28,6 +28,10 @@ const Task = Schema.Struct({ description: "Optional model override from agent_manager_models (e.g. 'Claude Opus 4.1'). Omit unless the user requests a different model. Agent Manager otherwise inherits the current turn's model. A qualified provider/model ID is also accepted to force a specific provider.", }), + provider: Schema.optional(Schema.NullOr(Schema.String)).annotate({ + description: + "Optional provider ID to constrain model resolution (e.g. 'anthropic'). Use with model to select a model from a specific provider; omit to use the current-turn provider preference.", + }), variant: Schema.optional(Schema.NullOr(Schema.String)).annotate({ description: "Optional reasoning variant override from agent_manager_models. Specify it without model to override the inherited model's variant. Omit both to inherit the current turn's selection.", @@ -41,6 +45,9 @@ const Task = Schema.Struct({ Schema.makeFilter((task) => task.model?.trim() && !task.prompt?.trim() ? "A task model requires an initial prompt" : undefined, ), + Schema.makeFilter((task) => + task.provider?.trim() && !task.model?.trim() ? "A task provider requires a model" : undefined, + ), Schema.makeFilter((task) => task.variant?.trim() && !task.prompt?.trim() ? "A task variant requires an initial prompt" : undefined, ), @@ -245,6 +252,7 @@ function select( ...(task.branchName != null ? { branchName: task.branchName } : {}), } const value = task.model?.trim() + const provider = task.provider?.trim() const variant = task.variant?.trim() if (!value) { if (!variant) { @@ -271,12 +279,21 @@ function select( return { task: { ...base, model: source.model, variant } } } - const { pool, names } = lookup(all, value) + const scope = provider ? all.filter((item) => item.providerID === provider) : all + if (provider && scope.length === 0) { + return { + error: `Task ${index + 1} provider is not available for model selection: ${provider}. Requested model: ${value}.`, + } + } + + const { pool, names } = lookup(scope, value) if (pool.length === 0) { - const close = suggest(all, value) + const close = suggest(scope, value) const hint = close.length ? ` Closest matches: ${close.join(", ")}.` : "" return { - error: `Task ${index + 1} model is not available: ${value}.${hint} Use agent_manager_models to search models.`, + error: provider + ? `Task ${index + 1} model is not available from provider "${provider}": ${value}.${hint} Use agent_manager_models to search models.` + : `Task ${index + 1} model is not available: ${value}.${hint} Use agent_manager_models to search models.`, } } if (names.length > 1) { @@ -479,8 +496,9 @@ export const AgentManagerTool = Tool.define< ...(msg.model.variant ? { variant: msg.model.variant } : {}), } : undefined - const need = params.tasks.some((task) => task.model?.trim() || task.variant?.trim()) - const all = need ? candidates(yield* provider.list()) : [] + const need = params.tasks.some((task) => task.model?.trim() || task.provider?.trim() || task.variant?.trim()) + const providers = need ? yield* provider.list() : undefined + const all = providers ? candidates(providers) : [] const preferred = need ? (source?.model.providerID ?? (yield* provider.defaultModel().pipe( diff --git a/packages/opencode/src/kilocode/tool/agent-manager.txt b/packages/opencode/src/kilocode/tool/agent-manager.txt index 1d081b91df..4dbb410b84 100644 --- a/packages/opencode/src/kilocode/tool/agent-manager.txt +++ b/packages/opencode/src/kilocode/tool/agent-manager.txt @@ -14,7 +14,7 @@ Modes: - `worktree`: creates a new Agent Manager git worktree for each task, like the New Worktree dialog. - `local`: creates Agent Manager sessions in the current workspace directory without git worktree isolation. -Each task may provide a prompt, a short display name, a branch name, a `model`, and a model-specific reasoning `variant`. By default, omit `model` and `variant`: prompted tasks inherit the exact model and reasoning variant used by the current turn. Only specify `model` when the user explicitly asks to use or compare a different model, and only specify `variant` when the user explicitly asks for a different reasoning variant. A variant can be specified without a model to override the inherited model's variant. Never choose a different model merely because work is being fanned out. Specify an override `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider used by the current turn and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model or variant selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Prepared sessions without an initial prompt use the normal defaults. The agent and base branch settings always use the normal defaults. +Each task may provide a prompt, a short display name, a branch name, a `model`, an optional `provider`, and a model-specific reasoning `variant`. By default, omit `model`, `provider`, and `variant`: prompted tasks inherit the exact model and reasoning variant used by the current turn. Only specify `model` when the user explicitly asks to use or compare a different model, and only specify `variant` when the user explicitly asks for a different reasoning variant. A variant can be specified without a model to override the inherited model's variant. Specify `provider` with `model` to force a model-name match to one provider ID. Never choose a different model merely because work is being fanned out. Specify an override `model` by name (e.g. "Claude Opus 4.1"); the name is matched leniently (case-insensitive, punctuation/spacing-insensitive, order-independent), so an approximate name like "opus 4.1" works and you do not need the exact name. Agent Manager picks the provider for you, preferring the provider used by the current turn and falling back to the Kilo Gateway. A qualified `provider/model` ID is also accepted to force a specific provider. If the name is ambiguous and matches several different models, the tool returns the candidates so you can choose. A model or variant selection requires an initial prompt so the session can persist that selection. Keep display names short because Agent Manager cards are narrow. Branch names are sanitized before worktree creation. Use `agent_manager_models` to search available models and variants on demand instead of guessing or loading the full model catalog. Prepared sessions without an initial prompt use the normal defaults. The agent and base branch settings always use the normal defaults. By default, multiple tasks are started as independent Agent Manager sessions. Set `versions` to true only when all tasks are alternate versions of the same work that should be compared together. Versioned worktrees are grouped in Agent Manager and branch names may receive version suffixes. diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 4e5f5dac4a..9b1b7066c4 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -64,7 +64,7 @@ function wrapSSE(res: Response, ms: number, ctl: AbortController) { const id = setTimeout(() => { const err = new ProviderError.ResponseStreamError("SSE read timed out") ctl.abort(err) - void reader.cancel(err) + void reader.cancel(err).catch(() => undefined) // kilocode_change - handle Bun 1.4 cancellation rejection reject(err) }, ms) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts index 026fee106b..28b545944b 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts @@ -1,11 +1,12 @@ import * as InstanceState from "@/effect/instance-state" +import { WorkspaceRef } from "@/effect/instance-ref" // kilocode_change - preserve the shared location key shape import { FileSystem } from "@opencode-ai/core/filesystem" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change - reuse the server location map import { Ripgrep } from "@opencode-ai/core/ripgrep" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" import { AbsolutePath, RelativePath } from "@opencode-ai/core/schema" -import { Effect, Layer, Option } from "effect" +import { Effect, Option } from "effect" // kilocode_change - location map is provided by the server import ignore from "ignore" import path from "path" import { HttpApiBuilder } from "effect/unstable/httpapi" @@ -17,11 +18,19 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl const locations = yield* LocationServiceMap.Service const filesystem = Effect.fnUntraced(function* (effect: Effect.Effect) { + // kilocode_change start - preserve the shared location key shape + const workspaceID = yield* WorkspaceRef return yield* effect.pipe( Effect.provide( - locations.get(Location.Ref.make({ directory: AbsolutePath.make((yield* InstanceState.context).directory) })), + locations.get( + Location.Ref.make({ + directory: AbsolutePath.make((yield* InstanceState.context).directory), + workspaceID, + }), + ), ), ) + // kilocode_change end }) const findText = Effect.fn("FileHttpApi.findText")(function* (ctx: { query: { pattern: string } }) { @@ -138,4 +147,4 @@ export const fileHandlers = HttpApiBuilder.group(InstanceHttpApi, "file", (handl .handle("content", content) .handle("status", status) }), -).pipe(Layer.provide(locationServiceMapLayer)) +) // kilocode_change - reuse the server location map diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts index aefd39005f..020387dfe6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/pty.ts @@ -6,7 +6,7 @@ import { Pty } from "@opencode-ai/core/pty" import { PtyProtocol } from "@opencode-ai/core/pty/protocol" import { PtyID } from "@opencode-ai/core/pty/schema" import { PtyTicket } from "@opencode-ai/core/pty/ticket" -import { LocationServiceMap, locationServiceMapLayer } from "@opencode-ai/core/location-services" +import { LocationServiceMap } from "@opencode-ai/core/location-services" // kilocode_change - reuse the server location map import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Shell } from "@opencode-ai/core/shell" @@ -16,7 +16,7 @@ import { PTY_CONNECT_TOKEN_HEADER, PTY_CONNECT_TOKEN_HEADER_VALUE, } from "@/server/shared/pty-ticket" -import { Effect, Layer, Option, Queue, Schema } from "effect" +import { Effect, Option, Queue, Schema } from "effect" // kilocode_change - location map is provided by the server import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" import { HttpApiBuilder } from "effect/unstable/httpapi" import * as Socket from "effect/unstable/socket/Socket" @@ -165,7 +165,7 @@ export const ptyHandlers = HttpApiBuilder.group(InstanceHttpApi, "pty", (handler .handle("remove", remove) .handle("connectToken", connectToken) }), -).pipe(Layer.provide(locationServiceMapLayer)) +) // kilocode_change - reuse the server location map export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-connect", (handlers) => Effect.gen(function* () { @@ -285,4 +285,4 @@ export const ptyConnectHandlers = HttpApiBuilder.group(PtyConnectApi, "pty-conne }), ) }), -).pipe(Layer.provide(locationServiceMapLayer)) +) // kilocode_change - reuse the server location map diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 092666a3ef..9c9ceb4106 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1718,7 +1718,7 @@ export const layer = Layer.effect( // kilocode_change start — ephemeral context injection + post-summary // media strip (keeps outgoing body under the gateway body-size limit // even when filterCompacted couldn't trim the pre-summary history). - KiloSessionPrompt.injectEditorContext({ msgs, lastUser, sessionID, cache: envCache }) + KiloSessionPrompt.injectEditorContext({ msgs, session, sessionID, cache: envCache }) msgs = KiloSessionPrompt.maybeStripHistoricalMedia(msgs) // kilocode_change end @@ -1742,7 +1742,7 @@ export const layer = Layer.effect( msgs = KiloSessionPromptQueue.scope(sessionID, msgs) msgs = KiloSessionPrompt.trimBeforeLastSummary(msgs) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - KiloSessionPrompt.injectEditorContext({ msgs, lastUser, sessionID, cache: envCache }) + KiloSessionPrompt.injectEditorContext({ msgs, session, sessionID, cache: envCache }) msgs = KiloSessionPrompt.maybeStripHistoricalMedia(msgs) modelMsgs = yield* MessageV2.toModelMessagesEffect(msgs, model).pipe( Effect.provideService(Database.Service, database), @@ -2518,7 +2518,10 @@ export const PromptInput = Schema.Struct({ // `parts` type from the exported Schema input types so callers see a proper // tagged union. type PartInputUnion = - MessageV2.TextPartInput | MessageV2.FilePartInput | MessageV2.AgentPartInput | MessageV2.SubtaskPartInput + | MessageV2.TextPartInput + | MessageV2.FilePartInput + | MessageV2.AgentPartInput + | MessageV2.SubtaskPartInput export type PromptInput = Omit, "parts" | "editorContext"> & { parts: PartInputUnion[] editorContext?: MessageV2.EditorContext diff --git a/packages/opencode/test/kilocode/agent-manager-tool.test.ts b/packages/opencode/test/kilocode/agent-manager-tool.test.ts index 69b33d4238..411d2d173f 100644 --- a/packages/opencode/test/kilocode/agent-manager-tool.test.ts +++ b/packages/opencode/test/kilocode/agent-manager-tool.test.ts @@ -229,6 +229,14 @@ describe("agent_manager tool", () => { expect(Schema.is(Params)({ action: "stop", sessionID: "invalid" })).toBe(false) }) + test("validates provider selectors at the task level", () => { + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: "kilo" }] })).toBe( + true, + ) + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", provider: "kilo" }] })).toBe(false) + expect(Schema.is(Params)({ mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: 42 }] })).toBe(false) + }) + // Regression for #13029: the OpenAI Responses API forces a value for every // advertised property. With action nullable the model can decline it and the // start request survives; with a populated action the action wins instead. @@ -673,6 +681,12 @@ describe("agent_manager tool", () => { expect(task?.variant).toBe("low") }) + test("uses an explicitly selected provider for a shared model name", async () => { + const task = await publish(runtime, { prompt: "Fix", model: " Shared ", provider: " kilo " }) + expect(String(task?.model?.providerID)).toBe("kilo") + expect(String(task?.model?.modelID)).toBe("kilo/shared") + }) + test("uses the provider of a different default model when that is the user's choice", async () => { const rt = makeRuntime("kilo") const task = await publish(rt, { prompt: "Fix", model: "Shared", variant: "low" }) @@ -717,6 +731,43 @@ describe("agent_manager tool", () => { expect(result.metadata.count).toBe(0) }) + test("reports a model unavailable from an explicit provider", async () => { + const tool = await init() + const calls: unknown[] = [] + + const result = await runtime.runPromise( + provideTmpdirInstance(() => + tool.execute( + { mode: "local", tasks: [{ prompt: "Fix", model: "Reasoning Model", provider: "kilo" }] }, + { ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) }, + ), + ).pipe(Effect.scoped), + ) + + expect(calls).toEqual([]) + expect(result.output).toContain('model is not available from provider "kilo": Reasoning Model') + expect(result.metadata.count).toBe(0) + }) + + test("rejects an unknown provider without touching inherited object properties", async () => { + const tool = await init() + const calls: unknown[] = [] + + const result = await runtime.runPromise( + provideTmpdirInstance(() => + tool.execute( + { mode: "local", tasks: [{ prompt: "Fix", model: "Shared", provider: "__proto__" }] }, + { ...ctx, ask: (input: unknown) => Effect.sync(() => calls.push(input)) }, + ), + ).pipe(Effect.scoped), + ) + + expect(calls).toEqual([]) + expect(result.output).toContain("provider is not available for model selection: __proto__") + expect(result.output).toContain("Requested model: Shared") + expect(result.metadata.count).toBe(0) + }) + test("echoes how each named model resolved", async () => { const tool = await init() const result = await runtime.runPromise( diff --git a/packages/opencode/test/kilocode/cli-shutdown.test.ts b/packages/opencode/test/kilocode/cli-shutdown.test.ts index 451124d154..60c61cf51c 100644 --- a/packages/opencode/test/kilocode/cli-shutdown.test.ts +++ b/packages/opencode/test/kilocode/cli-shutdown.test.ts @@ -4,7 +4,6 @@ import { KiloShutdown } from "../../src/kilocode/cli/shutdown" const calls: string[] = [] const timeouts: Array = [] let err: unknown -let drainErr: unknown let drainCalls = 0 let exit: string | number | null | undefined @@ -92,16 +91,6 @@ mock.module("@/kilocode/session-export", () => ({ }, })) -mock.module("@/kilo-sessions/kilo-sessions", () => ({ - KiloSessions: { - async drainIngestForShutdown() { - drainCalls += 1 - calls.push("drain") - if (drainErr) throw drainErr - }, - }, -})) - mock.module("@/kilocode/help-command", () => ({ createHelpCommand: () => ({ command: "help", handler() {} }), })) @@ -134,7 +123,6 @@ function registerDrain() { KiloShutdown.register(async () => { drainCalls += 1 calls.push("drain") - if (drainErr) throw drainErr }) } @@ -155,7 +143,6 @@ describe("KiloCli.shutdown", () => { calls.length = 0 timeouts.length = 0 err = undefined - drainErr = undefined drainCalls = 0 exit = process.exitCode process.exitCode = undefined @@ -165,19 +152,15 @@ describe("KiloCli.shutdown", () => { process.exitCode = exit }) - // Must stay first: setup registers the drain task once at import; KiloShutdown.run() clears it. - // Only this test pins that one-time module-scope registration (and the drain-before-dispose - // ordering it enables). Later tests call installDrain() so they do not rely on order. - test("rejects drain without blocking dispose", async () => { - drainErr = new Error("ingest drain failed") + test("does not load unused ingest shutdown work", async () => { process.exitCode = 0 const { KiloCli } = await import("../../src/kilocode/cli/setup") await expect(KiloCli.shutdown()).resolves.toBeUndefined() - expect(drainCalls).toBe(1) + expect(drainCalls).toBe(0) expect(timeouts).toEqual([2000]) - expect(calls).toEqual(["track:0", "session", "telemetry", "drain", "dispose"]) + expect(calls).toEqual(["track:0", "session", "telemetry", "dispose"]) expect(process.exitCode).toBe(0) }) diff --git a/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts b/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts new file mode 100644 index 0000000000..7f580e3b6f --- /dev/null +++ b/packages/opencode/test/kilocode/cli/bootstrap-runtime.test.ts @@ -0,0 +1,27 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { KiloCli } from "../../../src/kilocode/cli/setup" +import { createHelpCommand } from "../../../src/kilocode/help-command" +import { resetLazyCommandSelection } from "../../../src/kilocode/cli/lazy-commands" +import yargs from "yargs" + +describe("CLI bootstrap runtime selection", () => { + beforeEach(resetLazyCommandSelection) + afterEach(resetLazyCommandSelection) + + test("uses the narrow runtime for worker-backed TUI launches", () => { + expect(KiloCli.workerTui({ _: [] })).toBe(true) + expect(KiloCli.workerTui({ _: ["./project"] })).toBe(true) + }) + + test("keeps full bootstrap for explicit, mini, and worktree commands", () => { + expect(KiloCli.workerTui({ _: [], mini: true })).toBe(false) + expect(KiloCli.workerTui({ _: [], worktree: "feature" })).toBe(false) + }) + + test("keeps full bootstrap when the eager help command is selected", () => { + const command = createHelpCommand() + if (typeof command.builder !== "function") throw new Error("help builder is not a function") + command.builder(yargs([])) + expect(KiloCli.workerTui({ _: [] })).toBe(false) + }) +}) diff --git a/packages/opencode/test/kilocode/cli/lazy-commands.test.ts b/packages/opencode/test/kilocode/cli/lazy-commands.test.ts new file mode 100644 index 0000000000..42232f2c83 --- /dev/null +++ b/packages/opencode/test/kilocode/cli/lazy-commands.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test" +import yargs from "yargs" +import { hasLazyCommandSelection, lazy } from "../../../src/kilocode/cli/lazy-commands" + +describe("lazy CLI command", () => { + test("loads once after command selection", async () => { + const calls: string[] = [] + const command = lazy({ + command: "sample", + describe: "sample command", + async load() { + calls.push("load") + return { + command: "sample", + describe: "sample command", + builder: (args) => args.option("value", { type: "string", demandOption: true }), + handler: (args) => { + calls.push(String(args.value)) + }, + } + }, + }) + const cli = yargs([]).exitProcess(false).command(command) + + expect(calls).toEqual([]) + await cli.parseAsync(["sample", "--value", "ready"]) + expect(calls).toEqual(["load", "ready"]) + expect(hasLazyCommandSelection()).toBe(true) + }) + + test("preserves builder validation", async () => { + const command = lazy({ + command: "sample", + describe: "sample command", + async load() { + return { + command: "sample", + describe: "sample command", + builder: (args) => args.option("value", { type: "string", demandOption: true }), + handler() {}, + } + }, + }) + + await expect(yargs([]).exitProcess(false).command(command).parseAsync(["sample"])).rejects.toThrow( + "Missing required argument: value", + ) + }) +}) diff --git a/packages/opencode/test/kilocode/cli/lazy-completion.test.ts b/packages/opencode/test/kilocode/cli/lazy-completion.test.ts new file mode 100644 index 0000000000..095c2e837b --- /dev/null +++ b/packages/opencode/test/kilocode/cli/lazy-completion.test.ts @@ -0,0 +1,14 @@ +import { expect } from "bun:test" +import { Effect } from "effect" +import { cliIt } from "../../lib/cli-process" + +cliIt.live( + "nested lazy command completion includes subcommands", + ({ opencode }) => + Effect.gen(function* () { + const result = yield* opencode.spawn(["--get-yargs-completions", "auth", ""]) + opencode.expectExit(result, 0, "auth completion") + expect(result.stdout.split("\n")).toEqual(expect.arrayContaining(["list", "login", "logout"])) + }), + 60_000, +) diff --git a/packages/opencode/test/kilocode/editor-context-injection.test.ts b/packages/opencode/test/kilocode/editor-context-injection.test.ts new file mode 100644 index 0000000000..1e1f75f73a --- /dev/null +++ b/packages/opencode/test/kilocode/editor-context-injection.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, test } from "bun:test" +import path from "node:path" +import { ModelV2 } from "@opencode-ai/core/model" +import { ProviderV2 } from "@opencode-ai/core/provider" +import type { Provider } from "../../src/provider/provider" +import { KiloSessionPrompt } from "../../src/kilocode/session/prompt" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID, PartID, SessionID } from "../../src/session/schema" + +const sessionID = SessionID.make("ses_test") +const model = { + providerID: ProviderV2.ID.make("openai"), + modelID: ModelV2.ID.make("gpt-4"), +} +const session = { + directory: "/repo/session", + path: "session", +} +const mdl: Provider.Model = { + id: model.modelID, + providerID: model.providerID, + api: { id: model.modelID, url: "https://example.com", npm: "@ai-sdk/openai" }, + name: "Test Model", + capabilities: { + temperature: true, + reasoning: false, + attachment: false, + toolcall: true, + input: { text: true, audio: false, image: false, video: false, pdf: false }, + output: { text: true, audio: false, image: false, video: false, pdf: false }, + interleaved: false, + }, + cost: { input: 0, output: 0, cache: { read: 0, write: 0 } }, + limit: { context: 100_000, input: 100_000, output: 10_000 }, + status: "active", + options: {}, + headers: {}, + release_date: "2026-01-01", +} + +function user(text: string, created: number, activeFile?: string, route?: string) { + const id = MessageID.ascending() + return { + info: { + id, + role: "user" as const, + sessionID, + time: { created }, + agent: "code", + model, + editorContext: { + ...(route ? { directory: route, worktree: route } : {}), + ...(activeFile ? { activeFile } : {}), + }, + }, + parts: [{ id: PartID.ascending(), messageID: id, sessionID, type: "text" as const, text }], + } satisfies MessageV2.WithParts +} + +function assistant(parentID: MessageID, text: string) { + const id = MessageID.ascending() + return { + info: { + id, + role: "assistant" as const, + sessionID, + time: { created: Date.now() }, + parentID, + modelID: model.modelID, + providerID: model.providerID, + mode: "code", + agent: "code", + path: { cwd: "/tmp", root: "/tmp" }, + cost: 0, + tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + }, + parts: [{ id: PartID.ascending(), messageID: id, sessionID, type: "text" as const, text }], + } satisfies MessageV2.WithParts +} + +function inject(msgs: MessageV2.WithParts[], cache: KiloSessionPrompt.EnvCache = {}) { + KiloSessionPrompt.injectEditorContext({ msgs, session, sessionID, cache }) +} + +function blocks(msg: MessageV2.WithParts) { + return msg.parts.filter( + (part): part is MessageV2.TextPart => + part.type === "text" && !!part.synthetic && part.text.startsWith(""), + ) +} + +async function prompt(msgs: MessageV2.WithParts[]) { + return JSON.stringify(await MessageV2.toModelMessages(msgs, mdl)) +} + +describe("injectEditorContext", () => { + test("keeps the previous model prompt byte-identical across turns without persisting blocks", async () => { + const stored1 = user("2 + 2", Date.parse("2026-08-24T12:00:00Z"), "src/one.ts") + const turn1 = [structuredClone(stored1)] + inject(turn1) + const first = await prompt(turn1) + + expect(blocks(turn1[0])).toHaveLength(1) + expect(blocks(stored1)).toHaveLength(0) + + const stored2 = user("3 + 3", Date.parse("2026-08-24T12:01:00Z"), "src/two.ts", "/repo/next") + const turn2 = [structuredClone(stored1), assistant(stored1.info.id, "4"), structuredClone(stored2)] + inject(turn2) + + expect((await prompt(turn2)).startsWith(first.slice(0, -1))).toBe(true) + expect(blocks(turn2[0])).toHaveLength(1) + expect(blocks(turn2[2])).toHaveLength(1) + expect(blocks(turn2[0])[0].text).toContain("Active file: src/one.ts") + expect(blocks(turn2[2])[0].text).toContain("Active file: src/two.ts") + expect(blocks(turn2[0])[0].text).toContain("Message time: 2026-08-24T") + expect(blocks(turn2[0])[0].text).toContain("Working directory: /repo/session") + expect(blocks(turn2[0])[0].text).toContain(`Workspace root folder: ${path.resolve(session.directory, "..")}`) + expect(blocks(turn2[2])[0].text).toContain("Working directory: /repo/next") + }) + + test("is byte-identical across repeated loop iterations", async () => { + const stored = user("list files", Date.parse("2026-08-24T12:00:00Z")) + const cache: KiloSessionPrompt.EnvCache = {} + const first = [structuredClone(stored)] + const second = [structuredClone(stored)] + + inject(first, cache) + inject(second, cache) + + expect(await prompt(second)).toBe(await prompt(first)) + expect(blocks(second[0])).toHaveLength(1) + }) + + test("does not mistake user-authored markup for an injected block", () => { + const stored = user("example", Date.parse("2026-08-24T12:00:00Z")) + const msgs = [stored] + inject(msgs) + + expect(blocks(stored)).toHaveLength(1) + expect(stored.parts.filter((part) => part.type === "text")).toHaveLength(2) + }) +}) diff --git a/packages/opencode/test/kilocode/indexing-feature.test.ts b/packages/opencode/test/kilocode/indexing-feature.test.ts index 1cfde17a0b..c997547b3b 100644 --- a/packages/opencode/test/kilocode/indexing-feature.test.ts +++ b/packages/opencode/test/kilocode/indexing-feature.test.ts @@ -3,7 +3,6 @@ import { ensureIndexingPlugin, indexingEnabled, INDEXING_PLUGIN, - resolveIndexingPlugin, } from "../../src/kilocode/indexing-feature" describe("indexing plugin helpers", () => { @@ -28,13 +27,4 @@ describe("indexing plugin helpers", () => { const list = ensureIndexingPlugin(["global-plugin"], undefined) expect(list).toEqual(["global-plugin"]) }) - - test("falls back to package marker when resolver fails", () => { - const plugin = resolveIndexingPlugin({ - resolve() { - throw new Error("missing") - }, - }) - expect(plugin).toBe(INDEXING_PLUGIN) - }) }) diff --git a/packages/opencode/test/kilocode/reference.test.ts b/packages/opencode/test/kilocode/reference.test.ts index 1a14cafcce..b953ef8f68 100644 --- a/packages/opencode/test/kilocode/reference.test.ts +++ b/packages/opencode/test/kilocode/reference.test.ts @@ -7,7 +7,7 @@ import * as Reference from "../../src/kilocode/reference" import { Reference as CoreReference } from "@opencode-ai/core/reference" import { EventV2 } from "@opencode-ai/core/event" import { Global } from "@opencode-ai/core/global" -import { LocationServiceMap } from "@opencode-ai/core/location-services" +import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services" import { Location } from "@opencode-ai/core/location" import { AbsolutePath } from "@opencode-ai/core/schema" import { Config } from "../../src/config/config" @@ -141,6 +141,7 @@ describe("configured references", () => { }, }) const layer = locations.pipe( + Layer.provide(buildLocationServiceMap()), Layer.provide(AppNodeBuilder.build(Config.node)), Layer.provide(testInstanceStoreLayer), ) diff --git a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts index ebd28941f3..a7f873cfc7 100644 --- a/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts +++ b/packages/opencode/test/kilocode/server/httpapi-exercise-scenarios.ts @@ -586,6 +586,13 @@ export const kiloScenarios: Scenario[] = [ headers: ctx.headers(), })) .status(404), + http.protected + .post("/kilocode/background-jobs/{jobID}/promote", "kilocode.backgroundJob.promote") + .at((ctx) => ({ + path: route("/kilocode/background-jobs/{jobID}/promote", { jobID: "job_httpapi_missing" }), + headers: ctx.headers(), + })) + .status(404), http.protected .post("/kilocode/heap/snapshot", "kilocode.heap.snapshot") .mutating() diff --git a/packages/opencode/test/kilocode/sessions/ingest-shutdown-lifecycle.test.ts b/packages/opencode/test/kilocode/sessions/ingest-shutdown-lifecycle.test.ts new file mode 100644 index 0000000000..818ab62912 --- /dev/null +++ b/packages/opencode/test/kilocode/sessions/ingest-shutdown-lifecycle.test.ts @@ -0,0 +1,59 @@ +import { expect, spyOn, test } from "bun:test" +import { clearInFlightCache } from "../../../src/kilo-sessions/inflight-cache" +import { KiloShutdown } from "../../../src/kilocode/cli/shutdown" + +test("KiloSessions drains queued ingest before instance disposal", async () => { + const token = process.env.KILO_API_KEY + const base = process.env.KILO_SESSION_INGEST_URL + const calls: string[] = [] + let body: unknown + process.env.KILO_API_KEY = "shutdown-token" + process.env.KILO_SESSION_INGEST_URL = "https://ingest.test" + clearInFlightCache("kilo-sessions:token") + clearInFlightCache("kilo-sessions:client") + clearInFlightCache("kilo-sessions:token-valid:shutdown-token") + await KiloShutdown.run() + + const request = spyOn(globalThis, "fetch").mockImplementation( + Object.assign( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith("/api/user")) return new Response("{}", { status: 200 }) + if (url.endsWith("/api/session")) { + return Response.json({ id: "remote-shutdown", ingestPath: "/api/ingest/shutdown" }) + } + if (url.endsWith("/api/ingest/shutdown?v=2")) { + body = init?.body ? JSON.parse(String(init.body)) : undefined + calls.push("ingest") + return new Response("{}", { status: 200 }) + } + throw new Error(`Unexpected request: ${url}`) + }, + { preconnect: globalThis.fetch.preconnect }, + ), + ) + + try { + const url = new URL("../../../src/kilo-sessions/kilo-sessions.ts", import.meta.url) + url.searchParams.set("test", crypto.randomUUID()) + const { KiloSessions } = await import(url.href) + await KiloSessions.bootstrap("session-shutdown") + expect(await KiloSessions._queueIngestForTest("session-shutdown")).toBe(true) + + await KiloShutdown.run() + calls.push("dispose") + + expect(calls).toEqual(["ingest", "dispose"]) + expect(body).toEqual({ data: [{ type: "session_status", data: { status: "idle" } }] }) + } finally { + request.mockRestore() + if (token === undefined) delete process.env.KILO_API_KEY + else process.env.KILO_API_KEY = token + if (base === undefined) delete process.env.KILO_SESSION_INGEST_URL + else process.env.KILO_SESSION_INGEST_URL = base + clearInFlightCache("kilo-sessions:token") + clearInFlightCache("kilo-sessions:client") + clearInFlightCache("kilo-sessions:token-valid:shutdown-token") + await KiloShutdown.run() + } +}, 30_000) diff --git a/packages/opencode/test/kilocode/shared-location-map-key.test.ts b/packages/opencode/test/kilocode/shared-location-map-key.test.ts new file mode 100644 index 0000000000..3fa931e779 --- /dev/null +++ b/packages/opencode/test/kilocode/shared-location-map-key.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test" +import { Equal, Hash } from "effect" +import { readFileSync } from "node:fs" +import { Location } from "@opencode-ai/core/location" +import { AbsolutePath } from "@opencode-ai/core/schema" + +const opencode = new URL("../../src/", import.meta.url) +const server = new URL("../../../server/src/", import.meta.url) + +function source(root: URL, path: string) { + return readFileSync(new URL(path, root), "utf8") +} + +describe("shared location service map keys", () => { + test("all location route consumers include workspaceID in their cache key", () => { + const consumers = [ + [opencode, "server/routes/instance/httpapi/handlers/file.ts"], + [opencode, "server/routes/instance/httpapi/handlers/pty.ts"], + [server, "middleware/session-location.ts"], + ] as const + + for (const [root, path] of consumers) { + expect(source(root, path), path).toMatch(/Location\.Ref\.make\(\{[^}]*workspaceID/) + } + }) + + test("omitted and explicit undefined workspace IDs are distinct keys", () => { + const directory = AbsolutePath.make("/workspace") + const omitted = Location.Ref.make({ directory }) + const explicit = Location.Ref.make({ directory, workspaceID: undefined }) + + expect(Object.hasOwn(omitted, "workspaceID")).toBe(false) + expect(Object.hasOwn(explicit, "workspaceID")).toBe(true) + expect(Equal.equals(omitted, explicit)).toBe(false) + expect(Hash.hash(omitted)).not.toBe(Hash.hash(explicit)) + }) +}) diff --git a/packages/opencode/test/kilocode/shared-location-map.test.ts b/packages/opencode/test/kilocode/shared-location-map.test.ts new file mode 100644 index 0000000000..84e363d2a1 --- /dev/null +++ b/packages/opencode/test/kilocode/shared-location-map.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test" +import { readFileSync } from "node:fs" + +const root = new URL("../../src/", import.meta.url) + +function source(path: string) { + return readFileSync(new URL(path, root), "utf8") +} + +describe("shared location service map", () => { + test("server consumers do not build private location maps", () => { + const files = [ + "server/routes/instance/httpapi/handlers/file.ts", + "server/routes/instance/httpapi/handlers/pty.ts", + "kilocode/server/reference-reconciler.ts", + ] + + for (const file of files) { + expect(source(file), file).not.toContain("locationServiceMapLayer") + } + }) + + test("listener owns its location map scope", () => { + expect(source("server/routes/instance/httpapi/server.ts")).toContain( + "const locationServiceMapV2 = buildLocationServiceMap()", + ) + expect(source("server/routes/instance/httpapi/server.ts")).not.toContain( + "AppNodeBuilderV1.build(app, [[LocationServiceMap.node, locationServiceMapV2]])", + ) + expect(source("effect/app-runtime.ts")).not.toContain("LocationServiceMap.node") + }) +}) diff --git a/packages/opencode/test/kilocode/storage/json-migration.test.ts b/packages/opencode/test/kilocode/storage/json-migration.test.ts index 6b118a9a4c..e7e186623a 100644 --- a/packages/opencode/test/kilocode/storage/json-migration.test.ts +++ b/packages/opencode/test/kilocode/storage/json-migration.test.ts @@ -97,6 +97,22 @@ async function createTestDb() { return [sqlite, db, filename] as const } +test("bootstrap leaves fresh installs for the normal database initializer", async () => { + const marker = path.join(Global.Path.data, "json-migration-fresh.db") + const storage = path.join(Global.Path.data, "storage") + const previous = Flag.KILO_DB + Flag.KILO_DB = marker + await fs.rm(storage, { recursive: true, force: true }) + try { + await JsonMigration.bootstrap() + expect(await Bun.file(marker).exists()).toBe(false) + expect(await Bun.file(marker + ".json-migration").exists()).toBe(false) + } finally { + Flag.KILO_DB = previous + await Promise.all([marker, `${marker}-wal`, `${marker}-shm`].map(cleanup)) + } +}) + describe("JSON to SQLite migration", () => { let storageDir: string let sqlite: Database diff --git a/packages/opencode/test/kilocode/system-prompt.test.ts b/packages/opencode/test/kilocode/system-prompt.test.ts index 10cea1161d..81db8e3f98 100644 --- a/packages/opencode/test/kilocode/system-prompt.test.ts +++ b/packages/opencode/test/kilocode/system-prompt.test.ts @@ -141,4 +141,10 @@ describe("environmentDetails", () => { expect(result).toContain("Workspace root folder: /repo/.kilo/worktrees/feature") expect(result).toContain("Active file: src/app.ts") }) + + test("formats the supplied message time", () => { + const result = environmentDetails({}, new Date("2026-08-24T12:34:56.123Z")) + + expect(result).toContain("Message time: 2026-08-24T12:34:56Z") + }) }) diff --git a/packages/opencode/test/server/session-actions.test.ts b/packages/opencode/test/server/session-actions.test.ts index ee318274b3..306c3cf5c3 100644 --- a/packages/opencode/test/server/session-actions.test.ts +++ b/packages/opencode/test/server/session-actions.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, mock } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" -import { Effect, Fiber, Layer } from "effect" // kilocode_change +import { ConfigProvider, Effect, Fiber, Layer } from "effect" // kilocode_change import { BackgroundJob } from "@/background/job" // kilocode_change import { Session as SessionNs } from "@/session/session" import { disposeAllInstances, TestInstance } from "../fixture/fixture" @@ -9,7 +9,23 @@ import { httpApiLayer, requestInDirectory } from "./httpapi-layer" // kilocode_change start - provide the background-job service for promotion coverage const it = testEffect( - Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(BackgroundJob.node), httpApiLayer), // kilocode_change + Layer.mergeAll( + LayerNode.compile(SessionNs.node), + LayerNode.compile(BackgroundJob.node), + httpApiLayer, + ), // kilocode_change +) +const disabled = testEffect( + Layer.mergeAll(LayerNode.compile(SessionNs.node), LayerNode.compile(BackgroundJob.node), httpApiLayer).pipe( + Layer.provide( + ConfigProvider.layer( + ConfigProvider.fromUnknown({ + KILO_EXPERIMENTAL_BACKGROUND_SUBAGENTS: "false", + KILO_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + }), + ), + ), + ), ) // kilocode_change end @@ -127,6 +143,26 @@ describe("session action routes", () => { { git: true }, ) + disabled.instance( + "background job promotion is disabled when the flag is off", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const jobs = yield* BackgroundJob.Service + const job = yield* jobs.start({ type: "task", metadata: { parentSessionId: "ses_parent" }, run: Effect.never }) + + const res = yield* requestInDirectory(`/kilocode/background-jobs/${job.id}/promote`, test.directory, { + method: "POST", + }) + + expect(res.status).toBe(200) + expect(yield* res.json).toBe(false) + expect((yield* jobs.get(job.id))?.metadata?.background).toBeUndefined() + yield* jobs.cancel(job.id) + }), + { git: true }, + ) + // kilocode_change start - verify HTTP promotion of a running task it.instance( "experimental background route backgrounds a synchronous subagent", diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 3fd0f74781..7772086433 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -183,6 +183,8 @@ import type { KilocodeAgentManagerReplyResponses, KilocodeBackgroundJobCancelErrors, KilocodeBackgroundJobCancelResponses, + KilocodeBackgroundJobPromoteErrors, + KilocodeBackgroundJobPromoteResponses, KilocodeBackgroundJobsErrors, KilocodeBackgroundJobsResponses, KilocodeCommandFilesErrors, @@ -7886,6 +7888,42 @@ export class BackgroundJob extends HeyApiClient { ...params, }) } + + /** + * Promote background job + * + * Continue one foreground subagent in the background. + */ + public promote( + parameters: { + jobID: string + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "jobID" }, + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post< + KilocodeBackgroundJobPromoteResponses, + KilocodeBackgroundJobPromoteErrors, + ThrowOnError + >({ + url: "/kilocode/background-jobs/{jobID}/promote", + ...options, + ...params, + }) + } } export class SessionImport extends HeyApiClient { diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index fa6cd4046a..d3db868432 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -17172,6 +17172,42 @@ export type KilocodeBackgroundJobCancelResponses = { export type KilocodeBackgroundJobCancelResponse = KilocodeBackgroundJobCancelResponses[keyof KilocodeBackgroundJobCancelResponses] +export type KilocodeBackgroundJobPromoteData = { + body?: never + path: { + jobID: string + } + query?: { + directory?: string + workspace?: string + } + url: "/kilocode/background-jobs/{jobID}/promote" +} + +export type KilocodeBackgroundJobPromoteErrors = { + /** + * Bad request + */ + 400: BadRequestError + /** + * Not found + */ + 404: NotFoundError +} + +export type KilocodeBackgroundJobPromoteError = + KilocodeBackgroundJobPromoteErrors[keyof KilocodeBackgroundJobPromoteErrors] + +export type KilocodeBackgroundJobPromoteResponses = { + /** + * Background job promoted + */ + 200: boolean +} + +export type KilocodeBackgroundJobPromoteResponse = + KilocodeBackgroundJobPromoteResponses[keyof KilocodeBackgroundJobPromoteResponses] + export type AnacondaDesktopStatusData = { body?: never path?: never diff --git a/patches/@ff-labs%2Ffff-bun@0.9.4.patch b/patches/@ff-labs%2Ffff-bun@0.9.4.patch index 0216f860ca..40d4cd8151 100644 --- a/patches/@ff-labs%2Ffff-bun@0.9.4.patch +++ b/patches/@ff-labs%2Ffff-bun@0.9.4.patch @@ -29,3 +29,109 @@ index 3454256..6dca25a 100644 if (existsSync(binaryPath)) { return binaryPath; +diff --git a/src/ffi.ts b/src/ffi.ts +--- a/src/ffi.ts ++++ b/src/ffi.ts +@@ -328,6 +328,10 @@ + return new TextEncoder().encode(`${s}\0`); + } +- ++ ++function asResult(value: Pointer | bigint | null): Pointer | null { ++ return value as unknown as Pointer | null; ++} ++ + /** + * Convert snake_case keys to camelCase recursively + */ +@@ -1121,4 +1126,4 @@ + minComboCount, + ); +- return parseSearchResult(resultPtr); ++ return parseSearchResult(asResult(resultPtr)); + } +@@ -1145,4 +1150,4 @@ + pageSize, + ); +- return parseSearchResult(resultPtr); ++ return parseSearchResult(asResult(resultPtr)); + } +@@ -1168,4 +1173,4 @@ + pageSize, + ); +- return parseDirSearchResult(resultPtr); ++ return parseDirSearchResult(asResult(resultPtr)); + } +@@ -1195,4 +1200,4 @@ + minComboCount, + ); +- return parseMixedSearchResult(resultPtr); ++ return parseMixedSearchResult(asResult(resultPtr)); + } +@@ -1230,4 +1235,4 @@ + classifyDefinitions, + ); +- return parseGrepResult(resultPtr); ++ return parseGrepResult(asResult(resultPtr)); + } +@@ -1265,4 +1270,4 @@ + classifyDefinitions, + ); +- return parseGrepResult(resultPtr); ++ return parseGrepResult(asResult(resultPtr)); + } +@@ -1274,4 +1279,4 @@ + const library = loadLibrary(); + const resultPtr = library.symbols.fff_scan_files(handle); +- return parseVoidResult(resultPtr); ++ return parseVoidResult(asResult(resultPtr)); + } +@@ -1291,4 +1296,4 @@ + const library = loadLibrary(); + const resultPtr = library.symbols.fff_get_base_path(handle); +- return parseStringResult(resultPtr); ++ return parseStringResult(asResult(resultPtr)); + } +@@ -1336,4 +1341,4 @@ + const library = loadLibrary(); + const resultPtr = library.symbols.fff_wait_for_scan(handle, BigInt(timeoutMs)); +- return parseBoolResult(resultPtr); ++ return parseBoolResult(asResult(resultPtr)); + } +@@ -1351,4 +1356,4 @@ + const library = loadLibrary(); + const resultPtr = library.symbols.fff_restart_index(handle, ptr(encodeString(newPath))); +- return parseVoidResult(resultPtr); ++ return parseVoidResult(asResult(resultPtr)); + } +@@ -1360,4 +1365,4 @@ + const library = loadLibrary(); + const resultPtr = library.symbols.fff_refresh_git_status(handle); +- return parseIntResult(resultPtr); ++ return parseIntResult(asResult(resultPtr)); + } +@@ -1377,4 +1382,4 @@ + ptr(encodeString(filePath)), + ); +- return parseBoolResult(resultPtr); ++ return parseBoolResult(asResult(resultPtr)); + } +@@ -1392,4 +1397,4 @@ + const library = loadLibrary(); + const resultPtr = library.symbols.fff_get_historical_query(handle, BigInt(offset)); +- return parseStringResult(resultPtr); ++ return parseStringResult(asResult(resultPtr)); + } +@@ -1306,3 +1311,3 @@ + const library = loadLibrary(); + const resultPtr = library.symbols.fff_get_scan_progress(handle); +- const envelope = readResultEnvelope(resultPtr); ++ const envelope = readResultEnvelope(asResult(resultPtr)); +@@ -1406,6 +1411,6 @@ + const library = loadLibrary(); + const resultPtr = library.symbols.fff_health_check( + handle ?? (0 as unknown as Pointer), + ptr(encodeString(testPath)), + ); +- return parseJsonResult(resultPtr); ++ return parseJsonResult(asResult(resultPtr)); diff --git a/script/upstream/package.json b/script/upstream/package.json index 1705aea7ae..329c079a6e 100644 --- a/script/upstream/package.json +++ b/script/upstream/package.json @@ -22,7 +22,7 @@ "ts-morph": "^24.0.0" }, "devDependencies": { - "@types/bun": "1.3.14" + "@types/bun": "1.4.0" }, "peerDependencies": {} }