Merge remote-tracking branch 'origin/main' into gentle-badger

# Conflicts:
#	packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt
This commit is contained in:
kirillk
2026-08-25 15:50:33 -04:00
235 changed files with 4781 additions and 1553 deletions
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Fix project-scoped Agent Manager history activation and session placement.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Prevent overlapping Agent Manager history activations from leaving stale project-switch state.
@@ -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.
@@ -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
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Reduce duplicate event processing across VS Code when multiple sessions run concurrently.
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Load Agent Manager worktree diffs faster and keep warmed reviews visible when switching worktrees.
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Fix CLI help disposal and shell completion after startup optimization.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Prevent duplicate-event tracking from suppressing delayed sync events after reconnects or high event bursts.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Allow each foreground subagent to continue in the background independently, including when several subagents run in parallel.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Prevent duplicate reasoning and response text while subagent sessions are open.
+5
View File
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Improve CLI cold and warm startup time.
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Stop manually aborted turns without briefly showing an interruption warning.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Remove the model reset button from the chat and worktree prompt controls.
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Preserve the Agent Manager sidebar scroll position when worktrees are deleted.
@@ -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.
@@ -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.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep file route location services on the same cache key as workspace-aware server routes.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Use a browser-valid close code when Agent Manager terminal replay exceeds its buffer limit
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Build the CLI with Bun 1.4 to reduce compiled binary size and build time.
+2 -2
View File
@@ -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
+12 -12
View File
@@ -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=="],
+1 -1
View File
@@ -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"
+4 -4
View File
@@ -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 =
+4 -4
View File
@@ -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="
}
}
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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)
@@ -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:
@@ -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
@@ -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:**
@@ -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.
@@ -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" %}
@@ -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.
@@ -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`
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:00088eaafbe9b65c5baf0eb1ce185a95da3e8492bf3c77a940f20d329ff93046
size 39409
oid sha256:490320ae56acbbc5ea7283d72fb8dd4b93a28ce3f1ec9678ce8ff3ea1bab953e
size 34649
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:bbe086983757d02e90c26f3798a6a8e3883ac57e009bebdde0852836684c7595
size 32553
oid sha256:208d1aed2d0a3a3b5397d1a11155871f6bd8c01ee8772b81fb45875c36d43a17
size 32340
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:d866d08bc0d18ee2abc4c2bd246f2a958107ca1cb1771cb61e37aa2597504ed8
size 28266
oid sha256:e50f52681c7c255f4f65bea146dd4adbbb8a5d3a0027777406e42572c58378f4
size 29904
@@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c2c8b3e4ded1bde02fe2c72764d16c4e056a91e9fa935c9925b8095bfaf9a748
size 27272
oid sha256:7403709acf8593873394ba15f23a50b56b2008e7d99440df503089a3f21b2fe6
size 30279
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f347005b2fb066a836b865b236d4ca27e5a8a09dc80c7df6683520f000bdf4c3
size 5280
@@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ecc3761d4315beb12e69af19d391ecdede2e6d71586ee3a7bd6f46c32013c4c1
size 5546
@@ -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,
@@ -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 `<base>/.kilo/worktrees/<slug>` 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<KiloBackendAppService>().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<String>): 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 <T, R> parallel(items: List<T>, block: suspend (T) -> R): List<R> = 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<WorktreeDto> {
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<WorktreeDto> {
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<WorktreeDto>): List<WorktreeDto> {
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
}
}
@@ -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())
@@ -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()
}
}
@@ -20,6 +20,7 @@ sealed class KiloWorkspaceState {
val skills: List<SkillInfo>,
) : KiloWorkspaceState()
data class Unsupported(val reason: String) : KiloWorkspaceState()
data class Missing(val path: String) : KiloWorkspaceState()
data class Error(val message: String, val errors: List<LoadError> = emptyList()) : KiloWorkspaceState()
}
@@ -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 ->
@@ -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<KiloBackendAppService>()
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<IllegalStateException> {
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) }
}
}
}
@@ -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),
@@ -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.
@@ -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)
@@ -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<RemoveWorktreeResultDto>()
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
@@ -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()
@@ -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()
@@ -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<SessionControllerEvent.ConnectionChanged.ShowError>().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()
@@ -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<ai.kilocode.rpc.dto.ProviderActionResultDto>()
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<JBLabel>().single { it.name == "kilo.provider.oauth.qr" }
assertNotNull(qr.icon)
}
@@ -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<String>()
val files = mutableListOf<Path>()
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
}
@@ -8,6 +8,7 @@ enum class KiloWorkspaceStatusDto {
LOADING,
READY,
UNSUPPORTED,
MISSING,
ERROR,
}
@@ -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
+4
View File
@@ -70,6 +70,10 @@ const icons: Record<string, { path: string; viewBox: string }> = {
viewBox: "0 0 24 24",
path: `<path d="M12 14L9 10M12 14L15 10M21 15C21 18.866 17.866 22 14 22H10C6.134 22 3 18.866 3 15V9C3 5.134 6.134 2 10 2H14C17.866 2 21 5.134 21 9V15Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>`,
},
local: {
viewBox: "0 0 20 20",
path: `<rect x="2.5" y="3.5" width="15" height="10" rx="1" stroke="currentColor"/><path d="M6 16.5H14" stroke="currentColor" stroke-linecap="square"/><path d="M10 13.5V16.5" stroke="currentColor"/>`,
},
}
type Name = keyof typeof icons
@@ -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] {
@@ -163,6 +163,7 @@ export interface MessagePartProps {
working?: boolean
feedback?: MessageFeedbackControls
throughput?: JSX.Element
readonly?: boolean
}
export type PartComponent = Component<MessagePartProps>
@@ -1044,6 +1045,7 @@ export function Part(props: MessagePartProps) {
working={props.working}
feedback={props.feedback}
throughput={props.throughput}
readonly={props.readonly}
/>
</Show>
)
@@ -1068,6 +1070,7 @@ export interface ToolProps {
locked?: boolean
animate?: boolean
reveal?: boolean
readonly?: boolean
}
export type ToolComponent = Component<ToolProps>
@@ -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}
/>
</ToolApprovalProvider>
</Match>
@@ -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()
})
@@ -25,6 +25,7 @@ export function createAutoScroll(options: AutoScrollOptions) {
let settling = false
let settleTimer: ReturnType<typeof setTimeout> | 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,
@@ -276,7 +276,7 @@
<ul>
<li><strong>VSCode</strong> ≥ 1.105.1 (matches kilocode's <code>engines.vscode</code>)</li>
<li>
<strong>Bun</strong> ≥ 1.3.14 (the build script checks the version) &mdash; install via
<strong>Bun</strong> ≥ 1.4.0 (the build script checks the version) &mdash; install via
<code>brew install bun</code> or <a href="https://bun.sh">bun.sh</a>
</li>
<li><strong>GitHub CLI</strong> (<code>gh</code>) &mdash; optional but makes the PR checkout one command</li>
+1 -1
View File
@@ -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",
+18 -10
View File
@@ -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<void> {
private async promoteBackgroundJob(jobID: string, sessionID: string): Promise<void> {
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<void> {
@@ -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" })
}
@@ -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) => {
@@ -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<ExecResult> {
execGit(args: string[], cwd: string, options?: { stdin?: string; signal?: AbortSignal }): Promise<ExecResult> {
return this.exec(args, cwd, options)
}
execGitBuffer(args: string[], cwd: string): Promise<ExecBufferResult> {
return this.execBuffer(args, cwd)
execGitBuffer(args: string[], cwd: string, options?: { signal?: AbortSignal }): Promise<ExecBufferResult> {
return this.execBuffer(args, cwd, options)
}
private async exec(args: string[], cwd: string, options?: ExecOptions): Promise<ExecResult> {
@@ -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<string> {
@@ -642,7 +643,7 @@ export class GitOps {
}
private invoke(cmd: string, args: string[], cwd: string, options?: ExecOptions): Promise<ExecBufferResult> {
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),
@@ -8,7 +8,10 @@ import { recordForkHandoff } from "./fork-handoff"
export interface ContinueContext {
root: string
getClient: () => KiloClient
connection: {
getClient: () => KiloClient
runExplicitAbort: <T>(sessionId: string, directory: string, action: () => Promise<T>) => Promise<T>
}
createWorktreeOnDisk: (opts: { baseBranch: string; baseRef: string }) => Promise<{
worktree: { id: string }
result: CreateWorktreeResult
@@ -30,10 +33,13 @@ export type StepResult<T> = { 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<void> {
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<StepResult<Session>> {
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" }
@@ -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<string, { additions: number; deletions: number; binary: boolean }>()
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<string, { additions: number; deletions: number; binary: boolean }>()
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<string> {
@@ -144,7 +147,22 @@ async function statStamp(dir: string, file: string): Promise<string> {
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<number> {
@@ -166,28 +184,28 @@ function statusFromCode(code: string): Status {
}
async function list(git: GitOps, dir: string, anc: string, log?: Log): Promise<Meta[]> {
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<string>()
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<M
})
}
const untracked = await git.execGit(["ls-files", "--others", "--exclude-standard"], dir)
if (untracked.code !== 0) {
log?.("git ls-files --others failed", { code: untracked.code, stderr: untracked.stderr.trim() })
return result
@@ -265,28 +282,83 @@ export async function diffSummary(git: GitOps, dir: string, base: string, log?:
export function createLocalDiff(git: GitOps, log?: Log) {
const states = new Map<string, { anc: string; metas: Map<string, Meta> }>()
const generations = new Map<string, number>()
const details = new Map<string, { value: WorktreeDiffEntry; bytes: number; stamp: string }>()
const pending = new Map<string, { signal?: AbortSignal; work: Promise<WorktreeDiffEntry> }>()
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<WorktreeDiffEntry[]> => {
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<WorktreeDiffEntry | null> => {
file: async (dir: string, base: string, file: string, signal?: AbortSignal): Promise<WorktreeDiffEntry | null> => {
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<number> {
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<number> {
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<number> {
return stat?.size ?? 0
}
async function readBlob(git: GitOps, dir: string, ref: string, file: string): Promise<Buffer | undefined> {
const result = await git.execGitBuffer(["show", `${ref}:${file}`], dir)
async function readBlob(
git: GitOps,
dir: string,
ref: string,
file: string,
signal?: AbortSignal,
): Promise<Buffer | undefined> {
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<Buffer | undefined>
return readImageFile(full)
}
async function readBefore(git: GitOps, dir: string, anc: string, file: string, status: Status): Promise<string> {
async function readBefore(
git: GitOps,
dir: string,
anc: string,
file: string,
status: Status,
signal?: AbortSignal,
): Promise<string> {
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<string> {
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<string> {
async function unifiedPatch(
git: GitOps,
dir: string,
anc: string,
file: string,
signal?: AbortSignal,
): Promise<string> {
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<WorktreeDiffEntry> {
async function materialize(
git: GitOps,
dir: string,
anc: string,
meta: Meta,
log?: Log,
signal?: AbortSignal,
): Promise<WorktreeDiffEntry> {
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,
@@ -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<ProjectInitResult>
/** 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<void> {
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
@@ -9,6 +9,7 @@ export const STATE_GATED = new Set<string>([
"agentManager.deleteWorktree",
"agentManager.removeStaleWorktree",
"agentManager.openLocally",
"agentManager.openSessionLocally",
"agentManager.addSessionToWorktree",
"agentManager.closeSession",
"agentManager.persistSession",
@@ -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,
@@ -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<T>(fn: () => Promise<T>): Promise<T> {
await this.acquire()
async run<T>(fn: () => Promise<T>, signal?: AbortSignal): Promise<T> {
await this.acquire(signal)
try {
return await fn()
} finally {
@@ -20,22 +20,32 @@ export class Semaphore {
}
}
private acquire(): Promise<void> {
private acquire(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(signal.reason)
if (this.running < this.limit) {
this.running++
return Promise.resolve()
}
return new Promise<void>((resolve) => {
this.pending.push(() => {
this.running++
resolve()
})
return new Promise<void>((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()
}
}
@@ -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
@@ -67,6 +67,7 @@ export class SourceController {
private interval: ReturnType<typeof setInterval> | undefined
private lastHash: string | undefined
private epoch = 0
private readonly fetches = new Map<DiffSource, Promise<boolean>>()
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<void>((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<boolean> {
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
}
}
@@ -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<WorktreeDiffEntry[]>
file: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise<WorktreeDiffEntry | null>
}
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,
})
}
@@ -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<WorktreeDiffEntry[]>
file?: (dir: string, base: string, file: string, signal?: AbortSignal) => Promise<WorktreeDiffEntry | null>
}
/**
@@ -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
},
}
+15 -1
View File
@@ -1,4 +1,5 @@
import { hasIndexingPlugin } from "@kilocode/kilo-indexing/detect"
import type { KiloClient } from "@kilocode/sdk/v2"
type PluginSpec = string | [string, Record<string, unknown>]
@@ -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<KiloClient, "experimental">, 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
}
}
@@ -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) {
@@ -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<KiloClient, "config" | "global">
type Client = Pick<KiloClient, "config" | "global" | "experimental">
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),
}
}
@@ -23,7 +23,7 @@ type Ctx = {
modelUsage: (message: ModelUsageMessage) => Promise<void>
backgroundJobs: (sessionID: string, requestID: string) => Promise<void>
cancelBackgroundJob: (jobID: string, sessionID: string, requestID: string) => Promise<void>
backgroundSubagents: (sessionID: string) => Promise<void>
promoteBackgroundJob: (jobID: string, sessionID: string) => Promise<void>
}
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
@@ -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)
+5 -4
View File
@@ -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)
@@ -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)
@@ -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<SSEEventListener> = new Set()
private readonly filteredListeners = new Set<{ filter: SSEEventFilter; listener: SSEEventListener }>()
private readonly explicitAborts = new ExplicitAbortState()
private readonly stateListeners: Set<StateListener> = new Set()
private readonly notificationDismissListeners: Set<NotificationDismissListener> = new Set()
private readonly languageChangeListeners: Set<LanguageChangeListener> = 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<T>(sessionID: string, directory: string, action: () => Promise<T>): Promise<T> {
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)
@@ -4,6 +4,33 @@ export type { SSEPayload } from "./sdk-sse-adapter"
type SyncPayload = Extract<SSEPayload, { type: "sync" }>
type TransientPayload = Exclude<SSEPayload, SyncPayload>
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<string>()
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
@@ -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<number>; stopped: boolean; buffered: Buffered[]; generation: number; idle: boolean }
export class ExplicitAbortState {
private readonly active = new Set<string>()
private readonly states = new Map<string, State>()
private readonly generations = new Map<string, number>()
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<SSEPayload, { type: "session.status" }>, 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)}`
}
+26 -3
View File
@@ -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", () => {
@@ -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")
@@ -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('<Tooltip value={t("agentManager.session.openInWorktree")}')
expect(source).toContain('t("agentManager.session.openInWorktree")')
expect(source).not.toContain("TooltipKeybind")
})
})
@@ -0,0 +1,165 @@
import { afterEach, describe, expect, it } from "bun:test"
import { Window } from "happy-dom"
import { createSidebarScrollPreserver } from "../../webview-ui/agent-manager/sidebar-scroll"
const window = new Window()
const frames = new Map<number, FrameRequestCallback>()
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)
})
})
@@ -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", () => {
@@ -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<string | Uint8Array> = []
const cbs: Array<() => void> = []
const pending = new Map<number, () => 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", () => {
@@ -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")
@@ -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) })

Some files were not shown because too many files have changed in this diff Show More