diff --git a/.changeset/background-worktree-activity.md b/.changeset/background-worktree-activity.md new file mode 100644 index 0000000000..d754d47f7e --- /dev/null +++ b/.changeset/background-worktree-activity.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep Agent Manager worktree spinners active while background agents are running. diff --git a/.changeset/calm-prompt-navigator.md b/.changeset/calm-prompt-navigator.md new file mode 100644 index 0000000000..0bcefe67f2 --- /dev/null +++ b/.changeset/calm-prompt-navigator.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Place the prompt navigator on the outer sidebar edge and delay hover previews to avoid accidental popups. Keep the navigator on the right in Agent Manager and editor tabs. diff --git a/.changeset/clear-empty-failed-turn.md b/.changeset/clear-empty-failed-turn.md new file mode 100644 index 0000000000..ed3daa6b4b --- /dev/null +++ b/.changeset/clear-empty-failed-turn.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Clear a failed turn that produced no output from the conversation when the next message is sent, so an "An error occurred" placeholder no longer lingers in history. A turn that wrote text or ran a tool before failing is kept, since its record explains changes already made. diff --git a/.changeset/dompurify-security-update.md b/.changeset/dompurify-security-update.md deleted file mode 100644 index a0a1a6ec6b..0000000000 --- a/.changeset/dompurify-security-update.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Update DOMPurify to 3.4.13 to fix XSS vulnerabilities flagged by Dependabot in markdown sanitization. diff --git a/.changeset/fix-streaming-scroll-intent.md b/.changeset/fix-streaming-scroll-intent.md new file mode 100644 index 0000000000..82b6d09b94 --- /dev/null +++ b/.changeset/fix-streaming-scroll-intent.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Keep chat scroll position during streaming, including small upward gestures, direction changes, and scrolling over message controls. diff --git a/.changeset/fix-vscode-server-startup-output.md b/.changeset/fix-vscode-server-startup-output.md new file mode 100644 index 0000000000..e9d62c0255 --- /dev/null +++ b/.changeset/fix-vscode-server-startup-output.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Prevent intermittent server connection failures during VS Code startup. diff --git a/.changeset/fix-windows-worktree-git.md b/.changeset/fix-windows-worktree-git.md new file mode 100644 index 0000000000..887097f6c3 --- /dev/null +++ b/.changeset/fix-windows-worktree-git.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Use the Git executable configured in VS Code when creating worktrees on Windows. diff --git a/.changeset/hey-api-security-update.md b/.changeset/hey-api-security-update.md new file mode 100644 index 0000000000..c0c1bfdc8c --- /dev/null +++ b/.changeset/hey-api-security-update.md @@ -0,0 +1,5 @@ +--- +"@kilocode/sdk": patch +--- + +Fix a prototype pollution vulnerability in the generated SDK client request parameters (CVE-2026-48819). diff --git a/.changeset/jetbrains-deleted-session-badge.md b/.changeset/jetbrains-deleted-session-badge.md deleted file mode 100644 index 3697fecf5d..0000000000 --- a/.changeset/jetbrains-deleted-session-badge.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/kilo-jetbrains": patch ---- - -Stop showing a running badge for a session that was just deleted. diff --git a/.changeset/jetbrains-stopped-session-not-an-error.md b/.changeset/jetbrains-stopped-session-not-an-error.md new file mode 100644 index 0000000000..f4fc55352a --- /dev/null +++ b/.changeset/jetbrains-stopped-session-not-an-error.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": minor +--- + +Stop treating a manually stopped session as a failure, and add a Retry action to failed turns. Pressing Stop now shows a short "Stopped" note instead of an error badge and attention dot. A failed turn keeps the error badge and card and can be retried in place, using the model and effort selected at that moment — so switching away from an unavailable provider and pressing Retry continues the conversation. This includes failures that never produced a reply, such as missing provider credentials. diff --git a/.changeset/jetbrains-worktree-pr-detection.md b/.changeset/jetbrains-worktree-pr-detection.md new file mode 100644 index 0000000000..b1bfeef433 --- /dev/null +++ b/.changeset/jetbrains-worktree-pr-detection.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Detect a worktree's pull request reliably in Agent Manager. Imported PRs — including PRs from forks — hand-made worktrees, and locally renamed branches now show their PR badge, the current repository row gets one too, and a freshly imported PR no longer waits out the status poll. Imported PR branches also get proper git tracking, so `git push` and `git pull` work in the new worktree. diff --git a/.changeset/jetbrains-worktree-tab-artifacts.md b/.changeset/jetbrains-worktree-tab-artifacts.md new file mode 100644 index 0000000000..15a3d02cae --- /dev/null +++ b/.changeset/jetbrains-worktree-tab-artifacts.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-jetbrains": patch +--- + +Stop the New Worktree dialog from flashing the previous tab's content when switching between New, From PR, and From Branch. diff --git a/.changeset/mermaid-security-update.md b/.changeset/mermaid-security-update.md deleted file mode 100644 index a3ee070775..0000000000 --- a/.changeset/mermaid-security-update.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@opencode-ai/ui": patch -"@kilocode/kilo-docs": patch ---- - -Update Mermaid to 11.17.2 to resolve Dependabot security advisories. diff --git a/.changeset/minimatch-security-fix.md b/.changeset/minimatch-security-fix.md new file mode 100644 index 0000000000..debf6482ae --- /dev/null +++ b/.changeset/minimatch-security-fix.md @@ -0,0 +1,7 @@ +--- +"@kilocode/cli": patch +"@opencode-ai/core": patch +"@kilocode/kilo-indexing": patch +--- + +Fix ReDoS vulnerabilities in glob matching by upgrading minimatch to 10.2.6. diff --git a/.changeset/nanoid-security-update.md b/.changeset/nanoid-security-update.md new file mode 100644 index 0000000000..85690333e8 --- /dev/null +++ b/.changeset/nanoid-security-update.md @@ -0,0 +1,5 @@ +--- +"@kilocode/kilo-docs": patch +--- + +Update nanoid to 3.3.18 to fix the infinite loop vulnerability flagged by Dependabot. diff --git a/.changeset/quiet-agent-manager-stats.md b/.changeset/quiet-agent-manager-stats.md deleted file mode 100644 index edf9050895..0000000000 --- a/.changeset/quiet-agent-manager-stats.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Reduce Agent Manager background Git and GitHub activity without adding file watchers. diff --git a/.changeset/restore-cli-terminal-startup.md b/.changeset/restore-cli-terminal-startup.md deleted file mode 100644 index ef0132aeda..0000000000 --- a/.changeset/restore-cli-terminal-startup.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@kilocode/cli": patch ---- - -Prevent the CLI from opening to a blank terminal on startup. diff --git a/.changeset/restore-promoted-worktree-title.md b/.changeset/restore-promoted-worktree-title.md deleted file mode 100644 index 377bc212b9..0000000000 --- a/.changeset/restore-promoted-worktree-title.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"kilo-code": patch ---- - -Restore the session title and branch subtitle immediately when opening an existing session in a worktree. diff --git a/.changeset/review-agent-manager-worktrees.md b/.changeset/review-agent-manager-worktrees.md new file mode 100644 index 0000000000..40aaa74396 --- /dev/null +++ b/.changeset/review-agent-manager-worktrees.md @@ -0,0 +1,6 @@ +--- +"@kilocode/cli": minor +"kilo-code": minor +--- + +Review all committed and uncommitted Agent Manager worktree changes with `/review worktree`. diff --git a/.changeset/steady-agent-manager-ownership.md b/.changeset/steady-agent-manager-ownership.md new file mode 100644 index 0000000000..2f7cb3be4c --- /dev/null +++ b/.changeset/steady-agent-manager-ownership.md @@ -0,0 +1,5 @@ +--- +"kilo-code": patch +--- + +Recognize sessions discovered in managed Agent Manager worktrees during orchestration actions. diff --git a/.changeset/task-tool-empty-result.md b/.changeset/task-tool-empty-result.md new file mode 100644 index 0000000000..2ec2ed66ab --- /dev/null +++ b/.changeset/task-tool-empty-result.md @@ -0,0 +1,5 @@ +--- +"@kilocode/cli": patch +--- + +Fix the task tool intermittently returning an empty result. Subagents that ran with memory context had a synthetic marker part appended after their answer, which was picked up as the final text part and surfaced as an empty `` to the parent agent. The task tool now ignores synthetic, ignored, and empty text parts, and background jobs no longer let an empty run overwrite an earlier successful result, so resumed tasks keep their real output. diff --git a/AGENTS.md b/AGENTS.md index e9299c8c04..45963d7b0a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,6 +87,7 @@ Examples: `fix(tui): simplify thinking toggle styling`, `docs: update contributi - Keep things in one function unless composable or reusable - Avoid unnecessary destructuring. Instead of `const { a, b } = obj`, use `obj.a` and `obj.b` to preserve context +- Avoid possibly out-of-bounds array access. Instead of `array[index] ?? {}`, use `array.at(index) ?? {}`. Instead of `array[array.length - 1]`, use `array.at(-1)` - Avoid `try`/`catch` where possible - Avoid using the `any` type - Prefer single word variable names where possible diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 895870a494..69612910fe 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -16,7 +16,7 @@ The Kilo Community is [on Discord](https://kilo.ai/discord). ## Prerequisites -- **Bun 1.4.0+** — required for all packages. +- **Bun 1.3.14+** — 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.4.0+, Java 21 (see [Prerequisites](#prerequisites) above) +- **Requirements:** Bun 1.3.14+, Java 21 (see [Prerequisites](#prerequisites) above) - Install dependencies and start the CLI from the repo root: ```bash diff --git a/artifacts/glm52-rise-video/package.json b/artifacts/glm52-rise-video/package.json index 912704120a..3e8a74b358 100644 --- a/artifacts/glm52-rise-video/package.json +++ b/artifacts/glm52-rise-video/package.json @@ -21,5 +21,5 @@ "@types/react-dom": "^19.2.3", "typescript": "^5.8.2" }, - "version": "7.5.0" + "version": "7.5.5" } diff --git a/bun.lock b/bun.lock index 0eaf7e2217..37976cad94 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ }, "packages/client": { "name": "@opencode-ai/client", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@opencode-ai/protocol": "workspace:*", "@opencode-ai/schema": "workspace:*", @@ -56,7 +56,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -70,7 +70,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "7.5.0", + "version": "7.5.5", "bin": { "opencode": "./bin/opencode", }, @@ -134,7 +134,7 @@ "immer": "11.1.4", "jsonc-parser": "3.3.1", "mime-types": "3.0.2", - "minimatch": "10.2.5", + "minimatch": "10.2.6", "npm-package-arg": "13.0.2", "rotating-file-stream": "3.2.9", "semver": "^7.6.3", @@ -168,7 +168,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -182,7 +182,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "effect": "catalog:", }, @@ -194,7 +194,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -215,7 +215,7 @@ }, "packages/httpapi-codegen": { "name": "@opencode-ai/httpapi-codegen", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "effect": "catalog:", "prettier": "3.6.2", @@ -228,7 +228,7 @@ }, "packages/kilo-console": { "name": "@kilocode/kilo-console", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@kilocode/kilo-indexing": "workspace:*", "@kilocode/kilo-web-ui": "workspace:*", @@ -251,7 +251,7 @@ }, "packages/kilo-docs": { "name": "@kilocode/kilo-docs", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@docsearch/css": "^4", "@docsearch/js": "^4", @@ -281,7 +281,7 @@ }, "packages/kilo-gateway": { "name": "@kilocode/kilo-gateway", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.88", @@ -315,7 +315,7 @@ }, "packages/kilo-i18n": { "name": "@kilocode/kilo-i18n", - "version": "7.5.0", + "version": "7.5.5", "devDependencies": { "@tsconfig/node22": "catalog:", "@types/bun": "catalog:", @@ -325,7 +325,7 @@ }, "packages/kilo-indexing": { "name": "@kilocode/kilo-indexing", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@aws-sdk/client-bedrock-runtime": "3.1005.0", "@aws-sdk/credential-provider-ini": "3.972.31", @@ -340,7 +340,7 @@ "hono": "catalog:", "hono-openapi": "catalog:", "ignore": "7.0.5", - "minimatch": "10.2.5", + "minimatch": "10.2.6", "openai": "6.27.0", "p-limit": "7.3.0", "tree-sitter-wasms": "0.1.13", @@ -361,7 +361,7 @@ }, "packages/kilo-memory": { "name": "@kilocode/kilo-memory", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "effect": "catalog:", "zod": "catalog:", @@ -375,7 +375,7 @@ }, "packages/kilo-sandbox": { "name": "@kilocode/sandbox", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@anthropic-ai/sandbox-runtime": "catalog:", "effect": "catalog:", @@ -390,7 +390,7 @@ }, "packages/kilo-telemetry": { "name": "@kilocode/kilo-telemetry", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@kilocode/kilo-gateway": "workspace:*", "posthog-node": "4.4.0", @@ -404,7 +404,7 @@ }, "packages/kilo-ui": { "name": "@kilocode/kilo-ui", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "0.13.11", @@ -442,7 +442,7 @@ }, "packages/kilo-vscode": { "name": "kilo-code", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@anthropic-ai/sdk": "^0.39.0", "@kilocode/kilo-gateway": "workspace:*", @@ -515,7 +515,7 @@ }, "packages/kilo-web-ui": { "name": "@kilocode/kilo-web-ui", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@kilocode/kilo-ui": "workspace:*", "@kobalte/core": "catalog:", @@ -532,7 +532,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -551,7 +551,7 @@ }, "packages/opencode": { "name": "@kilocode/cli", - "version": "7.5.0", + "version": "7.5.5", "bin": { "kilo": "./bin/kilo", "kilocode": "./bin/kilo", @@ -653,7 +653,7 @@ "jsonc-parser": "3.3.1", "mammoth": "1.12.0", "mime-types": "3.0.2", - "minimatch": "10.0.3", + "minimatch": "10.2.6", "npm-package-arg": "13.0.2", "open": "10.1.2", "opencode-gitlab-auth": "2.1.0", @@ -719,7 +719,7 @@ }, "packages/plugin": { "name": "@kilocode/plugin", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@ai-sdk/provider": "3.0.8", "@kilocode/sdk": "workspace:*", @@ -748,7 +748,7 @@ }, "packages/plugin-atomic-chat": { "name": "@kilocode/plugin-atomic-chat", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@kilocode/plugin": "workspace:*", }, @@ -762,7 +762,7 @@ }, "packages/protocol": { "name": "@opencode-ai/protocol", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@opencode-ai/schema": "workspace:*", "effect": "catalog:", @@ -775,7 +775,7 @@ }, "packages/schema": { "name": "@opencode-ai/schema", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "effect": "catalog:", }, @@ -787,7 +787,7 @@ }, "packages/script": { "name": "@opencode-ai/script", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "semver": "^7.6.3", }, @@ -798,7 +798,7 @@ }, "packages/sdk-next": { "name": "@opencode-ai/sdk-next", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@opencode-ai/client": "workspace:*", "@opencode-ai/core": "workspace:*", @@ -813,12 +813,12 @@ }, "packages/sdk/js": { "name": "@kilocode/sdk", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "cross-spawn": "catalog:", }, "devDependencies": { - "@hey-api/openapi-ts": "0.90.10", + "@hey-api/openapi-ts": "0.97.3", "@tsconfig/node22": "catalog:", "@types/cross-spawn": "catalog:", "@types/node": "catalog:", @@ -828,7 +828,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -843,7 +843,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -888,7 +888,7 @@ }, "packages/storybook": { "name": "@opencode-ai/storybook", - "version": "7.5.0", + "version": "7.5.5", "devDependencies": { "@opencode-ai/session-ui": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -913,7 +913,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@kilocode/plugin": "workspace:*", "@kilocode/sdk": "workspace:*", @@ -939,7 +939,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "7.5.0", + "version": "7.5.5", "dependencies": { "@kilocode/sdk": "workspace:*", "@kobalte/core": "catalog:", @@ -1069,7 +1069,7 @@ "@tanstack/solid-virtual": "3.13.32", "@tsconfig/bun": "1.0.9", "@tsconfig/node22": "22.0.2", - "@types/bun": "1.4.0", + "@types/bun": "1.3.14", "@types/cross-spawn": "6.0.6", "@types/luxon": "3.7.1", "@types/node": "24.12.4", @@ -1572,13 +1572,17 @@ "@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="], - "@hey-api/codegen-core": ["@hey-api/codegen-core@0.5.5", "", { "dependencies": { "@hey-api/types": "0.1.2", "ansi-colors": "4.1.3", "c12": "3.3.3", "color-support": "1.1.3" }, "peerDependencies": { "typescript": ">=5.5.3" } }, "sha512-f2ZHucnA2wBGAY8ipB4wn/mrEYW+WUxU2huJmUvfDO6AE2vfILSHeF3wCO39Pz4wUYPoAWZByaauftLrOfC12Q=="], + "@hey-api/codegen-core": ["@hey-api/codegen-core@0.8.2", "", { "dependencies": { "@hey-api/types": "0.1.4", "ansi-colors": "4.1.3", "c12": "3.3.4", "color-support": "1.1.3" } }, "sha512-R2NMf3wq97rh1mjz33WJQU8svz3F0RYUjvx/QzXucjpSqQ3O5huTdDjErG4fMxSr1X+X56NuDrqtGfHmo1TRUQ=="], - "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.2.2", "", { "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", "js-yaml": "^4.1.1", "lodash": "^4.17.21" } }, "sha512-oS+5yAdwnK20lSeFO1d53Ku+yaGCsY8PcrmSq2GtSs3bsBfRnHAbpPKSVzQcaxAOrzj5NB+f34WhZglVrNayBA=="], + "@hey-api/json-schema-ref-parser": ["@hey-api/json-schema-ref-parser@1.4.2", "", { "dependencies": { "@jsdevtools/ono": "7.1.3", "@types/json-schema": "7.0.15", "js-yaml": "4.1.1" } }, "sha512-ZhCFSKI2ipZHEbgmtUHdyddvRU3wJ4elgCfYUC7T7hZa4EivSrVflTQf2w+v3TuaYxR1Y2V2kq3otqTttrrK8Q=="], - "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.90.10", "", { "dependencies": { "@hey-api/codegen-core": "^0.5.5", "@hey-api/json-schema-ref-parser": "1.2.2", "@hey-api/types": "0.1.2", "ansi-colors": "4.1.3", "color-support": "1.1.3", "commander": "14.0.2", "open": "11.0.0", "semver": "7.7.3" }, "peerDependencies": { "typescript": ">=5.5.3" }, "bin": { "openapi-ts": "bin/run.js" } }, "sha512-o0wlFxuLt1bcyIV/ZH8DQ1wrgODTnUYj/VfCHOOYgXUQlLp9Dm2PjihOz+WYrZLowhqUhSKeJRArOGzvLuOTsg=="], + "@hey-api/openapi-ts": ["@hey-api/openapi-ts@0.97.3", "", { "dependencies": { "@hey-api/codegen-core": "0.8.2", "@hey-api/json-schema-ref-parser": "1.4.2", "@hey-api/shared": "0.4.5", "@hey-api/spec-types": "0.2.0", "@hey-api/types": "0.1.4", "@lukeed/ms": "2.0.2", "ansi-colors": "4.1.3", "color-support": "1.1.3", "commander": "14.0.3", "get-tsconfig": "4.14.0" }, "peerDependencies": { "typescript": ">=5.5.3 || >=6.0.0 || 6.0.1-rc" }, "bin": { "openapi-ts": "./bin/run.js" } }, "sha512-4sR6/E/POuy7aPZW9DDjhObzZCq7eSJWiW0+epXeKNczoTWEwdOyWFy9Ca/CnXYlZ3oJsrv0ZD0OO+YuczT7CA=="], - "@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="], + "@hey-api/shared": ["@hey-api/shared@0.4.5", "", { "dependencies": { "@hey-api/codegen-core": "0.8.2", "@hey-api/json-schema-ref-parser": "1.4.2", "@hey-api/spec-types": "0.2.0", "@hey-api/types": "0.1.4", "ansi-colors": "4.1.3", "cross-spawn": "7.0.6", "open": "11.0.0", "semver": "7.7.4" } }, "sha512-au4eHpBXAe1du0iMp6ESYuEaMS2jsoEyrbcT246btRhI9rMeQFEs7ZjtcMGXGsxhpaR38A8cPGNHx7QOrWAdMw=="], + + "@hey-api/spec-types": ["@hey-api/spec-types@0.2.0", "", { "dependencies": { "@hey-api/types": "0.1.4" } }, "sha512-ibQ8Is7evMavzr8GNyJCcTg975d8DpaMUyLmOrQ85UBdy1l6t1KuRAwgChAbesJsIlNV6gjmlXruWyegDX18Fg=="], + + "@hey-api/types": ["@hey-api/types@0.1.4", "", {}, "sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg=="], "@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="], @@ -1658,10 +1662,6 @@ "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], - "@isaacs/balanced-match": ["@isaacs/balanced-match@4.0.1", "", {}, "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ=="], - - "@isaacs/brace-expansion": ["@isaacs/brace-expansion@5.0.1", "", { "dependencies": { "@isaacs/balanced-match": "^4.0.1" } }, "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ=="], - "@isaacs/cliui": ["@isaacs/cliui@9.0.0", "", {}, "sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg=="], "@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="], @@ -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.4.0", "", { "dependencies": { "bun-types": "1.4.0" } }, "sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ=="], + "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/cacache": ["@types/cacache@20.0.1", "", { "dependencies": { "@types/node": "*", "minipass": "*" } }, "sha512-QlKW3AFoFr/hvPHwFHMIVUH/ZCYeetBNou3PCmxu5LaNDvrtBlPJtIA6uhmU9JRt9oxj7IYoqoLcpxtzpPiTcw=="], @@ -2972,13 +2972,13 @@ "bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="], - "bun-types": ["bun-types@1.4.0", "", { "dependencies": { "@types/node": "*" } }, "sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], - "c12": ["c12@3.3.3", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.2", "defu": "^6.1.4", "dotenv": "^17.2.3", "exsolve": "^1.0.8", "giget": "^2.0.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.0.0", "pkg-types": "^2.3.0", "rc9": "^2.1.2" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q=="], + "c12": ["c12@3.3.4", "", { "dependencies": { "chokidar": "^5.0.0", "confbox": "^0.2.4", "defu": "^6.1.6", "dotenv": "^17.3.1", "exsolve": "^1.0.8", "giget": "^3.2.0", "jiti": "^2.6.1", "ohash": "^2.0.11", "pathe": "^2.0.3", "perfect-debounce": "^2.1.0", "pkg-types": "^2.3.0", "rc9": "^3.0.1" }, "peerDependencies": { "magicast": "*" }, "optionalPeers": ["magicast"] }, "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA=="], "c8": ["c8@10.1.3", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.1", "@istanbuljs/schema": "^0.1.3", "find-up": "^5.0.0", "foreground-child": "^3.1.1", "istanbul-lib-coverage": "^3.2.0", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.1.6", "test-exclude": "^7.0.1", "v8-to-istanbul": "^9.0.0", "yargs": "^17.7.2", "yargs-parser": "^21.1.1" }, "peerDependencies": { "monocart-coverage-reports": "^2" }, "optionalPeers": ["monocart-coverage-reports"], "bin": { "c8": "bin/c8.js" } }, "sha512-LvcyrOAaOnrrlMpW22n690PUvxiq4Uf9WMhQwNJ9vgagkL/ph1+D4uvjvDA5XCbykrc0sx+ay6pVi9YZ1GnhyA=="], @@ -3030,8 +3030,6 @@ "ci-parallel-vars": ["ci-parallel-vars@1.0.1", "", {}, "sha512-uvzpYrpmidaoxvIQHM+rKSrigjOe9feHYbw4uOI2gdfe1C3xIlxO+kVXq83WQWNniTf8bAxVpy+cQeFQsMERKg=="], - "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], - "classcat": ["classcat@5.0.5", "", {}, "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="], "clean-stack": ["clean-stack@4.2.0", "", { "dependencies": { "escape-string-regexp": "5.0.0" } }, "sha512-LYv6XPxoyODi36Dp976riBtSY27VmFo+MKqEU9QCCWyTrdEPDog+RWA7xQWHi6Vbp61j5c4cdzzX1NidnwtUWg=="], @@ -3090,8 +3088,6 @@ "confbox": ["confbox@0.2.4", "", {}, "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ=="], - "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], - "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -3530,7 +3526,7 @@ "ghostty-web": ["ghostty-web@0.4.0", "", {}, "sha512-0puDBik2qapbD/QQBW9o5ZHfXnZBqZWx/ctBiVtKZ6ZLds4NYb+wZuw1cRLXZk9zYovIQ908z3rvFhexAvc5Hg=="], - "giget": ["giget@2.0.0", "", { "dependencies": { "citty": "^0.1.6", "consola": "^3.4.0", "defu": "^6.1.4", "node-fetch-native": "^1.6.6", "nypm": "^0.6.0", "pathe": "^2.0.3" }, "bin": { "giget": "dist/cli.mjs" } }, "sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA=="], + "giget": ["giget@3.3.1", "", { "bin": { "giget": "dist/cli.mjs" } }, "sha512-r+mvuDjrjMpsdw46Kmeydb8bdHm7wOKw8wNBtTndkjbPjgAp5oUJUxRE76wZFknxIPokfWvep2qSXK37aXE6zg=="], "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], @@ -3952,7 +3948,7 @@ "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], - "minimatch": ["minimatch@10.0.3", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.0" } }, "sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw=="], + "minimatch": ["minimatch@10.2.6", "", { "dependencies": { "brace-expansion": "^5.0.8" } }, "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A=="], "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], @@ -4000,7 +3996,7 @@ "nanoevents": ["nanoevents@7.0.1", "", {}, "sha512-o6lpKiCxLeijK4hgsqfR6CNToPyRU3keKyyI6uwuHRvpRTbZ0wXw51WRgyldVugZqoJfkGFrjrIenYH3bfEO3Q=="], - "nanoid": ["nanoid@3.3.17", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g=="], + "nanoid": ["nanoid@3.3.18", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w=="], "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], @@ -4018,8 +4014,6 @@ "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], - "node-fetch-native": ["node-fetch-native@1.6.7", "", {}, "sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q=="], - "node-forge": ["node-forge@1.4.0", "", {}, "sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ=="], "node-gyp": ["node-gyp@12.4.0", "", { "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", "nopt": "^9.0.0", "proc-log": "^6.0.0", "semver": "^7.3.5", "tar": "^7.5.4", "tinyglobby": "^0.2.12", "undici": "^6.25.0", "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" } }, "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw=="], @@ -4060,8 +4054,6 @@ "nth-check": ["nth-check@2.1.1", "", { "dependencies": { "boolbase": "^1.0.0" } }, "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w=="], - "nypm": ["nypm@0.6.9", "", { "dependencies": { "citty": "^0.2.2", "pathe": "^2.0.3", "tinyexec": "^1.2.4" }, "bin": { "nypm": "./dist/cli.mjs" } }, "sha512-zxlE2yvSWZWmHcNdT3+5zV2lrCogeE9YOklHrR3dFjqutq5wO7GFDYLFDRXLsYnJzwvy/im9fYoxePvS0VTW0w=="], - "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], "object-hash": ["object-hash@2.2.0", "", {}, "sha512-gScRMn0bS5fH+IuwyIFgnh9zBdo4DV+6GhygmWM9HyNJSgS0hScp1f5vjtm7oIIOiT9trXrShAkLFSc2IqKNgw=="], @@ -4302,7 +4294,7 @@ "rc-config-loader": ["rc-config-loader@4.1.4", "", { "dependencies": { "debug": "^4.4.3", "js-yaml": "^4.1.1", "json5": "^2.2.3", "require-from-string": "^2.0.2" } }, "sha512-3GiwEzklkbXTDp52UR5nT8iXgYAx1V9ZG/kDZT7p60u2GCv2XTwQq4NzinMoMpNtXhmt3WkhYXcj6HH8HdwCEQ=="], - "rc9": ["rc9@2.1.2", "", { "dependencies": { "defu": "^6.1.4", "destr": "^2.0.3" } }, "sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg=="], + "rc9": ["rc9@3.0.1", "", { "dependencies": { "defu": "^6.1.6", "destr": "^2.0.5" } }, "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ=="], "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], @@ -5126,11 +5118,15 @@ "@gitlab/gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "@hey-api/openapi-ts/commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="], + "@hey-api/json-schema-ref-parser/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], - "@hey-api/openapi-ts/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + "@hey-api/openapi-ts/commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], - "@hey-api/openapi-ts/semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + "@hey-api/openapi-ts/get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + + "@hey-api/shared/open": ["open@11.0.0", "", { "dependencies": { "default-browser": "^5.4.0", "define-lazy-prop": "^3.0.0", "is-in-ssh": "^1.0.0", "is-inside-container": "^1.0.0", "powershell-utils": "^0.1.0", "wsl-utils": "^0.3.0" } }, "sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw=="], + + "@hey-api/shared/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], "@img/sharp-wasm32/@emnapi/runtime": ["@emnapi/runtime@1.11.3", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA=="], @@ -5140,8 +5136,6 @@ "@kilocode/kilo-indexing/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "@kilocode/kilo-indexing/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@kilocode/plugin/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], "@manypkg/find-root/find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], @@ -5154,16 +5148,12 @@ "@modelcontextprotocol/sdk/jose": ["jose@6.2.8", "", {}, "sha512-Bsdjwm3Qsd/P0jR+BHDe3LytDfY7WBq2HmCCLIwuVRHMuEC9ae7/R474GIUdF1NgCyZjzVo/A9DOiOBtXq8ZoQ=="], - "@npmcli/arborist/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "@npmcli/git/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], "@npmcli/map-workspaces/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], - "@npmcli/map-workspaces/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@npmcli/package-json/glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "@octokit/core/@octokit/graphql": ["@octokit/graphql@7.1.1", "", { "dependencies": { "@octokit/request": "^8.4.1", "@octokit/types": "^13.0.0", "universal-user-agent": "^6.0.0" } }, "sha512-3mkDltSfcDUoa176nlGoA32RGjeWjl3K7F/BwHwRMJUW/IteSa4bnSV8p2ThNkcIcZU2umkZWxwETSSCJf2Q7g=="], @@ -5206,8 +5196,6 @@ "@opencode-ai/core/@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], - "@opencode-ai/core/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@opencode-ai/plugin/@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], @@ -5298,12 +5286,6 @@ "@textlint/linter-formatter/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "@ts-morph/common/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@tufjs/models/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - - "@typescript-eslint/typescript-estree/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@typescript-eslint/visitor-keys/eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="], "@vitest/expect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], @@ -5324,8 +5306,6 @@ "@vscode/vsce/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - "@vscode/vsce/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "archiver-utils/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], "archiver-utils/is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], @@ -5448,16 +5428,12 @@ "gitlab-ai-provider/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], - "glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "globby/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "gray-matter/js-yaml": ["js-yaml@3.15.1", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag=="], "htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="], - "ignore-walk/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "import-fresh/resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], "istanbul-lib-report/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], @@ -5532,8 +5508,6 @@ "npm-run-path/path-key": ["path-key@4.0.0", "", {}, "sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ=="], - "nypm/citty": ["citty@0.2.2", "", {}, "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w=="], - "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], "openid-client/lru-cache": ["lru-cache@6.0.0", "", { "dependencies": { "yallist": "^4.0.0" } }, "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA=="], @@ -5632,8 +5606,6 @@ "test-exclude/glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], - "test-exclude/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "thread-stream/real-require": ["real-require@1.0.0", "", {}, "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g=="], "tree-sitter-bash/node-addon-api": ["node-addon-api@8.9.1", "", {}, "sha512-4eUQWVPCUUUiBjLnHS3cXWeC6ryoPUc0U3rP7IuzapoGbzMqd/r6KKO0clr0b+snQhsrueFEhCZDdK+LK7hxKg=="], @@ -5782,14 +5754,12 @@ "@eslint/eslintrc/minimatch/brace-expansion": ["brace-expansion@1.1.18", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw=="], - "@hey-api/openapi-ts/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], + "@hey-api/json-schema-ref-parser/js-yaml/argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], - "@joshwooding/vite-plugin-react-docgen-typescript/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + "@hey-api/shared/open/wsl-utils": ["wsl-utils@0.3.1", "", { "dependencies": { "is-wsl": "^3.1.0", "powershell-utils": "^0.1.0" } }, "sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg=="], "@manypkg/find-root/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], - "@npmcli/package-json/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], "@octokit/endpoint/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -5982,8 +5952,6 @@ "c8/yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "cacache/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "chalk-template/chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], "cli-truncate/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], @@ -6166,8 +6134,6 @@ "venice-ai-sdk-provider/@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.13", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-bjYld/2KGPLt78kpqbya+fD4LYS7BqVQJyUjE3qAHrYB0FR2Q90BaWEVIBZaguTWXf/A8L6uG1zO1v9TxVlGWg=="], - "vite-plugin-icons-spritesheet/glob/minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], - "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], diff --git a/bunfig.toml b/bunfig.toml index 2c85f59748..61bb6e0fdb 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Keep Kilo's longer supply-chain quarantine while allowing packages that must track coordinated releases. minimumReleaseAge = 410520 # seconds (~4.75 days / ~114 hours) -minimumReleaseAgeExcludes = ["mermaid", "@mermaid-js/parser", "@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish", "@types/bun", "bun-types"] # kilocode_change +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"] [test] root = "./do-not-run-tests-from-root" diff --git a/nix/bun.nix b/nix/bun.nix index f9bc254337..a880895349 100644 --- a/nix/bun.nix +++ b/nix/bun.nix @@ -14,19 +14,19 @@ let sources = { "aarch64-linux" = { name = "bun-linux-aarch64"; - hash = "sha256-SxozLuhhmD65O8/m93D/+U4+MbLDiL2uo8jtNeWO7Q4="; + hash = "sha256-on/7Y6gxA3WDbg1vZorhf6jY0YuIw3yCHGUzGXOhmjs="; }; "x86_64-linux" = { name = "bun-linux-x64"; - hash = "sha256-LQP7X7g6yLVnrKCigbLOGhoZ1Ij1bClo2Iw/Jekv5FI="; + hash = "sha256-lR7iruhV8IWVruxiJSJqKY0/6oOj3NZGXAnLzN9+hI8="; }; "aarch64-darwin" = { name = "bun-darwin-aarch64"; - hash = "sha256-xmnpf2Fk4cluBwF0jbmN+ndJKQjL2DlMdVcTSnNd44E="; + hash = "sha256-2LliIYKK1vl6x6wKt+lYcjQa92MAHogD6CZ2UsJlJiA="; }; "x86_64-darwin" = { name = "bun-darwin-x64"; - hash = "sha256-HQIRuPHcmRGCNEaHrRXnLuhvFUhFpff6R3mUzTQd2bA="; + hash = "sha256-QYPfM3RiPlurMVxUfPoJdFM81FfYa3O2OfeoeXTNZjM="; }; }; source = diff --git a/nix/hashes.json b/nix/hashes.json index 60a458efa2..b7d2743f87 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-gSexsvn9ZqrKapvVaH9O2byY5WMHzKPNqiAzfOrR9Fw=", - "aarch64-linux": "sha256-KOX1YC8tMRyzx1paVavNzlORXCTDZSQMOfBuPKxL6TU=", - "aarch64-darwin": "sha256-IKS43B5STRInn8XEtBn5VjHhjEWeXZv5NwTR9CQSijE=", - "x86_64-darwin": "sha256-G18diPUH/4A3WoOdrbkLofMS56DYYSkAoJCPGroxu00=" + "x86_64-linux": "sha256-chyF6ZLYzGsNcswGsiyCWhZb6vvfGQOT8vcP+KpxvzU=", + "aarch64-linux": "sha256-boS1OAxk95Q1a0wxg+u2ImWERLsYRaiPMMtqskd5lWk=", + "aarch64-darwin": "sha256-tUebDKzxBnr1fKAhwJGtJEE/pB1945BzcgRomsjvYnk=", + "x86_64-darwin": "sha256-ZoL5ChqVZSZe8ikLUqaN5K1iBMV6GvKOnuq310qDJEA=" } } diff --git a/package.json b/package.json index a211379204..7b0f1f521c 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "AI-powered development tool", "private": true, "type": "module", - "packageManager": "bun@1.4.0", + "packageManager": "bun@1.3.14", "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.4.0", + "@types/bun": "1.3.14", "@types/cross-spawn": "6.0.6", "@octokit/rest": "22.0.0", "@opentui/core": "0.4.5", @@ -177,6 +177,6 @@ "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "solid-js@1.9.12": "patches/solid-js@1.9.12.patch" }, - "version": "7.5.0", + "version": "7.5.5", "peerDependencies": {} } diff --git a/packages/client/package.json b/packages/client/package.json index 992c84a538..2b02c03f4d 100644 --- a/packages/client/package.json +++ b/packages/client/package.json @@ -38,5 +38,5 @@ "@typescript/native-preview": "catalog:", "effect": "catalog:" }, - "version": "7.5.0" + "version": "7.5.5" } diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 11f8455c54..e9cd38be67 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "7.5.0", + "version": "7.5.5", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/containers/bun-node/Dockerfile b/packages/containers/bun-node/Dockerfile index ddfe934f20..8c93e45502 100644 --- a/packages/containers/bun-node/Dockerfile +++ b/packages/containers/bun-node/Dockerfile @@ -6,7 +6,7 @@ FROM ${REGISTRY}/build/base:24.04 SHELL ["/bin/bash", "-lc"] ARG NODE_VERSION=24.4.0 -ARG BUN_VERSION=1.4.0 +ARG BUN_VERSION=1.3.14 ENV BUN_INSTALL=/opt/bun ENV PATH=/opt/bun/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin diff --git a/packages/core/package.json b/packages/core/package.json index e285d92085..b32061e572 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.5.0", + "version": "7.5.5", "name": "@opencode-ai/core", "type": "module", "license": "MIT", @@ -77,7 +77,7 @@ "cross-spawn": "catalog:", "glob": "13.0.5", "mime-types": "3.0.2", - "minimatch": "10.2.5", + "minimatch": "10.2.6", "npm-package-arg": "13.0.2", "rotating-file-stream": "3.2.9", "semver": "^7.6.3", diff --git a/packages/core/src/background-job.ts b/packages/core/src/background-job.ts index cdffd212bc..285e65bf81 100644 --- a/packages/core/src/background-job.ts +++ b/packages/core/src/background-job.ts @@ -137,7 +137,7 @@ export const make = Effect.gen(function* () { if (job.info.status !== "running") return [{ info: snapshot(job) }, jobs] const pending = job.pending - 1 const output = - Exit.isSuccess(exit) && (!job.output || sequence > job.output.sequence) + Exit.isSuccess(exit) && exit.value && sequence > (job.output?.sequence ?? -1) // kilocode_change - empty outputs never clobber; only the latest non-empty result wins (#13469) ? { sequence, text: exit.value } : job.output if (Exit.isSuccess(exit) && pending > 0) { diff --git a/packages/core/src/kilocode/powershell.ts b/packages/core/src/kilocode/powershell.ts index 13a0e17ab1..866f215e04 100644 --- a/packages/core/src/kilocode/powershell.ts +++ b/packages/core/src/kilocode/powershell.ts @@ -1,25 +1,7 @@ -import { statSync } from "fs" -import path from "path" -import { which } from "../util/which" - export function args(command: string) { return ["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", script(command)] } -export const locations = (env: NodeJS.ProcessEnv = process.env) => - [ - env["ProgramFiles"] && path.join(env["ProgramFiles"], "PowerShell", "7"), - env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "PowerShell", "7"), - env["LOCALAPPDATA"] && path.join(env["LOCALAPPDATA"], "Microsoft", "WindowsApps"), - ] - .filter((item): item is string => Boolean(item)) - .map((root) => path.join(root, "pwsh.exe")) - -export const probe = (env: NodeJS.ProcessEnv = process.env) => - locations(env).filter((file) => statSync(file, { throwIfNoEntry: false })?.isFile()) - -export const pwsh = (env: NodeJS.ProcessEnv = process.env) => which("pwsh", env) ?? probe(env)[0] - const setup = `[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false); [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); $OutputEncoding = [Console]::OutputEncoding; @@ -141,4 +123,4 @@ function block(command: string, start: number, open: string, close: string) { } } -export const PowerShell = { args, locations, probe, pwsh } +export const PowerShell = { args } diff --git a/packages/core/src/kilocode/pty/smoke.ts b/packages/core/src/kilocode/pty/smoke.ts index 47dee8ebba..fa5a4e01be 100644 --- a/packages/core/src/kilocode/pty/smoke.ts +++ b/packages/core/src/kilocode/pty/smoke.ts @@ -4,56 +4,6 @@ import { spawn } from "#pty" const TIMEOUT = 15_000 -async function render() { - const proc = spawn(process.execPath, ["--pure"], { - name: "xterm-256color", - cwd: process.cwd(), - env: { - ...process.env, - TERM: "xterm-256color", - KILO_TERMINAL: "1", - KILO_NO_DAEMON: "1", - KILO_DISABLE_AUTOUPDATE: "1", - KILO_DISABLE_MODELS_FETCH: "1", - KILO_DISABLE_PROJECT_CONFIG: "1", - KILO_DISABLE_DEFAULT_PLUGINS: "1", - KILO_DISABLE_TERMINAL_TITLE: "0", - KILO_CONFIG_CONTENT: "{}", - KILO_AUTH_CONTENT: "{}", - } as Record, - cols: 100, - rows: 40, - }) - const state = { output: "", exited: false } - const ready = Promise.withResolvers() - const data = proc.onData((chunk) => { - state.output = (state.output + chunk).slice(-20_000) - if (state.output.includes("Ask anything...")) ready.resolve() - }) - const exit = proc.onExit((event) => { - state.exited = true - ready.reject(new Error(`TUI exited before rendering (code ${event.exitCode}): ${JSON.stringify(state.output)}`)) - }) - const timeout = AbortSignal.timeout(TIMEOUT) - - try { - await Promise.race([ - ready.promise, - new Promise((_, reject) => - timeout.addEventListener( - "abort", - () => reject(new Error(`TUI produced no rendered frame within ${TIMEOUT}ms: ${JSON.stringify(state.output)}`)), - { once: true }, - ), - ), - ]) - } finally { - data.dispose() - exit.dispose() - if (!state.exited) await KiloPtyTermination.terminate(proc) - } -} - export async function smoke() { const proc = spawn(Shell.preferred(), [], { name: "xterm-256color", @@ -67,7 +17,8 @@ export async function smoke() { const exited = Promise.withResolvers() const data = proc.onData((chunk) => { state.output += chunk - if (/(?:^|[\r\n])KILO_PTY_READY(?:\r?\n|$)/.test(state.output)) output.resolve() + const lines = state.output.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "").split(/\r?\n/) + if (lines.some((line) => line.trim() === "KILO_PTY_READY")) output.resolve() }) const exit = proc.onExit((event) => { state.exited = true @@ -116,8 +67,6 @@ export async function smoke() { } finally { if (!stopped) active.kill() } - - await render() } export * as PtySmoke from "./smoke" diff --git a/packages/core/src/shell.ts b/packages/core/src/shell.ts index 821e69082a..f92906aebe 100644 --- a/packages/core/src/shell.ts +++ b/packages/core/src/shell.ts @@ -99,8 +99,7 @@ function resolve(file: string) { function win() { return Array.from( new Set( - // kilocode_change - probe known PowerShell 7 install locations so legacy 5.1 is not picked when pwsh is off PATH - [PowerShell.pwsh(), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"] // kilocode_change + [which("pwsh"), which("powershell"), gitbash(), process.env.COMSPEC || "cmd.exe"] .filter((item): item is string => Boolean(item)) .map(full), ), diff --git a/packages/core/test/background-job.test.ts b/packages/core/test/background-job.test.ts index 5ad1e061a5..8be1769c5e 100644 --- a/packages/core/test/background-job.test.ts +++ b/packages/core/test/background-job.test.ts @@ -86,6 +86,27 @@ describe("BackgroundJob", () => { }).pipe(Effect.provide(jobsLayer)), ) + // kilocode_change start - regression for #13469: an empty extended run must not clobber an earlier non-empty result + it.live("keeps the earlier non-empty output when an extended run returns empty", () => + Effect.gen(function* () { + const jobs = yield* BackgroundJob.Service + const first = yield* Deferred.make() + const job = yield* jobs.start({ + type: "test", + run: Deferred.await(first).pipe(Effect.as("real answer")), + }) + + expect(yield* jobs.extend({ id: job.id, run: Effect.succeed("") })).toBe(true) + + yield* Deferred.succeed(first, undefined) + expect(yield* jobs.wait({ id: job.id })).toMatchObject({ + timedOut: false, + info: { status: "completed", output: "real answer" }, + }) + }).pipe(Effect.provide(jobsLayer)), + ) + // kilocode_change end + it.live("interrupts live work without promising settlement after the owning process-local scope closes", () => Effect.gen(function* () { const scope = yield* Scope.make() diff --git a/packages/core/test/kilocode/powershell.test.ts b/packages/core/test/kilocode/powershell.test.ts deleted file mode 100644 index edca08ae54..0000000000 --- a/packages/core/test/kilocode/powershell.test.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { describe, expect, test } from "bun:test" -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs" -import { tmpdir } from "os" -import path from "path" -import { Shell } from "@opencode-ai/core/shell" -import { PowerShell } from "@opencode-ai/core/kilocode/powershell" -import { which } from "@opencode-ai/core/util/which" - -const LEGACY = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" - -const knownLocations = () => { - const roots = [ - process.env.ProgramFiles && path.join(process.env.ProgramFiles, "PowerShell", "7"), - process.env["ProgramFiles(x86)"] && path.join(process.env["ProgramFiles(x86)"], "PowerShell", "7"), - process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, "Microsoft", "WindowsApps"), - ].filter((item): item is string => Boolean(item)) - return roots.map((root) => path.join(root, "pwsh.exe")).filter((file) => existsSync(file)) -} - -const pwshInstalled = () => Boolean(which("pwsh")) || knownLocations().length > 0 - -// Remove every PATH directory that can resolve pwsh or powershell so detection -// cannot fall back to PATH lookup and must find installs on its own. -const withoutPowershellDirs = () => - (process.env.PATH ?? "") - .split(path.delimiter) - .filter(Boolean) - .filter((dir) => !/powershell/i.test(dir) && !existsSync(path.join(dir, "pwsh.exe"))) - .join(path.delimiter) - -function withEnv(env: { PATH?: string; SHELL?: string }, fn: () => void) { - const prevPath = process.env.PATH - const prevShell = process.env.SHELL - if (env.PATH === undefined) delete process.env.PATH - else process.env.PATH = env.PATH - if (env.SHELL === undefined) delete process.env.SHELL - else process.env.SHELL = env.SHELL - Shell.preferred.reset() - Shell.acceptable.reset() - try { - fn() - } finally { - if (prevPath === undefined) delete process.env.PATH - else process.env.PATH = prevPath - if (prevShell === undefined) delete process.env.SHELL - else process.env.SHELL = prevShell - Shell.preferred.reset() - Shell.acceptable.reset() - } -} - -if (process.platform === "win32") { - describe("windows powershell selection", () => { - test("prefers an installed powershell 7 when pwsh is absent from PATH", () => { - if (!pwshInstalled()) return - withEnv({ PATH: withoutPowershellDirs(), SHELL: undefined }, () => { - expect(Shell.name(Shell.preferred())).toBe("pwsh") - expect(Shell.name(Shell.acceptable())).toBe("pwsh") - }) - }) - - test("prefers pwsh over legacy 5.1 on the unmodified PATH", () => { - if (!pwshInstalled()) return - withEnv({ SHELL: undefined }, () => { - expect(Shell.name(Shell.preferred())).toBe("pwsh") - }) - }) - - test("explicit shell config still overrides detection", () => { - if (!existsSync(LEGACY)) return - expect(Shell.preferred(LEGACY)).toBe(LEGACY) - expect(Shell.acceptable(LEGACY)).toBe(LEGACY) - }) - }) -} - -describe("powershell install probing", () => { - test("lists known locations in priority order", () => { - expect( - PowerShell.locations({ - ProgramFiles: "C:\\Program Files", - "ProgramFiles(x86)": "C:\\Program Files (x86)", - LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", - }), - ).toEqual([ - path.join("C:\\Program Files", "PowerShell", "7", "pwsh.exe"), - path.join("C:\\Program Files (x86)", "PowerShell", "7", "pwsh.exe"), - path.join("C:\\Users\\u\\AppData\\Local", "Microsoft", "WindowsApps", "pwsh.exe"), - ]) - }) - - test("skips unset environment roots", () => { - expect(PowerShell.locations({})).toEqual([]) - }) - - test("probe and pwsh resolve an installed pwsh outside PATH", () => { - const root = mkdtempSync(path.join(tmpdir(), "pwsh-probe-")) - try { - const dir = path.join(root, "PowerShell", "7") - mkdirSync(dir, { recursive: true }) - const file = path.join(dir, "pwsh.exe") - writeFileSync(file, "") - expect(PowerShell.probe({ ProgramFiles: root })).toEqual([file]) - expect(PowerShell.pwsh({ PATH: "", ProgramFiles: root })).toBe(file) - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) - - test("probe ignores location roots without pwsh", () => { - const root = mkdtempSync(path.join(tmpdir(), "pwsh-probe-empty-")) - try { - mkdirSync(path.join(root, "PowerShell", "7"), { recursive: true }) - expect(PowerShell.probe({ ProgramFiles: root })).toEqual([]) - expect(PowerShell.pwsh({ PATH: "", ProgramFiles: root })).toBeUndefined() - } finally { - rmSync(root, { recursive: true, force: true }) - } - }) -}) diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index d4e2bf07d7..22b381d6af 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.5.0", + "version": "7.5.5", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 60b0128c2d..944cece2a0 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.5.0", + "version": "7.5.5", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/extensions/zed/extension.toml b/packages/extensions/zed/extension.toml index b8cd02809d..55362b6490 100644 --- a/packages/extensions/zed/extension.toml +++ b/packages/extensions/zed/extension.toml @@ -1,7 +1,7 @@ id = "kilo" name = "Kilo" description = "The open source coding agent." -version = "7.5.0" +version = "7.5.5" schema_version = 1 authors = ["Anomaly"] repository = "https://github.com/Kilo-Org/kilocode" @@ -11,26 +11,26 @@ name = "Kilo" icon = "./icons/opencode.svg" [agent_servers.opencode.targets.darwin-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.5.0/opencode-darwin-arm64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.5.5/opencode-darwin-arm64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.darwin-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.5.0/opencode-darwin-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.5.5/opencode-darwin-x64.zip" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-aarch64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.5.0/opencode-linux-arm64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.5.5/opencode-linux-arm64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.linux-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.5.0/opencode-linux-x64.tar.gz" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.5.5/opencode-linux-x64.tar.gz" cmd = "./opencode" args = ["acp"] [agent_servers.opencode.targets.windows-x86_64] -archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.5.0/opencode-windows-x64.zip" +archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.5.5/opencode-windows-x64.zip" cmd = "./opencode.exe" args = ["acp"] diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 9ab1749500..105edb946c 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "7.5.0", + "version": "7.5.5", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/httpapi-codegen/package.json b/packages/httpapi-codegen/package.json index ba065f2871..92bf4b7610 100644 --- a/packages/httpapi-codegen/package.json +++ b/packages/httpapi-codegen/package.json @@ -20,5 +20,5 @@ "@types/bun": "catalog:", "@typescript/native-preview": "catalog:" }, - "version": "7.5.0" + "version": "7.5.5" } diff --git a/packages/kilo-console/package.json b/packages/kilo-console/package.json index 76fa10396e..dc481a8270 100755 --- a/packages/kilo-console/package.json +++ b/packages/kilo-console/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-console", - "version": "7.5.0", + "version": "7.5.5", "private": true, "type": "module", "scripts": { diff --git a/packages/kilo-docs/package.json b/packages/kilo-docs/package.json index e211ba6f93..301d882a32 100644 --- a/packages/kilo-docs/package.json +++ b/packages/kilo-docs/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-docs", - "version": "7.5.0", + "version": "7.5.5", "private": true, "scripts": { "dev": "next dev --webpack --port 3002", diff --git a/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md b/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md index fac684c89c..ddbe85f307 100644 --- a/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md +++ b/packages/kilo-docs/pages/code-with-ai/platforms/cloud-agent.md @@ -23,9 +23,17 @@ Before using Cloud Agents: ## Cost -- **Compute is free during limited beta** - - Please provide any feedback in our Cloud Agents beta Discord channel: [Kilo Discord](https://kilo.ai/discord) -- **Kilo Code credits are still used** when the agent performs work (model usage, operations, etc.). +Cloud Agent compute is billed per second while the container is awake. Compute and model inference draw from the same Kilo credit balance, but they are charged separately. + +| Cloud Agent size | Hourly rate | +|---|---| +| Docker | $0.60 | +| Small | $0.60 | +| Standard | $1.20 | + +Usage is measured in whole seconds, with no rounding up to a longer billing interval and no minimum usage charge. Your balance must contain at least $5 to launch a container, but this is not an extra charge or minimum spend. BYOK users still pay for cloud compute because their provider keys cover inference only. + +See [Kilo Code pricing](https://kilo.ai/pricing) for current rates and pricing for other cloud products. ## How to Use diff --git a/packages/kilo-docs/pages/collaborate/enterprise/migration.md b/packages/kilo-docs/pages/collaborate/enterprise/migration.md index 14855d6a10..a5c50a9059 100644 --- a/packages/kilo-docs/pages/collaborate/enterprise/migration.md +++ b/packages/kilo-docs/pages/collaborate/enterprise/migration.md @@ -13,7 +13,7 @@ Switch to **Kilo Teams** or **Kilo Enterprise** from other AI coding tools and e **Other AI coding vendors** hide their true costs behind opaque subscription models, leaving you wondering what you're actually paying for. -**Kilo Teams** and **Kilo Enterprise** show you exactly what each AI request costs - no markup, no hidden fees, complete transparency. +**Kilo Teams** and **Kilo Enterprise** show you what each AI request costs. Model inference is charged at provider rates with no markup, and credit purchases have a disclosed 5% payment-processing fee. ### No Rate Limiting diff --git a/packages/kilo-docs/pages/collaborate/index.md b/packages/kilo-docs/pages/collaborate/index.md index 5d62434985..d9ec840cb4 100644 --- a/packages/kilo-docs/pages/collaborate/index.md +++ b/packages/kilo-docs/pages/collaborate/index.md @@ -23,7 +23,7 @@ Sessions are your platform-agnostic interaction with Kilo. They remember your re Kilo Code's paid plans provide powerful team management features: - [**About Plans**](/docs/collaborate/teams/about-plans) — Compare Teams and Enterprise plans -- **Teams ($15/user/month)** — Zero markup on AI costs, centralized billing, team analytics +- **Teams ($15/user/month)** — Inference at provider rates with no markup, centralized billing, team analytics; credit purchases have a 5% processing fee - **Enterprise ([Contact Sales](https://kilo.ai/contact-sales))** — Model controls, audit logs, SSO, dedicated support ### Team Management diff --git a/packages/kilo-docs/pages/collaborate/teams/about-plans.md b/packages/kilo-docs/pages/collaborate/teams/about-plans.md index 00bbcef1da..92c832903f 100644 --- a/packages/kilo-docs/pages/collaborate/teams/about-plans.md +++ b/packages/kilo-docs/pages/collaborate/teams/about-plans.md @@ -21,7 +21,7 @@ No credits are included with a Teams or Enterprise plan purchase. ## What You Get from Kilo Teams -- **Zero markup** on AI provider costs - pay exactly what providers charge +- **No inference markup** - model usage is charged at provider rates; credit purchases have a separate 5% payment-processing fee - **No rate limiting** or quality degradation during peak usage - **Centralized billing** - one invoice for your whole team - **Complete transparency** - see every request, cost, and usage pattern diff --git a/packages/kilo-docs/pages/collaborate/teams/billing.md b/packages/kilo-docs/pages/collaborate/teams/billing.md index 5b046926b2..39358ce6b0 100644 --- a/packages/kilo-docs/pages/collaborate/teams/billing.md +++ b/packages/kilo-docs/pages/collaborate/teams/billing.md @@ -5,7 +5,7 @@ description: "Manage billing and subscriptions for your team" # Billing -Kilo seats uses a transparent, two-part billing system: a monthly subscription per seat, plus pay-as-you-go Kilo credits with zero markup. +Kilo seats use a transparent, two-part billing system: a monthly subscription per seat, plus pay-as-you-go Kilo credits. Model inference is charged at provider rates with no markup. A separate 5% payment-processing fee applies when you purchase credits. {% callout type="note" %} @@ -13,6 +13,8 @@ Kilo Code seats purchases of Teams or Enterprise are separate from Kilo credits. No Kilo credits are included with a Teams or Enterprise purchase. +$1 of purchased credits funds $1 of usage. The 5% processing fee is charged separately and does not increase the organization's credit balance. + {% /callout %} ## Organization Credits diff --git a/packages/kilo-docs/pages/collaborate/teams/getting-started.md b/packages/kilo-docs/pages/collaborate/teams/getting-started.md index 126ebab154..827a229213 100644 --- a/packages/kilo-docs/pages/collaborate/teams/getting-started.md +++ b/packages/kilo-docs/pages/collaborate/teams/getting-started.md @@ -5,7 +5,7 @@ description: "Set up your Kilo Code team account" # Get Started with Kilo Seats in 10 Minutes -seats for Kilo in the Teams or Enterprise subscription brings transparent AI coding to your entire engineering organization. No markup on AI costs, no vendor lock-in, complete usage visibility. +Seats for Kilo in the Teams or Enterprise subscription bring transparent AI coding to your entire engineering organization. Model inference is charged at provider rates with no markup, while credit purchases have a separate 5% payment-processing fee. ## Before You Begin diff --git a/packages/kilo-docs/pages/contributing/development-environment.md b/packages/kilo-docs/pages/contributing/development-environment.md index a7c0c6830d..e2f77c6cc9 100644 --- a/packages/kilo-docs/pages/contributing/development-environment.md +++ b/packages/kilo-docs/pages/contributing/development-environment.md @@ -16,7 +16,7 @@ This document will help you set up your development environment and understand h Before you begin, make sure you have the following installed: 1. **Git** - For version control -2. **Bun 1.4.0+** - Required for installing dependencies and running scripts +2. **Bun 1.3.14+** - Required for installing dependencies and running scripts 3. **Visual Studio Code** - Our recommended IDE for development 4. **Java 21** - Required only when running JetBrains plugin checks or repo-level checks that include `@kilocode/kilo-jetbrains` diff --git a/packages/kilo-docs/pages/gateway/usage-and-billing.md b/packages/kilo-docs/pages/gateway/usage-and-billing.md index 1ddb9e78cc..cb0308f8ca 100644 --- a/packages/kilo-docs/pages/gateway/usage-and-billing.md +++ b/packages/kilo-docs/pages/gateway/usage-and-billing.md @@ -32,6 +32,8 @@ Costs are determined by the upstream provider's pricing based on token usage: ## Balance management +Model inference is deducted from your balance at the upstream provider's rate with no markup. A 5% payment-processing fee applies when you purchase Kilo credits; the fee is charged separately and does not increase your balance. For example, $1 of purchased credits funds $1 of usage. + ### Individual accounts Your account balance is the difference between total credits purchased and total usage. Check your balance in the [Kilo dashboard](https://app.kilo.ai). diff --git a/packages/kilo-docs/pages/getting-started/adding-credits.md b/packages/kilo-docs/pages/getting-started/adding-credits.md index b9d7d3cf14..bf49c766ee 100644 --- a/packages/kilo-docs/pages/getting-started/adding-credits.md +++ b/packages/kilo-docs/pages/getting-started/adding-credits.md @@ -19,11 +19,12 @@ You can also use subscriptions or credits you may have purchased directly with a At Kilo Code, we believe in complete pricing transparency: -- Our pricing matches the model provider's API rates exactly -- We don't take any commission or markup. -- $1 you give us becomes $1 of Kilo credits -- We debit your Kilo credits exactly what the provider charges us in dollars -- You only pay for what you use with no hidden fees +- Model inference through Kilo Gateway matches the provider's API rates with no markup. +- We debit your Kilo credits by the amount charged for inference or other metered Kilo services, such as cloud compute. +- $1 of purchased credits funds $1 of usage. +- A 5% payment-processing fee applies when you purchase credits. This fee is charged separately and does not increase your credit balance. + +For current platform, inference, credit purchase, and cloud compute pricing, see [Kilo Code pricing](https://kilo.ai/pricing). ## Future Plans diff --git a/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md b/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md index 9850e87765..b5e174cef3 100644 --- a/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md +++ b/packages/kilo-docs/pages/getting-started/rate-limits-and-costs.md @@ -73,7 +73,7 @@ Kilo automatically applies prompt caching on supported providers. Repeated conte ## How Costs Are Calculated -- Costs are a pass-through of provider pricing with no general markup. +- Inference costs are a pass-through of provider pricing with no markup. A separate 5% payment-processing fee applies when you purchase Kilo credits. - Kilo calculates an estimated cost for each request based on configured pricing. This estimate is shown per-request in the chat history. - Cache hits are billed at a discounted rate compared to regular input tokens. - Requests using **Auto Free** models are billed at $0 on Kilo's side. diff --git a/packages/kilo-docs/pnpm-lock.yaml b/packages/kilo-docs/pnpm-lock.yaml index 273293eeda..3bce44b8c5 100644 --- a/packages/kilo-docs/pnpm-lock.yaml +++ b/packages/kilo-docs/pnpm-lock.yaml @@ -1215,8 +1215,8 @@ packages: mermaid@11.17.2: resolution: {integrity: sha512-V6K3C8EBdEsPFZXSKMJe6ppQOENxuHARr9GvHX4hh47lAbhMRD9qf4oEK7LoaRQxULMa80/qt5gHO73aCleBBg==} - nanoid@3.3.17: - resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==} + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true @@ -2529,7 +2529,7 @@ snapshots: ts-dedent: 2.3.0 uuid: 14.0.1 - nanoid@3.3.17: {} + nanoid@3.3.18: {} next@16.3.0(@types/node@25.9.5)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: @@ -2581,13 +2581,13 @@ snapshots: postcss@8.5.23: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 postcss@8.5.26: dependencies: - nanoid: 3.3.17 + nanoid: 3.3.18 picocolors: 1.1.1 source-map-js: 1.2.1 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-left-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-left-chromium-linux.png new file mode 100644 index 0000000000..e8a68e4d88 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-left-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2f39a67f1abfc391d9784d0c0dec6abb33fc8940ce6c6bed07cc47f2ebcff0f5 +size 11599 diff --git a/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-right-chromium-linux.png b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-right-chromium-linux.png new file mode 100644 index 0000000000..a273054233 --- /dev/null +++ b/packages/kilo-docs/public/img/screenshot-tests/kilo-vscode/visual-regression/chat/prompt-rail-right-chromium-linux.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ee3282e45fbd929ec6f39c56dd9db08d6b5952b44a77245173d4bcf0eb58791a +size 11601 diff --git a/packages/kilo-gateway/package.json b/packages/kilo-gateway/package.json index 979dc8c305..a9eae19ceb 100644 --- a/packages/kilo-gateway/package.json +++ b/packages/kilo-gateway/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-gateway", - "version": "7.5.0", + "version": "7.5.5", "type": "module", "license": "MIT", "description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration", diff --git a/packages/kilo-i18n/package.json b/packages/kilo-i18n/package.json index 3b5f749b8d..196f77ab0e 100644 --- a/packages/kilo-i18n/package.json +++ b/packages/kilo-i18n/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-i18n", - "version": "7.5.0", + "version": "7.5.5", "type": "module", "license": "MIT", "description": "Kilo-specific i18n translations and overrides", diff --git a/packages/kilo-indexing/package.json b/packages/kilo-indexing/package.json index 8d73cf167b..037316be33 100644 --- a/packages/kilo-indexing/package.json +++ b/packages/kilo-indexing/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-indexing", - "version": "7.5.0", + "version": "7.5.5", "type": "module", "license": "MIT", "description": "Standalone indexing engine and host helpers for Kilo Code", @@ -47,7 +47,7 @@ "hono": "catalog:", "hono-openapi": "catalog:", "ignore": "7.0.5", - "minimatch": "10.2.5", + "minimatch": "10.2.6", "openai": "6.27.0", "p-limit": "7.3.0", "tree-sitter-wasms": "0.1.13", diff --git a/packages/kilo-jetbrains/CHANGELOG.md b/packages/kilo-jetbrains/CHANGELOG.md index 449002aee8..25e583e84e 100644 --- a/packages/kilo-jetbrains/CHANGELOG.md +++ b/packages/kilo-jetbrains/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 7.4.24 + +### Patch Changes + +- [#13470](https://github.com/Kilo-Org/kilocode/pull/13470) [`17bef75`](https://github.com/Kilo-Org/kilocode/commit/17bef75509e6fc0b8199fb19bba0ebdafb21c223) - Stop showing a running badge for a session that was just deleted. + ## 7.5.0 ### Minor Changes @@ -310,6 +316,61 @@ ## [Unreleased] +## [7.1.0] - 2026-08-26 + +### Added + +- Add the JetBrains Agent Manager beta for creating, opening, organizing, renaming, deleting, and tracking worktree-based tasks and their sessions from the IDE. +- Show Agent Manager worktree activity, changes, ahead/behind, pull request, failure, and attention badges with clearer row actions, menus, tooltips, and drag-and-drop reordering. +- Add New, From PR, and From Branch tabs to the New Worktree dialog for clearer worktree creation and import flows. +- Add JetBrains logging settings with log reveal and backend log download actions for easier diagnostics. +- Add JetBrains workflow settings so workflows can be reviewed and managed from the plugin. +- Show permission-prompt diffs and approval reasons on JetBrains tool cards before acting on tool requests. +- Open sub-agent sessions in JetBrains editor tabs with live collapsed task previews. +- Add Retry to failed JetBrains chat turns so the original request can be rerun without retyping. + +### Fixed + +- Improve Agent Manager reliability by anchoring worktrees to the main repository storage, pruning stale metadata, hiding dead managed worktrees, refusing unmanaged paths, and preserving session history by worktree. +- Keep failed, stopped, deleted, resumed, and recovered JetBrains sessions represented correctly in chat, session lists, worktree rows, activity badges, and Agents-tab attention dots. +- Detect unsupported JetBrains remote workspaces and missing or moved worktree folders with clearer in-session states instead of ambiguous failures. +- Keep slash completion open and responsive while typing quickly. +- Stabilize JetBrains chat and Agent Manager layout, including header popups, PR badges, row spacing, hover popups, overlays, worktree tab painting, and dialog branch pickers. +- Preserve project-level snapshot disabling across restarts after choosing to disable snapshots from the slow-repo prompt. +- Keep Ask and Plan modes read-only even when broad permission rules are configured. +- Improve Kilo Core reliability for JetBrains by preserving output budgets, recovering reasoning-only incomplete responses, preserving Cerebras completion limits, restoring terminal startup, and removing duplicate skill catalog content from prompts. +- Fix Agent Manager session creation on strict providers and OpenAI Responses API models by allowing nullable tool fields and explicit provider selection. +- Clear empty failed assistant responses when sending a normal follow-up after a provider failure. + +### Changed + +- Update the pinned Kilo Core CLI used by JetBrains from 7.4.22 to 7.4.23. +- Improve Kilo Core cold and warm startup speed for JetBrains and other clients. +- Show failed-turn details in a clearer error card with the error kind and retry action, while manually stopped turns render as a muted "Stopped" note. +- Put new, imported, or moved Agent Manager worktrees at the top of the list unless manually reordered. +- Make Agent Manager rows visually quieter with regular-weight labels, subdued idle icons, and less stale deleted-session status. +- Remove the experimental agent requirements and task-aware output pruning features from the bundled Kilo Core runtime. +- Remove an unused JetBrains Compose compiler plugin dependency. + +## [7.1.0-rc.5] - 2026-08-26 + +### Added + +- Add separate New, From PR, and From Branch tabs to the JetBrains New Worktree dialog, replacing the old import radio buttons with clearer workflows. +- Add Retry to failed JetBrains chat turns so you can roll back the failed response and rerun the original request without retyping it. + +### Fixed + +- Stop showing error badges and Agents-tab attention dots after you manually stop a turn. +- Keep deleted sessions from briefly reappearing as running, failed, or waiting in session lists and Agent Manager activity badges. +- Prevent the New Worktree dialog from crashing when IntelliJ drops an editable branch picker editor during layout. +- Detect pull requests for imported worktrees more reliably and avoid worktree tab paint artifacts. +- Clear empty failed assistant responses when you send a normal follow-up after a provider failure. + +### Changed + +- Show failed-turn details in a clearer error card with the error kind and retry action, while stopped turns now render as a simple muted "Stopped" note. + ## [7.1.0-rc.4] - 2026-08-25 ### Added diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt index 7cd7205388..a9a88b1e54 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/app/KiloBackendActivityManager.kt @@ -86,14 +86,15 @@ class KiloBackendActivityManager( is ChatEventDto.QuestionAsked -> questions.getOrPut(event.sessionID) { mutableMapOf() }[event.request.id] = plan(event) is ChatEventDto.QuestionReplied -> removeMap(questions, event.sessionID, event.requestID) is ChatEventDto.QuestionRejected -> removeMap(questions, event.sessionID, event.requestID) - is ChatEventDto.Error -> event.sessionID?.let { errors.add(it) } + // A Stop publishes MessageAbortedError. That is a deliberate user action, not a failure, so + // it must not badge the session list, worktree rows, or the Agents tab attention dot. + is ChatEventDto.Error -> if (event.error?.aborted != true) event.sessionID?.let { errors.add(it) } is ChatEventDto.TurnOpen -> errors.remove(event.sessionID) is ChatEventDto.SessionIdle -> clear(event.sessionID) is ChatEventDto.SessionStatusChanged -> when (event.status.type) { "idle" -> clear(event.sessionID) - // Work restarted, so whatever ended the previous turn (a Stop publishes - // MessageAbortedError) is stale. Not every resume path publishes a turn event, so - // busy has to clear the error itself. + // Work restarted, so whatever ended the previous turn is stale. Not every resume + // path publishes a turn event, so busy has to clear the error itself. "busy" -> errors.remove(event.sessionID) else -> Unit } diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt index 7c783ea59c..3cbb16bf42 100644 --- a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImpl.kt @@ -3,6 +3,7 @@ package ai.kilocode.backend.rpc import ai.kilocode.backend.app.KiloBackendAppService import ai.kilocode.log.KiloLog import ai.kilocode.rpc.KiloWorktreeRpcApi +import ai.kilocode.rpc.parsePrUrl import ai.kilocode.rpc.dto.BranchStatusDto import ai.kilocode.rpc.dto.CreateWorktreeRequestDto import ai.kilocode.rpc.dto.CreateWorktreeResultDto @@ -72,6 +73,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { private val bases = ConcurrentHashMap>() private val prs = ConcurrentHashMap>() private val branches = ConcurrentHashMap>() + private val resolver = PrResolver(gh = ::runGh, git = ::runGit) private val ghLock = Any() @Volatile private var ghProbe: Timed? = null @@ -153,8 +155,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val res = runGit(root, "worktree", "list", "--porcelain") if (!res.ok) return@withContext WorktreeStatsListDto() val items = managedWorktrees(parseWorktreeList(res.stdout)) - val main = items.firstOrNull { it.main } - val fallback = main?.branch?.takeIf { it.isNotBlank() && it != "(detached)" } ?: "HEAD" + val fallback = baseBranch(items) ?: "HEAD" WorktreeStatsListDto(parallel(items.filter { !it.main }) { item -> stats(item, fallback) }) } @@ -170,18 +171,17 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { if (available != GhAvailability.OK) return@withContext WorktreePrListDto(available).also { prs[directory] = Timed(now, it) } val res = runGit(root, "worktree", "list", "--porcelain") if (!res.ok) return@withContext WorktreePrListDto().also { prs[directory] = Timed(now, it) } - val items = managedWorktrees(parseWorktreeList(res.stdout)).filter { !it.main && it.branch != "(detached)" } + val all = managedWorktrees(parseWorktreeList(res.stdout)) + val items = prTargets(all) + val base = baseBranch(all) var status = GhAvailability.OK val data = parallel(items) { item -> if (status != GhAvailability.OK) return@parallel null - val out = runGh(Path.of(item.path).normalize(), "pr", "view", item.branch, "--json", "number,state,isDraft,url,title") - if (!out.ok) { - // prError only ever returns UNAUTH or OK; a missing gh/git binary is already caught - // by the upfront ghAvailable() check before this loop runs. - if (prError(out.stderr) == GhAvailability.UNAUTH) status = GhAvailability.UNAUTH - return@parallel null - } - parsePr(item.path, out.stdout) + val lookup = resolver.resolve(item.path, item.branch, base) + // The resolver only ever reports UNAUTH or OK; a missing gh/git binary is already + // caught by the upfront ghAvailable() check before this loop runs. + if (lookup.availability != GhAvailability.OK) status = lookup.availability + lookup.pr }.filterNotNull() val dto = WorktreePrListDto(status, if (status == GhAvailability.OK) data else emptyList()) prs[directory] = Timed(System.currentTimeMillis(), dto) @@ -195,15 +195,18 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { val branch = runGit(root, "branch", "--show-current").stdout.trim() val worktree = isLinkedWorktree(root) val availability = ghAvailable(root) - val pr = if (availability == GhAvailability.OK && branch.isNotBlank()) { - val out = runGh(root, "pr", "view", branch, "--json", "number,state,isDraft,url,title,headRefName") - // Only accept a PR whose head branch matches the current branch. Guards against gh - // resolving a PR via upstream/remote configuration that isn't for this branch. - if (out.ok && parsePrHeadRef(out.stdout) == branch) parsePr(directory, out.stdout) else null + val lookup = if (availability == GhAvailability.OK && branch.isNotBlank()) { + resolver.resolve(directory, branch, baseBranch(root)) } else { - null + PrLookup() } - val dto = BranchStatusDto(branch = branch, worktree = worktree, availability = availability, pr = pr) + val dto = BranchStatusDto( + branch = branch, + worktree = worktree, + // A PR lookup that hits an auth failure must not be reported as a branch without a PR. + availability = if (availability == GhAvailability.OK) lookup.availability else availability, + pr = lookup.pr, + ) branches[directory] = Timed(System.currentTimeMillis(), dto) dto } @@ -285,6 +288,23 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return Path.of(main.path).normalize() } + /** Branch checked out in the main working tree of the repo containing [root]. */ + private fun baseBranch(root: Path): String? { + val res = runGit(root, "worktree", "list", "--porcelain") + if (!res.ok) return null + return baseBranch(parseWorktreeList(res.stdout)) + } + + /** + * Drops the PR and branch caches so the next poll reflects a mutation immediately. Entries are + * keyed by the requesting directory and a mutation can change any repository the backend has + * answered for, so clear wholesale rather than by key. + */ + private fun invalidate() { + prs.clear() + branches.clear() + } + override suspend fun create(directory: String, request: CreateWorktreeRequestDto): CreateWorktreeResultDto = withContext(Dispatchers.IO) { val base = Path.of(directory).normalize() @@ -303,18 +323,18 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { GhAvailability.UNAUTH -> return@withContext CreateWorktreeResultDto(error = "GitHub CLI (gh) is not authorized") GhAvailability.OK -> Unit } - val view = runGh(base, "pr", "view", ref.number.toString(), "--repo", "${ref.owner}/${ref.repo}", "--json", "headRefName,title") + val fields = "headRefName,title,isCrossRepository,headRepositoryOwner" + val view = runGh(base, "pr", "view", ref.number.toString(), "--repo", "${ref.owner}/${ref.repo}", "--json", fields) if (!view.ok) { LOG.warn("pr import view failed: url=$url exit=${view.exit} stderr=${view.stderr.trim()}") return@withContext CreateWorktreeResultDto(error = view.stderr.ifBlank { "gh pr view failed" }) } - val branch = parsePrHeadRef(view.stdout).ifBlank { "pr-${ref.number}" } - // The pull ref works for both same-repo and fork PRs without adding a fork remote; the - // leading '+' force-updates a stale local branch from a previous import attempt. - val fetch = runGit(base, "fetch", "origin", "+refs/pull/${ref.number}/head:$branch") - if (!fetch.ok) { - LOG.warn("pr import fetch failed: url=$url exit=${fetch.exit} stderr=${fetch.stderr.trim()}") - return@withContext CreateWorktreeResultDto(error = fetch.stderr.ifBlank { "git fetch failed" }) + val head = parsePrHead(view.stdout) + val branch = prBranchName(head, ref.number) + val failure = fetchPrBranch({ args -> runGit(base, args) }, ref.number, head, branch) + if (failure != null) { + LOG.warn("pr import fetch failed: url=$url exit=${failure.exit} stderr=${failure.stderr.trim()}") + return@withContext CreateWorktreeResultDto(error = failure.stderr.ifBlank { "Failed to check out the pull request branch" }) } addWorktree(base, branch, existing = true, baseRef = null) } @@ -347,6 +367,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { return CreateWorktreeResultDto(error = res.stderr.ifBlank { "git worktree add failed" }) } LOG.info("worktree created: branch=$branch dir=$dir") + invalidate() val path = dir.toRealPath().toString() val list = runGit(base, "worktree", "list", "--porcelain") val items = if (list.ok) managedWorktrees(parseWorktreeList(list.stdout)) else emptyList() @@ -393,7 +414,7 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { // 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, "", "") + CmdOut(0, "", "") } else { runGit(base, "worktree", "remove", "--force", target.path) } @@ -411,6 +432,7 @@ 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)"}") + invalidate() 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()}") @@ -488,35 +510,35 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { } } - private data class GitResult(val exit: Int, val stdout: String, val stderr: String) { - val ok get() = exit == 0 - } - private data class Timed(val time: Long, val value: T) - private fun runGit(base: Path, vararg args: String): GitResult { + private fun runGit(base: Path, vararg args: String): CmdOut = runGit(base, args.toList()) + + private fun runGit(base: Path, args: List): CmdOut { return try { val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(base.toFile()) val out = CapturingProcessHandler(cmd).runProcess(30_000) - GitResult(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) + CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) } catch (e: Exception) { - GitResult(-1, "", e.message ?: "git failed") + CmdOut(-1, "", e.message ?: "git failed") } } - private fun runGh(base: Path, vararg args: String): GitResult { + private fun runGh(base: Path, vararg args: String): CmdOut = runGh(base, args.toList()) + + private fun runGh(base: Path, args: List): CmdOut { return try { val cmd = GeneralCommandLine(listOf("gh") + args) .withWorkDirectory(base.toFile()) .withParentEnvironmentType(ParentEnvironmentType.CONSOLE) val out = CapturingProcessHandler(cmd).runProcess(30_000) - GitResult(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) + CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) } catch (e: Exception) { - GitResult(-1, "", e.message ?: "gh failed") + CmdOut(-1, "", e.message ?: "gh failed") } } - private fun add(base: Path, args: List): GitResult { + private fun add(base: Path, args: List): CmdOut { val first = runGit(base, *args.toTypedArray()) if (first.ok || !stale(first.stderr)) return first val prune = runGit(base, "worktree", "prune") @@ -613,13 +635,6 @@ class KiloWorktreeRpcApiImpl : KiloWorktreeRpcApi { value } - private fun prError(stderr: String): GhAvailability { - val text = stderr.lowercase() - if (text.contains("not logged") || text.contains("gh auth login") || text.contains("authentication")) return GhAvailability.UNAUTH - if (text.contains("not found") || text.contains("no pull requests found")) return GhAvailability.OK - return GhAvailability.OK - } - private fun snippet(text: String): String { return text.trim().replace(Regex("\\s+"), " ").take(180) } @@ -650,21 +665,72 @@ internal fun parsePr(path: String, raw: String): WorktreePrDto? { return WorktreePrDto(path, number, state, url, title) } -internal data class PrRef(val owner: String, val repo: String, val number: Int) +/** Head of a pull request being imported. */ +internal data class PrHead(val ref: String = "", val cross: Boolean = false, val owner: String = "") -private val PR_URL = Regex("github\\.com[/:]([^/]+)/([^/]+?)(?:\\.git)?/pull/(\\d+)") - -/** Parses `https://github.com///pull/` (and ssh-style hosts) into its parts. */ -internal fun parsePrUrl(url: String): PrRef? { - val match = PR_URL.find(url.trim()) ?: return null - val number = match.groupValues[3].toIntOrNull() ?: return null - return PrRef(match.groupValues[1], match.groupValues[2], number) +/** Reads the head branch and its repository out of a `gh pr view --json` payload. */ +internal fun parsePrHead(raw: String): PrHead { + val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull() ?: return PrHead() + val ref = obj["headRefName"]?.jsonPrimitive?.content?.trim().orEmpty() + val cross = obj["isCrossRepository"]?.jsonPrimitive?.booleanOrNull == true + val owner = (obj["headRepositoryOwner"] as? JsonObject)?.get("login")?.jsonPrimitive?.content?.trim().orEmpty() + return PrHead(ref, cross, owner) } -/** Reads `headRefName` out of a `gh pr view --json` payload. */ -internal fun parsePrHeadRef(raw: String): String { - val obj = runCatching { json.parseToJsonElement(raw) as? JsonObject }.getOrNull() ?: return "" - return obj["headRefName"]?.jsonPrimitive?.content?.trim().orEmpty() +/** + * Local branch name for an imported PR. Fork PRs are prefixed with their owner so two PRs sharing a + * head branch name — `patch-1` is common — can be imported side by side. + */ +internal fun prBranchName(head: PrHead, number: Int): String { + if (head.ref.isBlank()) return "pr-$number" + val owner = head.owner.lowercase() + return if (head.cross && owner.isNotEmpty()) "$owner/${head.ref}" else head.ref +} + +/** + * Fetches the PR head into [branch] and records which PR it belongs to, mirroring `gh pr checkout`: + * a same-repo PR gets an ordinary upstream (so `git push`/`git pull` work in the imported worktree), + * while a fork PR is tracked through `refs/pull//head`, which `gh` resolves back to the PR + * by number. [run] executes git in the repository. Returns the failing command, or null on success. + */ +internal fun fetchPrBranch(run: (List) -> CmdOut, number: Int, head: PrHead, branch: String): CmdOut? { + val pull = "refs/pull/$number/head" + // A fork head lives in a repository we may have no remote for. The pull ref reaches it without + // adding one, and '+' force-updates a stale branch left by an earlier import attempt. + if (head.cross || head.ref.isBlank()) { + val fetch = run(listOf("fetch", "origin", "+$pull:$branch")) + if (!fetch.ok) return fetch + recordPrBranch(run, branch, pull) + return null + } + val tracking = "refs/remotes/origin/${head.ref}" + val direct = run(listOf("fetch", "origin", "+refs/heads/${head.ref}:$tracking")) + if (!direct.ok) { + // The head branch is gone — merged PR, or the author deleted it — but the pull ref survives. + val fallback = run(listOf("fetch", "origin", "+$pull:$tracking")) + if (!fallback.ok) return fallback + } + val point = run(listOf("branch", "--force", branch, tracking)) + if (!point.ok) return point + recordPrBranch(run, branch, if (direct.ok) "refs/heads/${head.ref}" else pull) + return null +} + +/** + * Records the branch's remote and merge ref. This is what lets a PR be recognised later without + * guessing from the branch name, so a failure only degrades PR detection to slower lookups and must + * never fail the import. + */ +private fun recordPrBranch(run: (List) -> CmdOut, branch: String, merge: String) { + listOf( + listOf("config", "branch.$branch.remote", "origin"), + listOf("config", "branch.$branch.merge", merge), + ).forEach { args -> + val res = run(args) + if (!res.ok) { + KiloWorktreeRpcApiImpl.LOG.warn("pr import config failed: args=$args exit=${res.exit} stderr=${res.stderr.trim()}") + } + } } private val json = Json { prettyPrint = true; ignoreUnknownKeys = true } @@ -737,6 +803,20 @@ internal fun managedWorktrees(items: List): List { } } +/** + * Worktrees eligible for a PR lookup. The main working tree is included — it can sit on a PR branch + * just like a linked worktree — while detached heads have no branch to resolve and prunable entries + * have no checkout left. + */ +internal fun prTargets(items: List): List { + return items.filter { !it.prunable && it.branch != "(detached)" } +} + +/** Branch checked out in the main working tree, or null when it is missing or detached. */ +internal fun baseBranch(items: List): String? { + return items.firstOrNull { it.main }?.branch?.takeIf { it.isNotBlank() && it != "(detached)" } +} + internal fun overlayWorktreeNames(items: List, names: Map): List { if (names.isEmpty()) return items return items.map { item -> diff --git a/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/PrResolver.kt b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/PrResolver.kt new file mode 100644 index 0000000000..46d4270f7c --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/main/kotlin/ai/kilocode/backend/rpc/PrResolver.kt @@ -0,0 +1,102 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.dto.GhAvailability +import ai.kilocode.rpc.dto.WorktreePrDto +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonPrimitive +import java.nio.file.Path + +/** Result of running a `git`/`gh` command. */ +internal data class CmdOut(val exit: Int, val stdout: String, val stderr: String) { + val ok get() = exit == 0 +} + +/** PR for one checkout, plus the gh availability observed while resolving it. */ +internal data class PrLookup(val pr: WorktreePrDto? = null, val availability: GhAvailability = GhAvailability.OK) + +internal const val PR_FIELDS = "number,state,isDraft,url,title" + +/** + * Resolves the pull request a checkout belongs to. A worktree can reach a PR in several ways — + * Kilo's PR import, `gh pr checkout`, a hand-made `git worktree add`, a branch renamed locally, a + * fork PR — so identity is resolved by branch config or head commit rather than by branch name + * alone, in increasing order of cost: + * + * 1. `gh pr view` with no selector. The only form that honours `branch..merge`, so it + * resolves `refs/pull/N/head` branches by PR number and fork PRs through the push remote. + * 2. `gh pr view `. Matches same-repo branches pushed to origin, no branch config needed. + * Cannot match a fork PR: gh compares against `owner:branch` for cross-repository heads. + * 3. `gh pr list --search ""`, accepting only an exact `headRefOid` match. + * + * Commands are injected so the strategy ladder is testable without `gh` or network access. + */ +internal class PrResolver( + private val gh: (Path, List) -> CmdOut, + private val git: (Path, List) -> CmdOut, +) { + /** + * Resolves the PR for the checkout at [path] on [branch]. [base] is the repository's base + * branch; a PR headed by it is not worth a search query, so strategy 3 is skipped there. + */ + fun resolve(path: String, branch: String, base: String?): PrLookup { + val dir = Path.of(path).normalize() + view(dir, path, null)?.let { return it } + view(dir, path, branch)?.let { return it } + if (branch == base) return PrLookup() + return search(dir, path) ?: PrLookup() + } + + /** Null means "no PR here, keep looking"; a value is terminal (a PR, or gh being unusable). */ + private fun view(dir: Path, path: String, branch: String?): PrLookup? { + val args = buildList { + add("pr") + add("view") + branch?.let { add(it) } + add("--json") + add(PR_FIELDS) + } + val out = gh(dir, args) + if (!out.ok) return unusable(out.stderr) + return parsePr(path, out.stdout)?.let { PrLookup(it) } + } + + private fun search(dir: Path, path: String): PrLookup? { + val head = git(dir, listOf("rev-parse", "HEAD")).stdout.trim() + if (head.isEmpty()) return null + val out = gh( + dir, + listOf("pr", "list", "--state", "all", "--search", "$head is:pr", "--limit", "5", "--json", "$PR_FIELDS,headRefOid"), + ) + if (!out.ok) return unusable(out.stderr) + val items = runCatching { json.parseToJsonElement(out.stdout) as? JsonArray }.getOrNull() ?: return null + for (item in items) { + val obj = item as? JsonObject ?: continue + // The search matches commit mentions too, so only an exact head match is our PR. + if (obj["headRefOid"]?.jsonPrimitive?.content != head) continue + parsePr(path, obj.toString())?.let { return PrLookup(it) } + } + return null + } + + private fun unusable(stderr: String): PrLookup? { + val status = prError(stderr) + return if (status == GhAvailability.OK) null else PrLookup(availability = status) + } +} + +/** + * Classifies a failing `gh pr` command. A missing PR is the normal case, so anything that is not a + * recognised authorization failure counts as OK — a missing `gh` binary is caught by the upfront + * availability probe instead. + */ +internal fun prError(stderr: String): GhAvailability { + val text = stderr.lowercase() + if (text.contains("not logged") || text.contains("gh auth login") || text.contains("authentication")) { + return GhAvailability.UNAUTH + } + return GhAvailability.OK +} + +private val json = Json { ignoreUnknownKeys = true } diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt index a06aa09b64..57961dff62 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/app/KiloBackendActivityManagerTest.kt @@ -2,6 +2,7 @@ package ai.kilocode.backend.app import ai.kilocode.backend.testing.TestLog import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.MessageErrorDto import ai.kilocode.rpc.dto.PermissionRequestDto import ai.kilocode.rpc.dto.QuestionInfoDto import ai.kilocode.rpc.dto.QuestionRequestDto @@ -55,7 +56,7 @@ class KiloBackendActivityManagerTest { } @Test - fun `permission asked overlays running and reply reverts`() = runBlocking { + fun `permission asked overlays running and reply reverts`() = runBlocking { directories["ses_1"] = "/repo/wt" statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) start() @@ -69,7 +70,7 @@ class KiloBackendActivityManagerTest { } @Test - fun `question kinds distinguish plain and plan followup`() = runBlocking { + fun `question kinds distinguish plain and plan followup`() = runBlocking { directories["ses_plain"] = "/repo/a" directories["ses_plan"] = "/repo/b" start() @@ -115,12 +116,26 @@ class KiloBackendActivityManagerTest { } @Test - fun `busy outranks a pending error so a resumed session runs`() = runBlocking { + fun `aborted error does not badge the session`() = runBlocking { + directories["ses_1"] = "/repo/wt" + statuses.value = mapOf("ses_1" to SessionStatusDto("busy")) + start() + await("ses_1", SessionActivityKindDto.RUNNING) + + events.emit(ChatEventDto.Error("ses_1", MessageErrorDto(MessageErrorDto.ABORTED, "aborted"))) + statuses.value = mapOf("ses_1" to SessionStatusDto("idle")) + events.emit(ChatEventDto.SessionIdle("ses_1")) + + withTimeout(5_000) { manager.activity.first { "ses_1" !in it } } + assertFalse("ses_1" in manager.activity.value) + } + + @Test + fun `busy outranks a pending provider error so a resumed session runs`() = runBlocking { directories["ses_1"] = "/repo/wt" start() - // A Stop leaves the session errored and idle. - events.emit(ChatEventDto.Error("ses_1")) + events.emit(ChatEventDto.Error("ses_1", MessageErrorDto("APIError", "Provider failed"))) await("ses_1", SessionActivityKindDto.ERROR) // Resumed: busy arrives before anything clears the error. diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt index dbaa975e97..89db0c7d00 100644 --- a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/KiloWorktreeRpcApiImplTest.kt @@ -1,5 +1,6 @@ package ai.kilocode.backend.rpc +import ai.kilocode.rpc.parsePrUrl import ai.kilocode.rpc.dto.CreateWorktreeRequestDto import ai.kilocode.rpc.dto.GhAvailability import ai.kilocode.rpc.dto.GhState @@ -22,11 +23,13 @@ import kotlin.test.assertTrue class KiloWorktreeRpcApiImplTest { private val repo: Path = Files.createTempDirectory("kilo-worktree") + private val remote: Path = Files.createTempDirectory("kilo-origin") private val api = KiloWorktreeRpcApiImpl() @AfterTest fun tearDown() { delete(repo) + delete(remote) } @Test @@ -593,9 +596,118 @@ class KiloWorktreeRpcApiImplTest { } @Test - fun `parsePrHeadRef reads headRefName`() { - assertEquals("feature/login", parsePrHeadRef("""{"headRefName":"feature/login","title":"x"}""")) - assertEquals("", parsePrHeadRef("not json")) + fun `parsePrHead reads head branch and repository`() { + val same = parsePrHead("""{"headRefName":"feature/login","title":"x","isCrossRepository":false}""") + assertEquals("feature/login", same.ref) + assertFalse(same.cross) + + val fork = parsePrHead( + """{"headRefName":"patch-1","isCrossRepository":true,"headRepositoryOwner":{"login":"Contributor"}}""", + ) + assertEquals("patch-1", fork.ref) + assertTrue(fork.cross) + assertEquals("Contributor", fork.owner) + + assertEquals(PrHead(), parsePrHead("not json")) + } + + @Test + fun `prBranchName prefixes fork heads and falls back to the pr number`() { + assertEquals("feature/login", prBranchName(PrHead("feature/login"), 7)) + assertEquals("contributor/patch-1", prBranchName(PrHead("patch-1", cross = true, owner = "Contributor"), 7)) + // A cross-repo PR whose owner gh did not report still needs a usable branch name. + assertEquals("patch-1", prBranchName(PrHead("patch-1", cross = true), 7)) + assertEquals("pr-7", prBranchName(PrHead(), 7)) + } + + @Test + fun `prTargets keeps the main tree and drops detached and prunable entries`() { + val items = listOf( + WorktreeDto("/repo", "repo", "main", "/repo", main = true), + WorktreeDto("/repo/.kilo/worktrees/a", "a", "feature/a", "/repo/.kilo/worktrees/a"), + WorktreeDto("/repo/.kilo/worktrees/detached", "detached", "(detached)", "/repo/.kilo/worktrees/detached"), + WorktreeDto("/repo/.kilo/worktrees/gone", "gone", "feature/gone", "/repo/.kilo/worktrees/gone", prunable = true), + ) + + assertEquals(listOf("/repo", "/repo/.kilo/worktrees/a"), prTargets(items).map { it.path }) + } + + @Test + fun `baseBranch reads the main tree branch and ignores a detached one`() { + val main = WorktreeDto("/repo", "repo", "main", "/repo", main = true) + val linked = WorktreeDto("/repo/.kilo/worktrees/a", "a", "feature/a", "/repo/.kilo/worktrees/a") + + assertEquals("main", baseBranch(listOf(main, linked))) + assertNull(baseBranch(listOf(main.copy(branch = "(detached)"), linked))) + assertNull(baseBranch(listOf(linked))) + } + + @Test + fun `fetchPrBranch tracks the head branch for a same-repo pull request`() { + initRepo() + val origin = originWith(pull = 7, head = "feature/login") + + val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login") + + assertNull(failure, "same-repo import should succeed") + assertEquals("origin", config("branch.feature/login.remote")) + assertEquals("refs/heads/feature/login", config("branch.feature/login.merge")) + assertEquals( + head(origin, "refs/heads/feature/login"), + head(repo, "refs/heads/feature/login"), + "local branch should point at the fetched head", + ) + } + + @Test + fun `fetchPrBranch falls back to the pull ref when the head branch is gone`() { + initRepo() + val origin = originWith(pull = 7, head = "feature/login") + git(origin, "update-ref", "-d", "refs/heads/feature/login") + + val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login") + + assertNull(failure, "import should fall back to the pull ref") + assertEquals("refs/pull/7/head", config("branch.feature/login.merge")) + assertEquals(head(origin, "refs/pull/7/head"), head(repo, "refs/heads/feature/login")) + } + + @Test + fun `fetchPrBranch tracks the pull ref for a fork pull request`() { + initRepo() + val origin = originWith(pull = 7, head = "patch-1") + // A fork head is not on origin at all; only the pull ref can reach it. + git(origin, "update-ref", "-d", "refs/heads/patch-1") + val fork = PrHead("patch-1", cross = true, owner = "contributor") + + val failure = fetchPrBranch(runner(repo), 7, fork, prBranchName(fork, 7)) + + assertNull(failure, "fork import should succeed") + assertEquals("origin", config("branch.contributor/patch-1.remote")) + assertEquals("refs/pull/7/head", config("branch.contributor/patch-1.merge")) + assertEquals(head(origin, "refs/pull/7/head"), head(repo, "refs/heads/contributor/patch-1")) + } + + @Test + fun `fetchPrBranch force updates a branch left by an earlier import`() { + initRepo() + val origin = originWith(pull = 7, head = "feature/login") + git(repo, "branch", "feature/login") + + val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login") + + assertNull(failure, "re-import should refresh the stale branch") + assertEquals(head(origin, "refs/heads/feature/login"), head(repo, "refs/heads/feature/login")) + } + + @Test + fun `fetchPrBranch reports the failing command`() { + initRepo() + + val failure = fetchPrBranch(runner(repo), 7, PrHead("feature/login"), "feature/login") + + assertNotNull(failure, "a repo without origin cannot fetch a pull request") + assertFalse(failure.ok) } @Test @@ -752,6 +864,39 @@ class KiloWorktreeRpcApiImplTest { git(repo, "commit", "-m", "init") } + /** + * Builds an "origin" repository holding [head] plus a `refs/pull//head` ref pointing at it, + * the shape GitHub exposes for a pull request, and registers it as [repo]'s origin. + */ + private fun originWith(pull: Int, head: String): Path { + git(remote, "init") + git(remote, "config", "user.email", "test@kilo.ai") + git(remote, "config", "user.name", "Kilo Test") + Files.writeString(remote.resolve("README.md"), "origin") + git(remote, "add", "README.md") + git(remote, "commit", "-m", "init") + val base = output(remote, "branch", "--show-current").trim() + git(remote, "checkout", "-b", head) + Files.writeString(remote.resolve("pr.txt"), "pr work\n") + git(remote, "add", "pr.txt") + git(remote, "commit", "-m", "pr work") + git(remote, "update-ref", "refs/pull/$pull/head", "refs/heads/$head") + // Leave the PR head unchecked out so tests can delete it to emulate a deleted branch. + git(remote, "checkout", base) + git(repo, "remote", "add", "origin", remote.toString()) + return remote + } + + private fun runner(dir: Path): (List) -> CmdOut = { args -> + val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile()) + val out = CapturingProcessHandler(cmd).runProcess(30_000) + CmdOut(if (out.isTimeout) -1 else out.exitCode, out.stdout, out.stderr) + } + + private fun config(key: String): String = output(repo, "config", "--get", key).trim() + + private fun head(dir: Path, ref: String): String = output(dir, "rev-parse", ref).trim() + private fun git(dir: Path, vararg args: String) { val cmd = GeneralCommandLine(listOf("git") + args).withWorkDirectory(dir.toFile()) val out = CapturingProcessHandler(cmd).runProcess(30_000) diff --git a/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/PrResolverTest.kt b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/PrResolverTest.kt new file mode 100644 index 0000000000..8c0de57dbc --- /dev/null +++ b/packages/kilo-jetbrains/backend/src/test/kotlin/ai/kilocode/backend/rpc/PrResolverTest.kt @@ -0,0 +1,120 @@ +package ai.kilocode.backend.rpc + +import ai.kilocode.rpc.dto.GhAvailability +import ai.kilocode.rpc.dto.GhState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class PrResolverTest { + private val path = "/repo/.kilo/worktrees/feature-x" + private val calls = mutableListOf>() + + @Test + fun `resolves through branch config without falling back`() { + val resolver = resolver(view = { pr(7, "OPEN") }) + + val lookup = resolver.resolve(path, "feature/x", base = "main") + + val pull = assertNotNull(lookup.pr) + assertEquals(7, pull.number) + assertEquals(path, pull.path) + assertEquals(GhState.OPEN, pull.state) + // The config-driven form answered, so the branch selector and the search never run. + assertEquals(listOf(listOf("pr", "view", "--json", PR_FIELDS)), calls) + } + + @Test + fun `falls back to the branch selector when config resolves nothing`() { + val resolver = resolver(view = { args -> if (args.contains("feature/x")) pr(8, "DRAFT") else missing() }) + + val lookup = resolver.resolve(path, "feature/x", base = "main") + + assertEquals(8, assertNotNull(lookup.pr).number) + assertEquals(GhState.DRAFT, lookup.pr?.state) + assertEquals(2, calls.size, "the head search should not run once the branch selector answered") + } + + @Test + fun `falls back to searching the head commit`() { + val resolver = resolver( + view = { missing() }, + list = { ok("""[{"number":9,"state":"MERGED","isDraft":false,"url":"https://pr/9","title":"Fork work","headRefOid":"$SHA"}]""") }, + ) + + val lookup = resolver.resolve(path, "renamed-locally", base = "main") + + val pull = assertNotNull(lookup.pr, "an exact head match should resolve the PR") + assertEquals(9, pull.number) + assertEquals(GhState.MERGED, pull.state) + assertTrue(calls.any { it.contains("$SHA is:pr") }, "the search should use the head sha") + } + + @Test + fun `rejects a search hit whose head commit differs`() { + val resolver = resolver( + view = { missing() }, + // The GitHub search also matches PRs that merely mention the commit. + list = { ok("""[{"number":9,"state":"OPEN","isDraft":false,"url":"https://pr/9","headRefOid":"deadbeef"}]""") }, + ) + + assertNull(resolver.resolve(path, "renamed-locally", base = "main").pr) + } + + @Test + fun `skips the head search for the base branch`() { + val resolver = resolver(view = { missing() }, list = { throw IllegalStateException("must not search") }) + + assertNull(resolver.resolve("/repo", "main", base = "main").pr) + assertEquals(2, calls.size, "only the two view forms should run for the base branch") + } + + @Test + fun `reports an authorization failure instead of a missing pull request`() { + val resolver = resolver(view = { CmdOut(1, "", "gh auth login required") }) + + val lookup = resolver.resolve(path, "feature/x", base = "main") + + assertNull(lookup.pr) + assertEquals(GhAvailability.UNAUTH, lookup.availability) + assertEquals(1, calls.size, "an unusable gh must stop the ladder immediately") + } + + @Test + fun `treats a missing pull request as a clean result`() { + val resolver = resolver(view = { missing() }, list = { ok("[]") }) + + val lookup = resolver.resolve(path, "feature/x", base = "main") + + assertNull(lookup.pr) + assertEquals(GhAvailability.OK, lookup.availability) + } + + private fun resolver( + view: (List) -> CmdOut, + list: (List) -> CmdOut = { ok("[]") }, + ): PrResolver = PrResolver( + gh = { _, args -> + calls.add(args) + if (args.getOrNull(1) == "list") list(args) else view(args) + }, + git = { _, args -> + calls.add(args) + assertEquals(listOf("rev-parse", "HEAD"), args) + ok("$SHA\n") + }, + ) + + private fun pr(number: Int, state: String): CmdOut = + ok("""{"number":$number,"state":"$state","isDraft":${state == "DRAFT"},"url":"https://pr/$number","title":"Work"}""") + + private fun ok(stdout: String) = CmdOut(0, stdout, "") + + private fun missing() = CmdOut(1, "", "no pull requests found for branch \"feature/x\"") + + private companion object { + const val SHA = "1111111111111111111111111111111111111111" + } +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt index c575dcb7c3..8f44a2c8f8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/AgentManagerPanel.kt @@ -1,8 +1,11 @@ package ai.kilocode.client.agentManager import ai.kilocode.client.KiloNotifications +import ai.kilocode.client.agentManager.worktree.CreateFailure +import ai.kilocode.client.agentManager.worktree.CreateKind import ai.kilocode.client.agentManager.worktree.NewWorktreeDialog import ai.kilocode.client.agentManager.worktree.NewWorktreeHandle +import ai.kilocode.client.agentManager.worktree.NewWorktreePlan import ai.kilocode.client.agentManager.worktree.GhBanner import ai.kilocode.client.agentManager.worktree.WorktreeController import ai.kilocode.client.agentManager.worktree.WorktreeDataKeys @@ -24,6 +27,7 @@ import ai.kilocode.client.diff.diffParams import ai.kilocode.client.diff.ensureDiffEditorKind import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.SessionActivityKind +import ai.kilocode.client.telemetry.Telemetry import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.list.ActiveList import ai.kilocode.client.ui.list.ActiveListBadge @@ -133,6 +137,13 @@ class AgentManagerPanel( if (list.select(key)) list.focusList() item(key)?.takeIf { controller.progress(it.id) == null }?.let { open(it, focus = false) } } + // A fresh worktree changes what git reports, so bypass the refresh throttle instead of + // leaving the new row without its stats and PR badge until the next poll. + controller.onCreated = { + project?.service()?.refreshStats() + project?.service()?.refreshPr(force = true) + } + controller.onReload = { sync() } controller.onCreateFailure = { err -> notifyCreateFailed(err) } controller.onMoveFailure = { err -> notifyMoveFailed(err) } controller.onRemoveSuccess = { item, index -> onRemoved(item, index) } @@ -183,7 +194,17 @@ class AgentManagerPanel( if (!handle.showAndGet()) return val plan = handle.result() ?: return onCreate() - controller.create(plan.branch, plan.base, prompt = plan.prompt) + when (plan) { + is NewWorktreePlan.Create -> controller.create(plan.branch, plan.base, prompt = plan.prompt) + is NewWorktreePlan.Branch -> { + Telemetry.send("Worktree Import Submitted", mapOf("kind" to "branch")) + controller.importBranch(plan.branch) + } + is NewWorktreePlan.Pr -> { + Telemetry.send("Worktree Import Submitted", mapOf("kind" to "pr")) + controller.importPr(plan.url) + } + } } internal fun move(sessionId: String?, directory: String) = controller.move(sessionId, directory) @@ -241,7 +262,7 @@ class AgentManagerPanel( /** The PR URL for [item], or null when it has none or is not in a stable, openable state. */ private fun prUrl(item: WorktreeDto?): String? { - if (item == null || item.main) return null + if (item == null) return null if (controller.progress(item.id) != null) return null return prs[normalizeWorktreePath(item.path)]?.url } @@ -322,8 +343,13 @@ class AgentManagerPanel( return controller.model.getElementAt(index.coerceIn(0, size - 1)) } - private fun notifyCreateFailed(err: String?) { - KiloNotifications.error(project, KiloBundle.message("worktree.create.failed.title"), err) + private fun notifyCreateFailed(failure: CreateFailure) { + val title = when (failure.kind) { + CreateKind.CREATE -> KiloBundle.message("worktree.create.failed.title") + CreateKind.BRANCH -> KiloBundle.message("worktree.import.branch.failed.title", failure.branch) + CreateKind.PR -> KiloBundle.message("worktree.import.pr.failed.title") + } + KiloNotifications.error(project, title, failure.error) } private fun notifyMoveFailed(err: String?) { @@ -374,7 +400,8 @@ class AgentManagerPanel( progress = null, kind = controller.kind(item.path), stats = null, - pr = null, + // The main checkout can sit on a PR branch just like a worktree can. + pr = prs[normalizeWorktreePath(item.path)], current = true, ) } @@ -434,6 +461,8 @@ class AgentManagerPanel( override fun dispose() { controller.onSelect = null + controller.onCreated = null + controller.onReload = null controller.onCreateFailure = null controller.onMoveFailure = null controller.onRemoveSuccess = null diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/BranchPicker.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/BranchPicker.kt new file mode 100644 index 0000000000..beafaea679 --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/BranchPicker.kt @@ -0,0 +1,127 @@ +package ai.kilocode.client.agentManager.worktree + +import ai.kilocode.client.session.ui.prompt.PromptFuzzyRanker +import com.intellij.openapi.ui.ComboBox +import com.intellij.ui.DocumentAdapter +import java.awt.Dimension +import java.awt.event.FocusAdapter +import java.awt.event.FocusEvent +import javax.swing.ComboBoxModel +import javax.swing.DefaultComboBoxModel +import javax.swing.JTextField +import javax.swing.event.DocumentEvent +import javax.swing.plaf.basic.BasicComboBoxUI +import javax.swing.plaf.basic.BasicComboPopup + +internal class BranchPicker(branches: List, private val default: String = "") : + ComboBox(model(branches, default)) { + private val branches = ordered(branches, default) + private val set = this.branches.toSet() + private var syncing = false + + val empty: Boolean get() = branches.isEmpty() + + init { + isEditable = true + if (default.isNotBlank()) selectedItem = default + wire() + } + + override fun getPreferredSize(): Dimension { + ensureEditor() + return super.getPreferredSize() + } + + fun resolve(): String? { + val value = text() + if (value.isEmpty()) { + val fallback = default.trim() + if (fallback.isNotEmpty()) set(fallback) + return fallback.takeIf { it.isNotEmpty() } + } + if (value in set) return value + val idx = match(value) ?: return value + val target = branches[idx] + set(target) + return target + } + + fun known(value: String?): Boolean = value == null || value in set + + fun focusText() { + field()?.apply { + requestFocusInWindow() + selectAll() + } + } + + private fun wire() { + val field = field() ?: return + field.document.addDocumentListener(object : DocumentAdapter() { + override fun textChanged(e: DocumentEvent) { + if (!syncing) sync(field.text, popup = true) + } + }) + field.addFocusListener(object : FocusAdapter() { + override fun focusLost(e: FocusEvent) { + restore() + } + }) + } + + private fun restore() { + if (default.isBlank() || text().isNotEmpty()) return + set(default) + } + + private fun sync(text: String, popup: Boolean) { + val value = text.trim() + if (value.isEmpty()) return + if (popup && isShowing && !isPopupVisible) isPopupVisible = true + val idx = match(value) ?: return + val list = popupList() ?: return + if (list.selectedIndex != idx) list.selectedIndex = idx + list.ensureIndexIsVisible(idx) + } + + private fun match(text: String): Int? { + val rank = PromptFuzzyRanker(text) + return branches.withIndex().mapNotNull { item -> + rank.score(item.value, emptyList())?.let { score -> item.index to score } + }.maxByOrNull { it.second }?.first + } + + private fun popupList() = (getAccessibleContext()?.getAccessibleChild(0) as? BasicComboPopup)?.list + + private fun field() = editor.editorComponent as? JTextField + + private fun text() = field()?.text?.trim() ?: editor.item?.toString()?.trim().orEmpty() + + private fun ensureEditor() { + if (!isEditable) return + val ui = ui as? BasicComboBoxUI ?: return + val comp = editor.editorComponent ?: return + if (components.none { it === comp }) ui.addEditor() + } + + private fun set(value: String) { + syncing = true + try { + selectedItem = value + field()?.text = value + } finally { + syncing = false + } + } +} + +private fun ordered(branches: List, default: String): List { + val ordered = LinkedHashSet() + if (default.isNotBlank()) ordered.add(default) + ordered.addAll(branches) + return ordered.toList() +} + +private fun model(branches: List, default: String): ComboBoxModel { + return DefaultComboBoxModel(ordered(branches, default).toTypedArray()) +} diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt index 0f7366015c..05be98ba1f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialog.kt @@ -10,22 +10,29 @@ import ai.kilocode.client.session.ui.model.ModelPicker import ai.kilocode.client.session.ui.model.modelItems import ai.kilocode.client.session.ui.prompt.KiloPromptCompletionProvider import ai.kilocode.client.session.ui.prompt.MentionAction -import ai.kilocode.client.session.ui.prompt.PromptFuzzyRanker import ai.kilocode.client.session.ui.prompt.PromptPanel import ai.kilocode.client.session.ui.prompt.SlashAction +import ai.kilocode.client.settings.base.BaseContentPanel +import ai.kilocode.client.settings.base.SettingsRows +import ai.kilocode.client.settings.base.SettingsStackedRow import ai.kilocode.client.ui.UiStyle import ai.kilocode.client.ui.layout.Stack import ai.kilocode.rpc.dto.ModelsWorkspaceDto +import ai.kilocode.rpc.parsePrUrl import com.intellij.openapi.application.ApplicationManager import com.intellij.openapi.application.ModalityState import com.intellij.openapi.components.service import com.intellij.openapi.project.Project -import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.ui.DialogWrapper -import com.intellij.ui.DocumentAdapter import com.intellij.ui.components.JBTextField +import com.intellij.ui.tabs.JBTabs +import com.intellij.ui.tabs.JBTabsFactory +import com.intellij.ui.tabs.JBTabsPosition +import com.intellij.ui.tabs.TabInfo +import com.intellij.ui.tabs.TabsListener import com.intellij.util.ui.FormBuilder import com.intellij.util.ui.JBUI +import com.intellij.util.ui.components.BorderLayoutPanel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -33,19 +40,16 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import java.awt.Component import java.awt.GridBagConstraints -import java.awt.event.FocusAdapter -import java.awt.event.FocusEvent -import javax.swing.ComboBoxModel -import javax.swing.DefaultComboBoxModel import javax.swing.JComponent -import javax.swing.JTextField -import javax.swing.event.DocumentEvent -import javax.swing.plaf.basic.BasicComboPopup private const val NAME_COLUMNS = 67 /** What the user confirmed in the New Worktree dialog. */ -data class NewWorktreePlan(val branch: String, val base: String?, val prompt: PendingPrompt?) +sealed interface NewWorktreePlan { + data class Create(val branch: String, val base: String?, val prompt: PendingPrompt?) : NewWorktreePlan + data class Branch(val branch: String) : NewWorktreePlan + data class Pr(val url: String) : NewWorktreePlan +} /** * The New Worktree dialog as seen by its caller: show it, then read what the user confirmed. @@ -57,10 +61,16 @@ interface NewWorktreeHandle { } /** - * New Worktree dialog with parity to the VS Code Agent Manager dialog: a worktree name (top), an - * initial prompt with the same mode / model / reasoning pickers as the chat prompt (center), and the - * branch name + base branch (bottom). Creating a worktree starts a session automatically with the - * prompt. + * New Worktree dialog with parity to the VS Code Agent Manager dialog, split into three tabs: + * + * - **New** creates a branch: a worktree name (top), an initial prompt with the same mode / model / + * reasoning pickers as the chat prompt (center), and the branch name + base branch (bottom). + * Creating a worktree starts a session automatically with the prompt. + * - **From PR** checks out a GitHub pull request by URL. + * - **From Branch** checks out a local branch that no worktree holds yet. + * + * Both import tabs carry no initial prompt, so the worktree opens with an empty session. The + * selected tab alone decides which input the OK button acts on. * * The dialog performs no worktree work itself — it records the confirmed [result] and closes; the * panel then drives the controller, so no view switch or worktree work runs while the modal dialog @@ -103,13 +113,12 @@ internal class NewWorktreeDialog( showEnhance = false, ) private val branch = JBTextField(suggestedName) - private val bases = baseBranches(branches, defaultBase) - private val baseSet = bases.toSet() - private val base = ComboBox(baseModel(bases)).apply { - isEditable = true - selectedItem = defaultBase + private val base = BranchPicker(branches, defaultBase) + private val url = JBTextField().apply { + emptyText.text = KiloBundle.message("worktree.import.pr.placeholder") } - private var syncing = false + private val pick = BranchPicker(branches) + private var tab = DialogTab.NEW private var plan: NewWorktreePlan? = null @@ -128,25 +137,25 @@ internal class NewWorktreeDialog( private var center: JComponent? = null init { - wireBase() + if (pick.empty) pick.isEnabled = false title = KiloBundle.message("worktree.configure.title") init() setOKButtonText(KiloBundle.message("worktree.dialog.create")) } - override fun createCenterPanel(): JComponent = content().also { center = it } + override fun createCenterPanel(): JComponent = tabs().also { center = it } /** The built content, so tests can drive the real Swing tree before the dialog is shown. */ internal fun centerComponent(): JComponent = center ?: error("center panel not built") override fun result(): NewWorktreePlan? = plan - override fun getPreferredFocusedComponent(): JComponent = prompt.defaultFocusedComponent + override fun getPreferredFocusedComponent(): JComponent = focus() // Versioned: DialogWrapper persists the size per key, so a stale entry would keep the old width. - override fun getDimensionServiceKey(): String = "ai.kilocode.NewWorktreeDialog.v2" + override fun getDimensionServiceKey(): String = "ai.kilocode.NewWorktreeDialog.v3" - override fun doOKAction() = submitCreate() + override fun doOKAction() = submit() override fun dispose() { disposed = true @@ -154,7 +163,50 @@ internal class NewWorktreeDialog( super.dispose() } - private fun content(): JComponent { + internal fun submit() { + setErrorText(null) + when (tab) { + DialogTab.PR -> submitPr() + DialogTab.BRANCH -> submitBranch() + DialogTab.NEW -> submitCreate() + } + } + + private fun tabs(): JComponent { + val fresh = TabInfo(newContent()).setText(KiloBundle.message("worktree.dialog.tab.new")) + val pr = TabInfo(prContent()).setText(KiloBundle.message("worktree.dialog.tab.pr")) + val local = TabInfo(branchContent()).setText(KiloBundle.message("worktree.dialog.tab.branch")) + val tabs: JBTabs = JBTabsFactory.createTabs(project, disposable).apply { + presentation.setSingleRow(true) + presentation.setTabsPosition(JBTabsPosition.top) + presentation.showBorder = false + addTab(fresh).setPreferredFocusableComponent(prompt.defaultFocusedComponent) + addTab(pr).setPreferredFocusableComponent(url) + addTab(local).setPreferredFocusableComponent(pick) + addListener(object : TabsListener { + override fun beforeSelectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) { + // JBTabs defers removing the old body while focus settles, and that body keeps + // its previous bounds. Hide it before layout so stale content cannot paint over + // the newly selected tab. + newSelection?.component?.isVisible = true + oldSelection?.component?.isVisible = false + } + + override fun selectionChanged(oldSelection: TabInfo?, newSelection: TabInfo?) { + tab = when { + newSelection === pr -> DialogTab.PR + newSelection === local -> DialogTab.BRANCH + else -> DialogTab.NEW + } + setOKButtonText(KiloBundle.message(if (tab == DialogTab.NEW) "worktree.dialog.create" else "worktree.dialog.import")) + ui { focus().requestFocusInWindow() } + } + }, disposable) + } + return tabs.component + } + + private fun newContent(): JComponent { wirePickers() loadModels() return Stack.vertical(gap = UiStyle.Gap.pad()) @@ -164,6 +216,27 @@ internal class NewWorktreeDialog( .apply { border = JBUI.Borders.empty(UiStyle.Gap.sm()) } } + private fun prContent(): JComponent = importContent(SettingsStackedRow( + KiloBundle.message("worktree.import.pr.section"), + description = KiloBundle.message("worktree.import.pr.description"), + value = url, + )) + + private fun branchContent(): JComponent = importContent(SettingsStackedRow( + KiloBundle.message("worktree.import.branch.section"), + description = KiloBundle.message(if (pick.empty) "worktree.import.branch.empty" else "worktree.import.branch.description"), + value = pick, + )) + + private fun importContent(row: JComponent): JComponent { + val body = BaseContentPanel().apply { + border = JBUI.Borders.empty(UiStyle.Gap.pad(), UiStyle.Gap.sm(), UiStyle.Gap.pad(), UiStyle.Gap.sm()) + } + body.next(SettingsRows().row(row)) + // Pinned to the top: the import forms are shorter than the New tab, which sizes the dialog. + return BorderLayoutPanel().apply { addToTop(body) } + } + // A FormBuilder that stretches every field to the full width, so the base-branch combo matches // the name field and prompt above it. private fun fields(): JComponent = object : FormBuilder() { @@ -229,99 +302,60 @@ internal class NewWorktreeDialog( ) } - private fun wireBase() { - val field = baseField() ?: return - field.document.addDocumentListener(object : DocumentAdapter() { - override fun textChanged(e: DocumentEvent) { - if (!syncing) syncBase(field.text, popup = true) - } - }) - field.addFocusListener(object : FocusAdapter() { - override fun focusLost(e: FocusEvent) { - restoreBase() - } - }) - } - - private fun restoreBase() { - if (baseText().isNotEmpty() || defaultBase.isBlank()) return - setBase(defaultBase) - } - - private fun syncBase(text: String, popup: Boolean) { - val value = text.trim() - if (value.isEmpty()) return - if (popup && base.isShowing && !base.isPopupVisible) { - base.isPopupVisible = true - } - val idx = matchBase(value) ?: return - val list = popupList() ?: return - if (list.selectedIndex != idx) list.selectedIndex = idx - list.ensureIndexIsVisible(idx) - } - - private fun matchBase(text: String): Int? { - val rank = PromptFuzzyRanker(text) - return bases.withIndex().mapNotNull { item -> - rank.score(item.value, emptyList())?.let { score -> item.index to score } - }.maxByOrNull { it.second }?.first - } - - private fun popupList() = (base.accessibleContext?.getAccessibleChild(0) as? BasicComboPopup)?.list - - private fun baseField() = base.editor.editorComponent as? JTextField - - private fun baseText() = baseField()?.text?.trim() - ?: base.editor.item?.toString()?.trim().orEmpty() - - private fun setBase(value: String) { - syncing = true - try { - base.selectedItem = value - baseField()?.text = value - } finally { - syncing = false - } - } - - private fun resolvedBase(): String? { - val value = baseText() - if (value.isEmpty()) { - val fallback = defaultBase.trim() - if (fallback.isNotEmpty()) setBase(fallback) - return fallback.takeIf { it.isNotEmpty() } - } - if (value in baseSet) return value - val idx = matchBase(value) ?: return value - val target = bases[idx] - setBase(target) - return target - } - private fun validBase(value: String?): Boolean { - if (value == null || value in baseSet) return true + if (base.known(value)) return true KiloNotifications.error( project, KiloBundle.message("worktree.configure.base.invalid.title"), - KiloBundle.message("worktree.configure.base.invalid.content", value), + KiloBundle.message("worktree.configure.base.invalid.content", value.orEmpty()), ) - baseField()?.apply { - requestFocusInWindow() - selectAll() - } - syncBase(value, popup = true) + base.focusText() return false } private fun submitCreate(text: String = prompt.text()) { val explicit = branch.text.trim() val resolved = explicit.ifEmpty { name.text.trim() }.ifEmpty { suggestedName } - val target = resolvedBase() + val target = base.resolve() if (!validBase(target)) return - plan = NewWorktreePlan(resolved, target, pending(text)) + plan = NewWorktreePlan.Create(resolved, target, pending(text)) close(OK_EXIT_CODE) } + private fun submitPr() { + val value = url.text.trim() + if (value.isEmpty()) { + setErrorText(KiloBundle.message("worktree.import.pr.required"), url) + url.requestFocusInWindow() + return + } + if (parsePrUrl(value) == null) { + setErrorText(KiloBundle.message("worktree.import.pr.invalid"), url) + url.requestFocusInWindow() + url.selectAll() + return + } + plan = NewWorktreePlan.Pr(value) + close(OK_EXIT_CODE) + } + + private fun submitBranch() { + val target = pick.resolve() + if (target == null || !pick.known(target)) { + setErrorText(KiloBundle.message("worktree.import.branch.invalid"), pick) + pick.focusText() + return + } + plan = NewWorktreePlan.Branch(target) + close(OK_EXIT_CODE) + } + + private fun focus(): JComponent = when (tab) { + DialogTab.PR -> url + DialogTab.BRANCH -> pick + DialogTab.NEW -> prompt.defaultFocusedComponent + } + /** Bundles the typed prompt with the picked mode / model / reasoning, or null when empty. */ private fun pending(text: String): PendingPrompt? { val body = text.trim() @@ -361,16 +395,7 @@ internal class NewWorktreeDialog( spec.available, ) - private fun baseBranches(branches: List, default: String): List { - val ordered = LinkedHashSet() - if (default.isNotBlank()) ordered.add(default) - ordered.addAll(branches) - return ordered.toList() - } - - private fun baseModel(branches: List): ComboBoxModel { - return DefaultComboBoxModel(branches.toTypedArray()) - } - private fun variantTitle(value: String): String = value.replaceFirstChar { it.titlecase() } + + private enum class DialogTab { NEW, PR, BRANCH } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt index a0ba0f8fb4..a43fdd7046 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/agentManager/worktree/WorktreeController.kt @@ -20,6 +20,10 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +enum class CreateKind { CREATE, BRANCH, PR } + +data class CreateFailure(val error: String?, val kind: CreateKind, val branch: String) + /** * Owns the worktree list model and drives the [KiloWorktreeService] off the EDT. Model mutations * are marshalled back onto the EDT via [edt]. Mirrors the History stack's controller shape. @@ -37,7 +41,17 @@ class WorktreeController( private val tasks = LinkedHashMap() private val moves = LinkedHashSet() var onSelect: ((String) -> Unit)? = null - var onCreateFailure: ((String?) -> Unit)? = null + + /** Fired on the EDT once a worktree exists, so callers can refresh state git just changed. */ + var onCreated: ((WorktreeDto) -> Unit)? = null + + /** + * Fired on the EDT after a reload settled. Replacing the model only notifies list listeners when + * the rows actually changed, so a repo whose sole worktree is the main one would otherwise never + * render its [current] row. + */ + var onReload: (() -> Unit)? = null + var onCreateFailure: ((CreateFailure) -> Unit)? = null var onMoveFailure: ((String?) -> Unit)? = null var onRemoveSuccess: ((WorktreeDto, Int) -> Unit)? = null var onActivityChanged: (() -> Unit)? = null @@ -97,6 +111,7 @@ class WorktreeController( val worktreeBranches = rows.mapTo(HashSet()) { it.branch } branches = branchInfo.branches.filter { it !in worktreeBranches } known = branchInfo.branches.toMutableSet().apply { addAll(rows.map { it.branch }) } + onReload?.invoke() telemetry("Worktree List Loaded", mapOf("count" to extra.size.toString())) } } @@ -109,13 +124,19 @@ class WorktreeController( fun quickCreate() = create(suggestName(), defaultBranch) /** Imports a worktree that checks out an existing local branch. */ - fun importBranch(branch: String) = create(branch, base = null, existingBranch = true) + fun importBranch(branch: String) = create(branch, base = null, existingBranch = true, kind = CreateKind.BRANCH) /** * Creates a worktree. When [prompt] is set, it is stashed for the worktree's first session so the * editor auto-sends it once it opens with its picked mode/model (see [PendingWorktreePrompt]). */ - fun create(branch: String, base: String?, existingBranch: Boolean = false, prompt: PendingPrompt? = null) { + fun create( + branch: String, + base: String?, + existingBranch: Boolean = false, + prompt: PendingPrompt? = null, + kind: CreateKind = CreateKind.CREATE, + ) { val id = "pending:$branch:${System.nanoTime()}" val temp = WorktreeDto(id, branch, branch, id) edt { @@ -126,7 +147,7 @@ class WorktreeController( } cs.launch { val result = service.create(directory, CreateWorktreeRequestDto(branch, base, existingBranch)) - finishCreate(temp, branch, prompt, result) + finishCreate(temp, branch, prompt, result, kind) } } @@ -141,7 +162,7 @@ class WorktreeController( } cs.launch { val result = service.importPr(directory, url) - finishCreate(temp, "pr", null, result) + finishCreate(temp, "pr", null, result, CreateKind.PR) } } @@ -150,6 +171,7 @@ class WorktreeController( branch: String, prompt: PendingPrompt?, result: CreateWorktreeResultDto, + kind: CreateKind, ) { val created = result.worktree edt { @@ -161,12 +183,13 @@ class WorktreeController( cache().put(created) prompt?.let { service().put(created.path, it) } onSelect?.invoke(created.id) + onCreated?.invoke(created) telemetry("Worktree Created", mapOf("branch" to branch)) return@edt } if (idx >= 0) model.remove(temp) telemetry("Worktree Create Failed", mapOf("branch" to branch)) - onCreateFailure?.invoke(result.error) + onCreateFailure?.invoke(CreateFailure(result.error, kind, branch)) } } @@ -249,6 +272,7 @@ class WorktreeController( // open; the tab's identity stays the worktree path alone. event.session?.let { service().put(worktree.path, it) } onSelect?.invoke(worktree.id) + onCreated?.invoke(worktree) telemetry( "Continue in Worktree", mapOf("surface" to "sidebar", "session" to (sessionId != null).toString()), diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt index 8d9a07ff8e..b340f6c8be 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/SessionUi.kt @@ -400,6 +400,8 @@ class SessionUi( outcome = SessionOutcomeView( selection = selection, focus = focus, + retry = if (readonly) null else controller::retry, + retryable = controller::canRetry, ) messageBody = SessionMessageListPanel( controller.model, diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt index 7d42ce9455..c79e3c282b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/controller/SessionController.kt @@ -24,7 +24,6 @@ import ai.kilocode.client.session.ui.mode.agentTitle import ai.kilocode.client.session.model.ToolCallRef import ai.kilocode.client.session.model.Text import ai.kilocode.client.session.model.Outcome -import ai.kilocode.client.session.model.OutcomeTone import ai.kilocode.client.session.model.TurnOutcome import ai.kilocode.client.plugin.KiloPluginSettings import ai.kilocode.client.session.SessionRef @@ -126,7 +125,6 @@ class SessionController( companion object { private val LOG = KiloLog.create(SessionController::class.java) - private const val ABORT_ERROR = "MessageAbortedError" internal const val RECENT_LIMIT = 5 internal const val DISPLAY_DELAY_MS = 1_000L internal const val REVERT_TIMEOUT_MS = 30_000L @@ -489,6 +487,98 @@ class SessionController( } } + /** + * Re-runs the last user turn after it failed, discarding the failed assistant turn first. + * + * Reverting to the failed assistant message restores the workspace when that turn already edited + * files (a no-op server-side when it edited nothing), and the prompt that follows is what actually + * removes the message: `SessionRevert.cleanup` drops everything at or after the revert target on the + * next prompt. The replay reuses the original user message id, so no synthetic message is appended. + * + * A turn that failed before the assistant message existed (model resolution, missing provider + * credentials) has nothing to roll back, so that path skips the revert and only replays. + */ + fun retry() { + assertEdt() + val id = sid ?: return + val target = retryTarget() ?: return + LOG.info("${ChatLogSummary.sid(id)} kind=retry clicked=true message=${target.assistant ?: "none"}") + val op = beginReverting( + KiloBundle.message("session.status.retrying"), + // No rollback marker: the transcript should not paint the failed turn as a revert target, + // it is about to be replaced. SessionMessageListPanel only marks when message != null. + SessionState.Reverting.Kind.ROLLBACK, + message = null, + ) ?: return + revertJob = cs.launch { + try { + target.assistant?.let { + sessions.revert(id, directory, it, null) + synchronizeFromDisk(id, "retry") + } + capture("Session Retry", sessionProps(id) + mapOf("rolledBack" to (target.assistant != null).toString())) + edt { + if (disposed) return@edt + clearReverting(op) + model.setState(SessionState.Busy(KiloBundle.message("session.status.considering"))) + } + sessions.prompt(id, directory, target.prompt) + LOG.info("${ChatLogSummary.sid(id)} kind=retry ok=true") + } catch (e: CancellationException) { + edt { cancelReverting(op) } + } catch (e: Exception) { + capture("Session Error", sessionProps(id) + mapOf("context" to "retry", "errorClass" to e::class.java.name)) + LOG.warn("${ChatLogSummary.sid(id)} kind=retry dir=${ChatLogSummary.dir(directory)} failed message=${e.message}", e) + edt { + if (disposed) return@edt + // The revert may already have landed. Leave it applied and surface the failure so the + // user can retry again or redo, rather than silently dropping back to idle. + if (revertOp?.key == op.key) failReverting(op, e) + else model.setState(SessionState.Error(e.message ?: KiloBundle.message("session.error.prompt"))) + } + } + } + } + + /** Whether the error card should offer Retry. Gates the action so it is never painted as a no-op. */ + @RequiresEdt + fun canRetry(): Boolean = retryTarget() != null + + /** + * The failed tail turn to replay, or null when retry does not apply: no session, an operation already + * in flight, a busy session, a turn that did not fail, or a tail that is neither the last user message + * nor the assistant that failed answering it. + */ + private fun retryTarget(): RetryTarget? { + assertEdt() + if (sid == null) return null + if (revertOp != null) return null + if (model.state.isBusy()) return null + val tail = model.messages().lastOrNull() ?: return null + val err = tail.info.error + val state = model.state + val failed = when { + // A user stop also lands an errored tail (MessageAbortedError), and it is not a failure. + err != null -> !err.aborted + // A turn that completed cleanly is not retryable even when a session-level error arrives + // afterwards: replaying it would revert work the model actually delivered. + tail.info.role == "assistant" && tail.info.time.completed != null -> false + else -> state is SessionState.Error || + (state is SessionState.TurnEnded && state.outcome == Outcome.FAILED) + } + if (!failed) return null + val prompt = retryPromptCurrent() ?: return null + // The failure hit before the assistant message existed — model resolution and provider + // credentials are checked ahead of it — so the user turn is the tail and nothing needs rolling + // back. + if (tail.info.id == prompt.messageID) return RetryTarget(null, prompt) + if (tail.info.role != "assistant") return null + if (tail.info.parentID != prompt.messageID) return null + return RetryTarget(tail.info.id, prompt) + } + + private data class RetryTarget(val assistant: String?, val prompt: PromptDto) + fun deleteQueuedMessage(message: String) { assertEdt() val id = sid ?: return @@ -1422,8 +1512,8 @@ class SessionController( private fun seedOutcome() { val err = model.messages().lastOrNull { it.info.role == "assistant" }?.info?.error ?: return - if (err.type == ABORT_ERROR) { - model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED, OutcomeTone.WARNING)) + if (err.aborted) { + model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED)) return } model.setState(SessionState.Error(err.message ?: err.type, err.type)) @@ -1510,7 +1600,7 @@ class SessionController( if (current is SessionState.Error && event.reason != "completed") return val ended = TurnOutcome.classify(event.reason) when { - ended != null -> model.setState(SessionState.TurnEnded(ended.first, ended.second)) + ended != null -> model.setState(SessionState.TurnEnded(ended)) event.reason == "completed" -> { capture("Task Completed", sessionProps(event.sessionID)) model.setState(SessionState.Idle) @@ -1522,7 +1612,7 @@ class SessionController( is ChatEventDto.SessionCreated -> adoptFollowup(event.info) is ChatEventDto.Error -> { - if (event.error?.type != ABORT_ERROR) { + if (event.error?.aborted != true) { capture("Session Error", sessionProps(event.sessionID) + mapOf("context" to "event", "errorClass" to (event.error?.type ?: "unknown"))) } error(event, true) @@ -1645,7 +1735,7 @@ class SessionController( model.setState(SessionState.LoginRequired(KiloBundle.message("session.login.required.description"))) return } - if (event.error?.type == ABORT_ERROR) return + if (event.error?.aborted == true) return val msg = event.error?.message ?: event.error?.type ?: KiloBundle.message("session.error.unknown") model.setState(SessionState.Error(msg, event.error?.type)) } @@ -1877,6 +1967,12 @@ class SessionController( } } + /** + * Replays the last user message with the agent/model recorded on it. + * + * Login resume needs exactly this: the user authenticated for the model that demanded it, so + * resuming must use that model rather than whatever is selected now. + */ private fun retryPrompt(): PromptDto? { val msg = model.messages().lastOrNull { it.info.role == "user" } ?: return null return PromptDto( @@ -1890,6 +1986,24 @@ class SessionController( ) } + /** + * Like [retryPrompt], but honours the *current* model/agent/effort selection. + * + * A turn usually fails because of the model it ran with — missing credentials, provider overload, + * context limit — so switching model or effort and pressing Retry has to pick that change up. + * Resolution mirrors [promptDto]; the recorded values are only a fallback for when no selection has + * resolved yet. + */ + private fun retryPromptCurrent(): PromptDto? { + val base = retryPrompt() ?: return null + val sel = model.model?.let(::parseModel) + return base.copy( + providerID = sel?.first ?: base.providerID, + modelID = sel?.second ?: base.modelID, + agent = model.agent ?: base.agent, + ) + } + private fun resumeAfterLogin() { assertEdt() val retry = loginRetry diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt index a55a225357..5223710761 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/SessionState.kt @@ -22,7 +22,7 @@ sealed class SessionState { data class Error(val message: String, val kind: String? = null) : SessionState() - data class TurnEnded(val outcome: Outcome, val tone: OutcomeTone) : SessionState() + data class TurnEnded(val outcome: Outcome) : SessionState() data class LoginRequired(val message: String) : SessionState() diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/TurnOutcome.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/TurnOutcome.kt index 137526d25d..009cd27834 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/TurnOutcome.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/model/TurnOutcome.kt @@ -2,12 +2,14 @@ package ai.kilocode.client.session.model enum class Outcome { INTERRUPTED, FAILED } -enum class OutcomeTone { WARNING, CRITICAL } - object TurnOutcome { - fun classify(reason: String): Pair? = when (reason) { - "interrupted" -> Outcome.INTERRUPTED to OutcomeTone.WARNING - "error" -> Outcome.FAILED to OutcomeTone.CRITICAL + /** + * Maps a `session.turn.close` reason to the outcome the transcript should show. `completed` and + * `superseded` are normal endings and return null so the session simply falls back to idle. + */ + fun classify(reason: String): Outcome? = when (reason) { + "interrupted" -> Outcome.INTERRUPTED + "error" -> Outcome.FAILED else -> null } } diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt index ce710c4751..ac59440a58 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanel.kt @@ -470,7 +470,7 @@ class SessionMessageListPanel( question?.hideView() permission?.hideView() login?.hideView() - outcome?.showOutcome(state.outcome, state.tone) + outcome?.showOutcome(state.outcome) } else -> { setHiddenQuestionTool(null) diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionOutcomeView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionOutcomeView.kt index 7e92be8a51..46397ac1e8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionOutcomeView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/SessionOutcomeView.kt @@ -2,7 +2,6 @@ package ai.kilocode.client.session.views import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Outcome -import ai.kilocode.client.session.model.OutcomeTone import ai.kilocode.client.session.ui.SessionView import ai.kilocode.client.session.ui.selection.SessionSelection import ai.kilocode.client.session.ui.style.SessionEditorStyle @@ -21,6 +20,8 @@ import javax.swing.ScrollPaneConstants class SessionOutcomeView( selection: SessionSelection? = null, focus: (() -> Unit)? = null, + private val retry: (() -> Unit)? = null, + private val retryable: (() -> Boolean)? = null, ) : DialogView(selection, focus), SessionView { override val sessionViewKind = SessionView.Kind.Default @@ -35,37 +36,70 @@ class SessionOutcomeView( @RequiresEdt fun showError(message: String, kind: String?) { + setOutlined(true) setHeaderIcon(AllIcons.General.Error, kind ?: KiloBundle.message("session.error.title")) - setHeader(KiloBundle.message("session.error.title")) + setHeader(KiloBundle.message("session.error.title"), kind) error.text = message setContentPadding(left = false, right = false) setContent(error.scroll) + syncRetry(true) isVisible = true refresh() } + /** + * A user-initiated stop is not a failure: it renders as one muted line with no icon and no card + * outline. Only a model/provider failure gets the error card treatment. + */ @RequiresEdt - fun showOutcome(outcome: Outcome, tone: OutcomeTone) { - val title = when (outcome) { - Outcome.INTERRUPTED -> KiloBundle.message("session.outcome.interrupted.title") - Outcome.FAILED -> KiloBundle.message("session.outcome.failed.title") + fun showOutcome(outcome: Outcome) { + when (outcome) { + Outcome.INTERRUPTED -> { + setOutlined(false) + setHeaderIcon(null) + setHeader("", KiloBundle.message("session.outcome.interrupted.note")) + syncRetry(false) + } + + Outcome.FAILED -> { + val title = KiloBundle.message("session.outcome.failed.title") + setOutlined(true) + setHeaderIcon(AllIcons.General.Error, title) + setHeader(title, KiloBundle.message("session.outcome.failed.description")) + syncRetry(true) + } } - val desc = when (outcome) { - Outcome.INTERRUPTED -> KiloBundle.message("session.outcome.interrupted.description") - Outcome.FAILED -> KiloBundle.message("session.outcome.failed.description") - } - val icon = when (tone) { - OutcomeTone.WARNING -> AllIcons.General.Warning - OutcomeTone.CRITICAL -> AllIcons.General.Error - } - setHeaderIcon(icon, title) - setHeader(title, desc) setContentPadding() setContent(null) isVisible = true refresh() } + /** + * Retry belongs to failures only; a user-initiated stop stays a plain note with no controls. + * + * [retryable] is asked on every show because the answer depends on the transcript tail, not on the + * outcome alone: a session-level error that arrived after a completed turn has nothing to replay. + */ + @RequiresEdt + private fun syncRetry(show: Boolean) { + val run = retry + if (run == null || !show || retryable?.invoke() == false) { + setActions(emptyList()) + return + } + setActions( + listOf( + Action( + id = RETRY_ACTION, + text = KiloBundle.message("session.outcome.retry"), + primary = true, + handler = run, + ), + ), + ) + } + @RequiresEdt fun hideView() { if (!isVisible) return @@ -78,6 +112,10 @@ class SessionOutcomeView( super.applyStyle(style) error.applyStyle(style) } + + private companion object { + const val RETRY_ACTION = "retry" + } } private class ErrorBody { diff --git a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt index 6087a62043..14cd3a8020 100644 --- a/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt +++ b/packages/kilo-jetbrains/frontend/src/main/kotlin/ai/kilocode/client/session/views/base/DialogView.kt @@ -87,7 +87,10 @@ open class DialogView( isVisible = false } - private val headerText: JBTextArea = makeText("", SessionUiStyle.Colors.foreground(), bold = true) + // Both rows start blank, so both start hidden; setHeader/setDescription drive visibility from text. + private val headerText: JBTextArea = makeText("", SessionUiStyle.Colors.foreground(), bold = true).apply { + isVisible = false + } private val descriptionText: JBTextArea = makeText("", SessionUiStyle.Text.Secondary.foreground(), bold = false).apply { isVisible = false } @@ -106,6 +109,7 @@ open class DialogView( private var padLeft = true private var padRight = true private var padBottom = true + private var outlined = true // action buttons keyed by id for retained updates private val actionButtons = mutableMapOf() @@ -137,6 +141,7 @@ open class DialogView( @RequiresEdt fun setHeader(text: String, description: String? = null) { headerText.text = text + headerText.isVisible = text.isNotBlank() setDescription(description) syncNorth() } @@ -303,6 +308,13 @@ open class DialogView( btn.text = text } + @RequiresEdt + fun setOutlined(value: Boolean) { + if (outlined == value) return + outlined = value + repaint() + } + /** Returns the retained action component for focus management, or this card when absent. */ @RequiresEdt fun preferredActionComponent(id: String): JComponent = actionButtons[id] ?: this @@ -327,7 +339,7 @@ open class DialogView( override fun contentColor(): Color = SessionUiStyle.View.Surface.bgColor() - override fun outlineColor(): Color = SessionUiStyle.View.Outline.brightColor() + override fun outlineColor(): Color? = if (outlined) SessionUiStyle.View.Outline.brightColor() else null // ---- private helpers ---- @@ -340,7 +352,7 @@ open class DialogView( north.repaint() } - private fun hasHeader() = icon.icon != null || headerText.text.isNotBlank() || descriptionText.isVisible + private fun hasHeader() = icon.icon != null || headerText.isVisible || descriptionText.isVisible private fun syncInsets() { val side = UiStyle.Gap.pad() diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties index a12c21fb71..f4f8db6c1e 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle.properties @@ -60,6 +60,7 @@ revert.banner.filesNotRestored=Snapshots are off - only the conversation was rev revert.banner.openDiff.title=Rolled back changes revert.message.rollback=Rollback to this message session.status.rollingback=Rolling back\u2026 +session.status.retrying=Retrying\u2026 session.status.redoing=Redoing\u2026 session.status.operation.finishing=Waiting for the operation to finish\u2026 session.error.revert.timeout=Operation timed out. Waiting for it to finish before continuing. @@ -214,8 +215,8 @@ session.error.title=Request failed session.error.unknown=Unknown error session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.login.required.title=You need to sign in to use this model session.login.required.description=Go to User Profile settings to sign in, then continue this session. @@ -415,13 +416,25 @@ worktree.configure.base.invalid.content=Select an existing base branch before cr worktree.dialog.name.placeholder=Worktree name (optional) worktree.dialog.prompt.placeholder=Describe what you want to start working on ({0} to create) worktree.dialog.create=Create Worktree +worktree.dialog.import=Import Worktree +worktree.dialog.tab.new=New +worktree.dialog.tab.pr=From PR +worktree.dialog.tab.branch=From Branch worktree.progress.creating=Creating worktree… worktree.progress.capturing=Capturing changes… worktree.progress.transferring=Transferring changes… worktree.progress.starting=Starting session… worktree.move.failed.title=Failed to move to worktree worktree.import.pr.section=Pull Request +worktree.import.pr.description=Paste a GitHub pull request URL. Kilo will fetch the PR head and open it in a worktree. +worktree.import.pr.placeholder=https://github.com/owner/repo/pull/123 +worktree.import.pr.required=Enter a pull request URL. +worktree.import.pr.invalid=Enter a valid GitHub pull request URL. worktree.import.pr.failed.title=Couldn''t import pull request +worktree.import.branch.section=Branch +worktree.import.branch.description=Choose an existing local branch that is not already checked out in another worktree. +worktree.import.branch.invalid=Select an existing branch before importing. +worktree.import.branch.empty=No local branches are available to import. worktree.import.branch.failed.title=Couldn''t import branch "{0}" worktree.stats.diff.tooltip={0} additions, {1} deletions worktree.stats.ahead.tooltip=Commits ahead of base branch diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties index 3e13dd70f0..55522110ac 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ar.properties @@ -56,6 +56,7 @@ session.status.searching.web=جار البحث على الويب… session.status.editing=جاري التحرير… session.status.commands=جاري تنفيذ الأوامر… session.status.rollingback=جار التراجع… +session.status.retrying=Retrying\u2026 session.status.redoing=جار الإعادة… session.status.operation.finishing=في انتظار انتهاء العملية… session.error.revert.timeout=انتهت مهلة العملية. جار انتظار انتهائها قبل المتابعة. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=خطأ غير معروف session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=الرموز session.header.tokens.description=الرموز المستخدمة في آخر رد للمساعد: الإدخال، الإخراج، كتابات المخزن المؤقت وقراءاته. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties index 1127915a57..301f8c2fec 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_bs.properties @@ -56,6 +56,7 @@ session.status.searching.web=Pretraživanje weba… session.status.editing=Uređivanje… session.status.commands=Pokretanje komandi… session.status.rollingback=Vraćanje unazad… +session.status.retrying=Retrying\u2026 session.status.redoing=Ponovno izvršavanje… session.status.operation.finishing=Čeka se završetak operacije… session.error.revert.timeout=Operacija je istekla. Čeka se da završi prije nastavka. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Nepoznata greška session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Tokeni session.header.tokens.description=Tokeni korišteni u posljednjem odgovoru asistenta: ulaz, izlaz, pisanja u keš i čitanja iz keša. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties index 35de81c384..f10dc7e7b8 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_da.properties @@ -56,6 +56,7 @@ session.status.searching.web=Søger på nettet… session.status.editing=Foretager redigeringer… session.status.commands=Kører kommandoer… session.status.rollingback=Ruller tilbage… +session.status.retrying=Retrying\u2026 session.status.redoing=Gentager… session.status.operation.finishing=Venter på, at handlingen afsluttes… session.error.revert.timeout=Handlingen fik timeout. Venter på, at den afsluttes, før der fortsættes. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Ukendt fejl session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens brugt af det seneste assistentsvar: input, output, cache-skrivninger og cache-læsninger. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties index c93405d6f0..7df185f3ae 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_de.properties @@ -56,6 +56,7 @@ session.status.searching.web=Web durchsuchen… session.status.editing=Änderungen vornehmen… session.status.commands=Befehle ausführen… session.status.rollingback=Rollback wird ausgeführt… +session.status.retrying=Retrying\u2026 session.status.redoing=Wird wiederholt… session.status.operation.finishing=Warten, bis der Vorgang abgeschlossen ist… session.error.revert.timeout=Zeitüberschreitung beim Vorgang. Es wird gewartet, bis er abgeschlossen ist, bevor fortgefahren wird. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Unbekannter Fehler session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Token session.header.tokens.description=Von der letzten Assistentenantwort verwendete Token: Eingabe, Ausgabe, Cache-Schreibvorgänge und Cache-Lesevorgänge. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties index 987175f6d0..8b6d662355 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_es.properties @@ -56,6 +56,7 @@ session.status.searching.web=Buscando en la web… session.status.editing=Realizando ediciones… session.status.commands=Ejecutando comandos… session.status.rollingback=Revirtiendo… +session.status.retrying=Retrying\u2026 session.status.redoing=Rehaciendo… session.status.operation.finishing=Esperando a que finalice la operación… session.error.revert.timeout=La operación ha agotado el tiempo. Esperando a que finalice antes de continuar. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Error desconocido session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens utilizados por la última respuesta del asistente: entrada, salida, escrituras en caché y lecturas en caché. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties index a8d5960d97..40c1aed80c 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_fr.properties @@ -56,6 +56,7 @@ session.status.searching.web=Recherche sur le web… session.status.editing=Modifications en cours… session.status.commands=Exécution des commandes… session.status.rollingback=Retour en arrière… +session.status.retrying=Retrying\u2026 session.status.redoing=Rétablissement… session.status.operation.finishing=En attente de la fin de l’opération… session.error.revert.timeout=L’opération a expiré. Attente de sa fin avant de continuer. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Erreur inconnue session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens utilisés par la dernière réponse de l'assistant : entrée, sortie, écritures en cache et lectures en cache. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties index 313fdd9b6a..d1e39040ee 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ja.properties @@ -56,6 +56,7 @@ session.status.searching.web=ウェブを検索中… session.status.editing=編集中… session.status.commands=コマンドを実行中… session.status.rollingback=ロールバック中… +session.status.retrying=Retrying\u2026 session.status.redoing=やり直し中… session.status.operation.finishing=操作の完了を待機しています… session.error.revert.timeout=操作がタイムアウトしました。続行する前に完了を待機しています。 @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=不明なエラー session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=トークン session.header.tokens.description=最新のアシスタントの回答で使用されたトークン:入力、出力、キャッシュ書き込み、キャッシュ読み取り。 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties index 8d131c6cdd..78c97e633f 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ko.properties @@ -56,6 +56,7 @@ session.status.searching.web=웹 검색 중… session.status.editing=편집 중… session.status.commands=명령 실행 중… session.status.rollingback=롤백 중… +session.status.retrying=Retrying\u2026 session.status.redoing=다시 실행 중… session.status.operation.finishing=작업이 완료될 때까지 기다리는 중… session.error.revert.timeout=작업 시간이 초과되었습니다. 계속하기 전에 완료될 때까지 기다리는 중입니다. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=알 수 없는 오류 session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=토큰 session.header.tokens.description=마지막 어시스턴트 응답에서 사용된 토큰: 입력, 출력, 캐시 쓰기, 캐시 읽기. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties index da4d33ac6a..ea86c878b0 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_nl.properties @@ -56,6 +56,7 @@ session.status.searching.web=Web doorzoeken… session.status.editing=Bewerkingen uitvoeren… session.status.commands=Opdrachten uitvoeren… session.status.rollingback=Terugdraaien… +session.status.retrying=Retrying\u2026 session.status.redoing=Opnieuw uitvoeren… session.status.operation.finishing=Wachten tot de bewerking is voltooid… session.error.revert.timeout=Time-out van bewerking. Wachten tot deze is voltooid voordat wordt doorgegaan. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Onbekende fout session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens gebruikt door de laatste assistent-reactie: invoer, uitvoer, cache-schrijfacties en cache-leesacties. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties index cfad206788..d7d4d01344 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_no.properties @@ -56,6 +56,7 @@ session.status.searching.web=Søker på nettet… session.status.editing=Gjør redigeringer… session.status.commands=Kjører kommandoer… session.status.rollingback=Ruller tilbake… +session.status.retrying=Retrying\u2026 session.status.redoing=Gjør om… session.status.operation.finishing=Venter på at operasjonen skal fullføres… session.error.revert.timeout=Operasjonen tidsavbrøt. Venter på at den skal fullføres før vi fortsetter. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Ukjent feil session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens brukt av siste assistentsvar: inndata, utdata, cache-skrivninger og cache-lesninger. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties index 7374c84ee5..140f89f54b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pl.properties @@ -56,6 +56,7 @@ session.status.searching.web=Przeszukiwanie sieci… session.status.editing=Dokonywanie edycji… session.status.commands=Uruchamianie poleceń… session.status.rollingback=Wycofywanie… +session.status.retrying=Retrying\u2026 session.status.redoing=Ponawianie… session.status.operation.finishing=Oczekiwanie na zakończenie operacji… session.error.revert.timeout=Upłynął limit czasu operacji. Oczekiwanie na jej zakończenie przed kontynuacją. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Nieznany błąd session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Tokeny session.header.tokens.description=Tokeny użyte przez ostatnią odpowiedź asystenta: wejściowe, wyjściowe, zapisy do bufora i odczyty z bufora. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties index 91556b034d..ed51cb5a9b 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_pt_BR.properties @@ -56,6 +56,7 @@ session.status.searching.web=Pesquisando na web… session.status.editing=Realizando edições… session.status.commands=Executando comandos… session.status.rollingback=Revertendo… +session.status.retrying=Retrying\u2026 session.status.redoing=Refazendo… session.status.operation.finishing=Aguardando a operação terminar… session.error.revert.timeout=A operação atingiu o tempo limite. Aguardando sua conclusão antes de continuar. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Erro desconhecido session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Tokens session.header.tokens.description=Tokens usados pela última resposta do assistente: entrada, saída, escritas em cache e leituras em cache. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties index a3bb483a21..94c8e65c0d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_ru.properties @@ -56,6 +56,7 @@ session.status.searching.web=Поиск в интернете… session.status.editing=Вношу изменения… session.status.commands=Выполняю команды… session.status.rollingback=Выполняется откат… +session.status.retrying=Retrying\u2026 session.status.redoing=Повторное применение… session.status.operation.finishing=Ожидание завершения операции… session.error.revert.timeout=Время ожидания операции истекло. Ждем ее завершения перед продолжением. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Неизвестная ошибка session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Токены session.header.tokens.description=Токены, использованные последним ответом ассистента: входные, исходные, запись в кэш и чтение из кэша. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties index 77fae80689..031d159ed1 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_th.properties @@ -56,6 +56,7 @@ session.status.searching.web=กำลังค้นหาบนเว็บ… session.status.editing=กำลังแก้ไข… session.status.commands=กำลังเรียกใช้คำสั่ง… session.status.rollingback=กำลังย้อนกลับ… +session.status.retrying=Retrying\u2026 session.status.redoing=กำลังทำซ้ำ… session.status.operation.finishing=กำลังรอให้การดำเนินการเสร็จสิ้น… session.error.revert.timeout=การดำเนินการหมดเวลา กำลังรอให้เสร็จสิ้นก่อนดำเนินการต่อ @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=ข้อผิดพลาดที่ไม่ทราบ session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=โทเคน session.header.tokens.description=โทเคนที่ใช้โดยคำตอบล่าสุดของผู้ช่วย: อินพุต เอาต์พุต การเขียนแคช และการอ่านแคช diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties index 62400e996d..edfdcafd34 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_tr.properties @@ -56,6 +56,7 @@ session.status.searching.web=Web aranıyor… session.status.editing=Düzenleme yapılıyor… session.status.commands=Komutlar çalıştırılıyor… session.status.rollingback=Geri alınıyor… +session.status.retrying=Retrying\u2026 session.status.redoing=Yeniden uygulanıyor… session.status.operation.finishing=İşlemin tamamlanması bekleniyor… session.error.revert.timeout=İşlem zaman aşımına uğradı. Devam etmeden önce tamamlanması bekleniyor. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Bilinmeyen hata session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Jeton session.header.tokens.description=Son asistan yanıtında kullanılan jetonlar: giriş, çıkış, önbelleğe yazma ve önbellekten okuma. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties index 8bbd4b8d38..51b50da55d 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_uk.properties @@ -56,6 +56,7 @@ session.status.searching.web=Шукаю в інтернеті… session.status.editing=Вношу зміни… session.status.commands=Виконую команди… session.status.rollingback=Виконується відкат… +session.status.retrying=Retrying\u2026 session.status.redoing=Повторне застосування… session.status.operation.finishing=Очікування завершення операції… session.error.revert.timeout=Час очікування операції минув. Очікуємо її завершення перед продовженням. @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=Невідома помилка session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=Токени session.header.tokens.description=Токени, використані останньою відповіддю асистента: вхід, вихід, запис у кеш і читання з кешу. diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties index 4951eacc80..7c79292775 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_CN.properties @@ -56,6 +56,7 @@ session.status.searching.web=搜索网页… session.status.editing=正在编辑… session.status.commands=运行命令… session.status.rollingback=正在回滚… +session.status.retrying=Retrying\u2026 session.status.redoing=正在重做… session.status.operation.finishing=正在等待操作完成… session.error.revert.timeout=操作超时。继续前正在等待其完成。 @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=未知错误 session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=令牌 session.header.tokens.description=最近一次助手回复中使用的令牌:输入、输出、缓存写入和缓存读取。 diff --git a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties index faf77135c3..c135ded064 100644 --- a/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties +++ b/packages/kilo-jetbrains/frontend/src/main/resources/messages/KiloBundle_zh_TW.properties @@ -56,6 +56,7 @@ session.status.searching.web=搜尋網頁… session.status.editing=進行編輯… session.status.commands=執行指令… session.status.rollingback=正在復原… +session.status.retrying=Retrying\u2026 session.status.redoing=正在重做… session.status.operation.finishing=正在等待操作完成… session.error.revert.timeout=操作逾時。繼續前正在等待其完成。 @@ -77,8 +78,8 @@ session.error.title=Request failed session.error.unknown=未知錯誤 session.outcome.failed.description=The model stopped this turn with an error. session.outcome.failed.title=Response failed -session.outcome.interrupted.description=This turn was interrupted before it finished. -session.outcome.interrupted.title=Response stopped +session.outcome.retry=Retry +session.outcome.interrupted.note=Stopped session.header.tokens=記號 session.header.tokens.description=最新助手回覆使用的記號:輸入、輸出、快取寫入和快取讀取。 diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt index 9c47e660c4..5733e92c29 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/AgentManagerPanelTest.kt @@ -134,7 +134,7 @@ class AgentManagerPanelTest : BasePlatformTestCase() { fun `test configure creates the worktree only after the dialog closes`() { val order = mutableListOf() - val plan = NewWorktreePlan("feature/y", "main", PendingPrompt("build it")) + val plan = NewWorktreePlan.Create("feature/y", "main", PendingPrompt("build it")) val controller = WorktreeController(service, "/test", coroutines.scope) val panel = edt { AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) }) @@ -166,6 +166,36 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertTrue(rpc.creates.isEmpty()) } + fun `test configure imports an existing branch`() { + val order = mutableListOf() + val plan = NewWorktreePlan.Branch("feature/x") + val controller = WorktreeController(service, "/test", coroutines.scope) + val panel = edt { + AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) }) + } + + edt { panel.configure() } + flush() + + val req = rpc.creates.single() + assertEquals("feature/x", req.branch) + assertTrue("branch import checks out an existing branch", req.existingBranch) + } + + fun `test configure imports a pull request`() { + val order = mutableListOf() + val plan = NewWorktreePlan.Pr("https://github.com/o/r/pull/7") + val controller = WorktreeController(service, "/test", coroutines.scope) + val panel = edt { + AgentManagerPanel(testRootDisposable, controller, project, dialog = { _, _ -> FakeWorktreeDialog(plan, order) }) + } + + edt { panel.configure() } + flush() + + assertEquals(listOf("https://github.com/o/r/pull/7"), rpc.prImports.toList()) + } + fun `test panel hides worktree search field`() { val controller = WorktreeController(service, "/test", coroutines.scope) val panel = edt { AgentManagerPanel(testRootDisposable, controller) } @@ -649,6 +679,43 @@ class AgentManagerPanelTest : BasePlatformTestCase() { assertTrue(edt { panel.canShowRename(item) }) } + fun `test current row renders without any linked worktrees`() { + rpc.listed += main() + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + + edt { controller.reload() } + flush() + + // Replacing an empty model with an empty list notifies nobody, so the row has to come from + // the reload itself. + assertEquals(1, rows(panel)) + assertEquals("main", row(panel, 0).title) + } + + fun `test current row shows the pr badge for the main checkout`() { + val main = main() + rpc.listed += main + rpc.prResult = WorktreePrListDto( + GhAvailability.OK, + listOf(WorktreePrDto(main.path, 12, GhState.OPEN, "https://example.test/pr/12", "Main work")), + ) + val timers = TestUiTimers() + ApplicationManager.getApplication().replaceService(KiloWorktreeService::class.java, service, testRootDisposable) + project.replaceService(WorktreeStatusService::class.java, WorktreeStatusService(project, coroutines.scope, timers), testRootDisposable) + val controller = WorktreeController(service, project.basePath!!, coroutines.scope) + val panel = edt { AgentManagerPanel(testRootDisposable, controller, project) } + edt { controller.reload() } + timers.advanceBy(300) + waitUntil { rows(panel) > 0 && row(panel, 0).metrics != null } + + // The current row keeps the branch as its title; the PR arrives as a badge beside it. + val current = row(panel, 0) + assertEquals("main", current.title) + assertEquals("#12", current.metrics?.pr?.text) + assertTrue(edt { panel.canOpenPr(main) }) + } + fun `test pr title replaces row name and tooltip reveals custom name`() { val path = "${project.basePath!!}/.kilo/worktrees/feature-x" val item = WorktreeDto(path, "Feature Label", "feature/x", path) @@ -875,6 +942,11 @@ class AgentManagerPanelTest : BasePlatformTestCase() { return edt { list.model.getElementAt(idx) as ActiveListItem } } + private fun rows(panel: AgentManagerPanel): Int { + val list = edt { UIUtil.findComponentOfType(panel, JBList::class.java)!! } + return edt { list.model.size } + } + private fun components(root: Component): List { val out = mutableListOf() fun visit(item: Component) { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt index d798260f49..4e509f3736 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/WorktreeControllerTest.kt @@ -1,6 +1,8 @@ package ai.kilocode.client.agentManager import ai.kilocode.client.agentManager.worktree.WorktreeIcons +import ai.kilocode.client.agentManager.worktree.CreateFailure +import ai.kilocode.client.agentManager.worktree.CreateKind import ai.kilocode.client.agentManager.worktree.KiloWorktreeService import ai.kilocode.client.agentManager.worktree.WorktreeController import ai.kilocode.client.agentManager.worktree.PendingPrompt @@ -121,10 +123,35 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertFalse(controller.isPending(controller.model.getElementAt(0).id)) } + fun `test created worktrees are announced once they exist`() { + val controller = controller() + val created = mutableListOf() + controller.onCreated = { created.add(it) } + + ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) } + // Nothing exists on disk while the create is still pending. + assertEquals(emptyList(), created) + flush() + + assertEquals(listOf("feature/y"), created.map { it.branch }) + } + + fun `test a failed create announces nothing`() { + rpc.createResult = { CreateWorktreeResultDto(error = "boom") } + val controller = controller() + val created = mutableListOf() + controller.onCreated = { created.add(it) } + + ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) } + flush() + + assertEquals(emptyList(), created) + } + fun `test create failure removes placeholder and reports the error`() { rpc.createResult = { CreateWorktreeResultDto(error = "boom") } val controller = controller() - val failures = mutableListOf() + val failures = mutableListOf() controller.onCreateFailure = { failures.add(it) } ApplicationManager.getApplication().invokeAndWait { controller.create("feature/y", null) } @@ -134,7 +161,7 @@ class WorktreeControllerTest : BasePlatformTestCase() { assertEquals(0, controller.model.size) assertFalse(controller.isPending(id)) - assertEquals(listOf("boom"), failures) + assertEquals(listOf(CreateFailure("boom", CreateKind.CREATE, "feature/y")), failures) } fun `test reload preserves pending worktrees`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt index 2896016d10..0edfd4b885 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/agentManager/worktree/NewWorktreeDialogTest.kt @@ -21,6 +21,9 @@ import com.intellij.openapi.ui.ComboBox import com.intellij.openapi.util.Disposer import com.intellij.testFramework.fixtures.BasePlatformTestCase import com.intellij.ui.components.JBPanel +import com.intellij.ui.components.JBTextField +import com.intellij.ui.tabs.JBTabs +import com.intellij.ui.tabs.TabInfo import com.intellij.util.ui.UIUtil import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -31,6 +34,7 @@ import java.awt.Component import java.awt.Container import java.awt.event.FocusEvent import javax.swing.JTextField +import javax.swing.plaf.basic.BasicComboBoxUI import javax.swing.plaf.basic.BasicComboPopup class NewWorktreeDialogTest : BasePlatformTestCase() { @@ -144,6 +148,21 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { } } + fun `test base picker survives a dropped editor during layout`() { + open() + + edt { + val picker = combo() as BranchPicker + val ui = picker.ui as BasicComboBoxUI + ui.removeEditor() + + picker.preferredSize + + val comp = picker.editor.editorComponent + assertTrue(picker.components.any { it === comp }) + } + } + fun `test creating with empty base branch falls back to default`() { open() flushUntil { edt { model().selectionKeyForTest() != null } } @@ -184,6 +203,114 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { assertNull(plan()) } + fun `test importing a pr url produces a pr plan`() { + open() + selectPr() + edt { + url().text = "https://github.com/o/r/pull/7" + submit() + } + + assertEquals(NewWorktreePlan.Pr("https://github.com/o/r/pull/7"), taken()) + } + + fun `test blank pr url does not import`() { + open() + selectPr() + edt { submit() } + + assertNull(plan()) + } + + fun `test non-pr url does not import`() { + open() + selectPr() + edt { + url().text = "https://github.com/o/r/issues/7" + submit() + } + + assertNull(plan()) + } + + fun `test picking a branch produces a branch plan`() { + open(branches = listOf("main", "feature/x")) + selectBranch() + edt { + pickField().text = "feature/x" + submit() + } + + assertEquals(NewWorktreePlan.Branch("feature/x"), taken()) + } + + fun `test importing a fuzzy branch resolves to the real branch`() { + open(branches = listOf("main", "feature/refactor-ui")) + selectBranch() + edt { + pickField().text = "refui" + submit() + } + + assertEquals(NewWorktreePlan.Branch("feature/refactor-ui"), taken()) + } + + fun `test importing an unknown branch does not import`() { + open(branches = listOf("main", "feature/x")) + selectBranch() + edt { + pickField().text = "zzzzzz" + submit() + } + + assertNull(plan()) + } + + fun `test the new tab creates while the pr tab imports`() { + open() + + edt { assertEquals(3, tabs().tabs.size) } + selectPr() + edt { + url().text = "https://github.com/o/r/pull/7" + submit() + } + + assertEquals(NewWorktreePlan.Pr("https://github.com/o/r/pull/7"), taken()) + } + + fun `test the deselected tab stops painting`() { + open() + val fresh = newTab() + + selectPr() + + assertFalse(edt { fresh.isVisible }) + assertTrue(edt { prTab().isVisible }) + } + + fun `test reselecting a tab shows it again`() { + open() + selectPr() + + select(0) + + assertTrue(edt { newTab().isVisible }) + assertFalse(edt { prTab().isVisible }) + } + + fun `test an empty branch list disables the branch picker`() { + open(branches = emptyList()) + selectBranch() + + edt { + assertFalse(pick().isEnabled) + submit() + } + + assertNull(plan()) + } + private fun open(branches: List = listOf("main")) { dialog = edt { NewWorktreeDialog( @@ -201,10 +328,13 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { private fun plan(): NewWorktreePlan? = edt { requireNotNull(dialog).result() } + /** Reads the plan after a confirming submit, then forgets the dialog: closing already disposed it. */ + private fun taken(): NewWorktreePlan = requireNotNull(plan()).also { dialog = null } + /** Waits for the dialog to accept a create, then forgets it: closing already disposed it. */ - private fun submitted(): NewWorktreePlan { + private fun submitted(): NewWorktreePlan.Create { flushUntil { plan() != null } - return requireNotNull(plan()).also { dialog = null } + return (requireNotNull(plan()) as NewWorktreePlan.Create).also { dialog = null } } private fun workspace(): ModelsWorkspaceDto { @@ -231,15 +361,39 @@ class NewWorktreeDialogTest : BasePlatformTestCase() { private fun reasoning(): ReasoningPicker = prompt().reasoning - private fun prompt(): PromptPanel = descendants(root()).filterIsInstance().single() + private fun prompt(): PromptPanel = descendants(newTab()).filterIsInstance().single() - private fun combo(): ComboBox<*> = descendants(root()).filterIsInstance>().single() + private fun combo(): ComboBox<*> = descendants(newTab()).filterIsInstance>().single() private fun field(): JTextField = combo().editor.editorComponent as JTextField private fun popup(): BasicComboPopup = combo().accessibleContext.getAccessibleChild(0) as BasicComboPopup - private fun root(): Component = requireNotNull(dialog).centerComponent() + private fun tabs(): JBTabs = requireNotNull(dialog).centerComponent() as JBTabs + + private fun newTab(): Component = tabs().tabs[0].component + + private fun prTab(): Component = tabs().tabs[1].component + + private fun branchTab(): Component = tabs().tabs[2].component + + private fun selectPr() = select(1) + + private fun selectBranch() = select(2) + + private fun select(index: Int) = edt { + val info: TabInfo = tabs().tabs[index] + tabs().select(info, false) + UIUtil.dispatchAllInvocationEvents() + } + + private fun url(): JBTextField = descendants(prTab()).filterIsInstance().single() + + private fun pick(): ComboBox<*> = descendants(branchTab()).filterIsInstance>().single() + + private fun pickField(): JTextField = pick().editor.editorComponent as JTextField + + private fun submit() = requireNotNull(dialog).submit() private fun descendants(root: Component): List { val out = mutableListOf() diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt new file mode 100644 index 0000000000..eb98b9e1ed --- /dev/null +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/SessionRetryTest.kt @@ -0,0 +1,417 @@ +package ai.kilocode.client.session.controller + +import ai.kilocode.client.session.model.SessionState +import ai.kilocode.rpc.dto.ChatEventDto +import ai.kilocode.rpc.dto.ConfigDto +import ai.kilocode.rpc.dto.KiloAppStateDto +import ai.kilocode.rpc.dto.KiloAppStatusDto +import ai.kilocode.rpc.dto.MessageErrorDto +import ai.kilocode.rpc.dto.MessageTimeDto +import ai.kilocode.rpc.dto.MessageWithPartsDto +import ai.kilocode.rpc.dto.ModelDto +import ai.kilocode.rpc.dto.ProviderDto +import ai.kilocode.rpc.dto.SessionStatusDto +import kotlinx.coroutines.CompletableDeferred + +/** + * Retry replays the last user turn after a failure: revert to the failed assistant message (which + * restores files when that turn edited any), then re-prompt reusing the original user message id so no + * synthetic message is appended. The failed message itself is removed server-side by + * `SessionRevert.cleanup` on the prompt that follows. + */ +class SessionRetryTest : SessionControllerTestBase() { + + override fun setUp() { + super.setUp() + rpc.session = rpc.session.copy(id = "ses_test") + appRpc.state.value = KiloAppStateDto(KiloAppStatusDto.READY, config = ConfigDto(model = "kilo/gpt-5")) + } + + /** Two connected models so a test can switch selection after the failure. */ + private fun providers() = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf( + "gpt-5" to ModelDto( + id = "gpt-5", + name = "GPT-5", + reasoning = true, + variants = listOf("low", "high"), + ), + ), + ), + ProviderDto( + id = "anthropic", + name = "Anthropic", + models = mapOf("claude-opus-5" to ModelDto(id = "claude-opus-5", name = "Claude Opus 5")), + ), + ) + + private fun failed(error: MessageErrorDto? = MessageErrorDto(type = "APIError", message = "provider overloaded")) { + // The model/agent a turn ran with live on the user message, which is what the replay reuses. + rpc.history.add( + MessageWithPartsDto( + msg("msg_user", "ses_test", "user").copy(providerID = "kilo", modelID = "gpt-5", agent = "code"), + emptyList(), + ), + ) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy(parentID = "msg_user", error = error), + emptyList(), + ), + ) + projectRpc.state.value = workspaceReady(providers = providers(), connected = listOf("kilo", "anthropic")) + } + + /** + * A turn that never reached the model has no assistant message at all: the CLI resolves the model + * (and its credentials) before writing one, so the transcript tail is the user message. + */ + private fun unanswered() { + rpc.history.add( + MessageWithPartsDto( + msg("msg_user", "ses_test", "user").copy(providerID = "snowflake", modelID = "cortex", agent = "code"), + emptyList(), + ), + ) + projectRpc.state.value = workspaceReady( + providers = providers() + ProviderDto( + id = "snowflake", + name = "Snowflake", + models = mapOf("cortex" to ModelDto(id = "cortex", name = "Cortex")), + ), + connected = listOf("kilo", "anthropic", "snowflake"), + ) + } + + fun `test retry reverts the failed turn then replays the user message`() { + failed() + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertEquals(1, rpc.reverts.size) + val revert = rpc.reverts.single() + assertEquals("ses_test", revert.id) + assertEquals("msg_fail", revert.message) + assertNull("Reverting the whole message, not truncating its parts", revert.part) + + assertEquals(1, rpc.prompts.size) + val prompt = rpc.prompts.single().third + assertEquals("Replays the existing user message, no synthetic one", "msg_user", prompt.messageID) + assertTrue("An empty part list leaves the original user parts intact", prompt.parts.isEmpty()) + assertEquals("kilo", prompt.providerID) + assertEquals("gpt-5", prompt.modelID) + assertEquals("code", prompt.agent) + } + + fun `test retry uses the model selected after the failure`() { + failed() + val m = controller("ses_test") + flush() + + // The usual reason a turn fails is the model it ran with, so switching model and hitting Retry + // has to pick the new one up rather than replaying the one that just failed. + edt { m.selectModel("anthropic", "claude-opus-5") } + flush() + edt { m.retry() } + flush() + + val prompt = rpc.prompts.single().third + assertEquals("anthropic", prompt.providerID) + assertEquals("claude-opus-5", prompt.modelID) + assertEquals("Still replays the original user message", "msg_user", prompt.messageID) + } + + fun `test retry uses the effort selected after the failure`() { + failed() + val m = controller("ses_test") + flush() + + edt { m.selectVariant("high") } + flush() + edt { m.retry() } + flush() + + assertEquals("high", rpc.prompts.single().third.variant) + } + + /** + * Guards the distinction from login resume, which must keep the model recorded on the failed turn — + * the user authenticated for that model. Only Retry follows the live selection. + */ + fun `test retry follows the live selection even when it differs from the failed turn`() { + rpc.history.add( + MessageWithPartsDto( + msg("msg_user", "ses_test", "user").copy(providerID = "kilo", modelID = "gpt-5", agent = "code"), + emptyList(), + ), + ) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy( + parentID = "msg_user", + error = MessageErrorDto(type = "APIError", message = "missing credentials"), + ), + emptyList(), + ), + ) + projectRpc.state.value = workspaceReady(providers = providers(), connected = listOf("kilo", "anthropic")) + val m = controller("ses_test") + flush() + + edt { m.selectModel("anthropic", "claude-opus-5") } + flush() + edt { m.retry() } + flush() + + val prompt = rpc.prompts.single().third + assertEquals("anthropic", prompt.providerID) + assertEquals("claude-opus-5", prompt.modelID) + } + + /** The auto-routing model id contains a slash ("kilo-auto/free"), so only the provider may split off. */ + fun `test retry uses an auto routing model whose id contains a slash`() { + rpc.history.add( + MessageWithPartsDto( + msg("msg_user", "ses_test", "user").copy( + providerID = "snowflake", + modelID = "cortex", + agent = "code", + ), + emptyList(), + ), + ) + rpc.history.add( + MessageWithPartsDto( + msg("msg_fail", "ses_test", "assistant").copy( + parentID = "msg_user", + error = MessageErrorDto(type = "UnknownError", message = "missing credentials"), + ), + emptyList(), + ), + ) + projectRpc.state.value = workspaceReady( + providers = listOf( + ProviderDto( + id = "kilo", + name = "Kilo", + models = mapOf("kilo-auto/free" to ModelDto(id = "kilo-auto/free", name = "Auto Free")), + ), + ProviderDto( + id = "snowflake", + name = "Snowflake", + models = mapOf("cortex" to ModelDto(id = "cortex", name = "Cortex")), + ), + ), + connected = listOf("kilo", "snowflake"), + ) + val m = controller("ses_test") + flush() + + edt { m.selectModel("kilo", "kilo-auto/free") } + flush() + edt { m.retry() } + flush() + + val prompt = rpc.prompts.single().third + assertEquals("kilo", prompt.providerID) + assertEquals("kilo-auto/free", prompt.modelID) + } + + fun `test retry does not prompt until the revert completes`() { + failed() + val gate = CompletableDeferred() + rpc.revertGate = gate + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertTrue("The prompt must not race the workspace restore", rpc.prompts.isEmpty()) + assertTrue(m.model.state is SessionState.Reverting) + + gate.complete(Unit) + flush() + + assertEquals(1, rpc.reverts.size) + assertEquals(1, rpc.prompts.size) + } + + fun `test retry lands on busy not idle`() { + failed() + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertTrue("Retry must hand off to the running turn", m.model.state is SessionState.Busy) + } + + fun `test retry is unavailable after a user stop`() { + failed(MessageErrorDto(type = MessageErrorDto.ABORTED, message = "aborted")) + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertTrue("A stop is not a failure", rpc.reverts.isEmpty()) + assertTrue(rpc.prompts.isEmpty()) + } + + fun `test retry is unavailable while the session is busy`() { + failed() + rpc.statuses.value = mapOf("ses_test" to SessionStatusDto("busy")) + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertTrue(rpc.reverts.isEmpty()) + assertTrue(rpc.prompts.isEmpty()) + } + + fun `test retry is unavailable when nothing failed`() { + rpc.history.add(MessageWithPartsDto(msg("msg_user", "ses_test", "user"), emptyList())) + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + + edt { assertFalse(m.canRetry()) } + edt { m.retry() } + flush() + + assertTrue(rpc.reverts.isEmpty()) + assertTrue(rpc.prompts.isEmpty()) + } + + /** + * Missing provider credentials fail during model resolution, before the assistant message exists, so + * the failure only surfaces as a session error over a user-message tail. There is nothing to roll + * back — Retry must still replay, otherwise the card's only action is dead. + */ + fun `test retry replays a turn that failed before the assistant message existed`() { + unanswered() + val m = controller("ses_test") + flush() + emit( + ChatEventDto.Error( + "ses_test", + MessageErrorDto(type = "UnknownError", message = "Snowflake Cortex: missing credentials"), + ), + ) + + edt { assertTrue(m.canRetry()) } + edt { m.selectModel("anthropic", "claude-opus-5") } + flush() + edt { m.retry() } + flush() + + assertTrue("Nothing was produced, so there is no message to roll back", rpc.reverts.isEmpty()) + val prompt = rpc.prompts.single().third + assertEquals("Replays the existing user message, no synthetic one", "msg_user", prompt.messageID) + assertTrue(prompt.parts.isEmpty()) + assertEquals("anthropic", prompt.providerID) + assertEquals("claude-opus-5", prompt.modelID) + assertTrue("Retry must hand off to the running turn", m.model.state is SessionState.Busy) + } + + /** The same failure also arrives as a turn close with reason "error" when no session error follows. */ + fun `test retry replays an unanswered turn reported only by turn close`() { + unanswered() + val m = controller("ses_test") + flush() + emit(ChatEventDto.TurnClose("ses_test", "error")) + + // Switch off the model that could not authenticate, then raise its effort. + edt { m.selectModel("kilo", "gpt-5") } + flush() + edt { m.selectVariant("high") } + flush() + edt { m.retry() } + flush() + + assertTrue(rpc.reverts.isEmpty()) + val prompt = rpc.prompts.single().third + assertEquals("msg_user", prompt.messageID) + assertEquals("kilo", prompt.providerID) + assertEquals("gpt-5", prompt.modelID) + assertEquals("Effort switched after the failure has to reach the replay", "high", prompt.variant) + } + + /** + * A session-level error (a bad config, a plugin failure) can land after a turn that delivered its + * answer. Retrying then would revert real work, so the card must not offer it. + */ + fun `test retry is unavailable when the last turn completed`() { + rpc.history.add(MessageWithPartsDto(msg("msg_user", "ses_test", "user"), emptyList())) + rpc.history.add( + MessageWithPartsDto( + msg("msg_ok", "ses_test", "assistant").copy( + parentID = "msg_user", + time = MessageTimeDto(created = 0.0, completed = 1.0), + ), + emptyList(), + ), + ) + projectRpc.state.value = workspaceReady(providers = providers(), connected = listOf("kilo", "anthropic")) + val m = controller("ses_test") + flush() + emit(ChatEventDto.Error(null, MessageErrorDto(type = "UnknownError", message = "invalid kilo.json"))) + + edt { assertFalse(m.canRetry()) } + edt { m.retry() } + flush() + + assertTrue("A completed turn must not be rolled back", rpc.reverts.isEmpty()) + assertTrue(rpc.prompts.isEmpty()) + } + + fun `test retry is unavailable when the session has no user message`() { + projectRpc.state.value = workspaceReady() + val m = controller("ses_test") + flush() + emit(ChatEventDto.Error(null, MessageErrorDto(type = "UnknownError", message = "invalid kilo.json"))) + + edt { assertFalse("Nothing to replay, so the card must not offer Retry", m.canRetry()) } + } + + fun `test retry is offered for a failed assistant turn`() { + failed() + val m = controller("ses_test") + flush() + + edt { assertTrue(m.canRetry()) } + } + + fun `test retry is not offered after a user stop`() { + failed(MessageErrorDto(type = MessageErrorDto.ABORTED, message = "aborted")) + val m = controller("ses_test") + flush() + + edt { assertFalse(m.canRetry()) } + } + + fun `test retry surfaces an error when the revert fails`() { + failed() + rpc.revertThrows = RuntimeException("snapshot unavailable") + val m = controller("ses_test") + flush() + + edt { m.retry() } + flush() + + assertTrue(rpc.prompts.isEmpty()) + val state = m.model.state + assertTrue("A failed revert must stay visible", state is SessionState.Error) + assertEquals("snapshot unavailable", (state as SessionState.Error).message) + } +} diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt index b199a3f113..a2fc73d2b6 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/controller/TurnLifecycleTest.kt @@ -2,7 +2,6 @@ package ai.kilocode.client.session.controller import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Outcome -import ai.kilocode.client.session.model.OutcomeTone import ai.kilocode.client.session.model.SessionState import ai.kilocode.client.session.model.TurnOutcome import ai.kilocode.client.testing.FakeSessionRpcApi @@ -274,8 +273,8 @@ class TurnLifecycleTest : SessionControllerTestBase() { fun `test turn outcome classifier`() { assertNull(TurnOutcome.classify("completed")) assertNull(TurnOutcome.classify("superseded")) - assertEquals(Outcome.INTERRUPTED to OutcomeTone.WARNING, TurnOutcome.classify("interrupted")) - assertEquals(Outcome.FAILED to OutcomeTone.CRITICAL, TurnOutcome.classify("error")) + assertEquals(Outcome.INTERRUPTED, TurnOutcome.classify("interrupted")) + assertEquals(Outcome.FAILED, TurnOutcome.classify("error")) } fun `test TurnClose completed preserves AwaitingQuestion state`() { diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt index 38c940fb29..ff7e0cdf47 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/ui/SessionMessageListPanelTest.kt @@ -4,7 +4,6 @@ import ai.kilocode.client.session.SessionFileOpener import ai.kilocode.client.session.model.Permission import ai.kilocode.client.session.model.PermissionMeta import ai.kilocode.client.session.model.Outcome -import ai.kilocode.client.session.model.OutcomeTone import ai.kilocode.client.session.model.Question import ai.kilocode.client.session.model.QuestionItem import ai.kilocode.client.session.model.QuestionOption @@ -1097,20 +1096,20 @@ class SessionMessageListPanelTest : BasePlatformTestCase() { fun `test turn ended state makes outcome view visible`() { val item = panelWithPrompts() - model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED, OutcomeTone.WARNING)) + model.setState(SessionState.TurnEnded(Outcome.INTERRUPTED)) val ov = find(item)!! val comps = item.components.toList() assertTrue(ov.isVisible) - assertNotNull(text(item, KiloBundle.message("session.outcome.interrupted.description"))) + assertNotNull(text(item, KiloBundle.message("session.outcome.interrupted.note"))) assertTrue(comps.indexOf(ov) < comps.indexOf(item.progress)) assertSame(item.progress, comps.last()) } fun `test returning to idle hides outcome view`() { val item = panelWithPrompts() - model.setState(SessionState.TurnEnded(Outcome.FAILED, OutcomeTone.CRITICAL)) + model.setState(SessionState.TurnEnded(Outcome.FAILED)) model.setState(SessionState.Idle) val ov = find(item)!! diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SessionOutcomeViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SessionOutcomeViewTest.kt index b4266614f7..1b7cfc4cda 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SessionOutcomeViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/SessionOutcomeViewTest.kt @@ -2,7 +2,6 @@ package ai.kilocode.client.session.views import ai.kilocode.client.plugin.KiloBundle import ai.kilocode.client.session.model.Outcome -import ai.kilocode.client.session.model.OutcomeTone import ai.kilocode.client.session.ui.SessionLayout import ai.kilocode.client.session.ui.style.SessionEditorStyle import ai.kilocode.client.session.ui.style.SessionUiStyle @@ -18,6 +17,7 @@ import java.awt.Dimension import java.awt.event.ComponentAdapter import java.awt.event.ComponentEvent import javax.swing.Icon +import javax.swing.JButton import javax.swing.JPanel import javax.swing.ScrollPaneConstants @@ -86,27 +86,26 @@ class SessionOutcomeViewTest : BasePlatformTestCase() { } } - fun `test showOutcome renders interrupted copy and warning icon`() { + fun `test showOutcome renders interrupted note without icon`() { edt { val view = SessionOutcomeView() - view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING) + view.showOutcome(Outcome.INTERRUPTED) assertTrue(view.isVisible) - assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.title"))) - assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.description"))) - assertIcons(view, AllIcons.General.Warning) + assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.note"))) + assertTrue(findAll(view).none { it.icon != null && it.isVisible }) } } fun `test showOutcome updates without stale text`() { edt { val view = SessionOutcomeView() - view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING) - view.showOutcome(Outcome.FAILED, OutcomeTone.CRITICAL) + view.showOutcome(Outcome.INTERRUPTED) + view.showOutcome(Outcome.FAILED) assertNotNull(findText(view, KiloBundle.message("session.outcome.failed.title"))) assertNotNull(findText(view, KiloBundle.message("session.outcome.failed.description"))) - assertNull(findText(view, KiloBundle.message("session.outcome.interrupted.description"))) + assertNull(findText(view, KiloBundle.message("session.outcome.interrupted.note"))) assertIcons(view, AllIcons.General.Error) } } @@ -115,14 +114,119 @@ class SessionOutcomeViewTest : BasePlatformTestCase() { edt { val view = SessionOutcomeView() view.showError("Provider balance is too low", "APIError") - view.showOutcome(Outcome.INTERRUPTED, OutcomeTone.WARNING) + view.showOutcome(Outcome.INTERRUPTED) assertNull(findText(view, "Provider balance is too low")) assertNull(findErrorScroll(view, "Provider balance is too low")) - assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.description"))) + assertNotNull(findText(view, KiloBundle.message("session.outcome.interrupted.note"))) } } + fun `test showError surfaces error kind`() { + edt { + val view = SessionOutcomeView() + view.showError("Provider balance is too low", "APIError") + + assertNotNull(findText(view, "APIError")) + } + } + + // ------ retry action ------ + + fun `test error card offers retry`() { + edt { + var clicked = 0 + val view = SessionOutcomeView(retry = { clicked++ }) + view.showError("Provider balance is too low", "APIError") + + val button = retryButton(view) + assertNotNull("Error card should offer Retry", button) + button!!.doClick() + assertEquals(1, clicked) + } + } + + fun `test failed outcome offers retry`() { + edt { + val view = SessionOutcomeView(retry = {}) + view.showOutcome(Outcome.FAILED) + + assertNotNull("Failed outcome should offer Retry", retryButton(view)) + } + } + + fun `test interrupted note offers no retry`() { + edt { + val view = SessionOutcomeView(retry = {}) + view.showOutcome(Outcome.INTERRUPTED) + + assertNull("A user stop is not a failure and must not offer Retry", retryButton(view)) + } + } + + fun `test readonly outcome view offers no retry`() { + edt { + val view = SessionOutcomeView(retry = null) + view.showError("Provider balance is too low", "APIError") + + assertNull("Readonly sessions cannot retry", retryButton(view)) + } + } + + fun `test error card hides retry when the transcript has nothing to replay`() { + edt { + val view = SessionOutcomeView(retry = {}, retryable = { false }) + view.showError("invalid kilo.json", "UnknownError") + + assertNull("A dead Retry must not be painted", retryButton(view)) + } + } + + fun `test failed outcome hides retry when the transcript has nothing to replay`() { + edt { + val view = SessionOutcomeView(retry = {}, retryable = { false }) + view.showOutcome(Outcome.FAILED) + + assertNull(retryButton(view)) + } + } + + fun `test retry appears once the transcript becomes replayable`() { + edt { + var replayable = false + val view = SessionOutcomeView(retry = {}, retryable = { replayable }) + view.showError("Provider balance is too low", "APIError") + assertNull(retryButton(view)) + + replayable = true + view.showError("Provider balance is too low", "APIError") + + val buttons = findAll(view).filter { it.text == KiloBundle.message("session.outcome.retry") } + assertEquals("Exactly one live Retry button", 1, buttons.size) + } + } + + fun `test toggling outcomes does not accumulate retry buttons`() { + edt { + var clicked = 0 + val view = SessionOutcomeView(retry = { clicked++ }) + repeat(3) { + view.showOutcome(Outcome.FAILED) + view.showOutcome(Outcome.INTERRUPTED) + } + assertNull("The note detaches the footer entirely", retryButton(view)) + view.showOutcome(Outcome.FAILED) + + val buttons = findAll(view).filter { it.text == KiloBundle.message("session.outcome.retry") } + assertEquals("Exactly one live Retry button", 1, buttons.size) + buttons.single().doClick() + assertEquals("The live button is wired to the current handler", 1, clicked) + } + } + + private fun retryButton(root: Container) = + findAll(root).firstOrNull { it.text == KiloBundle.message("session.outcome.retry") } + fun `test hideView makes view invisible`() { edt { val view = SessionOutcomeView() @@ -294,8 +398,9 @@ class SessionOutcomeViewTest : BasePlatformTestCase() { private fun findAllCls(root: Container, cls: Class): List { val result = mutableListOf() if (cls.isInstance(root)) result.add(cls.cast(root)) + // Only recurse. Matching a child here as well would double-count any hit that is itself a + // Container (every Swing component is), because the recursive call re-checks it as its own root. for (child in root.components) { - if (cls.isInstance(child)) result.add(cls.cast(child)) if (child is Container) result.addAll(findAllCls(child, cls)) } return result diff --git a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt index 5f1063cf3c..a6f4e52cdd 100644 --- a/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt +++ b/packages/kilo-jetbrains/frontend/src/test/kotlin/ai/kilocode/client/session/views/base/DialogViewTest.kt @@ -60,6 +60,29 @@ class DialogViewTest : BasePlatformTestCase() { } } + fun `test setHeader with blank title hides header text`() { + edt { + val panel = DialogView() + panel.setHeader("", "Stopped") + val areas = findAll(panel) + + assertTrue("Blank header text area should be hidden", areas.filter { it.text.isBlank() }.all { !it.isVisible }) + assertNotNull("Description should remain visible", areas.firstOrNull { it.text == "Stopped" && it.isVisible }) + } + } + + fun `test setOutlined toggles outline color`() { + edt { + val panel = InspectDialogView() + + assertNotNull(panel.line()) + panel.setOutlined(false) + assertNull(panel.line()) + panel.setOutlined(true) + assertNotNull(panel.line()) + } + } + fun `test setDescription with blank hides description`() { edt { val panel = DialogView() @@ -530,4 +553,8 @@ class DialogViewTest : BasePlatformTestCase() { } return result } + + private class InspectDialogView : DialogView() { + fun line() = outlineColor() + } } diff --git a/packages/kilo-jetbrains/gradle.properties b/packages/kilo-jetbrains/gradle.properties index df9565b004..556b498edd 100644 --- a/packages/kilo-jetbrains/gradle.properties +++ b/packages/kilo-jetbrains/gradle.properties @@ -1,5 +1,5 @@ kotlin.stdlib.default.dependency=false -kilo.jetbrains.version=7.1.0-rc.4 +kilo.jetbrains.version=7.1.0 # When true (default) the JetBrains plugin uses the pinned CLI release from package.json. # Set to false ONLY for local dev: generate the client from local source + bundle the local binary. # false is NOT releasable -- production builds fail unless this is true. diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt index 3fe5072cf1..08b0699b6e 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/KiloWorktreeRpcApi.kt @@ -63,7 +63,9 @@ interface KiloWorktreeRpcApi : RemoteApi { /** * Imports a worktree from a GitHub pull request [url]. Resolves the PR's head branch via `gh`, - * fetches it (adding a fork remote for cross-repo PRs), then checks it out into a new worktree. + * fetches it, records the branch's remote and merge ref so the PR stays identifiable, then + * checks it out into a new worktree. Fork PRs are fetched through `refs/pull//head` and + * get an owner-prefixed local branch; no fork remote is added. */ suspend fun importPr(directory: String, url: String): CreateWorktreeResultDto diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt new file mode 100644 index 0000000000..d7c530b08e --- /dev/null +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/PrUrl.kt @@ -0,0 +1,12 @@ +package ai.kilocode.rpc + +data class PrRef(val owner: String, val repo: String, val number: Int) + +private val PR_URL = Regex("github\\.com[/:]([^/]+)/([^/]+?)(?:\\.git)?/pull/(\\d+)") + +/** Parses `https://github.com///pull/` (and ssh-style hosts) into its parts. */ +fun parsePrUrl(url: String): PrRef? { + val match = PR_URL.find(url.trim()) ?: return null + val number = match.groupValues[3].toIntOrNull() ?: return null + return PrRef(match.groupValues[1], match.groupValues[2], number) +} diff --git a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt index acac40bb3e..9a0318477f 100644 --- a/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt +++ b/packages/kilo-jetbrains/shared/src/main/kotlin/ai/kilocode/rpc/dto/ChatDto.kt @@ -49,7 +49,13 @@ data class MessageErrorDto( val responseBody: String? = null, val dataKeys: List = emptyList(), val ref: String? = null, -) +) { + val aborted: Boolean get() = type == ABORTED + + companion object { + const val ABORTED = "MessageAbortedError" + } +} @Serializable data class MessageWithPartsDto( diff --git a/packages/kilo-memory/package.json b/packages/kilo-memory/package.json index 24008799a9..d4190e9a36 100644 --- a/packages/kilo-memory/package.json +++ b/packages/kilo-memory/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-memory", - "version": "7.5.0", + "version": "7.5.5", "type": "module", "license": "MIT", "description": "Project memory storage, indexing, recall, and command helpers for Kilo Code", diff --git a/packages/kilo-sandbox/package.json b/packages/kilo-sandbox/package.json index aad0a371f7..932d7e001d 100644 --- a/packages/kilo-sandbox/package.json +++ b/packages/kilo-sandbox/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/sandbox", - "version": "7.5.0", + "version": "7.5.5", "type": "module", "license": "MIT", "private": true, diff --git a/packages/kilo-telemetry/package.json b/packages/kilo-telemetry/package.json index d367446966..bb708834a9 100644 --- a/packages/kilo-telemetry/package.json +++ b/packages/kilo-telemetry/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@kilocode/kilo-telemetry", - "version": "7.5.0", + "version": "7.5.5", "type": "module", "license": "MIT", "description": "Telemetry for Kilo CLI - PostHog analytics integration", diff --git a/packages/kilo-ui/package.json b/packages/kilo-ui/package.json index 9feaa06b0c..f47554e3cf 100644 --- a/packages/kilo-ui/package.json +++ b/packages/kilo-ui/package.json @@ -1,6 +1,6 @@ { "name": "@kilocode/kilo-ui", - "version": "7.5.0", + "version": "7.5.5", "type": "module", "license": "MIT", "exports": { diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx index fb72b94652..a0a8e694ee 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.test.tsx @@ -27,17 +27,18 @@ class FakeElement { scrollTop = 0 style = { overflowAnchor: "" } hovered = false + control = false dir = "" rect = { left: 0, top: 0, right: 100, bottom: 100 } ownerDocument!: FakeDocument private children = new Set() private listeners = new Map() - closest() { - return null + closest(selector: string) { + return this.control && selector === "button, input, textarea, select" ? this : null } - contains(node: unknown) { + contains(node: unknown): boolean { return node === this || (node instanceof FakeElement && [...this.children].some((child) => child.contains(node))) } @@ -313,6 +314,140 @@ describe("createAutoScroll non-scrollable layouts", () => { ctx.dispose() }) + test.each([0, 0.5, 1])("preserves upward intent after a %spx scroll near the bottom", (offset) => { + const ctx = setup({ working: true }) + overflow(ctx) + let now = 10 + const clock = spyOn(performance, "now").mockImplementation(() => now) + + try { + ctx.el.fire("wheel", new FakeWheelEvent(-1, ctx.el) as unknown as Event) + ctx.el.scrollTop -= offset + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + now = 500 + ctx.scroll.handleScroll() + ctx.el.scrollHeight = 1100 + ctx.mutate() + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(800 - offset) + } finally { + clock.mockRestore() + ctx.dispose() + } + }) + + test("pauses for upward wheel input over a transcript button", () => { + const ctx = setup({ working: true }) + overflow(ctx) + const button = new FakeElement() + button.control = true + ctx.el.append(button) + + ctx.el.fire("wheel", new FakeWheelEvent(-240, button) as unknown as Event) + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollTop = 560 + ctx.scroll.handleScroll() + ctx.el.scrollHeight = 1100 + ctx.mutate() + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(560) + ctx.dispose() + }) + + test("does not treat button clicks and key presses as scroll input", () => { + const ctx = setup({ working: true }) + overflow(ctx) + const button = new FakeElement() + button.control = true + ctx.el.append(button) + + ctx.el.fire("pointerdown", new FakePointerEvent(1, button) as unknown as Event) + ctx.doc.fire("keydown", new FakeKeyboardEvent("ArrowUp", button) as unknown as Event) + ctx.el.scrollTop = 600 + ctx.scroll.handleScroll() + + expect(ctx.scroll.userScrolled()).toBe(false) + expect(ctx.el.scrollTop).toBe(1000) + ctx.dispose() + }) + + test("keeps a pause when a layout change puts the same position at the bottom", () => { + const ctx = setup({ working: true }) + overflow(ctx, 1000, 400) + ctx.scroll.pause() + + ctx.el.scrollHeight = 600 + ctx.scroll.handleScroll() + ctx.el.scrollHeight = 1000 + ctx.mutate() + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(400) + ctx.dispose() + }) + + test.each(["wheel", "keyboard"])("preserves new %s input before a pending bottom scroll", (input) => { + const ctx = setup({ working: true }) + overflow(ctx) + let now = 10 + const clock = spyOn(performance, "now").mockImplementation(() => now) + + try { + ctx.el.fire("wheel", new FakeWheelEvent(-20, ctx.el) as unknown as Event) + ctx.el.scrollTop = 780 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollTop = 800 + if (input === "wheel") ctx.el.fire("wheel", new FakeWheelEvent(-20, ctx.el) as unknown as Event) + if (input === "keyboard") { + ctx.doc.fire("keydown", new FakeKeyboardEvent("ArrowUp", ctx.el) as unknown as Event) + } + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollTop = 780 + ctx.scroll.handleScroll() + now = 500 + ctx.el.scrollHeight = 1100 + ctx.mutate() + ctx.resize() + + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(780) + } finally { + clock.mockRestore() + ctx.dispose() + } + }) + + test("reattaches when a downward wheel returns to the bottom", () => { + const ctx = setup({ working: true }) + overflow(ctx) + ctx.el.fire("wheel", new FakeWheelEvent(-20, ctx.el) as unknown as Event) + ctx.el.scrollTop = 780 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.fire("wheel", new FakeWheelEvent(20, ctx.el) as unknown as Event) + ctx.el.scrollTop = 800 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(false) + + ctx.el.scrollHeight = 1100 + ctx.mutate() + expect(ctx.el.scrollTop).toBe(1100) + ctx.dispose() + }) + test("continues following streaming growth after a downward wheel at the bottom", () => { const ctx = setup({ working: true }) ctx.el.scrollHeight = 1000 @@ -634,6 +769,33 @@ describe("createAutoScroll non-scrollable layouts", () => { ctx.dispose() }) + test("keeps a scrollbar gesture active after reaching the bottom", () => { + const ctx = setup({ working: true }) + overflow(ctx) + let now = 10 + const clock = spyOn(performance, "now").mockImplementation(() => now) + + try { + ctx.doc.fire("pointerdown", new FakePointerEvent(1, ctx.el) as unknown as Event) + ctx.el.scrollTop = 600 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + + ctx.el.scrollTop = 800 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(false) + + now = 1000 + ctx.el.scrollTop = 600 + ctx.scroll.handleScroll() + expect(ctx.scroll.userScrolled()).toBe(true) + expect(ctx.el.scrollTop).toBe(600) + } finally { + clock.mockRestore() + ctx.dispose() + } + }) + test("keeps a pointer gesture active beyond the grace period", () => { const ctx = setup({ working: true }) overflow(ctx) diff --git a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx index 64e059df8d..e294655355 100644 --- a/packages/kilo-ui/src/hooks/create-auto-scroll.tsx +++ b/packages/kilo-ui/src/hooks/create-auto-scroll.tsx @@ -22,6 +22,7 @@ export function createAutoScroll(options: AutoScrollOptions) { // --------------------------------------------------------------------------- let scroll: HTMLElement | undefined + let top = 0 let settling = false let settleTimer: ReturnType | undefined let cleanup: (() => void) | undefined @@ -73,7 +74,9 @@ export function createAutoScroll(options: AutoScrollOptions) { } const pause = () => { - if (!scroll || store.userScrolled) return + if (!scroll) return + top = scroll.scrollTop + if (store.userScrolled) return setStore("userScrolled", true) options.onUserInteracted?.() } @@ -91,7 +94,7 @@ export function createAutoScroll(options: AutoScrollOptions) { grace: USER_INTERACTION_GRACE_MS, // Upward wheel input anywhere in the transcript expresses the user's // intent to review earlier content, even when a nested region consumes it. - onWheelUp: stop, + onUp: stop, }) // --------------------------------------------------------------------------- @@ -101,13 +104,19 @@ export function createAutoScroll(options: AutoScrollOptions) { const handleScroll = () => { if (!scroll) return + const position = scroll.scrollTop + const down = position > top + top = position const input = userActivity.consumeScroll() const distance = distanceFromBottom(scroll) if (!canScroll(scroll)) return if (distance < threshold()) { - if (store.userScrolled && (distance < 2 || !userActivity.isRecent())) setStore("userScrolled", false) + if (store.userScrolled && down && (distance < 2 || !userActivity.isRecent())) { + userActivity.clear() + setStore("userScrolled", false) + } return } @@ -187,7 +196,7 @@ export function createAutoScroll(options: AutoScrollOptions) { settleTimer = undefined if (working) { - force() + follow() return } @@ -234,6 +243,7 @@ export function createAutoScroll(options: AutoScrollOptions) { } scroll = el + top = el?.scrollTop ?? 0 setStore("scrollRef", el) if (!el) return diff --git a/packages/kilo-ui/src/hooks/scroll-user-activity.ts b/packages/kilo-ui/src/hooks/scroll-user-activity.ts index a0bcdf0c17..f82d78e77d 100644 --- a/packages/kilo-ui/src/hooks/scroll-user-activity.ts +++ b/packages/kilo-ui/src/hooks/scroll-user-activity.ts @@ -1,6 +1,6 @@ interface UserActivityOptions { grace: number - onWheelUp: () => void + onUp: () => void } type Kind = "pointer" | "mouse" | "touch" @@ -94,19 +94,22 @@ export const createUserActivity = (options: UserActivityOptions) => { gestures.delete(doc) } - const reset = () => { - if (doc && scroll && gestures.get(doc) === scroll) gestures.delete(doc) + const clear = () => { marked = false time = 0 + } + + const reset = () => { + if (doc && scroll && gestures.get(doc) === scroll) gestures.delete(doc) + clear() gesture = undefined } const wheel = (event: WheelEvent) => { - if (!isPotentialScrollInput(event)) return if (!scroll || scroll.scrollHeight - scroll.clientHeight <= 1) return if (event.deltaY >= 0 || scroll.scrollTop <= 0) return - mark(event) - options.onWheelUp() + mark() + options.onUp() } const key = (event: KeyboardEvent) => { @@ -125,6 +128,7 @@ export const createUserActivity = (options: UserActivityOptions) => { }) if (deepest(matches) !== scroll) return mark(event) + if (up) options.onUp() } return { @@ -179,6 +183,7 @@ export const createUserActivity = (options: UserActivityOptions) => { return value }, isRecent: () => gesture !== undefined || (time > 0 && performance.now() - time < options.grace), + clear, reset, } } diff --git a/packages/kilo-vscode/CHANGELOG.md b/packages/kilo-vscode/CHANGELOG.md index def48943ed..0307cb3c14 100644 --- a/packages/kilo-vscode/CHANGELOG.md +++ b/packages/kilo-vscode/CHANGELOG.md @@ -1,5 +1,25 @@ # kilo-code +## 7.5.5 + +### Patch Changes + +- [#13489](https://github.com/Kilo-Org/kilocode/pull/13489) [`b74dc0c`](https://github.com/Kilo-Org/kilocode/commit/b74dc0c1b60007fedf7a13259e35ee6f040fa89a) - Prevent inaccessible Windows PowerShell execution aliases from blocking CLI and extension startup. + +## 7.5.3 + +### Patch Changes + +- [#13465](https://github.com/Kilo-Org/kilocode/pull/13465) [`4e38e0c`](https://github.com/Kilo-Org/kilocode/commit/4e38e0c2e4d3375e34b06663ad6d1f72e9975ade) Thanks [@WebReflection](https://github.com/WebReflection)! - Update DOMPurify to 3.4.13 to fix XSS vulnerabilities flagged by Dependabot in markdown sanitization. + +- [#13463](https://github.com/Kilo-Org/kilocode/pull/13463) [`743fafc`](https://github.com/Kilo-Org/kilocode/commit/743fafce09e5cc264df1cc4152cd86ec92a0f333) - Reduce Agent Manager background Git and GitHub activity without adding file watchers. + +- [#13459](https://github.com/Kilo-Org/kilocode/pull/13459) [`f9606d8`](https://github.com/Kilo-Org/kilocode/commit/f9606d8def5010fea7801722759eab621346bb46) - Restore the session title and branch subtitle immediately when opening an existing session in a worktree. + +- Updated dependencies [[`e4003da`](https://github.com/Kilo-Org/kilocode/commit/e4003da9e1842e0bc8f49777619faa3284b24f95)]: + - @opencode-ai/ui@7.5.1 + - @kilocode/kilo-ui@7.5.1 + ## 7.5.0 ### Minor Changes diff --git a/packages/kilo-vscode/docs/mercury-next-edit-testing.html b/packages/kilo-vscode/docs/mercury-next-edit-testing.html index 599d30a4ba..5772426f25 100644 --- a/packages/kilo-vscode/docs/mercury-next-edit-testing.html +++ b/packages/kilo-vscode/docs/mercury-next-edit-testing.html @@ -276,7 +276,7 @@
  • VSCode ≥ 1.105.1 (matches kilocode's engines.vscode)
  • - Bun ≥ 1.4.0 (the build script checks the version) — install via + Bun ≥ 1.3.14 (the build script checks the version) — install via brew install bun or bun.sh
  • GitHub CLI (gh) — optional but makes the PR checkout one command
  • diff --git a/packages/kilo-vscode/package.json b/packages/kilo-vscode/package.json index 8ba728c462..b71014bffd 100644 --- a/packages/kilo-vscode/package.json +++ b/packages/kilo-vscode/package.json @@ -2,7 +2,7 @@ "name": "kilo-code", "displayName": "Kilo Code: AI Coding Agent, Copilot, and Autocomplete", "description": "Open Source AI coding agent that generates code from natural language, automates tasks, and runs terminal commands. Features inline autocomplete, browser automation, automated refactoring, and custom modes for planning, coding, and debugging. Supports 500+ AI models including Claude (Anthropic), Gemini, Grok, GPT, Codex and GLM.", - "version": "7.5.0", + "version": "7.5.5", "icon": "assets/icons/logo-outline-black.png", "galleryBanner": { "color": "#FFFFFF", diff --git a/packages/kilo-vscode/src/KiloProvider.ts b/packages/kilo-vscode/src/KiloProvider.ts index 954ce7d449..520c484b70 100644 --- a/packages/kilo-vscode/src/KiloProvider.ts +++ b/packages/kilo-vscode/src/KiloProvider.ts @@ -752,7 +752,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper localResourceRoots: [this.extensionUri], } - webviewView.webview.html = this._getHtmlForWebview(webviewView.webview) + webviewView.webview.html = this._getHtmlForWebview(webviewView.webview, true) this.setupWebviewMessageHandler(webviewView.webview) this.setSidebarVisible(webviewView.visible) @@ -5323,8 +5323,14 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper return resolveProjectDirectory(this.projectDirectory, () => this.getWorkspaceDirectory(sessionId)) } - private _getHtmlForWebview(webview: vscode.Webview): string { + private _getHtmlForWebview(webview: vscode.Webview, sidebar = false): string { return buildWebviewHtml(webview, { + // The rail follows the physical workbench edge. RTL text direction must not move it between chat and code. + sidebar: sidebar + ? vscode.workspace.getConfiguration("workbench").get("sideBar.location") === "right" + ? "right" + : "left" + : undefined, scriptUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.js")), styleUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "dist", "webview.css")), iconsBaseUri: webview.asWebviewUri(vscode.Uri.joinPath(this.extensionUri, "assets", "icons")), diff --git a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts index 78c30a193c..f4af07bf98 100644 --- a/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts +++ b/packages/kilo-vscode/src/agent-manager/AgentManagerProvider.ts @@ -46,6 +46,7 @@ import { forkSession } from "./fork-session" import { AgentManagerVisiblePresence } from "./am-visible-presence" import { continueInWorktree } from "./continue-in-worktree" import { WorktreeDiffController } from "./worktree-diff-controller" +import { createWorktreeActivity } from "./worktree-activity" import { sendDiffBranches as postDiffBranches } from "./project/diff-branches" import { WorktreeImporter } from "./worktree-importer" import { @@ -106,8 +107,7 @@ export class AgentManagerProvider implements Disposable { private cachedWorktreeStats: { type: "agentManager.worktreeStats"; stats: WorktreeStats[] } | undefined private cachedLocalStats: { type: "agentManager.localStats"; stats: LocalStats } | undefined private unsubTool: (() => void) | undefined - private unsubStatus: (() => void) | undefined - private unsubSessions: (() => void) | undefined + private activity: ReturnType private unsubFont: (() => void) | undefined private unsubProjects: (() => void) | undefined /** Scratch set returned when no active context exists; mutations are discarded. */ @@ -130,7 +130,7 @@ export class AgentManagerProvider implements Disposable { constructor( private readonly host: Host, private readonly connectionService: KiloConnectionService, - binary: GitExecutable = () => Promise.resolve("git"), + binary: GitExecutable | string = "git", ) { this.outputChannel = host.createOutput("Kilo Agent Manager") this.terminalManager = new SessionTerminalManager( @@ -275,6 +275,7 @@ export class AgentManagerProvider implements Disposable { getPrs: () => this.prBridge.snapshot(), pushState: (ctx) => this.pushState(ctx), hasPanelSession: (id) => this.panelSessions.has(id), + routeSession: (id, dir) => this.panel?.sessions.setSessionDirectory(id, dir), closeSession: (id) => this.onCloseSession(id), postSessionClosed: (id, projectId) => this.postToWebview({ type: "agentManager.sessionClosed", sessionId: id, projectId }), @@ -284,22 +285,15 @@ export class AgentManagerProvider implements Disposable { (event) => (event as { type?: string }).type === "kilocode.agent_manager.start", (event, directory) => this.onToolEvent(event, directory), ) - this.unsubStatus = this.connectionService.onEventFiltered( - (event) => (event as { type?: string }).type === "session.status", - (event) => this.onSessionStatus(event), - ) - this.unsubSessions = this.connectionService.onEventFiltered( - (event) => { - const type = (event as { type?: string }).type - return ( - type === "session.created" || - type === "session.updated" || - type === "session.deleted" || - type === "session.error" - ) - }, - (event) => this.onSessionLifecycle(event), - ) + this.activity = createWorktreeActivity({ + connection: this.connectionService, + paths: () => + [...this.contexts.values()].flatMap((ctx) => ctx.peekState()?.getWorktrees() ?? []).map((wt) => wt.path), + post: (active) => this.postToWebview({ type: "agentManager.worktreeActivity", active }), + status: (event) => this.onSessionStatus(event), + lifecycle: (event) => this.onSessionLifecycle(event), + log: (err) => this.log("Failed to load worktree activity:", err), + }) } /** * Keep each project's cached sidebar session list in sync with backend @@ -905,6 +899,7 @@ export class AgentManagerProvider implements Disposable { // instance are reaped by the router's generation guard. void this.terminalRouter.dispose() this.scripts.manager.snapshot() + void this.activity.sync(true) this.log( `onRequestState: stateReady=${this.stateReady ? "pending" : "missing"}, state=${this.state ? "ok" : "missing"}`, ) @@ -1426,6 +1421,7 @@ export class AgentManagerProvider implements Disposable { activeTarget: state.getActiveTarget(), ...(active ? this.runStateFor(target) : {}), }) + void this.activity.sync() void pushProjectSessions(target, this.panel?.sessions, (message) => this.postToWebview(message)) if (!active) return @@ -1439,6 +1435,7 @@ export class AgentManagerProvider implements Disposable { /** Push empty state when the folder is not a git repo or has no folder open. */ private pushEmptyState(): void { + void this.activity.sync() this.staleWorktreeIds.clear() this.postToWebview({ type: "agentManager.state", @@ -1619,6 +1616,7 @@ export class AgentManagerProvider implements Disposable { private pushProjects(): void { const projects = this.contexts.snapshots() + void this.activity.sync() this.postToWebview({ type: "agentManager.projects", multiProject: this.host.multiProject(), @@ -1798,10 +1796,10 @@ export class AgentManagerProvider implements Disposable { this.openPanel() await this.waitForStateReady("continueFromSidebar") - await continueInWorktree( { root, + binary: this.gitOps.path, getClient: () => this.connectionService.getClient(), createWorktreeOnDisk: (opts) => this.createWorktreeOnDisk(opts), runSetupScript: (p, b, id) => this.runSetupScriptForWorktree(p, b, id), @@ -1870,8 +1868,7 @@ export class AgentManagerProvider implements Disposable { await this.stateReady?.catch((err) => this.log("dispose: stateReady rejected:", err)) await this.contexts.dispose() this.unsubTool?.() - this.unsubStatus?.() - this.unsubSessions?.() + this.activity.dispose() this.unsubFont?.() this.unsubProjects?.() this.unsubDestination?.() diff --git a/packages/kilo-vscode/src/agent-manager/GitOps.ts b/packages/kilo-vscode/src/agent-manager/GitOps.ts index e31ebc00c7..bc1a4a27bf 100644 --- a/packages/kilo-vscode/src/agent-manager/GitOps.ts +++ b/packages/kilo-vscode/src/agent-manager/GitOps.ts @@ -20,7 +20,7 @@ interface GitOpsOptions { /** Shared concurrency gate for child process spawning. */ semaphore?: Semaphore /** Validated Git executable shared by Agent Manager operations. */ - binary?: GitExecutable + binary?: GitExecutable | string } export interface ApplyConflict { @@ -131,6 +131,8 @@ export class GitOps { private static readonly DEFAULT_BRANCH_CACHE_TTL_MS = 10 * 60_000 private static readonly MAX_CACHE_SIZE = 100 + public readonly path: string + get disposed(): boolean { return this.controller.signal.aborted } @@ -138,7 +140,12 @@ export class GitOps { constructor(options: GitOpsOptions) { this.log = options.log this.semaphore = options.semaphore - this.binary = options.binary ?? (() => Promise.resolve("git")) + const configured = options.binary + this.path = typeof configured === "string" ? configured : "git" + this.binary = + typeof configured === "string" + ? () => Promise.resolve(configured) + : (configured ?? (() => Promise.resolve("git"))) this.injected = options.runGit !== undefined this.runGit = options.runGit ?? @@ -147,6 +154,7 @@ export class GitOps { return simpleGit(cwd, { abort: this.controller.signal, binary, + unsafe: { allowUnsafeCustomBinary: binary !== "git" }, }) .raw(args) .then((out) => out.trim()) diff --git a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts index 3f2ecb5662..6aa46ff789 100644 --- a/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts +++ b/packages/kilo-vscode/src/agent-manager/SetupScriptRunner.ts @@ -5,7 +5,6 @@ * actual execution to an injected RunTask callback (provided by the caller). */ -import { powershellCommand } from "../util/powershell" import { SetupScriptService, type SetupScriptInfo } from "./SetupScriptService" interface SetupScriptEnvironment { @@ -32,7 +31,7 @@ function quoteCmdArg(value: string): string { export function buildSetupTaskCommand(script: SetupScriptInfo): { command: string; args: string[] } { if (script.kind === "powershell") { return { - command: powershellCommand(), + command: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.path], } } diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts index adb0a510a9..069e51f346 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeManager.ts @@ -89,14 +89,16 @@ export class WorktreeManager { private readonly dir: string private readonly git: SimpleGit private readonly ops: GitOps | undefined + private readonly binary: string private readonly log: (msg: string) => void private migrated = false - constructor(root: string, log: (msg: string) => void, ops?: GitOps) { + constructor(root: string, log: (msg: string) => void, ops?: GitOps, binary?: string) { this.root = root this.dir = path.join(root, KILO_DIR, "worktrees") - this.git = simpleGit(root) this.ops = ops + this.binary = binary ?? ops?.path ?? "git" + this.git = this.client(root) this.log = log } @@ -121,8 +123,8 @@ export class WorktreeManager { // Key: `${root}:${remote}:${branch}`, Value: timestamp when fetch was done private static fetchCache = new Map() private static readonly FETCH_CACHE_TTL = 60_000 // 1 minute - private static gitAvailable = false - private static lfsAvailable: boolean | undefined + private gitAvailable = false + private lfsAvailable: boolean | undefined private withGitLock(fn: () => Promise): Promise { const key = this.root @@ -136,6 +138,16 @@ export class WorktreeManager { return result } + private client(cwd: string, ssh = false): SimpleGit { + return simpleGit(cwd, { + binary: this.binary, + unsafe: { + allowUnsafeCustomBinary: this.binary !== "git", + allowUnsafeSshCommand: ssh, + }, + }) + } + // --------------------------------------------------------------------------- // Public API (acquires git lock) // --------------------------------------------------------------------------- @@ -169,7 +181,7 @@ export class WorktreeManager { async hasWork(worktreePath: string, base: string): Promise { if (!this.isManagedPath(worktreePath)) return false return this.withGitLock(async () => { - const git = simpleGit(worktreePath) + const git = this.client(worktreePath) const status = await git.status() if (status.files.length > 0) return true return git @@ -185,12 +197,12 @@ export class WorktreeManager { } private async ensureGitAvailable(): Promise { - if (WorktreeManager.gitAvailable) return + if (this.gitAvailable) return try { - await execWithShellEnv("git", ["--version"]) - WorktreeManager.gitAvailable = true + await execWithShellEnv(this.binary, ["--version"]) + this.gitAvailable = true } catch (error) { - WorktreeManager.gitAvailable = false + this.gitAvailable = false if (error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") { throw new Error( "Git is not installed or not found in PATH. Please install Git (https://git-scm.com) and restart VS Code.", @@ -313,7 +325,7 @@ export class WorktreeManager { private async renameBranchImpl(worktreePath: string, current: string, requested: string): Promise { if (!this.isManagedPath(worktreePath)) throw new Error("Worktree is not managed by Agent Manager") - const git = simpleGit(worktreePath) + const git = this.client(worktreePath) const actual = (await git.revparse(["--abbrev-ref", "HEAD"])).trim() if (actual === "HEAD" || actual !== current) throw new Error("Branch changed before automatic naming") @@ -721,7 +733,7 @@ export class WorktreeManager { } try { - const git = simpleGit(wtPath) + const git = this.client(wtPath) const [branch, stat, meta] = await Promise.all([ git.revparse(["--abbrev-ref", "HEAD"]), fs.promises.stat(wtPath), @@ -855,7 +867,7 @@ export class WorktreeManager { // is the fixed value Kilo injects — never for an inherited one, which // could be attacker-controlled. const env = nonInteractiveEnv() - await simpleGit(this.root, { unsafe: { allowUnsafeSshCommand: isKiloOwnedSshCommand(env) } }) + await this.client(this.root, isKiloOwnedSshCommand(env)) .env(env) .raw(["fetch", "--quiet", "--no-tags", remote, `+refs/heads/${branch}:refs/remotes/${remote}/${branch}`]) WorktreeManager.fetchCache.set(key, Date.now()) @@ -925,13 +937,13 @@ export class WorktreeManager { } async checkLfsAvailable(): Promise { - if (WorktreeManager.lfsAvailable) return true + if (this.lfsAvailable) return true try { - await execWithShellEnv("git", ["lfs", "version"], { cwd: this.root, timeout: 5000 }) - WorktreeManager.lfsAvailable = true + await execWithShellEnv(this.binary, ["lfs", "version"], { cwd: this.root, timeout: 5000 }) + this.lfsAvailable = true return true } catch { - WorktreeManager.lfsAvailable = false + this.lfsAvailable = false // git-lfs not installed return false } @@ -1074,6 +1086,7 @@ export class WorktreeManager { throw new Error("This PR's branch is already checked out in another worktree") } + const base = await this.resolvePRBase(info) await this.fetchPRBranch(info, parsed, isFork, forkOwner) if (isFork && forkOwner) { @@ -1083,7 +1096,15 @@ export class WorktreeManager { await this.git.raw(["branch", branch, `${forkOwner}/${info.headRefName}`]) } - return this.createWorktreeImpl({ existingBranch: branch }) + const result = await this.createWorktreeImpl({ existingBranch: branch }) + return { ...result, parentBranch: base.branch, remote: base.remote } + } + + private async resolvePRBase(info: PRInfo): Promise<{ branch: string; remote?: string }> { + if (info.baseRefName === undefined) return this.resolveBaseBranch() + validateGitRef(info.baseRefName, "base branch") + const point = await this.resolveStartPoint(info.baseRefName, undefined, { allowFallback: false }) + return { branch: point.branch, remote: point.remote } } private async fetchPRInfo(parsed: { owner: string; repo: string; number: number }): Promise { @@ -1096,7 +1117,7 @@ export class WorktreeManager { "--repo", `${parsed.owner}/${parsed.repo}`, "--json", - "headRefName,headRepositoryOwner,isCrossRepository,title", + "headRefName,baseRefName,headRepositoryOwner,isCrossRepository,title", ], 30000, ) @@ -1165,7 +1186,7 @@ export class WorktreeManager { } private async gitExec(args: string[]): Promise { - await this.exec("git", args) + await this.exec(this.binary, args) } private async gitTry(args: string[]): Promise { diff --git a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts index d924129626..85c29c10ce 100644 --- a/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts +++ b/packages/kilo-vscode/src/agent-manager/WorktreeStateManager.ts @@ -88,6 +88,7 @@ export interface ManagedSession { interface StateFile { worktrees: Record> sessions: Record> + closedSessions?: Record sections?: Record> tabOrder?: Record worktreeOrder?: string[] @@ -107,6 +108,7 @@ export interface StateLoadResult extends MigrationResult { import { KILO_DIR, migrateAgentManagerData, type MigrationResult } from "./constants" const STATE_FILE = "agent-manager.json" +const CLOSED_LIMIT = 1_000 let counter = 0 @@ -118,6 +120,7 @@ export class WorktreeStateManager { private readonly file: string private worktrees = new Map() private sessions = new Map() + private closed = new Map() private sections = new Map() private tabOrder: Record = {} private worktreeOrder: string[] = [] @@ -172,6 +175,10 @@ export class WorktreeStateManager { return this.sessions.get(id) } + isSessionClosed(id: string): boolean { + return this.closed.has(id) + } + /** Returns the worktree directory for a session, or undefined for local sessions. */ directoryFor(sessionId: string): string | undefined { const session = this.sessions.get(sessionId) @@ -328,6 +335,10 @@ export class WorktreeStateManager { } } + for (const [session, worktree] of this.closed) { + if (worktree === id) this.closed.delete(session) + } + // Clean up tab order for this worktree delete this.tabOrder[id] @@ -339,6 +350,7 @@ export class WorktreeStateManager { } addSession(sessionId: string, worktreeId: string | null): ManagedSession { + this.closed.delete(sessionId) const session: ManagedSession = { id: sessionId, worktreeId, createdAt: new Date().toISOString() } this.sessions.set(sessionId, session) const worktree = worktreeId ? this.worktrees.get(worktreeId) : undefined @@ -370,6 +382,13 @@ export class WorktreeStateManager { void this.save() } + closeSession(id: string, worktreeId: string | null): void { + this.closed.delete(id) + this.closed.set(id, worktreeId) + if (this.closed.size > CLOSED_LIMIT) this.closed.delete(this.closed.keys().next().value!) + void this.save() + } + removeSession(id: string): void { this.sessions.delete(id) @@ -709,6 +728,7 @@ export class WorktreeStateManager { const data = JSON.parse(content) as StateFile this.worktrees.clear() this.sessions.clear() + this.closed.clear() this.sections.clear() this.tabOrder = {} this.worktreeOrder = [] @@ -737,6 +757,7 @@ export class WorktreeStateManager { } this.sessions.set(id, session) } + this.restoreClosed(data.closedSessions) for (const [id, sec] of Object.entries(data.sections ?? {})) { this.sections.set(id, { id, ...sec }) } @@ -762,6 +783,13 @@ export class WorktreeStateManager { } } + private restoreClosed(value: StateFile["closedSessions"]): void { + if (!value || typeof value !== "object" || Array.isArray(value)) return + for (const [id, ref] of Object.entries(value)) { + if (ref === null || (typeof ref === "string" && this.worktrees.has(ref))) this.closed.set(id, ref) + } + } + /** Remove worktrees whose directories no longer exist on disk and prune orphaned sessions. */ async validate(root: string): Promise { let changed = false @@ -840,6 +868,7 @@ export class WorktreeStateManager { const { id: _, ...rest } = s data.sessions[id] = rest } + if (this.closed.size > 0) data.closedSessions = Object.fromEntries(this.closed) if (this.sections.size > 0) { data.sections = {} for (const [id, sec] of this.sections) { diff --git a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts index 853221dde8..7c2c8fa5cf 100644 --- a/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts +++ b/packages/kilo-vscode/src/agent-manager/continue-in-worktree.ts @@ -8,6 +8,7 @@ import { recordForkHandoff } from "./fork-handoff" export interface ContinueContext { root: string + binary?: string getClient: () => KiloClient createWorktreeOnDisk: (opts: { baseBranch: string; baseRef: string }) => Promise<{ worktree: { id: string } @@ -42,7 +43,7 @@ export async function abortSession(ctx: ContinueContext, sessionId: string): Pro /** Capture git state from the workspace root. */ export async function captureState(ctx: ContinueContext): Promise> { try { - const snapshot = await captureGitState(ctx.root, (...args) => ctx.log(...args)) + const snapshot = await captureGitState(ctx.root, (...args) => ctx.log(...args), ctx.binary) return { ok: true, value: snapshot } } catch (err) { return { ok: false, error: `Failed to capture git state: ${getErrorMessage(err)}` } @@ -67,7 +68,7 @@ export async function transferState( snapshot: GitSnapshot, target: string, ): Promise> { - const applied = await applyGitState(snapshot, target, (...args) => ctx.log(...args)) + const applied = await applyGitState(snapshot, target, (...args) => ctx.log(...args), ctx.binary) if (!applied.ok) { ctx.log("Git state transfer failed:", applied.error) return { ok: false, error: applied.error ?? "Failed to apply changes to worktree" } diff --git a/packages/kilo-vscode/src/agent-manager/git-import.ts b/packages/kilo-vscode/src/agent-manager/git-import.ts index ae3f2c37a7..7fc2b66b1f 100644 --- a/packages/kilo-vscode/src/agent-manager/git-import.ts +++ b/packages/kilo-vscode/src/agent-manager/git-import.ts @@ -15,6 +15,7 @@ interface PRUrlParts { export interface PRInfo { headRefName: string + baseRefName?: string headRepositoryOwner?: { login: string } isCrossRepository: boolean title: string diff --git a/packages/kilo-vscode/src/agent-manager/git-transfer.ts b/packages/kilo-vscode/src/agent-manager/git-transfer.ts index 54916204c7..1beb223ce2 100644 --- a/packages/kilo-vscode/src/agent-manager/git-transfer.ts +++ b/packages/kilo-vscode/src/agent-manager/git-transfer.ts @@ -29,11 +29,16 @@ export interface UntrackedFile { const MAX_FILE = 10 * 1024 * 1024 // 10 MB -function git(args: string[], cwd: string, stdin?: string): Promise<{ code: number; stdout: string; stderr: string }> { +function git( + args: string[], + cwd: string, + stdin?: string, + binary = "git", +): Promise<{ code: number; stdout: string; stderr: string }> { return new Promise((resolve) => { if (stdin !== undefined) { // Use spawn for stdin piping — execFile doesn't reliably create a stdin pipe - const child = cp.spawn("git", args, { cwd, windowsHide: true }) + const child = cp.spawn(binary, args, { cwd, windowsHide: true }) let stdout = "" let stderr = "" child.stdout.on("data", (d: Buffer) => (stdout += d.toString())) @@ -42,7 +47,7 @@ function git(args: string[], cwd: string, stdin?: string): Promise<{ code: numbe child.stdin.end(stdin) } else { cp.execFile( - "git", + binary, args, { cwd, encoding: "utf8", maxBuffer: 64 * 1024 * 1024, windowsHide: true }, (error, stdout, stderr) => { @@ -58,8 +63,8 @@ function git(args: string[], cwd: string, stdin?: string): Promise<{ code: numbe }) } -async function raw(args: string[], cwd: string): Promise { - const result = await git(args, cwd) +async function raw(args: string[], cwd: string, binary = "git"): Promise { + const result = await git(args, cwd, undefined, binary) return result.stdout.trim() } @@ -67,19 +72,19 @@ async function raw(args: string[], cwd: string): Promise { * Capture the current git state from `cwd` as a portable snapshot. * This is a read-only operation — the source directory is never modified. */ -export async function capture(cwd: string, log: (...args: unknown[]) => void): Promise { +export async function capture(cwd: string, log: (...args: unknown[]) => void, binary = "git"): Promise { const patch = (args: string[]) => - git(args, cwd).then((r) => { + git(args, cwd, undefined, binary).then((r) => { const out = r.stdout return out.trim() ? out : null }) const [branch, head, unstaged, staged, untrackedRaw] = await Promise.all([ - raw(["branch", "--show-current"], cwd), - raw(["rev-parse", "HEAD"], cwd), + raw(["branch", "--show-current"], cwd, binary), + raw(["rev-parse", "HEAD"], cwd, binary), patch(["diff", "--binary"]), patch(["diff", "--cached", "--binary"]), - raw(["ls-files", "--others", "--exclude-standard"], cwd).then((s: string) => + raw(["ls-files", "--others", "--exclude-standard"], cwd, binary).then((s: string) => s.split("\n").filter((l: string) => l.length > 0), ), ]) @@ -111,10 +116,11 @@ export async function apply( snapshot: GitSnapshot, target: string, log: (...args: unknown[]) => void, + binary = "git", ): Promise<{ ok: boolean; error?: string }> { // Apply staged patch first, then re-stage those files if (snapshot.staged) { - const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.staged) + const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.staged, binary) if (result.code !== 0) { const msg = result.stderr.trim() || "Patch did not apply" log("Failed to apply staged patch:", msg) @@ -122,13 +128,13 @@ export async function apply( } const files = parsePatchFiles(snapshot.staged) if (files.length > 0) { - await git(["add", "--", ...files], target) + await git(["add", "--", ...files], target, undefined, binary) } } // Apply unstaged patch (leave as unstaged working-tree changes) if (snapshot.unstaged) { - const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.unstaged) + const result = await git(["apply", "--whitespace=nowarn", "-"], target, snapshot.unstaged, binary) if (result.code !== 0) { const msg = result.stderr.trim() || "Patch did not apply" log("Failed to apply unstaged patch:", msg) diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts index 3912fa4cf1..e057bbe1af 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-bridge.ts @@ -4,7 +4,7 @@ import type { SSEPayload } from "../services/cli-backend/sdk-sse-adapter" import { sameDirectory } from "../kilo-provider-utils" import type { LocalStats, WorktreeStats } from "./GitStatsPoller" import type { PRStatus } from "./types" -import type { WorktreeStateManager } from "./WorktreeStateManager" +import type { ManagedSession, WorktreeStateManager } from "./WorktreeStateManager" import { OrchestrationError, answer, @@ -50,6 +50,7 @@ interface Options { stats(directory?: string): Promise<{ worktrees: WorktreeStats[]; local?: LocalStats }> prs(directory?: string): Map push(directory?: string): void + resolve?(sessionID: string, directory?: string): ManagedSession | undefined managed(sessionID: string, directory?: string): boolean close(sessionID: string, directory?: string): Promise directories?(): string[] @@ -287,6 +288,7 @@ export class AgentManagerOrchestrationBridge { text: request.prompt, messageID: request.id, signal: active.controller.signal, + managed: this.options.resolve?.(request.targetSessionID, origin.directory), }) if (this.disposed || active.cancelled) return return { result: { operation: "prompt", sessionID: request.targetSessionID, delivered: true } } @@ -295,7 +297,12 @@ export class AgentManagerOrchestrationBridge { return await this.resolveQuestion(client, root, state, request, origin, active) } if (request.operation === "move") { - move({ state, sessionID: request.targetSessionID, sectionID: request.sectionID }) + move({ + state, + sessionID: request.targetSessionID, + sectionID: request.sectionID, + managed: this.options.resolve?.(request.targetSessionID, origin.directory), + }) this.options.push(origin.directory) if (this.disposed || active.cancelled) return return { @@ -338,6 +345,7 @@ export class AgentManagerOrchestrationBridge { sessionID: request.targetSessionID, questionID: request.questionID, answers: request.answers, + managed: this.options.resolve?.(request.targetSessionID, origin.directory), }) if (this.disposed || active.cancelled) return return { diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts b/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts index 0f6a1f3f5f..b356293197 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-domain.ts @@ -316,6 +316,7 @@ interface Target { root: string state: WorktreeStateManager sessionID: string + managed?: ManagedSession } interface Located { @@ -326,8 +327,8 @@ interface Located { // Verify the target is a live managed session of this workspace and return its authoritative // directory plus display name, so error messages can echo exact IDs back to the caller. async function locate(input: Target): Promise { - const managed = input.state.getSession(input.sessionID) - if (!managed) + const managed = input.state.getSession(input.sessionID) ?? input.managed + if (!managed || managed.id !== input.sessionID) throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace") const dir = directory(input.root, input.state, managed) if ( @@ -398,6 +399,7 @@ export async function prompt(input: { messageID: string signal?: AbortSignal idleTimeoutMs?: number + managed?: ManagedSession }): Promise { if (input.signal?.aborted) return const target = await locate(input) @@ -424,6 +426,7 @@ export async function answer(input: { sessionID: string questionID?: string answers: string[][] + managed?: ManagedSession }): Promise<{ questionID: string }> { const dir = (await locate(input)).dir const listed = await input.client.question.list({ directory: dir }) @@ -487,9 +490,14 @@ async function waitForIdle( return waitForIdle(client, directory, sessionID, signal, timeout, start) } -export function move(input: { state: WorktreeStateManager; sessionID: string; sectionID: string | null }): void { - const session = input.state.getSession(input.sessionID) - if (!session) +export function move(input: { + state: WorktreeStateManager + sessionID: string + sectionID: string | null + managed?: ManagedSession +}): void { + const session = input.state.getSession(input.sessionID) ?? input.managed + if (!session || session.id !== input.sessionID) throw new OrchestrationError("unknown_session", "The session is not managed by this Agent Manager workspace") if (!session.worktreeId) { if (input.sectionID === null) return diff --git a/packages/kilo-vscode/src/agent-manager/orchestration-setup.ts b/packages/kilo-vscode/src/agent-manager/orchestration-setup.ts index 507d0e79d0..349d1a8747 100644 --- a/packages/kilo-vscode/src/agent-manager/orchestration-setup.ts +++ b/packages/kilo-vscode/src/agent-manager/orchestration-setup.ts @@ -20,6 +20,7 @@ export interface OrchestrationBridgeDeps { getPrs: () => Map pushState: (ctx?: ProjectContext) => void hasPanelSession: (id: string) => boolean + routeSession: (id: string, directory: string) => void closeSession: (id: string) => Promise postSessionClosed: (id: string, projectId?: string) => void log: (...args: unknown[]) => void @@ -45,15 +46,37 @@ export function createOrchestrationBridge(deps: OrchestrationBridgeDeps): AgentM const ctx = dir ? deps.contexts.byDirectory(dir) : undefined deps.pushState(ctx) }, + resolve: (id, dir) => { + const ctx = dir ? deps.contexts.byDirectory(dir) : undefined + const state = ctx?.peekState() + if (state?.isSessionClosed(id)) return undefined + const stored = state?.getSession(id) + if (stored) return stored + if (!ctx) return undefined + const live = ctx.sessions().find((session) => session.id === id) + if (!live?.worktreeId || !state?.getWorktree(live.worktreeId)) return undefined + return { id, worktreeId: live.worktreeId, createdAt: live.createdAt } + }, managed: (id, dir) => { const ctx = dir ? deps.contexts.byDirectory(dir) : undefined - if (ctx) return ctx.hasLiveSession(id) || !!ctx.peekState()?.getSession(id) + if (ctx) { + const state = ctx.peekState() + return !state?.isSessionClosed(id) && (!!state?.getSession(id) || ctx.hasLiveSession(id)) + } return deps.hasPanelSession(id) || !!deps.getState()?.getSession(id) }, close: async (id, dir) => { const ctx = dir ? deps.contexts.byDirectory(dir) : undefined if (ctx) { + const state = ctx.peekState() + const stored = state?.getSession(id) + const live = ctx.sessions().find((session) => session.id === id) + const wt = live?.worktreeId ? state?.getWorktree(live.worktreeId) : undefined + if (wt && !stored) deps.routeSession(id, wt.path) await deps.projectScope.run(ctx, () => deps.closeSession(id)) + state?.closeSession(id, wt?.id ?? stored?.worktreeId ?? null) + await state?.flush() + ctx.removeLiveSession(id) } else { await deps.closeSession(id) } diff --git a/packages/kilo-vscode/src/agent-manager/project/messages.ts b/packages/kilo-vscode/src/agent-manager/project/messages.ts index d3fa3bb80b..7adb073823 100644 --- a/packages/kilo-vscode/src/agent-manager/project/messages.ts +++ b/packages/kilo-vscode/src/agent-manager/project/messages.ts @@ -7,6 +7,7 @@ */ import simpleGit from "simple-git" +import type { GitOps } from "../GitOps" import type { AgentManagerInMessage } from "../types" import type { ProjectRegistry } from "./registry" import type { ProjectContext, ProjectInitResult } from "./context" @@ -57,6 +58,7 @@ export interface ProjectMessageDeps { ready: (ctx: ProjectContext) => Promise /** Route one session to a directory inside a project (session override + project route). */ routeSession?: (projectId: string, sessionId: string, directory: string, generation: number) => void + git?: GitOps log: (...args: unknown[]) => void } @@ -206,7 +208,17 @@ async function addProject(deps: ProjectMessageDeps): Promise { if (!dir) return // resolveProjectRoot (not resolveGitRoot) so a folder inside a linked worktree // registers the primary checkout and cannot duplicate an existing project. - const root = await resolveProjectRoot(dir, (cwd, args) => simpleGit(cwd).raw(args)) + const git = deps.git + const root = await resolveProjectRoot( + dir, + git + ? async (cwd, args) => { + const result = await git.execGit(args, cwd) + if (result.code !== 0) throw new Error(result.stderr) + return result.stdout + } + : (cwd, args) => simpleGit(cwd).raw(args), + ) if (!root) { deps.error("The selected folder is not inside a Git repository.") return diff --git a/packages/kilo-vscode/src/agent-manager/project/wiring.ts b/packages/kilo-vscode/src/agent-manager/project/wiring.ts index 59a47ed011..065c6c71b6 100644 --- a/packages/kilo-vscode/src/agent-manager/project/wiring.ts +++ b/packages/kilo-vscode/src/agent-manager/project/wiring.ts @@ -70,6 +70,7 @@ export function createProjectWiring(opts: { pushState: opts.pushState, selected: opts.selected, routeSession: opts.routeSession, + git: opts.git, error: (message) => opts.host.showError(message), openSettings: (tab, projectId) => opts.host.openSettings(tab, projectId), log: opts.log, diff --git a/packages/kilo-vscode/src/agent-manager/run/service.ts b/packages/kilo-vscode/src/agent-manager/run/service.ts index 197f1c6579..a2d13ac319 100644 --- a/packages/kilo-vscode/src/agent-manager/run/service.ts +++ b/packages/kilo-vscode/src/agent-manager/run/service.ts @@ -1,7 +1,6 @@ import * as fs from "node:fs" import * as path from "node:path" import { KILO_DIR } from "../constants" -import { powershellCommand } from "../../util/powershell" const RUN_SCRIPT_FILENAME = "run-script" const RUN_SCRIPT_SHELL_FILENAME = "run-script.sh" @@ -86,7 +85,7 @@ function validated(file: string, dir: string): boolean { export function buildRunTaskCommand(script: RunScriptInfo): { command: string; args: string[] } { if (script.kind === "powershell") { return { - command: powershellCommand(), + command: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", script.path], } } diff --git a/packages/kilo-vscode/src/agent-manager/state-recovery.ts b/packages/kilo-vscode/src/agent-manager/state-recovery.ts index b39cbc0f52..e94541301d 100644 --- a/packages/kilo-vscode/src/agent-manager/state-recovery.ts +++ b/packages/kilo-vscode/src/agent-manager/state-recovery.ts @@ -22,7 +22,7 @@ export function restoreWorktrees(state: WorktreeStateManager, infos: WorktreeInf }) if (!existing) result.worktrees++ - if (!info.sessionId) continue + if (!info.sessionId || state.isSessionClosed(info.sessionId)) continue const session = state.getSession(info.sessionId) if (!session) { diff --git a/packages/kilo-vscode/src/agent-manager/types.ts b/packages/kilo-vscode/src/agent-manager/types.ts index 25334278a1..ba7ac6d4dd 100644 --- a/packages/kilo-vscode/src/agent-manager/types.ts +++ b/packages/kilo-vscode/src/agent-manager/types.ts @@ -127,6 +127,11 @@ interface WorktreeStatsMessage { stats: WorktreeStats[] } +interface WorktreeActivityMessage { + type: "agentManager.worktreeActivity" + active: string[] +} + interface LocalStatsMessage { type: "agentManager.localStats" /** Owning project; absent in single-project mode. */ @@ -455,6 +460,7 @@ interface RunStatusMessage extends RunStatus { /** All messages the Agent Manager extension sends to the webview. */ export type AgentManagerOutMessage = + | WorktreeActivityMessage | WorktreeStatsMessage | LocalStatsMessage | WorktreeSetupMessage diff --git a/packages/kilo-vscode/src/agent-manager/worktree-activity.ts b/packages/kilo-vscode/src/agent-manager/worktree-activity.ts new file mode 100644 index 0000000000..449604c0f9 --- /dev/null +++ b/packages/kilo-vscode/src/agent-manager/worktree-activity.ts @@ -0,0 +1,399 @@ +import { samePath } from "./project/paths" +import type { KiloConnectionService } from "../services/cli-backend" + +type Snapshot = { + statuses: Record + permissions: Array<{ id: string; sessionID: string }> + questions: Array<{ id: string; sessionID: string; blocking?: boolean }> +} + +type Change = + | { kind: "status"; sessionID: string; type: string } + | { kind: "permission.add"; id: string; sessionID: string } + | { kind: "permission.remove"; id: string; sessionID: string } + | { kind: "question.add"; id: string; sessionID: string } + | { kind: "question.remove"; id: string; sessionID: string } + | { kind: "clear"; sessionID: string } + +type State = { + dir: string + loaded: boolean + statuses: Map + permissions: Map + questions: Map + request?: Request +} + +type Request = { + readonly state: State + readonly events: Change[] + readonly promise: Promise +} + +type Options = { + paths: () => string[] + load: (dir: string) => Promise + post: (active: string[]) => void + log: (err: unknown) => void +} + +type FactoryOptions = { + connection: KiloConnectionService + paths: () => string[] + post: (active: string[]) => void + status: (event: unknown) => void + lifecycle: (event: unknown) => void + log: (err: unknown) => void +} + +const TYPES = new Set([ + "session.status", + "session.deleted", + "session.error", + "permission.asked", + "permission.replied", + "question.asked", + "question.replied", + "question.rejected", + "server.instance.disposed", +]) + +function normalize(dir: string): string { + const value = dir.replace(/\\/g, "/") + if (/^[A-Za-z]:\/+$/u.test(value)) return `${value.slice(0, 2)}/` + const result = value.replace(/\/+$/u, "") + return result || "/" +} + +function matches(a: string, b: string): boolean { + return samePath(normalize(a), normalize(b)) +} + +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null ? (value as Record) : undefined +} + +function string(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined +} + +export class WorktreeActivity { + private states: State[] = [] + private cache: string[] = [] + private dead = false + + constructor(private readonly opts: Options) {} + + static accepts(event: unknown): boolean { + const value = record(event) + return value !== undefined && typeof value.type === "string" && TYPES.has(value.type) + } + + async sync(force = false): Promise { + if (this.dead) return + + let changed = false + const wanted: State[] = [] + const current = this.states + for (const dir of this.opts.paths()) { + const state = this.find(current, wanted, dir) + if (!state) continue + if (wanted.includes(state)) continue + if (state.dir !== dir) changed = true + state.dir = dir + wanted.push(state) + } + + changed = this.prune(current, wanted) || changed + if (wanted.length !== current.length) changed = true + this.states = wanted + + const jobs: Promise[] = [] + changed = this.load(wanted, force, jobs) || changed + await Promise.all(jobs) + if (changed) this.publish() + } + + replay(): void { + if (this.dead) return + this.opts.post(this.cache.slice()) + } + + event(event: unknown, directory?: string): void { + if (this.dead || !WorktreeActivity.accepts(event)) return + const value = record(event) + if (!value) return + + const type = value.type + if (type === "server.instance.disposed") { + const props = record(value.properties) + const dir = string(props?.directory) ?? directory + if (!dir) return + const state = this.states.find((item) => matches(item.dir, dir)) + if (!state) return + this.invalidate(state) + state.loaded = false + this.publish() + return + } + if (!directory) return + const state = this.states.find((item) => matches(item.dir, directory)) + if (!state) return + + const change = this.change(type, value.properties) + if (!change) return + if (state.request) state.request.events.push(change) + this.apply(state, change) + this.publish() + } + + clear(): void { + if (this.dead) return + for (const state of this.states) this.invalidate(state) + this.states = [] + this.cache = [] + this.opts.post([]) + } + + dispose(): void { + if (this.dead) return + this.dead = true + for (const state of this.states) this.invalidate(state) + this.states = [] + this.cache = [] + } + + private state(dir: string): State { + return { + dir, + loaded: false, + statuses: new Map(), + permissions: new Map(), + questions: new Map(), + } + } + + private find(current: State[], wanted: State[], dir: unknown): State | undefined { + if (typeof dir !== "string" || !dir) return undefined + return ( + current.find((item) => matches(item.dir, dir)) ?? wanted.find((item) => matches(item.dir, dir)) ?? this.state(dir) + ) + } + + private prune(current: State[], wanted: State[]): boolean { + let changed = false + for (const state of current) { + if (wanted.includes(state)) continue + this.invalidate(state) + changed = true + } + return changed + } + + private load(wanted: State[], force: boolean, jobs: Promise[]): boolean { + let changed = false + for (const state of wanted) { + const req = state.request ?? (!state.loaded || force ? this.request(state) : undefined) + if (req) jobs.push(req.promise) + if (req || !state.loaded || force) changed = true + } + return changed + } + + private request(state: State): Request { + const req: Request = { + state, + events: [], + promise: Promise.resolve() + .then(() => this.opts.load(state.dir)) + .then((snapshot) => this.finish(req, snapshot)) + .catch((err: unknown) => { + if (this.valid(req)) req.state.loaded = false + this.opts.log(err) + }) + .finally(() => { + if (state.request === req) state.request = undefined + }), + } + state.request = req + return req + } + + private finish(req: Request, snapshot: Snapshot): void { + if (!this.valid(req)) return + const next = this.state(req.state.dir) + for (const [sessionID, status] of Object.entries(snapshot.statuses ?? {})) { + if (typeof status?.type === "string") next.statuses.set(sessionID, status.type) + } + for (const item of snapshot.permissions ?? []) { + if (typeof item?.id === "string" && typeof item.sessionID === "string") + next.permissions.set(item.id, item.sessionID) + } + for (const item of snapshot.questions ?? []) { + if (item?.blocking !== false && typeof item?.id === "string" && typeof item.sessionID === "string") + next.questions.set(item.id, item.sessionID) + } + for (const change of req.events) this.apply(next, change) + Object.assign(req.state, { + statuses: next.statuses, + permissions: next.permissions, + questions: next.questions, + loaded: true, + }) + this.publish() + } + + private valid(req: Request): boolean { + return !this.dead && req.state.request === req && this.states.includes(req.state) + } + + private invalidate(state: State): void { + state.request = undefined + state.loaded = false + state.statuses.clear() + state.permissions.clear() + state.questions.clear() + } + + private change(type: unknown, props: unknown): Change | undefined { + const value = record(props) + if (!value) return undefined + if (type === "session.status") return this.status(value) + if (type === "session.deleted" || type === "session.error") return this.cleared(value) + if (type === "permission.asked" || type === "question.asked") return this.add(type, value) + if (type === "permission.replied" || type === "question.replied" || type === "question.rejected") + return this.remove(type, value) + return undefined + } + + private status(value: Record): Change | undefined { + const sessionID = string(value.sessionID) + const status = record(value.status) + const type = string(status?.type) + return sessionID && type ? { kind: "status", sessionID, type } : undefined + } + + private cleared(value: Record): Change | undefined { + const info = record(value.info) + const sessionID = string(value.sessionID) ?? string(info?.id) + return sessionID ? { kind: "clear", sessionID } : undefined + } + + private add(type: unknown, value: Record): Change | undefined { + const id = string(value.id) + const sessionID = string(value.sessionID) + if (!id || !sessionID) return undefined + if (type === "question.asked" && value.blocking === false) return { kind: "question.remove", id, sessionID } + return type === "permission.asked" + ? { kind: "permission.add", id, sessionID } + : { kind: "question.add", id, sessionID } + } + + private remove(type: unknown, value: Record): Change | undefined { + const id = string(value.requestID) + const sessionID = string(value.sessionID) + if (!id || !sessionID) return undefined + return type === "permission.replied" + ? { kind: "permission.remove", id, sessionID } + : { kind: "question.remove", id, sessionID } + } + + private apply(state: State, change: Change): void { + if (change.kind === "status") { + state.statuses.set(change.sessionID, change.type) + return + } + if (change.kind === "permission.add") { + state.permissions.set(change.id, change.sessionID) + return + } + if (change.kind === "permission.remove") { + if (state.permissions.get(change.id) === change.sessionID) state.permissions.delete(change.id) + return + } + if (change.kind === "question.add") { + state.questions.set(change.id, change.sessionID) + return + } + if (change.kind === "question.remove") { + if (state.questions.get(change.id) === change.sessionID) state.questions.delete(change.id) + return + } + state.statuses.delete(change.sessionID) + for (const [id, sessionID] of state.permissions) { + if (sessionID === change.sessionID) state.permissions.delete(id) + } + for (const [id, sessionID] of state.questions) { + if (sessionID === change.sessionID) state.questions.delete(id) + } + } + + private active(state: State): boolean { + const blocked = new Set([...state.permissions.values(), ...state.questions.values()]) + for (const [sessionID, type] of state.statuses) { + if ((type === "busy" || type === "retry") && !blocked.has(sessionID)) return true + } + return false + } + + private publish(): void { + if (this.dead) return + const active = this.states.filter((state) => this.active(state)).map((state) => state.dir) + this.cache = active.slice() + this.opts.post(active) + } +} + +export function createWorktreeActivity(opts: FactoryOptions) { + const activity = new WorktreeActivity({ + paths: opts.paths, + load: async (dir) => { + const client = opts.connection.getClient() + const [status, permission, question] = await Promise.all([ + client.session.status({ directory: dir }, { throwOnError: true }), + client.permission.list({ directory: dir }, { throwOnError: true }), + client.question.list({ directory: dir }, { throwOnError: true }), + ]) + return { + statuses: status.data ?? {}, + permissions: permission.data ?? [], + questions: question.data ?? [], + } + }, + post: opts.post, + log: opts.log, + }) + const filter = (event: unknown) => { + const type = record(event)?.type + return WorktreeActivity.accepts(event) || type === "session.created" || type === "session.updated" + } + const unsubEvent = opts.connection.onEventFiltered(filter, (event, directory) => { + activity.event(event, directory) + const type = record(event)?.type + if (type === "session.status") opts.status(event) + if ( + type === "session.created" || + type === "session.updated" || + type === "session.deleted" || + type === "session.error" + ) + opts.lifecycle(event) + }) + const sync = (force = false) => { + if (force) activity.replay() + if (opts.connection.getConnectionState() !== "connected") return Promise.resolve() + return activity.sync(force) + } + const unsubState = opts.connection.onStateChange((state) => { + if (state !== "connected") return activity.clear() + void sync(true) + }) + return { + sync, + dispose: () => { + unsubEvent() + unsubState() + activity.dispose() + }, + } +} diff --git a/packages/kilo-vscode/src/extension.ts b/packages/kilo-vscode/src/extension.ts index 428ca16864..dcb13e2b34 100644 --- a/packages/kilo-vscode/src/extension.ts +++ b/packages/kilo-vscode/src/extension.ts @@ -46,7 +46,7 @@ const panelTitleHandler = (panel: vscode.WebviewPanel) => (title: string) => { // keybindings, autocomplete, commit-message generation, and URI deep links all work immediately — // without requiring the user to open a Kilo sidebar or panel first. The CLI backend is NOT spawned here; // it starts lazily when a webview connects or when ensureBackendForAutocomplete() triggers it. -export function activate(context: vscode.ExtensionContext) { +export async function activate(context: vscode.ExtensionContext) { console.log("Kilo Code extension is now active") shuttingDown = false @@ -162,9 +162,16 @@ export function activate(context: vscode.ExtensionContext) { // Create Agent Manager provider for editor panel const agentManagerHost = new VscodeHost(context.extensionUri, connectionService, context, remoteService) const git = createGitExecutable({ + preferred: async () => { + const extension = vscode.extensions.getExtension("vscode.git") + if (!extension) return undefined + if (!extension.isActive) await extension.activate() + return extension.exports?.getAPI(1).git.path + }, log: (message) => console.warn(`[Kilo New] ${message}`), }) - const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, git) + const binary = process.platform === "win32" ? await git() : git + const agentManagerProvider = new AgentManagerProvider(agentManagerHost, connectionService, binary) agentManagerProvider.onPanelVisibilityChange((visible) => remember({ agentManager: visible })) agentManager = agentManagerProvider context.subscriptions.push(agentManagerProvider) diff --git a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts index 9edea2476c..8f6e776c9e 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-manager.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-manager.ts @@ -6,7 +6,7 @@ import * as path from "path" import * as vscode from "vscode" import { resolveLocalBwrapEnv, resolveTreeSitterEnv } from "./cli-resources" import { t } from "./i18n" -import { parseServerPort } from "./server-utils" +import { scanServerPort } from "./server-utils" export interface ServerInstance { port: number @@ -15,6 +15,7 @@ export interface ServerInstance { } const STARTUP_TIMEOUT_SECONDS = 30 +const STARTUP_OUTPUT_LIMIT = 1024 type WorkspaceFolderLike = { uri: { fsPath: string } } type ServerExitListener = (code: number | null, signal: NodeJS.Signals | null) => void @@ -162,13 +163,16 @@ export class ServerManager { console.log("[Kilo New] ServerManager: 📦 Process spawned with PID:", serverProcess.pid) let resolved = false + let output = "" const stderrLines: string[] = [] serverProcess.stdout?.on("data", (data: Buffer) => { - const output = data.toString() - console.log("[Kilo New] ServerManager: 📥 CLI Server stdout:", output) + const chunk = data.toString() + console.log("[Kilo New] ServerManager: 📥 CLI Server stdout:", chunk) - const port = parseServerPort(output) + const state = scanServerPort(output, chunk, STARTUP_OUTPUT_LIMIT) + output = state.output + const port = state.port if (port !== null && !resolved) { resolved = true console.log("[Kilo New] ServerManager: 🎯 Port detected:", port) diff --git a/packages/kilo-vscode/src/services/cli-backend/server-utils.ts b/packages/kilo-vscode/src/services/cli-backend/server-utils.ts index 0daf711d02..bedad7d1db 100644 --- a/packages/kilo-vscode/src/services/cli-backend/server-utils.ts +++ b/packages/kilo-vscode/src/services/cli-backend/server-utils.ts @@ -3,8 +3,15 @@ * Matches lines like: "kilo server listening on http://127.0.0.1:12345" * Returns the port number or null if not found. */ -export function parseServerPort(output: string): number | null { - const match = output.match(/listening on http:\/\/[\w.]+:(\d+)/) +export function parseServerPort(output: string, complete = false): number | null { + const match = output.match( + complete ? /listening on http:\/\/[\w.]+:(\d+)\r?\n/ : /listening on http:\/\/[\w.]+:(\d+)/, + ) if (!match) return null return parseInt(match[1]!, 10) } + +export function scanServerPort(output: string, chunk: string, limit: number) { + const text = `${output}${chunk}` + return { output: text.slice(-limit), port: parseServerPort(text, true) } +} diff --git a/packages/kilo-vscode/src/util/git-executable.ts b/packages/kilo-vscode/src/util/git-executable.ts index a4fa64f377..5c2239d934 100644 --- a/packages/kilo-vscode/src/util/git-executable.ts +++ b/packages/kilo-vscode/src/util/git-executable.ts @@ -11,6 +11,8 @@ interface GitExecutableOptions { run?: (cmd: string, args: string[]) => Promise<{ stdout: string }> access?: (file: string, mode: number) => Promise realpath?: (file: string) => Promise + preferred?: () => Promise + timeout?: number log?: (message: string) => void } @@ -29,6 +31,27 @@ export function createGitExecutable(options: GitExecutableOptions = {}): GitExec return (): Promise => { cached ??= (async () => { + if (platform === "win32") { + const timeout = options.timeout ?? 3_000 + let timer: ReturnType | undefined + try { + return ( + (await Promise.race([ + options.preferred?.(), + new Promise((_, reject) => { + timer = setTimeout(() => { + reject(new Error(`VS Code Git activation timed out after ${timeout}ms`)) + }, timeout) + }), + ])) ?? "git" + ) + } catch (err) { + log(`Unable to resolve the preferred Git executable, using PATH: ${err}`) + return "git" + } finally { + if (timer !== undefined) clearTimeout(timer) + } + } if (platform !== "darwin") return "git" try { diff --git a/packages/kilo-vscode/src/util/powershell.ts b/packages/kilo-vscode/src/util/powershell.ts deleted file mode 100644 index 07543571ff..0000000000 --- a/packages/kilo-vscode/src/util/powershell.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { statSync } from "node:fs" -import * as path from "node:path" - -/** - * Well-known PowerShell 7 install locations on Windows. The Store install only - * exposes `pwsh.exe` through the WindowsApps execution alias, which is often - * missing from the PATH of spawned child processes. - */ -export function locations(env: NodeJS.ProcessEnv = process.env): string[] { - const roots = [ - env["ProgramFiles"] && path.join(env["ProgramFiles"], "PowerShell", "7"), - env["ProgramFiles(x86)"] && path.join(env["ProgramFiles(x86)"], "PowerShell", "7"), - env["LOCALAPPDATA"] && path.join(env["LOCALAPPDATA"], "Microsoft", "WindowsApps"), - ].filter((item): item is string => Boolean(item)) - return roots.map((root) => path.join(root, "pwsh.exe")) -} - -function exists(file: string): boolean { - return statSync(file, { throwIfNoEntry: false })?.isFile() === true -} - -export function pwshPath(env: NodeJS.ProcessEnv = process.env): string | undefined { - const dirs = [...(env.PATH ?? env.Path ?? "").split(path.delimiter), ...locations(env)] - return dirs - .filter(Boolean) - .map((dir) => path.join(dir, "pwsh.exe")) - .find(exists) -} - -/** Prefer PowerShell 7; legacy 5.1 writes UTF-16LE BOM output on redirection. */ -export function powershellCommand(env: NodeJS.ProcessEnv = process.env): string { - return pwshPath(env) ?? "powershell.exe" -} diff --git a/packages/kilo-vscode/src/utils.ts b/packages/kilo-vscode/src/utils.ts index ce17c8d754..2d952bbdcc 100644 --- a/packages/kilo-vscode/src/utils.ts +++ b/packages/kilo-vscode/src/utils.ts @@ -66,6 +66,7 @@ export function buildWebviewHtml( topBar?: boolean topBarSurface?: string agentManagerSettings?: boolean + sidebar?: "left" | "right" }, ): string { const nonce = getNonce() @@ -73,7 +74,7 @@ export function buildWebviewHtml( const markdownWorkerUri = opts.workerUri.toString().replace(/shiki-worker\.js$/, "markdown-shiki-worker.js") return ` - + diff --git a/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts b/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts index cef78a90d8..8a7f6f9197 100644 --- a/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts +++ b/packages/kilo-vscode/tests/chat-auto-scroll.spec.ts @@ -192,3 +192,145 @@ test("keeps a long native scrollbar drag user-controlled", async ({ page }) => { await expect.poll(() => distance(page)).toBeGreaterThan(40) await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible() }) + +test("pauses on an upward wheel over the Copy response button", async ({ page }) => { + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + const list = page.locator(".message-list") + const copy = page.getByRole("button", { name: "Copy response" }).first() + await expect(list).toBeVisible() + await expect(copy).toBeVisible() + await settle(page, 10) + await expect.poll(() => distance(page)).toBeLessThanOrEqual(2) + + await copy.hover() + await page.mouse.wheel(0, -240) + + await expect.poll(() => distance(page)).toBeGreaterThan(40) + await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible() +}) + +test("keeps a one-pixel upward wheel pause through delayed streaming", async ({ page }) => { + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + const list = page.locator(".message-list") + const copy = page.getByRole("button", { name: "Copy response" }).first() + await expect(list).toBeVisible() + await expect(copy).toBeVisible() + await settle(page, 10) + await expect.poll(() => distance(page)).toBeLessThanOrEqual(2) + + await copy.hover() + await page.mouse.wheel(0, -1) + await expect.poll(() => distance(page)).toBeGreaterThan(0) + const before = await state(page) + + await page.waitForTimeout(350) + await page.getByTestId("append-stream").click() + + await expect.poll(() => list.evaluate((el) => el.scrollHeight)).toBeGreaterThan(before.height) + await settle(page, 10) + const after = await state(page) + expect(after.top).toBeCloseTo(before.top, 0) + expect(after.distance).toBeGreaterThan(40) + await expect(page.getByRole("button", { name: "Scroll to bottom" })).toBeVisible() +}) + +test("keeps the pause after a pending bottom scroll event", async ({ page }) => { + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + const list = page.locator(".message-list") + const copy = page.getByRole("button", { name: "Copy response" }).first() + const bottom = page.getByRole("button", { name: "Scroll to bottom" }) + await expect(list).toBeVisible() + await expect(copy).toBeVisible() + await settle(page, 10) + await expect.poll(() => distance(page)).toBeLessThanOrEqual(2) + + await list.evaluate((el) => { + const fire = () => { + el.dataset.pending = "1" + el.dispatchEvent(new Event("scroll")) + } + const wheel = (event: Event) => { + if (event.target !== el && event.target instanceof Element && !el.contains(event.target)) return + queueMicrotask(fire) + el.ownerDocument.removeEventListener("wheel", wheel, true) + } + el.dataset.pending = "0" + el.ownerDocument.addEventListener("wheel", wheel, true) + }) + + await copy.hover() + await page.mouse.wheel(0, -1) + await expect.poll(() => list.getAttribute("data-pending")).toBe("1") + await expect.poll(() => distance(page)).toBeGreaterThan(0) + await expect(bottom).toBeVisible() +}) + +for (const input of ["wheel", "keyboard"] as const) { + test(`keeps new upward ${input} input before a pending return-to-bottom scroll`, async ({ page }) => { + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + const list = page.locator(".message-list") + const bottom = page.getByRole("button", { name: "Scroll to bottom" }) + await expect(list).toBeVisible() + await settle(page, 10) + await expect.poll(() => distance(page)).toBeLessThanOrEqual(2) + + await list.hover() + await page.mouse.wheel(0, -240) + await expect.poll(() => distance(page)).toBeGreaterThan(40) + await expect(bottom).toBeVisible() + await settle(page, 4) + + await list.evaluate((el, input) => { + if (input === "keyboard") { + el.tabIndex = 0 + el.focus({ preventScroll: true }) + } + const type = input === "wheel" ? "wheel" : "keydown" + const prime = () => { + el.scrollTop = el.scrollHeight - el.clientHeight + } + const pending = () => { + el.dispatchEvent(new Event("scroll")) + el.dataset.pending = "1" + } + el.ownerDocument.addEventListener(type, prime, { capture: true, passive: false, once: true }) + const target = input === "wheel" ? el : el.ownerDocument + target.addEventListener(type, pending, { capture: input === "wheel", passive: false, once: true }) + }, input) + + if (input === "wheel") await page.mouse.wheel(0, -20) + if (input === "keyboard") await page.keyboard.press("ArrowUp") + await expect.poll(() => list.getAttribute("data-pending")).toBe("1") + await expect.poll(() => distance(page)).toBeGreaterThan(10) + await expect(bottom).toBeVisible() + await settle(page, 20) + const before = await state(page) + + await page.waitForTimeout(350) + await page.getByTestId("append-stream").click() + await expect.poll(() => list.evaluate((el) => el.scrollHeight)).toBeGreaterThan(before.height) + await settle(page, 10) + expect((await state(page)).top).toBeCloseTo(before.top, 0) + await expect(bottom).toBeVisible() + }) +} + +test("preserves the pause across working status changes", async ({ page }) => { + await page.goto(`/iframe.html?id=${STORY_ID}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + const list = page.locator(".message-list") + const bottom = page.getByRole("button", { name: "Scroll to bottom" }) + await expect(list).toBeVisible() + await settle(page, 10) + await expect.poll(() => distance(page)).toBeLessThanOrEqual(2) + + await list.hover() + await page.mouse.wheel(0, -240) + await expect.poll(() => distance(page)).toBeGreaterThan(40) + await expect(bottom).toBeVisible() + await page.getByTestId("toggle-status").click() + await page.getByTestId("toggle-status").click() + await settle(page, 4) + + await expect.poll(() => distance(page)).toBeGreaterThan(40) + await expect(bottom).toBeVisible() +}) diff --git a/packages/kilo-vscode/tests/package.json b/packages/kilo-vscode/tests/package.json index 35d2945c19..a003df9cab 100644 --- a/packages/kilo-vscode/tests/package.json +++ b/packages/kilo-vscode/tests/package.json @@ -1,6 +1,6 @@ { "type": "module", - "version": "7.5.0", + "version": "7.5.5", "dependencies": {}, "devDependencies": {}, "peerDependencies": {} diff --git a/packages/kilo-vscode/tests/prompt-rail.spec.ts b/packages/kilo-vscode/tests/prompt-rail.spec.ts new file mode 100644 index 0000000000..8a924460ff --- /dev/null +++ b/packages/kilo-vscode/tests/prompt-rail.spec.ts @@ -0,0 +1,180 @@ +import { expect, test, type Page } from "@playwright/test" + +const GLOBALS = "colorScheme:dark;theme:kilo-vscode;vscodeTheme:dark-modern" + +async function open(page: Page, side: "left" | "right" = "left", width = 420) { + await page.setViewportSize({ width, height: 720 }) + await page.goto(`/iframe.html?id=chat--prompt-rail-${side}&viewMode=story&globals=${GLOBALS}`, { waitUntil: "load" }) + await expect(page.locator(".prompt-rail-tick")).toHaveCount(5) + await page.evaluate(() => document.fonts.ready) + await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }) + await page.clock.pauseAt(new Date("2026-01-01T00:00:01Z")) +} + +for (const side of ["left", "right"] as const) { + for (const width of [200, 420]) { + test(`opens inward from the ${side} edge at ${width}px`, async ({ page }) => { + await open(page, side, width) + if (width === 200) await page.evaluate(() => (document.documentElement.dir = "rtl")) + const rail = page.locator(".prompt-rail") + const lane = await page.locator(".message-list").evaluate((el) => el.clientWidth) + await expect(rail).toHaveAttribute("data-side", side) + await rail.locator(".prompt-rail-tick").first().focus() + const card = page.locator(".prompt-rail-card") + await expect(card).toBeVisible() + await expect(card).toHaveAttribute("data-side", side) + await expect(card).toHaveCSS("transform", "none") + const tick = await rail.boundingBox() + const box = await card.boundingBox() + if (!tick || !box) throw new Error("Prompt navigator geometry is missing") + expect(box.x).toBeGreaterThanOrEqual(12) + expect(box.x + box.width).toBeLessThanOrEqual(width - 12) + expect(box.y).toBeGreaterThanOrEqual(12) + expect(box.y + box.height).toBeLessThanOrEqual(708) + expect(await page.locator(".message-list").evaluate((el) => el.clientWidth)).toBe(lane) + if (side === "left") { + expect(tick.x).toBe(8) + expect(box.x - tick.x - tick.width).toBe(8) + return + } + expect(width - tick.x - tick.width).toBe(8) + expect(tick.x - box.x - box.width).toBe(8) + }) + } +} + +test("ignores brief crossings and restarts the delay for a different tick", async ({ page }) => { + await open(page) + const ticks = page.locator(".prompt-rail-tick") + const card = page.locator(".prompt-rail-card") + await ticks.first().hover() + await page.clock.runFor(200) + await ticks.nth(1).hover() + await page.clock.runFor(200) + await expect(card).toBeHidden() + await page.getByTestId("prompt-rail-content").hover() + await page.clock.runFor(500) + await expect(card).toBeHidden() + await expect(page.locator(".prompt-rail")).toHaveCSS("opacity", "0.5") +}) + +test("opens after a deliberate hover and keeps the rail-to-card bridge", async ({ page }) => { + await open(page) + const ticks = page.locator(".prompt-rail-tick") + const card = page.locator(".prompt-rail-card") + await ticks.first().hover() + await page.clock.runFor(349) + await expect(card).toBeHidden() + await page.clock.runFor(1) + await expect(card).toBeVisible() + await ticks.nth(1).hover() + await expect(card.locator('[data-prompt-index="1"]')).toHaveClass(/prompt-rail-row--hover/) + await expect(card).toHaveCSS("transform", "none") + const tick = await ticks.nth(1).boundingBox() + const box = await card.boundingBox() + if (!tick || !box) throw new Error("Prompt navigator geometry is missing") + await page.mouse.move((tick.x + tick.width + box.x) / 2, tick.y + tick.height / 2) + await page.clock.runFor(80) + await card.hover() + await page.clock.runFor(500) + await expect(card).toBeVisible() + await page.getByTestId("prompt-rail-content").hover() + await page.clock.runFor(119) + await expect(card).toBeVisible() + await page.clock.runFor(1) + await expect(card).toBeHidden() +}) + +test("keeps click and keyboard navigation immediate", async ({ page }) => { + await open(page, "right") + const ticks = page.locator(".prompt-rail-tick") + const host = page.getByTestId("prompt-rail-host") + const card = page.locator(".prompt-rail-card") + await ticks.last().click() + await expect(host).toHaveAttribute("data-selected", "rail-user-5:user") + await expect(card).toBeVisible() + await page.keyboard.press("Escape") + await page.clock.runFor(500) + await expect(card).toBeHidden() + await ticks.first().focus() + await expect(card).toBeVisible() + await page.keyboard.press("End") + await expect(ticks.last()).toBeFocused() + await page.keyboard.press("Home") + await expect(ticks.first()).toBeFocused() + await page.keyboard.press("ArrowDown") + await expect(ticks.nth(1)).toBeFocused() + await page.keyboard.press("Enter") + await expect(host).toHaveAttribute("data-selected", "rail-user-2:user") + await page.keyboard.press("ArrowDown") + await page.keyboard.press("Space") + await expect(host).toHaveAttribute("data-selected", "rail-user-3:user") + await card.getByRole("button", { name: "Latest prompt", exact: true }).click() + await expect(host).toHaveAttribute("data-selected", "rail-user-5:user") + await card.getByRole("button", { name: "First prompt", exact: true }).click() + await expect(host).toHaveAttribute("data-selected", "rail-user-1:user") + await card.locator('[data-prompt-index="3"]').click() + await expect(host).toHaveAttribute("data-selected", "rail-user-4:user") +}) + +test("Escape dismisses a hover preview before other chat shortcuts", async ({ page }) => { + await open(page) + await page.getByTestId("prompt-rail-content").focus() + await page.evaluate(() => { + document.body.dataset.escapes = "0" + document.addEventListener("keydown", (event) => { + if (event.key !== "Escape") return + document.body.dataset.escapes = String(Number(document.body.dataset.escapes) + 1) + }) + }) + await page.locator(".prompt-rail-tick").first().hover() + await page.clock.runFor(350) + await expect(page.locator(".prompt-rail-card")).toBeVisible() + await page.keyboard.press("Escape") + await page.clock.runFor(500) + await expect(page.locator(".prompt-rail-card")).toBeHidden() + await expect(page.locator("body")).toHaveAttribute("data-escapes", "0") + await page.keyboard.press("Escape") + await expect(page.locator("body")).toHaveAttribute("data-escapes", "1") +}) + +test("does not open during a drag or while scrolling over the rail", async ({ page }) => { + await open(page) + const tick = page.locator(".prompt-rail-tick").first() + await page.getByTestId("prompt-rail-content").hover() + await page.mouse.down() + await tick.hover() + await page.clock.runFor(500) + await expect(page.locator(".prompt-rail-card")).toBeHidden() + await page.mouse.up() + await page.getByTestId("prompt-rail-content").hover() + await tick.hover() + await page.mouse.wheel(0, -120) + await expect(page.getByTestId("prompt-rail-host")).toHaveAttribute("data-wheel", "-120") + await page.clock.runFor(500) + await expect(page.locator(".prompt-rail-card")).toBeHidden() +}) + +test("closes after keyboard focus leaves the navigator", async ({ page }) => { + await open(page) + const card = page.locator(".prompt-rail-card") + await page.locator(".prompt-rail-tick").first().focus() + await card.locator(".prompt-rail-row").first().focus() + await page.clock.runFor(200) + await expect(card).toBeVisible() + await page.getByTestId("prompt-rail-content").focus() + await page.clock.runFor(120) + await expect(card).toBeHidden() +}) + +test("retains the virtualized navigator and older-history navigation", async ({ page }) => { + await page.goto(`/iframe.html?id=chat--prompt-rail-many-prompts&viewMode=story&globals=${GLOBALS}`, { + waitUntil: "load", + }) + const card = page.locator(".prompt-rail-card") + await page.locator(".prompt-rail-tick").first().focus() + await expect(card).toHaveAttribute("data-virtualized", "true") + await card.getByRole("button", { name: "First prompt", exact: true }).click() + await expect(page.locator(".message-list-turns")).toHaveAttribute("data-loaded-messages", "160") + await expect(card.locator('[data-prompt-index="0"]')).toBeVisible() +}) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts index a543211b41..93c724715d 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-bridge.test.ts @@ -4,6 +4,9 @@ import * as os from "os" import * as path from "path" import type { AgentManagerRequest, Session } from "@kilocode/sdk/v2/client" import { AgentManagerOrchestrationBridge } from "../../src/agent-manager/orchestration-bridge" +import { createOrchestrationBridge } from "../../src/agent-manager/orchestration-setup" +import { ProjectContexts } from "../../src/agent-manager/project/contexts" +import { ProjectScope } from "../../src/agent-manager/project/scope" import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" import type { SSEPayload } from "../../src/services/cli-backend/sdk-sse-adapter" @@ -113,6 +116,7 @@ describe("AgentManagerOrchestrationBridge", () => { }, prs: (dir) => (overrides?.prs ? overrides.prs(dir) : new Map()), push: (dir) => (overrides?.push ? overrides.push(dir) : push()), + resolve: (id, dir) => overrides?.resolve?.(id, dir), managed: (id, dir) => (overrides?.managed ? overrides.managed(id, dir) : managed.has(id)), close: async (id, dir) => (overrides?.close ? overrides.close(id, dir) : close(id, dir)), log: () => undefined, @@ -126,6 +130,7 @@ describe("AgentManagerOrchestrationBridge", () => { bridge, client, close, + connection, handlers, lists, managed, @@ -308,6 +313,186 @@ describe("AgentManagerOrchestrationBridge", () => { test.bridge.dispose() }) + it("routes prompt, answer, move, and stop for a live-only managed worktree session", async () => { + const wt = state.getWorktrees()[0]! + const live = { id: "ses_live", worktreeId: wt.id, createdAt: "" } + const section = state.addSection("Review", null) + const contexts = new ProjectContexts({ + workspaceRoot: () => root, + registry: { list: () => [], get: () => undefined }, + enabled: () => false, + deps: { log: () => undefined, state: () => state }, + }) + const ctx = contexts.active()! + ctx.stateManager() + ctx.upsertSession({ + ...live, + parentID: null, + title: "Live", + updatedAt: "", + revert: null, + summary: null, + }) + const routes = new Map() + const test = harness() + test.bridge.dispose() + const close = mock(async (id: string) => { + expect(routes.get(id)).toBe(dir) + routes.delete(id) + }) + const bridge = createOrchestrationBridge({ + connectionService: test.connection as never, + contexts, + projectScope: new ProjectScope(), + getRoot: () => ctx.root, + getState: () => state, + getStateReady: () => Promise.resolve(), + initStateReady: () => Promise.resolve(), + getStats: async () => ({ worktrees: [] }), + getPrs: () => new Map(), + pushState: () => undefined, + hasPanelSession: () => false, + routeSession: (id, path) => void routes.set(id, path), + closeSession: close, + postSessionClosed: () => undefined, + log: () => undefined, + }) + const send = (request: AgentManagerRequest) => test.request(request, ctx.root) + + send({ + id: "amr_live_prompt", + sessionID: "ses_caller", + operation: "prompt", + targetSessionID: live.id, + prompt: "Continue", + }) + await waitFor(() => test.replies.length === 1) + expect(test.promptAsync).toHaveBeenCalledWith(expect.objectContaining({ sessionID: live.id, directory: dir }), { + throwOnError: true, + }) + ;(test.client.question.list as ReturnType).mockImplementation(async () => ({ + data: [ + { + id: "que_live", + sessionID: live.id, + questions: [{ header: "Approve", question: "Proceed?", options: [{ label: "Yes", description: "go" }] }], + }, + ], + })) + send({ + id: "amr_live_answer", + sessionID: "ses_caller", + operation: "answer", + targetSessionID: live.id, + answers: [["Yes"]], + }) + await waitFor(() => test.replies.length === 2) + expect(test.questionReply).toHaveBeenCalledWith( + { requestID: "que_live", answers: [["Yes"]], directory: dir }, + { throwOnError: true }, + ) + + send({ + id: "amr_live_move", + sessionID: "ses_caller", + operation: "move", + targetSessionID: live.id, + sectionID: section.id, + }) + await waitFor(() => test.replies.length === 3) + expect(state.getWorktree(wt.id)?.sectionId).toBe(section.id) + + send({ + id: "amr_live_stop", + sessionID: "ses_caller", + operation: "stop", + targetSessionID: live.id, + }) + await waitFor(() => test.replies.length === 4) + expect(close).toHaveBeenCalledWith(live.id) + expect(ctx.hasLiveSession(live.id)).toBe(false) + expect(state.getSession(live.id)).toBeUndefined() + + ctx.upsertSession({ + ...live, + parentID: null, + title: "Live", + updatedAt: "", + revert: null, + summary: null, + }) + send({ + id: "amr_live_closed", + sessionID: "ses_caller", + operation: "prompt", + targetSessionID: live.id, + prompt: "Do not reopen", + }) + await waitFor(() => test.rejections.length === 1) + expect(test.rejections[0]).toMatchObject({ error: { code: "unknown_session" } }) + bridge.dispose() + }) + + it("rejects a stopped live-only session after its project state is restored", async () => { + const wt = state.getWorktrees()[0]! + state.closeSession("ses_stopped", wt.id) + await state.flush() + const restored = new WorktreeStateManager(root, () => undefined) + await restored.load() + const contexts = new ProjectContexts({ + workspaceRoot: () => root, + registry: { list: () => [], get: () => undefined }, + enabled: () => false, + deps: { log: () => undefined, state: () => restored }, + }) + const ctx = contexts.active()! + ctx.stateManager() + ctx.upsertSession({ + id: "ses_stopped", + worktreeId: wt.id, + parentID: null, + title: "Stopped", + createdAt: "", + updatedAt: "", + revert: null, + summary: null, + }) + const test = harness() + test.bridge.dispose() + const bridge = createOrchestrationBridge({ + connectionService: test.connection as never, + contexts, + projectScope: new ProjectScope(), + getRoot: () => ctx.root, + getState: () => restored, + getStateReady: () => Promise.resolve(), + initStateReady: () => Promise.resolve(), + getStats: async () => ({ worktrees: [] }), + getPrs: () => new Map(), + pushState: () => undefined, + hasPanelSession: () => false, + routeSession: () => undefined, + closeSession: async () => undefined, + postSessionClosed: () => undefined, + log: () => undefined, + }) + + test.request( + { + id: "amr_restored_stopped", + sessionID: "ses_caller", + operation: "prompt", + targetSessionID: "ses_stopped", + prompt: "Do not reopen", + }, + ctx.root, + ) + await waitFor(() => test.rejections.length === 1) + expect(test.rejections[0]).toMatchObject({ error: { code: "unknown_session" } }) + expect(test.promptAsync).not.toHaveBeenCalled() + bridge.dispose() + }) + it("answers a managed session's pending question through the backend reply route", async () => { const test = harness() ;(test.client.question.list as ReturnType).mockImplementation(async () => ({ @@ -451,6 +636,53 @@ describe("AgentManagerOrchestrationBridge", () => { test.bridge.dispose() }) + it("keeps live-only secondary worktree sessions scoped to their owning project", async () => { + const secondary = fs.mkdtempSync(path.join(os.tmpdir(), "am-orchestration-secondary-live-")) + const worktree = path.join(secondary, "worktree") + fs.mkdirSync(path.join(secondary, ".kilo"), { recursive: true }) + fs.mkdirSync(worktree) + const other = new WorktreeStateManager(secondary, () => undefined) + const wt = other.addWorktree({ branch: "fix/secondary-live", path: worktree, parentBranch: "main" }) + const live = { id: "ses_secondary_live", worktreeId: wt.id, createdAt: "" } + const test = harness({ + root: (origin) => (origin === secondary ? secondary : root), + ready: async (origin) => (origin === secondary ? other : state), + state: (origin) => (origin === secondary ? other : state), + resolve: (id, origin) => (id === live.id && origin === secondary ? live : undefined), + }) + + test.request( + { + id: "amr_secondary_live", + sessionID: "ses_caller", + operation: "prompt", + targetSessionID: live.id, + prompt: "Continue", + }, + secondary, + ) + await waitFor(() => test.replies.length === 1) + expect(test.promptAsync).toHaveBeenCalledWith( + expect.objectContaining({ sessionID: live.id, directory: worktree }), + { throwOnError: true }, + ) + expect(other.getSession(live.id)).toBeUndefined() + + test.request({ + id: "amr_secondary_foreign", + sessionID: "ses_caller", + operation: "prompt", + targetSessionID: live.id, + prompt: "Cross project", + }) + await waitFor(() => test.rejections.length === 1) + expect(test.rejections[0]).toMatchObject({ error: { code: "unknown_session" } }) + + test.bridge.dispose() + await other.flush() + fs.rmSync(secondary, { recursive: true, force: true }) + }) + it("handles requests for secondary project directories in multi-project mode", async () => { const secondaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "am-orchestration-secondary-")) fs.mkdirSync(path.join(secondaryRoot, ".kilo"), { recursive: true }) diff --git a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts index b8f29e480d..e2dd882902 100644 --- a/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-manager-orchestration-domain.test.ts @@ -3,8 +3,10 @@ import * as fs from "fs" import * as os from "os" import * as path from "path" import type { KiloClient, QuestionRequest, Session } from "@kilocode/sdk/v2/client" -import { OrchestrationError, answer, overview, prompt } from "../../src/agent-manager/orchestration-domain" +import { OrchestrationError, answer, move, overview, prompt } from "../../src/agent-manager/orchestration-domain" import { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager" +import { ProjectContext } from "../../src/agent-manager/project/context" +import { collectProjectSessions } from "../../src/agent-manager/project/init" import type { PRStatus as AgentManagerPRStatus } from "../../src/agent-manager/types" const noQuestions: QuestionRequest[] = [] @@ -210,6 +212,96 @@ describe("Agent Manager orchestration domain", () => { ) }) + it("prompts, answers, and moves a session discovered in a managed worktree", async () => { + const wt = state.addWorktree({ branch: "fix/discovered", path: worktree, parentBranch: "main" }) + const section = state.addSection("Review", null) + const session = { + id: "ses_discovered", + slug: "discovered", + projectID: "prj-test", + directory: worktree, + title: "Discovered", + version: "1", + time: { created: 1, updated: 1 }, + } satisfies Session + const ctx = new ProjectContext("prj-test", root, true, { log: () => undefined, state: () => state }) + ctx.stateManager() + const views = await collectProjectSessions(ctx, { + listSessions: async (dir) => (dir === worktree ? [session] : []), + setSessionDirectory: () => undefined, + }) + expect(views).toEqual([expect.objectContaining({ id: session.id, worktreeId: wt.id })]) + ctx.setSessions(views) + expect(state.getSession(session.id)).toBeUndefined() + const managed = { id: session.id, worktreeId: views[0]!.worktreeId, createdAt: views[0]!.createdAt } + + const questions: QuestionRequest[] = [] + const delivered = mock(async () => ({ data: undefined })) + const replied = mock(async () => ({ data: true })) + const client = { + session: { + get: mock(async () => ({ data: session })), + status: mock(async () => ({ data: {} })), + promptAsync: delivered, + }, + permission: { list: mock(async () => ({ data: [] })) }, + question: { list: mock(async () => ({ data: questions })), reply: replied }, + } as unknown as KiloClient + + await prompt({ client, root, state, sessionID: session.id, text: "Continue", messageID: "amr_discovered", managed }) + expect(delivered).toHaveBeenCalledWith(expect.objectContaining({ sessionID: session.id, directory: worktree }), { + throwOnError: true, + }) + + questions.push({ + id: "que_discovered", + sessionID: session.id, + questions: [{ header: "Approve", question: "Proceed?", options: [{ label: "Yes", description: "Continue" }] }], + }) + await answer({ client, root, state, sessionID: session.id, answers: [["Yes"]], managed }) + expect(replied).toHaveBeenCalledWith( + { requestID: "que_discovered", answers: [["Yes"]], directory: worktree }, + { throwOnError: true }, + ) + + move({ state, sessionID: session.id, sectionID: section.id, managed }) + expect(state.getWorktree(wt.id)?.sectionId).toBe(section.id) + expect(state.getSession(session.id)).toBeUndefined() + }) + + it("recognizes a worktree session received through a live lifecycle event", async () => { + const wt = state.addWorktree({ branch: "fix/live", path: worktree, parentBranch: "main" }) + const ctx = new ProjectContext("prj-test", root, true, { log: () => undefined, state: () => state }) + ctx.stateManager() + ctx.upsertSession({ + id: "ses_live", + parentID: null, + title: "Live", + createdAt: "", + updatedAt: "", + revert: null, + summary: null, + worktreeId: wt.id, + }) + + expect(ctx.hasLiveSession("ses_live")).toBe(true) + expect(state.getSession("ses_live")).toBeUndefined() + const managed = { id: "ses_live", worktreeId: wt.id, createdAt: "" } + const delivered = mock(async () => ({ data: undefined })) + const client = { + session: { + get: mock(async () => ({ data: { id: "ses_live", directory: worktree, title: "Live" } as Session })), + status: mock(async () => ({ data: {} })), + promptAsync: delivered, + }, + permission: { list: mock(async () => ({ data: [] })) }, + question: { list: mock(async () => ({ data: [] })) }, + } as unknown as KiloClient + + await prompt({ client, root, state, sessionID: "ses_live", text: "Continue", messageID: "amr_live", managed }) + expect(delivered).toHaveBeenCalledWith(expect.objectContaining({ directory: worktree }), { throwOnError: true }) + }) + it("waits for a busy managed session to become idle before prompting", async () => { const managed = state.addWorktree({ branch: "fix/wait", path: worktree, parentBranch: "main" }) state.addSession("ses_wait", managed.id) @@ -352,6 +444,28 @@ describe("Agent Manager orchestration domain", () => { ).rejects.toMatchObject({ code: "unknown_session", } satisfies Partial) + await expect( + prompt({ + client, + root, + state, + sessionID: "ses_unknown", + text: "Continue", + messageID: "amr_mismatch", + managed: { id: "ses_target", worktreeId: managed.id, createdAt: "" }, + }), + ).rejects.toMatchObject({ code: "unknown_session" } satisfies Partial) + await expect( + prompt({ + client, + root, + state, + sessionID: "ses_foreign", + text: "Continue", + messageID: "amr_foreign", + managed: { id: "ses_foreign", worktreeId: "wt_foreign", createdAt: "" }, + }), + ).rejects.toMatchObject({ code: "stale_session" } satisfies Partial) await expect( prompt({ client, root, state, sessionID: "ses_target", text: "Continue", messageID: "amr_cross" }), ).rejects.toMatchObject({ diff --git a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts index ed8542518d..0b4ba95c8a 100644 --- a/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts +++ b/packages/kilo-vscode/tests/unit/agent-project-messages.test.ts @@ -3,6 +3,7 @@ import * as fs from "fs" import * as os from "os" import * as path from "path" import { execFileSync } from "child_process" +import { GitOps } from "../../src/agent-manager/GitOps" import { handleProjectMessage, type ProjectMessageDeps } from "../../src/agent-manager/project/messages" import { ProjectRegistry, type RegistryStorage } from "../../src/agent-manager/project/registry" import { ProjectContexts } from "../../src/agent-manager/project/contexts" @@ -17,7 +18,7 @@ function gitRepo(): string { return fs.realpathSync(dir) } -function setup(opts: { enabled?: boolean; workspace?: string } = {}) { +function setup(opts: { enabled?: boolean; workspace?: string; git?: GitOps } = {}) { let stored: unknown let pickResult: string | undefined const storage: RegistryStorage = { @@ -58,6 +59,7 @@ function setup(opts: { enabled?: boolean; workspace?: string } = {}) { calls.ready.push(ctx.id) return calls.readyResult }, + git: opts.git, log: () => {}, } const pick = (dir: string | undefined) => { @@ -118,6 +120,19 @@ describe("handleProjectMessage", () => { expect(calls.error).toEqual(["The selected folder is not inside a Git repository."]) }) + it("uses the configured Git executable when adding a project", async () => { + const repo = gitRepo() + const git = new GitOps({ log: () => {}, binary: path.join(repo, "missing-git") }) + const { deps, calls, registry, pick } = setup({ git }) + pick(repo) + + await handleProjectMessage(msg("agentManager.addProject"), deps) + git.dispose() + + expect(registry.list()).toEqual([]) + expect(calls.error).toEqual(["The selected folder is not inside a Git repository."]) + }) + it("rejects the pinned workspace repository", async () => { const repo = gitRepo() const { deps, calls, pick } = setup({ workspace: repo }) diff --git a/packages/kilo-vscode/tests/unit/git-executable.test.ts b/packages/kilo-vscode/tests/unit/git-executable.test.ts index f6850a391d..b7e448f21f 100644 --- a/packages/kilo-vscode/tests/unit/git-executable.test.ts +++ b/packages/kilo-vscode/tests/unit/git-executable.test.ts @@ -2,6 +2,112 @@ import { describe, expect, it } from "bun:test" import { createGitExecutable } from "../../src/util/git-executable" describe("createGitExecutable", () => { + it("uses the configured Git executable on Windows", async () => { + const git = createGitExecutable({ + platform: "win32", + preferred: async () => "C:\\Program Files\\Git\\cmd\\git.exe", + }) + + expect(await git()).toBe("C:\\Program Files\\Git\\cmd\\git.exe") + }) + + it("falls back to PATH when the preferred Windows executable is missing", async () => { + const git = createGitExecutable({ + platform: "win32", + preferred: async () => undefined, + }) + + expect(await git()).toBe("git") + }) + + it("logs and falls back to PATH when preferred Windows resolution fails", async () => { + const messages: string[] = [] + const git = createGitExecutable({ + platform: "win32", + preferred: async () => { + throw new Error("Git API unavailable") + }, + log: (message) => messages.push(message), + }) + + expect(await git()).toBe("git") + expect(messages).toEqual(["Unable to resolve the preferred Git executable, using PATH: Error: Git API unavailable"]) + }) + + it("falls back to PATH when preferred Windows resolution hangs", async () => { + const messages: string[] = [] + let calls = 0 + const git = createGitExecutable({ + platform: "win32", + timeout: 10, + preferred: () => { + calls++ + return new Promise(() => undefined) + }, + log: (message) => messages.push(message), + }) + + expect(await Promise.all([git(), git(), git()])).toEqual(["git", "git", "git"]) + expect(await git()).toBe("git") + expect(calls).toBe(1) + expect(messages).toEqual([ + "Unable to resolve the preferred Git executable, using PATH: Error: VS Code Git activation timed out after 10ms", + ]) + }, 1_000) + + it("keeps the PATH fallback when preferred Windows resolution rejects after timeout", async () => { + const messages: string[] = [] + let reject!: (error: Error) => void + const pending = new Promise((_, fail) => { + reject = fail + }) + const git = createGitExecutable({ + platform: "win32", + timeout: 10, + preferred: () => pending, + log: (message) => messages.push(message), + }) + + expect(await git()).toBe("git") + reject(new Error("Late Git activation failure")) + await Bun.sleep(0) + expect(await git()).toBe("git") + expect(messages).toHaveLength(1) + }, 1_000) + + it("keeps the preferred Windows executable after its timeout deadline", async () => { + const messages: string[] = [] + const git = createGitExecutable({ + platform: "win32", + timeout: 10, + preferred: async () => "C:\\Program Files\\Git\\cmd\\git.exe", + log: (message) => messages.push(message), + }) + + expect(await git()).toBe("C:\\Program Files\\Git\\cmd\\git.exe") + await Bun.sleep(20) + expect(await git()).toBe("C:\\Program Files\\Git\\cmd\\git.exe") + expect(messages).toEqual([]) + }) + + it("caches the preferred Windows executable", async () => { + let calls = 0 + const git = createGitExecutable({ + platform: "win32", + preferred: async () => { + calls++ + return "C:\\Git\\git.exe" + }, + }) + + expect(await Promise.all([git(), git(), git()])).toEqual([ + "C:\\Git\\git.exe", + "C:\\Git\\git.exe", + "C:\\Git\\git.exe", + ]) + expect(calls).toBe(1) + }) + it("preserves PATH lookup on other platforms", async () => { const git = createGitExecutable({ platform: "linux", diff --git a/packages/kilo-vscode/tests/unit/git-ops.test.ts b/packages/kilo-vscode/tests/unit/git-ops.test.ts index c90aa3a7c0..803f730326 100644 --- a/packages/kilo-vscode/tests/unit/git-ops.test.ts +++ b/packages/kilo-vscode/tests/unit/git-ops.test.ts @@ -57,6 +57,25 @@ describe("GitOps", () => { }) }) + it("uses an explicit Git executable path with spaces", async () => { + await withRepo(async (cwd) => { + const real = Bun.which("git") + if (!real) throw new Error("Git is required for this test") + + const dir = await fs.mkdtemp(nodePath.join(os.tmpdir(), "kilo-gitops executable-")) + const binary = process.platform === "win32" ? real : nodePath.join(dir, "git") + try { + if (process.platform !== "win32") await fs.symlink(real, binary) + + const git = new GitOps({ log: () => undefined, binary }) + expect(git.path).toBe(binary) + expect(await fs.realpath(await git.root(cwd))).toBe(await fs.realpath(cwd)) + } finally { + await fs.rm(dir, { recursive: true, force: true }) + } + }) + }) + it("does not hold a semaphore slot while resolving Git", async () => { const semaphore = new Semaphore(1) let resolve!: (value: string) => void diff --git a/packages/kilo-vscode/tests/unit/powershell.test.ts b/packages/kilo-vscode/tests/unit/powershell.test.ts deleted file mode 100644 index cb3946bea2..0000000000 --- a/packages/kilo-vscode/tests/unit/powershell.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, it, expect } from "bun:test" -import * as fs from "node:fs" -import * as os from "node:os" -import * as path from "node:path" -import { locations, powershellCommand, pwshPath } from "../../src/util/powershell" - -describe("powershellCommand", () => { - it("lists known Windows install locations in priority order", () => { - expect( - locations({ - ProgramFiles: "C:\\Program Files", - "ProgramFiles(x86)": "C:\\Program Files (x86)", - LOCALAPPDATA: "C:\\Users\\u\\AppData\\Local", - }), - ).toEqual([ - path.join("C:\\Program Files", "PowerShell", "7", "pwsh.exe"), - path.join("C:\\Program Files (x86)", "PowerShell", "7", "pwsh.exe"), - path.join("C:\\Users\\u\\AppData\\Local", "Microsoft", "WindowsApps", "pwsh.exe"), - ]) - expect(locations({})).toEqual([]) - }) - - it("falls back to legacy powershell.exe when nothing is found", () => { - expect(powershellCommand({ PATH: "" })).toBe("powershell.exe") - }) - - it("prefers a pwsh.exe found on the injected PATH", () => { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "pwsh-path-")) - try { - const file = path.join(root, "pwsh.exe") - fs.writeFileSync(file, "") - expect(pwshPath({ PATH: root })).toBe(file) - expect(powershellCommand({ PATH: root })).toBe(file) - } finally { - fs.rmSync(root, { recursive: true, force: true }) - } - }) -}) diff --git a/packages/kilo-vscode/tests/unit/project-session-busy.test.ts b/packages/kilo-vscode/tests/unit/project-session-busy.test.ts index d5b3de4869..53515eb2dd 100644 --- a/packages/kilo-vscode/tests/unit/project-session-busy.test.ts +++ b/packages/kilo-vscode/tests/unit/project-session-busy.test.ts @@ -1,20 +1,21 @@ import { describe, expect, it } from "bun:test" -import { createSessionBusy } from "../../webview-ui/agent-manager/project/session-busy" +import { createSessionBusy, createWorktreeBusy } from "../../webview-ui/agent-manager/project/session-busy" +import type { ExtensionMessage } from "../../webview-ui/src/types/messages" -const busy = (statuses: Record) => - createSessionBusy({ - statuses: () => statuses, - permissions: () => [], - questions: () => [], - managed: () => [ - { id: "unknown", worktreeId: "wt-unknown" }, - { id: "idle", worktreeId: "wt-idle" }, - { id: "working", worktreeId: "wt-working" }, - ], - local: () => [], - projects: () => ({ background: [{ id: "unknown", worktreeId: "wt-unknown" }] }), - active: () => "project-a", - }) +const options = (statuses: Record) => ({ + statuses: () => statuses, + permissions: () => [], + questions: () => [], + managed: () => [ + { id: "unknown", worktreeId: "wt-unknown" }, + { id: "idle", worktreeId: "wt-idle" }, + { id: "working", worktreeId: "wt-working" }, + ], + local: () => [], + projects: () => ({ background: [{ id: "unknown", worktreeId: "wt-unknown" }] }), + active: () => "project-a", +}) +const busy = (statuses: Record) => createSessionBusy(options(statuses)) describe("createSessionBusy", () => { it("does not mark stopped or unknown sessions as busy", () => { @@ -25,17 +26,79 @@ describe("createSessionBusy", () => { expect(state.project("background", "wt-unknown")).toBe(false) }) - it("marks sessions with an active status as busy", () => { - expect(busy({ working: { type: "busy" } }).agent("wt-working")).toBe(true) + it.each(["busy", "retry"])("marks sessions with an active %s status as busy", (type) => { + expect(busy({ working: { type } }).agent("wt-working")).toBe(true) }) - it.each(["permission", "question"] as const)( + it("keeps running for non-blocking questions", () => { + const questions: { sessionID: string; blocking?: boolean }[] = [{ sessionID: "working", blocking: false }] + const state = createSessionBusy({ + ...options({ working: { type: "busy" } }), + questions: () => questions, + }) + expect(state.agent("wt-working")).toBe(true) + questions[0].blocking = true + expect(state.agent("wt-working")).toBe(false) + delete questions[0].blocking + expect(state.agent("wt-working")).toBe(false) + }) + + it("does not keep a spinner for an offline session", () => { + const state = busy({ working: { type: "offline" }, unknown: { type: "offline" } }) + + expect(state.agent("wt-working")).toBe(false) + expect(state.session("working")).toBe(false) + expect(state.project("background", "wt-unknown")).toBe(false) + expect(state.agent("wt-working", true)).toBe(true) + expect(state.project("background", "wt-unknown", true)).toBe(true) + }) +}) + +describe("createWorktreeBusy", () => { + it("keeps directory activity separate from parent status and other projects", () => { + const listeners = new Set<(message: ExtensionMessage) => void>() + const state = createWorktreeBusy({ + ...options({ idle: { type: "idle" }, working: { type: "busy" } }), + worktrees: (project) => [ + { id: "wt-idle", path: project === "background" ? "/other/worktree" : "/repo/worktree" }, + ], + subscribe: (callback) => { + listeners.add(callback) + return () => listeners.delete(callback) + }, + }) + const send = (active: string[]) => { + for (const callback of listeners) callback({ type: "agentManager.worktreeActivity", active }) + } + + expect(state.agent("wt-idle")).toBe(false) + expect(state.agent("wt-working")).toBe(true) + send(["/repo/worktree"]) + expect(state.agent("wt-idle")).toBe(true) + expect(state.project("project-a", "wt-idle")).toBe(true) + expect(state.project("background", "wt-idle")).toBe(false) + expect(state.project("background", null)).toBe(false) + expect(state.agent("missing")).toBe(false) + expect(state.session("idle")).toBe(false) + expect(state.local()).toBe(false) + + send(["/other/worktree"]) + expect(state.agent("wt-idle")).toBe(false) + expect(state.project("background", "wt-idle")).toBe(true) + send([]) + expect(state.project("background", "wt-idle")).toBe(false) + expect(state.agent("wt-working")).toBe(true) + }) + + it.each(["permission", "question", "non-blocking question"] as const)( "blocks deletion for a pending %s without showing a running spinner", (kind) => { - const state = createSessionBusy({ - statuses: () => ({ session: { type: "busy" } }), + const state = createWorktreeBusy({ + statuses: () => ({ session: { type: "idle" } }), permissions: () => (kind === "permission" ? [{ sessionID: "session" }] : []), - questions: () => (kind === "question" ? [{ sessionID: "session" }] : []), + questions: () => (kind !== "permission" ? [{ sessionID: "session", blocking: kind === "question" }] : []), + worktrees: () => [], + subscribe: () => () => undefined, managed: () => [{ id: "session", worktreeId: "worktree" }], local: () => [], projects: () => ({ other: [{ id: "session", worktreeId: "worktree" }] }), diff --git a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts index 1674d9a56e..17a9093f48 100644 --- a/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts +++ b/packages/kilo-vscode/tests/unit/prompt-send-contract.test.ts @@ -18,6 +18,7 @@ import { clearIfOn } from "../../webview-ui/src/context/session-cloud-prune" const ROOT = path.resolve(import.meta.dir, "../..") const SESSION_FILE = path.join(ROOT, "webview-ui/src/context/session.tsx") const CHATVIEW_FILE = path.join(ROOT, "webview-ui/src/components/chat/ChatView.tsx") +const AGENT_MANAGER_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx") const PROMPT_UTILS_FILE = path.join(ROOT, "webview-ui/src/components/chat/prompt-input-utils.ts") const PROMPT_FILE = path.join(ROOT, "webview-ui/src/components/chat/PromptInput.tsx") const KILOPROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts") @@ -148,6 +149,26 @@ describe("ChatView prompt-block contract", () => { }) }) +describe("review worktree visibility contract", () => { + it("passes the worktree prop from ChatView to PromptInput", () => { + const source = readFile(CHATVIEW_FILE) + expect(source).toMatch(/worktree\?: boolean/) + expect(source).toMatch(/ { + const source = readFile(PROMPT_FILE) + expect(source).toMatch(/worktree\?: boolean/) + expect(source).toMatch(/if \(props\.worktree !== true\) hidden\.add\("review worktree"\)/) + }) + + it("uses registered worktree membership for Agent Manager visibility", () => { + const source = readFile(AGENT_MANAGER_FILE) + expect(source).toMatch(/worktree=\{worktrees\(\)\.some\(\(wt\) => wt\.id === selection\(\)\)\}/) + expect(source).not.toMatch(/worktree=\{selection\(\(\)\) !== LOCAL\}/) + }) +}) + describe("isPromptBlocked signature contract", () => { const source = readFile(PROMPT_UTILS_FILE) diff --git a/packages/kilo-vscode/tests/unit/run-script-service.test.ts b/packages/kilo-vscode/tests/unit/run-script-service.test.ts index 1836063567..f1823bde2a 100644 --- a/packages/kilo-vscode/tests/unit/run-script-service.test.ts +++ b/packages/kilo-vscode/tests/unit/run-script-service.test.ts @@ -3,7 +3,6 @@ import * as fs from "node:fs" import * as os from "node:os" import * as path from "node:path" import { buildRunTaskCommand, RunScriptService } from "../../src/agent-manager/run/service" -import { powershellCommand } from "../../src/util/powershell" function tmpdir(): string { return fs.mkdtempSync(path.join(os.tmpdir(), "run-script-service-test-")) @@ -60,7 +59,7 @@ describe("RunScriptService", () => { args: ["/tmp/run-script"], }) expect(buildRunTaskCommand({ path: "C:\\repo\\.kilo\\run-script.ps1", kind: "powershell" })).toEqual({ - command: powershellCommand(), + command: "powershell.exe", args: ["-NoLogo", "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\repo\\.kilo\\run-script.ps1"], }) expect(buildRunTaskCommand({ path: "C:\\repo path\\.kilo\\run-script.cmd", kind: "cmd" })).toEqual({ diff --git a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts index c62e3b51eb..9380e9724d 100644 --- a/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts +++ b/packages/kilo-vscode/tests/unit/server-manager-utils.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test" -import { parseServerPort } from "../../src/services/cli-backend/server-utils" +import { parseServerPort, scanServerPort } from "../../src/services/cli-backend/server-utils" import { resolveServerCwd, resolveIndexingEnv, @@ -61,6 +61,51 @@ describe("parseServerPort", () => { const output = "listening on http://127.0.0.1:3000 and http://127.0.0.1:4000" expect(parseServerPort(output)).toBe(3000) }) + + it("waits for the complete startup line before resolving a split port", () => { + const first = "kilo server listening on http://127.0.0.1:43" + + expect(parseServerPort(first, true)).toBeNull() + expect(parseServerPort(`${first}123\n`, true)).toBe(43123) + }) + + it("detects a startup announcement split across stdout chunks", () => { + const first = "kilo server listening on http://127.0." + const second = "0.1:43123\n" + + expect(parseServerPort(first, true)).toBeNull() + expect(parseServerPort(`${first}${second}`, true)).toBe(43123) + }) + + it("accepts complete Windows startup lines", () => { + expect(parseServerPort("kilo server listening on http://127.0.0.1:43123\r\n", true)).toBe(43123) + }) +}) + +describe("scanServerPort", () => { + it("detects startup announcements split across stdout chunks", () => { + const first = scanServerPort("", "kilo server listening on http://127.0.", 1024) + const second = scanServerPort(first.output, "0.1:43123\n", 1024) + + expect(first.port).toBeNull() + expect(second.port).toBe(43123) + }) + + it("waits for split port digits before resolving startup", () => { + const first = scanServerPort("", "kilo server listening on http://127.0.0.1:43", 1024) + const second = scanServerPort(first.output, "123\n", 1024) + + expect(first.port).toBeNull() + expect(second.port).toBe(43123) + }) + + it("preserves startup announcements followed by oversized stdout chunks", () => { + const chunk = `kilo server listening on http://127.0.0.1:43123\n${"x".repeat(1024)}` + const state = scanServerPort("", chunk, 1024) + + expect(state.port).toBe(43123) + expect(state.output).toHaveLength(1024) + }) }) describe("cli tree-sitter resources", () => { diff --git a/packages/kilo-vscode/tests/unit/sidebar-position.test.ts b/packages/kilo-vscode/tests/unit/sidebar-position.test.ts new file mode 100644 index 0000000000..21ea1a5930 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/sidebar-position.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "bun:test" +import { edge } from "../../webview-ui/src/sidebar-position" + +const view = { screenX: 144, outerWidth: 1440, innerWidth: 299 } + +describe("sidebar position", () => { + it.each([ + [0, 192, "left"], + [149.5, 341.5, "left"], + [299, 491, "left"], + [0, 1285, "right"], + [149.5, 1434.5, "right"], + [299, 1584, "right"], + ] as const)("resolves client %s at screen %s to the %s edge", (client, screen, side) => { + expect(edge({ clientX: client, screenX: screen }, view)).toBe(side) + }) + + it("uses the window origin on a monitor with negative coordinates", () => { + const host = { ...view, screenX: -1440 } + expect(edge({ clientX: 149.5, screenX: -1242.5 }, host)).toBe("left") + expect(edge({ clientX: 149.5, screenX: -149.5 }, host)).toBe("right") + }) + + it("keeps the outer edge when the sidebar is wider than half the window", () => { + const host = { screenX: 0, outerWidth: 1440, innerWidth: 1000 } + expect(edge({ clientX: 950, screenX: 998 }, host)).toBe("left") + expect(edge({ clientX: 50, screenX: 490 }, host)).toBe("right") + }) + + it("handles pointer coordinates from a zoomed webview", () => { + expect(edge({ clientX: 149.5703125, screenX: 1404.484375 }, view)).toBe("right") + expect(edge({ clientX: 149.5, screenX: 381 }, view)).toBe("left") + }) + + it("ignores unavailable or invalid geometry", () => { + const event = { clientX: 149.5, screenX: 341.5 } + expect(edge(event, { ...view, outerWidth: 0 })).toBeUndefined() + expect(edge(event, { ...view, innerWidth: 0 })).toBeUndefined() + expect(edge(event, { ...view, outerWidth: Number.NaN })).toBeUndefined() + expect(edge(event, { ...view, innerWidth: Number.POSITIVE_INFINITY })).toBeUndefined() + expect(edge({ ...event, screenX: -10000 }, view)).toBeUndefined() + expect(edge({ ...event, screenX: 10000 }, view)).toBeUndefined() + expect(edge({ ...event, clientX: Number.NaN }, view)).toBeUndefined() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts index 7e9eb11483..27ca2d4259 100644 --- a/packages/kilo-vscode/tests/unit/use-slash-command.test.ts +++ b/packages/kilo-vscode/tests/unit/use-slash-command.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test" -import { createRoot } from "solid-js" +import { createRoot, createSignal } from "solid-js" import { useSlashCommand } from "../../webview-ui/src/hooks/useSlashCommand" import type { ExtensionMessage, WebviewMessage } from "../../webview-ui/src/types/messages" @@ -211,6 +211,7 @@ describe("useSlashCommand sandbox action", () => { expect(ctx.slash.results()).toContainEqual( expect.objectContaining({ name: "review", description: expect.stringContaining("Review code changes") }), ) + expect(ctx.slash.results().find((command) => command.name === "review")?.description).not.toContain("worktree") ctx.slash.select(ctx.slash.results().find((c) => c.name === "review")!, textarea, (text) => (state.text = text)) expect(state.text).toBe("/review ") expect(ctx.slash.results().map((command) => command.name)).toEqual([ @@ -218,6 +219,7 @@ describe("useSlashCommand sandbox action", () => { "review staged", "review unpushed", "review branch", + "review worktree", "review quick", ]) ctx.dispose() @@ -242,6 +244,31 @@ describe("useSlashCommand sandbox action", () => { ctx.dispose() }) + it("reactively re-includes worktree review without changing nested ordering", () => { + const [allowed, setAllowed] = createSignal(false) + const ctx = setup(() => {}, { exclude: () => (allowed() ? new Set() : new Set(["review worktree"])) }) + + ctx.slash.onInput("/review ", 8) + expect(ctx.slash.results().map((command) => command.name)).toEqual([ + "review uncommitted", + "review staged", + "review unpushed", + "review branch", + "review quick", + ]) + + setAllowed(true) + expect(ctx.slash.results().map((command) => command.name)).toEqual([ + "review uncommitted", + "review staged", + "review unpushed", + "review branch", + "review worktree", + "review quick", + ]) + ctx.dispose() + }) + it("preserves model, agent, and variant metadata on loaded server commands", () => { const ctx = setup(() => {}) diff --git a/packages/kilo-vscode/tests/unit/worktree-activity.test.ts b/packages/kilo-vscode/tests/unit/worktree-activity.test.ts new file mode 100644 index 0000000000..dc53919067 --- /dev/null +++ b/packages/kilo-vscode/tests/unit/worktree-activity.test.ts @@ -0,0 +1,476 @@ +import { describe, expect, it } from "bun:test" +import { createWorktreeActivity, WorktreeActivity } from "../../src/agent-manager/worktree-activity" + +type Snapshot = { + statuses: Record + permissions: Array<{ id: string; sessionID: string }> + questions: Array<{ id: string; sessionID: string; blocking?: boolean }> +} + +function defer() { + return Promise.withResolvers() +} + +function snapshot( + statuses: Record = {}, + permissions: string[][] = [], + questions: string[][] = [], +): Snapshot { + return { + statuses: Object.fromEntries(Object.entries(statuses).map(([id, type]) => [id, { type }])), + permissions: permissions.map(([id, sessionID]) => ({ id, sessionID })), + questions: questions.map(([id, sessionID]) => ({ id, sessionID })), + } +} + +function status(sessionID: string, type: string) { + return { type: "session.status", properties: { sessionID, status: { type } } } +} + +function asked(type: "permission" | "question", id: string, sessionID: string) { + return { type: `${type}.asked`, properties: { id, sessionID } } +} + +function replied(type: "permission" | "question", requestID: string, sessionID: string, kind = "replied") { + return { type: `${type}.${kind}`, properties: { requestID, sessionID } } +} + +function setup(dirs: string[], load: (dir: string) => Promise) { + const posted: string[][] = [] + const errors: unknown[] = [] + const activity = new WorktreeActivity({ + paths: () => dirs, + load, + post: (active) => posted.push(active), + log: (err) => errors.push(err), + }) + return { activity, posted, errors } +} + +function connection(client: unknown, state = "connected") { + let stateListener: ((value: string) => void) | undefined + let eventListener: ((event: unknown, directory?: string) => void) | undefined + let eventFilter: ((event: unknown) => boolean) | undefined + const value = { + getClient: () => client, + getConnectionState: () => state, + onStateChange: (listener: (value: string) => void) => { + stateListener = listener + return () => { + stateListener = undefined + } + }, + onEventFiltered: (filter: (event: unknown) => boolean, listener: (event: unknown, directory?: string) => void) => { + eventFilter = filter + eventListener = listener + return () => { + eventFilter = undefined + eventListener = undefined + } + }, + change: (next: string) => { + state = next + stateListener?.(next) + }, + set: (next: string) => { + state = next + }, + emit: (event: unknown, directory?: string) => { + if (eventFilter?.(event)) eventListener?.(event, directory) + }, + } + return value +} + +describe("WorktreeActivity", () => { + it("accepts only relevant SDK events", () => { + expect(WorktreeActivity.accepts({ type: "session.status" })).toBe(true) + expect(WorktreeActivity.accepts({ type: "session.deleted" })).toBe(true) + expect(WorktreeActivity.accepts({ type: "session.error" })).toBe(true) + expect(WorktreeActivity.accepts({ type: "permission.asked" })).toBe(true) + expect(WorktreeActivity.accepts({ type: "permission.replied" })).toBe(true) + expect(WorktreeActivity.accepts({ type: "question.asked" })).toBe(true) + expect(WorktreeActivity.accepts({ type: "question.replied" })).toBe(true) + expect(WorktreeActivity.accepts({ type: "question.rejected" })).toBe(true) + expect(WorktreeActivity.accepts({ type: "server.instance.disposed" })).toBe(true) + expect(WorktreeActivity.accepts({ type: "session.created" })).toBe(false) + expect(WorktreeActivity.accepts(null)).toBe(false) + expect(WorktreeActivity.accepts("session.status")).toBe(false) + }) + + it("counts busy and retry children while ignoring idle and offline sessions", async () => { + const test = setup(["/repo"], async () => snapshot({ parent: "idle", child: "busy" })) + await test.activity.sync() + expect(test.posted.at(-1)).toEqual(["/repo"]) + + test.activity.event(status("child", "idle"), "/repo") + expect(test.posted.at(-1)).toEqual([]) + test.activity.event(status("parent", "retry"), "/repo") + expect(test.posted.at(-1)).toEqual(["/repo"]) + test.activity.event(status("parent", "offline"), "/repo") + expect(test.posted.at(-1)).toEqual([]) + }) + + it("excludes blocked children without suppressing active siblings", async () => { + const test = setup(["/repo"], async () => snapshot({ child: "busy", sibling: "idle" })) + await test.activity.sync() + + test.activity.event(asked("permission", "p1", "child"), "/repo") + test.activity.event(asked("permission", "p2", "child"), "/repo") + test.activity.event(asked("question", "q1", "child"), "/repo") + expect(test.posted.at(-1)).toEqual([]) + + test.activity.event(status("sibling", "busy"), "/repo") + test.activity.event(replied("permission", "p1", "child"), "/repo") + test.activity.event(replied("question", "q1", "child"), "/repo") + expect(test.posted.at(-1)).toEqual(["/repo"]) + + test.activity.event(replied("permission", "p2", "child"), "/repo") + expect(test.posted.at(-1)).toEqual(["/repo"]) + test.activity.event(status("sibling", "idle"), "/repo") + expect(test.posted.at(-1)).toEqual(["/repo"]) + test.activity.event(status("child", "idle"), "/repo") + expect(test.posted.at(-1)).toEqual([]) + + test.activity.event(asked("question", "q2", "child"), "/repo") + test.activity.event(replied("question", "q2", "child", "rejected"), "/repo") + expect(test.posted.at(-1)).toEqual([]) + }) + + it("ignores non-blocking questions in snapshots and live events", async () => { + const test = setup(["/repo"], async () => ({ + ...snapshot({ child: "busy" }), + questions: [{ id: "note", sessionID: "child", blocking: false }], + })) + await test.activity.sync() + expect(test.posted.at(-1)).toEqual(["/repo"]) + + const question = asked("question", "live", "child") + test.activity.event({ ...question, properties: { ...question.properties, blocking: false } }, "/repo") + expect(test.posted.at(-1)).toEqual(["/repo"]) + test.activity.event(question, "/repo") + expect(test.posted.at(-1)).toEqual([]) + test.activity.event({ ...question, properties: { ...question.properties, blocking: false } }, "/repo") + expect(test.posted.at(-1)).toEqual(["/repo"]) + }) + + it("clears sessions on completion, deletion, errors, and offline status", async () => { + const test = setup(["/repo"], async () => snapshot()) + await test.activity.sync() + test.activity.event(status("s1", "busy"), "/repo") + test.activity.event(status("s1", "complete"), "/repo") + expect(test.posted.at(-1)).toEqual([]) + + test.activity.event(status("s1", "busy"), "/repo") + test.activity.event({ type: "session.error", properties: { sessionID: "s1" } }, "/repo") + expect(test.posted.at(-1)).toEqual([]) + + test.activity.event(status("s1", "busy"), "/repo") + test.activity.event({ type: "session.deleted", properties: { info: { id: "s1" } } }, "/repo") + expect(test.posted.at(-1)).toEqual([]) + + test.activity.event(status("s1", "busy"), "/repo") + test.activity.event(status("s1", "offline"), "/repo") + expect(test.posted.at(-1)).toEqual([]) + }) + + it("isolates normalized directories and ignores unknown ownership", async () => { + const dirs = ["/repo/a/", "/repo/b"] + const test = setup(dirs, async () => snapshot()) + await test.activity.sync() + + test.activity.event(status("a1", "busy"), "\\repo\\a\\") + expect(test.posted.at(-1)).toEqual(["/repo/a/"]) + test.activity.event(status("b1", "busy"), "/repo/b/") + expect(test.posted.at(-1)).toEqual(["/repo/a/", "/repo/b"]) + test.activity.event(status("unknown", "busy"), "/repo/unknown") + expect(test.posted.at(-1)).toEqual(["/repo/a/", "/repo/b"]) + }) + + it("deduplicates loads, hydrates new paths, force refreshes, and replays cached output", async () => { + const dirs = ["/repo"] + const firstGate = defer() + const forceGate = defer() + const newGate = defer() + const gates = [firstGate, forceGate, newGate] + const calls: string[] = [] + const test = setup(dirs, (dir) => { + calls.push(dir) + const gate = gates.shift() + if (!gate) return Promise.resolve(snapshot()) + return gate.promise + }) + + const first = test.activity.sync() + const second = test.activity.sync() + await Bun.sleep(0) + expect(calls).toEqual(["/repo"]) + test.activity.event(status("s1", "busy"), "/repo") + expect(test.posted.at(-1)).toEqual(["/repo"]) + firstGate.resolve(snapshot({ s1: "busy" })) + await Promise.all([first, second]) + expect(calls).toEqual(["/repo"]) + + test.activity.replay() + expect(test.posted.at(-1)).toEqual(["/repo"]) + const force = test.activity.sync(true) + await Bun.sleep(0) + expect(calls).toEqual(["/repo", "/repo"]) + forceGate.resolve(snapshot()) + await force + expect(test.posted.at(-1)).toEqual([]) + + dirs.push("/repo/new") + const add = test.activity.sync() + await Bun.sleep(0) + expect(calls).toEqual(["/repo", "/repo", "/repo/new"]) + newGate.resolve(snapshot({ newSession: "retry" })) + await add + expect(test.posted.at(-1)).toEqual(["/repo/new"]) + }) + + it("defers the loader and recovers from synchronous failures", async () => { + const error = new Error("load failed") + let calls = 0 + const test = setup(["/repo"], () => { + calls += 1 + if (calls === 1) throw error + return Promise.resolve(snapshot({ child: "busy" })) + }) + const pending = test.activity.sync() + expect(calls).toBe(0) + await pending + expect(test.errors).toEqual([error]) + + await test.activity.sync() + expect(calls).toBe(2) + expect(test.posted.at(-1)).toEqual(["/repo"]) + await test.activity.sync() + expect(calls).toBe(2) + }) + + it("commits snapshots to the tracked state used by later events and refreshes", async () => { + const initial = snapshot({ child: "busy" }, [["p1", "child"]], [["q1", "child"]]) + const snapshots = [initial, snapshot({ child: "retry" })] + const test = setup(["/repo"], async () => snapshots.shift()!) + await test.activity.sync() + expect(test.posted.at(-1)).toEqual([]) + + test.activity.event(replied("permission", "p1", "child"), "/repo") + expect(test.posted.at(-1)).toEqual([]) + test.activity.event(replied("question", "q1", "child"), "/repo") + expect(test.posted.at(-1)).toEqual(["/repo"]) + + await test.activity.sync(true) + expect(test.posted.at(-1)).toEqual(["/repo"]) + test.activity.event(status("child", "idle"), "/repo") + expect(test.posted.at(-1)).toEqual([]) + expect(initial).toEqual(snapshot({ child: "busy" }, [["p1", "child"]], [["q1", "child"]])) + expect(test.errors).toEqual([]) + }) + + it("does not let a snapshot overwrite newer events or remove other active children", async () => { + const gate = defer() + const test = setup(["/repo"], () => gate.promise) + const pending = test.activity.sync() + test.activity.event(status("one", "idle"), "/repo") + test.activity.event(status("two", "idle"), "/repo") + test.activity.event(status("one", "busy"), "/repo") + test.activity.event(status("two", "busy"), "/repo") + test.activity.event(status("one", "idle"), "/repo") + gate.resolve(snapshot({ one: "busy", two: "busy" })) + await pending + expect(test.posted.at(-1)).toEqual(["/repo"]) + test.activity.event(status("two", "idle"), "/repo") + expect(test.posted.at(-1)).toEqual([]) + }) + + it("prunes removed paths and prevents old loads from reviving activity", async () => { + const dirs = ["/repo"] + const gate = defer() + const test = setup(dirs, () => gate.promise) + const pending = test.activity.sync() + test.activity.event(status("s1", "busy"), "/repo") + dirs.length = 0 + await test.activity.sync() + expect(test.posted.at(-1)).toEqual([]) + test.activity.event(status("s1", "busy"), "/repo") + expect(test.posted.at(-1)).toEqual([]) + gate.resolve(snapshot({ s1: "busy" })) + await pending + expect(test.posted.at(-1)).toEqual([]) + }) + + it("clears on disconnect and disposal, then allows a fresh sync", async () => { + const dirs = ["/repo"] + const firstGate = defer() + const nextGate = defer() + const gates = [firstGate, nextGate] + const test = setup(dirs, () => gates.shift()!.promise) + const first = test.activity.sync() + test.activity.event(status("s1", "busy"), "/repo") + test.activity.event({ type: "server.instance.disposed", properties: { directory: "/repo" } }, "/repo") + expect(test.posted.at(-1)).toEqual([]) + firstGate.resolve(snapshot({ s1: "busy" })) + await first + expect(test.posted.at(-1)).toEqual([]) + + test.activity.clear() + expect(test.posted.at(-1)).toEqual([]) + const next = test.activity.sync() + nextGate.resolve(snapshot({ s2: "busy" })) + await next + expect(test.posted.at(-1)).toEqual(["/repo"]) + + test.activity.dispose() + const count = test.posted.length + test.activity.event(status("s3", "busy"), "/repo") + await test.activity.sync() + test.activity.replay() + expect(test.posted).toHaveLength(count) + }) + + it("logs failed paths without erasing successful siblings and retries them", async () => { + const dirs = ["/repo/a", "/repo/b"] + const a = defer() + const b = defer() + const retry = defer() + const calls: string[] = [] + const test = setup(dirs, (dir) => { + calls.push(dir) + if (dir === "/repo/a") return a.promise + if (calls.filter((item) => item === "/repo/b").length === 1) return b.promise + return retry.promise + }) + const pending = test.activity.sync() + a.resolve(snapshot({ a1: "busy" })) + b.reject(new Error("b failed")) + await pending + expect(test.errors).toHaveLength(1) + expect(test.posted.at(-1)).toEqual(["/repo/a"]) + expect(calls).toEqual(["/repo/a", "/repo/b"]) + + const again = test.activity.sync() + await Bun.sleep(0) + expect(calls).toEqual(["/repo/a", "/repo/b", "/repo/b"]) + retry.resolve(snapshot()) + await again + expect(test.posted.at(-1)).toEqual(["/repo/a"]) + }) + + it("does not replay failed-load events over a newer recovery snapshot", async () => { + const first = defer() + const next = defer() + const gates = [first, next] + const test = setup(["/repo"], () => gates.shift()!.promise) + const pending = test.activity.sync() + test.activity.event(status("child", "busy"), "/repo") + first.reject(new Error("snapshot failed")) + await pending + expect(test.posted.at(-1)).toEqual(["/repo"]) + + const recovery = test.activity.sync(true) + next.resolve(snapshot()) + await recovery + expect(test.posted.at(-1)).toEqual([]) + }) + + it("publishes a ready worktree while another snapshot is still loading", async () => { + const gate = defer() + const ready = defer() + const activity = new WorktreeActivity({ + paths: () => ["/fast", "/slow"], + load: async (dir) => (dir === "/fast" ? snapshot({ child: "busy" }) : gate.promise), + post: (active) => ready.resolve(active), + log: (err) => { + throw err + }, + }) + const pending = activity.sync() + expect(await ready.promise).toEqual(["/fast"]) + gate.resolve(snapshot()) + await pending + activity.dispose() + }) + + it("wires the activity wrapper to the connection without loading histories", async () => { + const calls: string[] = [] + const client = { + session: { + status: async (input: { directory: string }, options: { throwOnError: true }) => { + calls.push(`status:${input.directory}:${options.throwOnError}`) + return { data: { s1: { type: "busy" } } } + }, + }, + permission: { + list: async (input: { directory: string }, options: { throwOnError: true }) => { + calls.push(`permission:${input.directory}:${options.throwOnError}`) + return { data: [] } + }, + }, + question: { + list: async (input: { directory: string }, options: { throwOnError: true }) => { + calls.push(`question:${input.directory}:${options.throwOnError}`) + return { data: [] } + }, + }, + } + const conn = connection(client) + const posted: string[][] = [] + const statuses: unknown[] = [] + const lifecycle: unknown[] = [] + const wrapper = createWorktreeActivity({ + connection: conn as never, + paths: () => ["/repo"], + post: (active) => posted.push(active), + status: (event) => statuses.push(event), + lifecycle: (event) => lifecycle.push(event), + log: () => {}, + }) + + await wrapper.sync() + expect(calls).toEqual(["status:/repo:true", "permission:/repo:true", "question:/repo:true"]) + expect(posted.at(-1)).toEqual(["/repo"]) + expect(conn.emit({ type: "session.created", properties: {} }, "/repo")).toBeUndefined() + expect(conn.emit(status("s1", "idle"), "/repo")).toBeUndefined() + expect(statuses).toHaveLength(1) + expect(lifecycle).toHaveLength(1) + expect(posted.at(-1)).toEqual([]) + + conn.change("disconnected") + expect(posted.at(-1)).toEqual([]) + await wrapper.sync() + expect(calls).toHaveLength(3) + + conn.change("connected") + await Bun.sleep(0) + expect(calls).toHaveLength(6) + wrapper.dispose() + conn.emit(status("s1", "busy"), "/repo") + expect(statuses).toHaveLength(1) + }) + + it("replays before a forced sync checks connection state", async () => { + const client = { + session: { status: async () => ({ data: { s1: { type: "busy" } } }) }, + permission: { list: async () => ({ data: [] }) }, + question: { list: async () => ({ data: [] }) }, + } + const conn = connection(client) + const posted: string[][] = [] + const wrapper = createWorktreeActivity({ + connection: conn as never, + paths: () => ["/repo"], + post: (active) => posted.push(active), + status: () => {}, + lifecycle: () => {}, + log: () => {}, + }) + await wrapper.sync() + conn.set("disconnected") + posted.length = 0 + await wrapper.sync(true) + expect(posted).toEqual([["/repo"]]) + wrapper.dispose() + }) +}) diff --git a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts index b8f23e47b7..e78cadfecd 100644 --- a/packages/kilo-vscode/tests/unit/worktree-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-manager.test.ts @@ -263,6 +263,96 @@ describe("WorktreeStateManager.updateWorktreeLabel", () => { // --------------------------------------------------------------------------- describe("WorktreeManager.createWorktree", () => { + it("uses a configured Git executable for worktree creation", async () => { + const root = await createTempRepo() + gitExec(["git", "-C", root, "config", "core.autocrlf", "false"]) + gitExec(["git", "-C", root, "config", "core.eol", "lf"]) + const real = Bun.which("git") + if (!real) throw new Error("Git is required for this test") + + const fake = await fs.mkdtemp(path.join(os.tmpdir(), "kilo-wt-no-git-")) + tempDirs.push(fake) + const file = path.join(fake, process.platform === "win32" ? "git.cmd" : "git") + await fs.writeFile(file, process.platform === "win32" ? "@exit /b 127\r\n" : "#!/bin/sh\nexit 127\n") + if (process.platform !== "win32") await fs.chmod(file, 0o755) + + const bin = + process.platform === "win32" + ? real + : path.join(await fs.mkdtemp(path.join(os.tmpdir(), "kilo-git executable-")), "git") + if (process.platform !== "win32") { + const dir = path.dirname(bin) + tempDirs.push(dir) + await fs.symlink(real, bin) + } + + const env: Record = {} + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string" && key.toLowerCase() !== "path") env[key] = value + } + const key = Object.keys(process.env).find((name) => name.toLowerCase() === "path") ?? "PATH" + const dirs = [fake] + if (process.platform === "win32") { + const root = process.env.SystemRoot ?? process.env.windir + if (root) { + dirs.push( + path.join(root, "System32"), + path.join(root, "System32", "Wbem"), + path.join(root, "System32", "WindowsPowerShell", "v1.0"), + ) + } + } + env[key] = dirs.join(path.delimiter) + env.KILO_TEST_ROOT = root + env.KILO_TEST_GIT = bin + + const script = ` + import { existsSync } from "node:fs" + import path from "node:path" + import { GitOps } from "./src/agent-manager/GitOps" + import { apply, capture } from "./src/agent-manager/git-transfer" + import { WorktreeManager } from "./src/agent-manager/WorktreeManager" + + const root = process.env.KILO_TEST_ROOT + const git = process.env.KILO_TEST_GIT + if (!root || !git) throw new Error("Missing configured Git test environment") + + const ops = new GitOps({ log: () => undefined, binary: git }) + const manager = new WorktreeManager(root, () => undefined, ops) + const result = await manager.createWorktree({ branchName: "configured-git" }) + if (!existsSync(path.join(result.path, ".git"))) throw new Error("Worktree was not created") + if ((await ops.currentBranch(result.path)) !== result.branch) throw new Error("GitOps did not use configured Git") + if (await manager.hasWork(result.path, result.parentBranch)) throw new Error("New worktree unexpectedly has work") + await Bun.write(path.join(result.path, "configured.txt"), "configured") + if (!(await manager.hasWork(result.path, result.parentBranch))) throw new Error("WorktreeManager did not use configured Git") + await Bun.write(path.join(root, "README.md"), "staged\\n") + const staged = await ops.execGit(["add", "README.md"], root) + if (staged.code !== 0) throw new Error("Could not stage configured Git test change") + await Bun.write(path.join(root, "README.md"), "unstaged\\n") + const snapshot = await capture(root, () => undefined, git) + if (!snapshot.staged?.includes("staged") || !snapshot.unstaged?.includes("unstaged")) { + throw new Error("Git transfer did not capture staged and unstaged changes") + } + const applied = await apply(snapshot, result.path, () => undefined, git) + if (!applied.ok) throw new Error(applied.error ?? "Git transfer did not apply changes") + if ((await Bun.file(path.join(result.path, "README.md")).text()) !== "unstaged\\n") { + throw new Error("Git transfer did not apply the working tree content") + } + const status = (await ops.execGit(["status", "--porcelain", "--", "README.md"], result.path)).stdout.trim() + if (status !== "MM README.md") throw new Error("Git transfer did not preserve staged state: " + status) + ` + const child = Bun.spawnSync([process.execPath, "-e", script], { + cwd: process.cwd(), + env, + stdout: "pipe", + stderr: "pipe", + }) + const stderr = child.stderr.toString("utf8") + + expect(child.exitCode, stderr).toBe(0) + expect(existsSync(path.join(root, ".kilo", "worktrees", "configured-git", ".git"))).toBe(true) + }, 120_000) + it("creates a worktree with a new branch", async () => { const root = await createTempRepo() const mgr = createManager(root) @@ -1251,6 +1341,7 @@ describe("WorktreeManager.createWorktree advanced", () => { } internal.fetchPRInfo = async () => ({ headRefName: "topic", + baseRefName: "main", isCrossRepository: false, title: "Topic PR", }) @@ -1260,7 +1351,8 @@ describe("WorktreeManager.createWorktree advanced", () => { const worktreeHead = (await simpleGit(result.path).revparse(["HEAD"])).trim() expect(worktreeHead).toBe(remoteHead) - expect(result.parentBranch).toBe("topic") + expect(result.parentBranch).toBe("main") + expect(result.remote).toBe("origin") }) it("does not track a deleted PR source branch when using the pull ref fallback", async () => { @@ -1295,6 +1387,63 @@ describe("WorktreeManager.createWorktree advanced", () => { expect(worktreeHead).toBe(head) expect(upstream.trim()).toBe("") + expect(result.parentBranch).toBe("main") + expect(result.remote).toBe("origin") + }) + + it("preserves a non-default PR target branch for comparison", async () => { + const { clone } = await createTempRepoWithOrigin() + const git = simpleGit(clone) + await git.checkoutLocalBranch("develop") + await fs.writeFile(path.join(clone, "develop.txt"), "develop") + await git.add(".") + await git.commit("develop commit") + await git.push("origin", "develop") + await git.checkout("main") + await git.checkoutLocalBranch("topic") + await fs.writeFile(path.join(clone, "topic.txt"), "topic") + await git.add(".") + await git.commit("topic commit") + await git.push("origin", "topic") + await git.checkout("main") + + const manager = createManager(clone) + const internal = manager as unknown as { + fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise + } + internal.fetchPRInfo = async () => ({ + headRefName: "topic", + baseRefName: "develop", + isCrossRepository: false, + title: "Topic PR", + }) + + const result = await manager.createFromPR("https://github.com/org/repo/pull/1") + const target = (await git.revparse(["refs/remotes/origin/develop"])).trim() + const head = (await simpleGit(result.path).revparse(["HEAD"])).trim() + + expect(result.parentBranch).toBe("develop") + expect(result.remote).toBe("origin") + expect(head).not.toBe(target) + }) + + it("fails before creating a worktree for an unavailable PR target", async () => { + const { clone } = await createTempRepoWithOrigin() + const manager = createManager(clone) + const internal = manager as unknown as { + fetchPRInfo: (parsed: { owner: string; repo: string; number: number }) => Promise + } + internal.fetchPRInfo = async () => ({ + headRefName: "topic", + baseRefName: "missing", + isCrossRepository: false, + title: "Topic PR", + }) + + await expect(manager.createFromPR("https://github.com/org/repo/pull/1")).rejects.toThrow( + 'Could not resolve start point for branch "missing"', + ) + expect(existsSync(path.join(clone, ".kilo", "worktrees"))).toBe(false) }) }) diff --git a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts index 854b6b420d..540c63b653 100644 --- a/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts +++ b/packages/kilo-vscode/tests/unit/worktree-state-manager.test.ts @@ -199,6 +199,27 @@ describe("WorktreeStateManager", () => { manager.removeSession("s1") expect(manager.getSession("s1")).toBeUndefined() }) + + it("persists stopped worktree sessions across reloads", async () => { + const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" }) + manager.closeSession("ses-stopped", wt.id) + await manager.flush() + + const restored = new WorktreeStateManager(root, () => undefined) + await restored.load() + + expect(restored.isSessionClosed("ses-stopped")).toBe(true) + restored.addSession("ses-stopped", wt.id) + expect(restored.isSessionClosed("ses-stopped")).toBe(false) + await restored.flush() + }) + + it("removes stopped-session records when their worktree is deleted", () => { + const wt = manager.addWorktree({ branch: "fix", path: "/tmp/fix", parentBranch: "main" }) + manager.closeSession("ses-stopped", wt.id) + manager.removeWorktree(wt.id) + expect(manager.isSessionClosed("ses-stopped")).toBe(false) + }) }) describe("directoryFor", () => { @@ -375,6 +396,24 @@ describe("WorktreeStateManager", () => { expect(worktree?.remote).toBe("origin") expect(manager.getSession("sess-recovered")?.worktreeId).toBe(worktree?.id) }) + + it("does not recover a session that was explicitly stopped", () => { + const wt = manager.addWorktree({ branch: "fix-recovered", path: "/tmp/recovered", parentBranch: "main" }) + manager.closeSession("sess-stopped", wt.id) + const result = restoreWorktrees(manager, [ + { + branch: "fix-recovered", + path: "/tmp/recovered", + parentBranch: "main", + createdAt: Date.UTC(2026, 0, 1), + sessionId: "sess-stopped", + }, + ]) + + expect(result).toEqual({ worktrees: 0, sessions: 0 }) + expect(manager.getSession("sess-stopped")).toBeUndefined() + expect(manager.isSessionClosed("sess-stopped")).toBe(true) + }) }) describe("tab order", () => { diff --git a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx index 5fa343c217..5e9287906b 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx +++ b/packages/kilo-vscode/webview-ui/agent-manager/AgentManagerApp.tsx @@ -91,7 +91,7 @@ import { createProjectRegistry, type PersistedProjectTabs } from "./project/regi import type { WorktreeBusyState } from "./project/store" import { rememberTarget, restoreProjectTarget } from "./project/restore" import { createProjectStateRouter } from "./project/state" -import { createSessionBusy } from "./project/session-busy" +import { createWorktreeBusy } from "./project/session-busy" import { switchProject } from "./project/switch" import { createProjectStateHandlers } from "./project/state-handlers" import { ownsParent as ownsParentSession, isCurrent } from "./project/message-ownership" @@ -900,7 +900,7 @@ const AgentManagerContent: Component = () => { const isStaleWorktree = (worktreeId: string): boolean => staleWorktreeIds().has(worktreeId) - const busy = createSessionBusy({ + const busy = createWorktreeBusy({ statuses: session.allStatusMap, permissions: session.permissions, questions: session.questions, @@ -908,6 +908,8 @@ const AgentManagerContent: Component = () => { local: localSessionIDs, projects: projectSessionsLive, active: activeProjectId, + worktrees: (id) => (id ? registry.ensure(id) : registry.active()).worktrees(), + subscribe: vscode.onMessage, }) const isAgentBusy = busy.agent const isLocalBusy = busy.local @@ -2564,6 +2566,7 @@ const AgentManagerContent: Component = () => { onForkSession={readOnly() ? undefined : handleForkSession} readonly={readOnly()} continueInWorktree={selection() === LOCAL} + worktree={worktrees().some((wt) => wt.id === selection())} promptBoxId={`agent-manager:${selection() ?? "unassigned"}`} terminalContext={() => selection() ?? undefined} deferFocusToQuestion={hasQuestionOption} diff --git a/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts b/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts index b310d23dde..4f45a751a5 100644 --- a/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts +++ b/packages/kilo-vscode/webview-ui/agent-manager/project/session-busy.ts @@ -1,3 +1,6 @@ +import { createSignal, onCleanup } from "solid-js" +import type { ExtensionMessage } from "../../src/types/messages" + interface Item { id: string worktreeId?: string | null @@ -9,6 +12,7 @@ interface Status { interface Prompt { sessionID: string + blocking?: boolean } export function createSessionBusy(opts: { @@ -23,11 +27,19 @@ export function createSessionBusy(opts: { const any = (ids: string[], waiting = false) => { if (ids.length === 0) return false const statuses = opts.statuses() - const blocked = new Set([...opts.permissions(), ...opts.questions()].map((item) => item.sessionID)) + const blocked = new Set( + [...opts.permissions(), ...opts.questions().filter((item) => item.blocking !== false)].map( + (item) => item.sessionID, + ), + ) return ids.some((id) => { const status = statuses[id] - if (waiting && blocked.has(id)) return true - return !!status && status.type !== "idle" && (waiting || !blocked.has(id)) + if (waiting) + return ( + (!!status && status.type !== "idle") || + [...opts.permissions(), ...opts.questions()].some((prompt) => prompt.sessionID === id) + ) + return (status?.type === "busy" || status?.type === "retry") && !blocked.has(id) }) } const agent = (id: string, waiting = false) => @@ -48,3 +60,26 @@ export function createSessionBusy(opts: { } return { any, agent, local, project, session: (id: string) => any([id]) } } + +export function createWorktreeBusy( + opts: Parameters[0] & { + worktrees: (project?: string) => { id: string; path: string }[] + subscribe: (callback: (message: ExtensionMessage) => void) => () => void + }, +) { + const busy = createSessionBusy(opts) + const [active, setActive] = createSignal(new Set()) + onCleanup( + opts.subscribe((message) => { + if (message.type === "agentManager.worktreeActivity") setActive(new Set(message.active)) + }), + ) + const working = (id: string, project?: string) => + active().has(opts.worktrees(project).find((worktree) => worktree.id === id)?.path ?? "") + return { + ...busy, + agent: (id: string, waiting = false) => busy.agent(id, waiting) || working(id), + project: (project: string, id: string | null, waiting = false) => + busy.project(project, id, waiting) || (id !== null && working(id, project)), + } +} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx index 09b917c646..ff1619d608 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/ChatView.tsx @@ -37,6 +37,7 @@ interface ChatViewProps { readonly?: boolean /** When true, show the "Continue in Worktree" button. Defaults to true in the sidebar. */ continueInWorktree?: boolean + worktree?: boolean promptBoxId?: string terminalContext?: () => string | undefined deferFocusToQuestion?: () => boolean @@ -387,6 +388,7 @@ export const ChatView: Component = (props) => { blocked={blocked} suggesting={suggesting} questioning={questioning} + worktree={props.worktree} boxId={props.promptBoxId} terminalContext={props.terminalContext} deferFocusToQuestion={props.deferFocusToQuestion} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx index 820f632fba..0e6a779897 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/MessageList.tsx @@ -26,6 +26,7 @@ import { Spinner } from "@kilocode/kilo-ui/spinner" import { createAutoScroll } from "@kilocode/kilo-ui/hooks" import { useSession } from "../../context/session" import { useServer } from "../../context/server" +import { useVSCode } from "../../context/vscode" import { useLanguage } from "../../context/language" import { useI18n } from "@kilocode/kilo-ui/context/i18n" import { useProvider } from "../../context/provider" @@ -103,6 +104,7 @@ interface MessageListProps { export const MessageList: Component = (props) => { const session = useSession() const server = useServer() + const vscode = useVSCode() const language = useLanguage() const provider = useProvider() const i18n = useI18n() @@ -1374,6 +1376,8 @@ export const MessageList: Component = (props) => { railActiveKey()} diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx index cb667f28ec..3375151914 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptInput.tsx @@ -117,6 +117,7 @@ interface PromptInputProps { questioning?: () => boolean /** When true, defer prompt focus while switching to a pending question */ deferFocusToQuestion?: () => boolean + worktree?: boolean boxId?: string terminalContext?: () => string | undefined pendingSessionID?: string @@ -310,6 +311,7 @@ export const PromptInput: Component = (props) => { const hidden = new Set() if (session.variantList(sid()).length === 0) hidden.add("variant") if (!sandboxVisible()) hidden.add("sandbox") + if (props.worktree !== true) hidden.add("review worktree") return hidden }, ) diff --git a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx index 0e487aa850..1ca5caace0 100644 --- a/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx +++ b/packages/kilo-vscode/webview-ui/src/components/chat/PromptRail.tsx @@ -1,22 +1,16 @@ /** @jsxImportSource solid-js */ -/** - * PromptRail component - * Thin vertical summary rail on the left edge of the transcript. Hovering or - * focusing opens a bounded navigator for every loaded prompt; clicking jumps - * the virtualized transcript without mounting the intervening rows. - */ - import { IconButton } from "@kilocode/kilo-ui/icon-button" import { Spinner } from "@kilocode/kilo-ui/spinner" import { Tooltip } from "@kilocode/kilo-ui/tooltip" -import { For, Show, createEffect, createMemo, createSignal, onCleanup, type Accessor } from "solid-js" +import { For, Show, createEffect, createMemo, createSignal, on, onCleanup, type Accessor } from "solid-js" import { Portal } from "solid-js/web" import { VList, type VListHandle } from "virtua/solid" import { useLanguage } from "../../context/language" import { RAIL_INSET, ROW_HEIGHT, TICK_MIN, TICK_STEP, type PromptRailEntry, type PromptRailItem } from "./prompt-rail" interface PromptRailProps { + side: "left" | "right" entries: Accessor items: Accessor /** Row key of the item whose turn is currently at the top of the transcript. */ @@ -35,6 +29,7 @@ interface PromptRailProps { seeking: Accessor } +const OPEN_DELAY = 350 const CLOSE_DELAY = 120 const EDGE = 12 const GAP = 8 @@ -47,11 +42,12 @@ export function PromptRail(props: PromptRailProps) { const [open, setOpen] = createSignal(false) const [hover, setHover] = createSignal() const [focused, setFocused] = createSignal() - const [anchor, setAnchor] = createSignal<{ top: number; left: number; height: number }>() + const [anchor, setAnchor] = createSignal<{ top: number; edge: number; height: number }>() let rail: HTMLElement | undefined let card: HTMLDivElement | undefined let list: VListHandle | undefined let timer: ReturnType | undefined + let pending: ReturnType | undefined let frame: number | undefined let revealing = false @@ -98,11 +94,16 @@ export function PromptRail(props: PromptRailProps) { const center = rect.top + rect.height / 2 - height / 2 setAnchor({ top: max < min ? min : Math.min(Math.max(center, min), max), - left: rect.right + GAP, + edge: (props.side === "right" ? window.innerWidth - rect.left : rect.right) + GAP, height: limit, }) } + const cancelOpen = () => { + if (pending !== undefined) clearTimeout(pending) + pending = undefined + } + const cancelClose = () => { if (timer !== undefined) clearTimeout(timer) timer = undefined @@ -135,9 +136,11 @@ export function PromptRail(props: PromptRailProps) { const dragging = (event: MouseEvent) => event.buttons !== 0 const openCard = (index: number) => { + cancelOpen() cancelClose() const entry = entries()[index] - const item = entry && entryItem(entry) + if (!entry || entries().length < 2) return + const item = entryItem(entry) setFocused(index) setHover(item?.key) place() @@ -145,19 +148,62 @@ export function PromptRail(props: PromptRailProps) { if (item) reveal(items().findIndex((candidate) => candidate.key === item.key)) } - const closeCard = () => { + const preview = (index: number, event: MouseEvent) => { + cancelOpen() + if (dragging(event)) return cancelClose() + if (open()) return openCard(index) + const entry = entries()[index] + if (!entry) return + const key = entryItem(entry)?.key + pending = setTimeout(() => { + pending = undefined + const entry = entries()[index] + if (!entry || entryItem(entry)?.key !== key) return + openCard(index) + }, OPEN_DELAY) + } + + const dismiss = () => { + cancelOpen() + cancelClose() + setOpen(false) + setHover(undefined) + } + + const closeCard = () => { + cancelOpen() + cancelClose() + if (!open()) return timer = setTimeout(() => { + timer = undefined setOpen(false) setHover(undefined) }, CLOSE_DELAY) } - onCleanup(cancelClose) + const escape = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented) return + cancelOpen() + if (!open()) return + event.preventDefault() + event.stopPropagation() + dismiss() + } + + window.addEventListener("keydown", escape, true) onCleanup(() => { + cancelOpen() + cancelClose() + window.removeEventListener("keydown", escape, true) if (frame !== undefined) cancelAnimationFrame(frame) }) + createEffect(on(() => props.side, cancelOpen, { defer: true })) + createEffect(() => { + if (entries().length < 2) dismiss() + }) + // Resizing the panel moves the rail out from under an open card. createEffect(() => { if (!open()) return @@ -168,11 +214,14 @@ export function PromptRail(props: PromptRailProps) { // Re-place once the card is measurable, so rows that wrap differently than // the estimate still end up centered on the ticks. - createEffect(() => { - if (!open() || !card) return - const frame = requestAnimationFrame(() => place()) - onCleanup(() => cancelAnimationFrame(frame)) - }) + createEffect( + on([open, () => props.side], () => { + if (!open() || !card) return + place() + const frame = requestAnimationFrame(() => place()) + onCleanup(() => cancelAnimationFrame(frame)) + }), + ) let seeking = false createEffect(() => { @@ -190,9 +239,7 @@ export function PromptRail(props: PromptRailProps) { const current = focused() ?? 0 if (event.key === "Escape") { event.preventDefault() - cancelClose() - setOpen(false) - setHover(undefined) + dismiss() return } if (event.key === "Enter" || event.key === " ") { @@ -279,6 +326,7 @@ export function PromptRail(props: PromptRailProps) {