mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 11:05:31 +08:00
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:
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Fix CLI help disposal and shell completion after startup optimization.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Prevent duplicate reasoning and response text while subagent sessions are open.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Stop manually aborted turns without briefly showing an interruption warning.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep file route location services on the same cache key as workspace-aware server routes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Use a browser-valid close code when Agent Manager terminal replay exceeds its buffer limit
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Build the CLI with Bun 1.4 to reduce compiled binary size and build time.
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:00088eaafbe9b65c5baf0eb1ce185a95da3e8492bf3c77a940f20d329ff93046
|
||||
size 39409
|
||||
oid sha256:490320ae56acbbc5ea7283d72fb8dd4b93a28ce3f1ec9678ce8ff3ea1bab953e
|
||||
size 34649
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bbe086983757d02e90c26f3798a6a8e3883ac57e009bebdde0852836684c7595
|
||||
size 32553
|
||||
oid sha256:208d1aed2d0a3a3b5397d1a11155871f6bd8c01ee8772b81fb45875c36d43a17
|
||||
size 32340
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d866d08bc0d18ee2abc4c2bd246f2a958107ca1cb1771cb61e37aa2597504ed8
|
||||
size 28266
|
||||
oid sha256:e50f52681c7c255f4f65bea146dd4adbbb8a5d3a0027777406e42572c58378f4
|
||||
size 29904
|
||||
|
||||
+2
-2
@@ -1,3 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c2c8b3e4ded1bde02fe2c72764d16c4e056a91e9fa935c9925b8095bfaf9a748
|
||||
size 27272
|
||||
oid sha256:7403709acf8593873394ba15f23a50b56b2008e7d99440df503089a3f21b2fe6
|
||||
size 30279
|
||||
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f347005b2fb066a836b865b236d4ca27e5a8a09dc80c7df6683520f000bdf4c3
|
||||
size 5280
|
||||
-3
@@ -1,3 +0,0 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:ecc3761d4315beb12e69af19d391ecdede2e6d71586ee3a7bd6f46c32013c4c1
|
||||
size 5546
|
||||
+4
@@ -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,
|
||||
|
||||
+75
-12
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
@@ -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())
|
||||
|
||||
|
||||
+22
-2
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
+174
@@ -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 ->
|
||||
|
||||
+53
-16
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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.
|
||||
|
||||
+2
@@ -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)
|
||||
|
||||
+19
@@ -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
|
||||
|
||||
+2
@@ -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()
|
||||
|
||||
+2
@@ -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()
|
||||
|
||||
+24
@@ -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()
|
||||
|
||||
+3
@@ -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)
|
||||
}
|
||||
|
||||
+37
@@ -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
|
||||
}
|
||||
+1
@@ -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
|
||||
|
||||
@@ -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) — install via
|
||||
<strong>Bun</strong> ≥ 1.4.0 (the build script checks the version) — 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>) — optional but makes the PR checkout one command</li>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user