Merge branch 'feat/execute-cmds-in-skill-context' into feat/execute-cmds-in-skill-context-vscode

This commit is contained in:
Bruno Agatao
2026-07-29 17:54:35 +02:00
223 changed files with 6850 additions and 1536 deletions
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Support adaptive thinking levels for Claude Opus and Sonnet 5 and later.
@@ -1,5 +0,0 @@
---
"kilo-code": minor
---
Let users open Agent Manager terminals in the VS Code terminal or an embedded side panel. The terminal button's dropdown picks the destination; the side panel shares the right-hand inspector with the diff view and keeps running in the background when hidden.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": patch
---
Keep the Agent Manager terminal cursor visible on the bottom row.
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Include basic-auth credentials in the Local and Network Console URLs printed by `kilo console`, so users on headless hosts (no `DISPLAY`/`WAYLAND_DISPLAY`, SSH sessions, CI runners) can open the URL in a browser on another machine and reach the Console.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Keep Nix builds on the Bun version required by the repository.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Fix settings changes sometimes failing to save and apply in VS Code.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/cli": patch
---
Keep RC installations up to date when a newer stable CLI release is published.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Preserve parenthesized tilde expressions as literal text in rendered chat messages.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Fix session transcripts losing their final messages when the CLI exits — pending uploads are now flushed on shutdown and as soon as a session closes.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Show prompt input toggle tooltips instantly on hover instead of after a delay.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": minor
---
Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/kilo-jetbrains": patch
---
Allow sending prompts while a session is busy and show queued prompts with a remove action.
-30
View File
@@ -1,30 +0,0 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Changes from opencode v1.17.5 to v1.17.9 upstream:
- Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities.
- Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port.
- Core Bugfixes: ACP shell tool calls now show the command and working directory from the start.
- Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions.
- Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense)
- Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang)
- Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties.
- Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx)
- Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled.
- Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure.
- Core Bugfixes: MCP OAuth error pages now escape provider error text correctly.
- Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run.
- Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first.
- Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes.
- Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China)
- Core Bugfixes: Pass configured custom headers to Copilot model requests.
- Core Improvements: MCP servers can now receive the current workspace as a client root.
- Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps.
- Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte)
- Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective.
- TUI Bugfixes: MCP debug now uses the SDK's latest protocol version.
- TUI Bugfixes: Only show the background subagent shortcut when the server supports it.
- UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown.
+5
View File
@@ -0,0 +1,5 @@
---
"kilo-code": minor
---
Add Persian (Farsi) as a UI language, including right-to-left layout. Contributed by Babak Safabahar.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Fix bash permission rules being bypassed on PowerShell for commands containing a bare `--` such as `git checkout -- <file>`. Commands the shell parser cannot parse now get checked against their raw command text instead of executing without a permission check.
-5
View File
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Emit each agent event once from `kilo run --format json`.
-6
View File
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree.
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Keep message and response copy buttons working after switching focus away from VS Code.
@@ -1,6 +0,0 @@
---
"@kilocode/cli": patch
"kilo-code": patch
---
Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed.
+1 -1
View File
@@ -2,4 +2,4 @@
"@kilocode/cli": minor
---
Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run when the skill loads and their output is inlined into the skill, gated by a single up-front approval that lists every command. Only trusted skills can run commands, and `KILO_DISABLE_SKILL_SHELL` disables the behavior.
Support executing shell commands embedded in skill files. Commands written as `` !`command` `` in a SKILL.md run and their output is inlined into the skill. Only trusted skills can run commands and `KILO_DISABLE_SKILL_SHELL` disables the behavior; when the model loads a skill, the commands are shown in a single up-front approval before running.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Bound the wait for a provider's first response byte by the request timeout. A provider that accepts a request and returns headers but never sends body data now fails and retries instead of leaving the turn hanging after a tool call completes. The same `timeout` value now covers both the connection phase and the wait for the first byte as a single deadline; streaming responses that have already produced data are unaffected.
@@ -1,5 +0,0 @@
---
"@kilocode/cli": patch
---
Stabilize cross-platform CLI subprocess tests under constrained CI runners
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Remove unused translation keys from the Agent Manager, sidebar webview, shared kilo-i18n, and autocomplete dictionaries across all locales, and add a conservative lint test for unreferenced, unprotected dictionary keys.
+5
View File
@@ -0,0 +1,5 @@
---
"@kilocode/kilo-telemetry": patch
---
Include the host operating system name, version, and architecture in telemetry events.
-5
View File
@@ -1,5 +0,0 @@
---
"kilo-code": patch
---
Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints
+2
View File
@@ -73,6 +73,8 @@ jobs:
- name: Run JetBrains unit tests
run: bun script/test-ci.ts
working-directory: packages/kilo-jetbrains
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish JetBrains unit reports
if: always()
+37 -37
View File
@@ -32,7 +32,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "7.4.16",
"version": "7.4.17",
"bin": {
"opencode": "./bin/opencode",
},
@@ -127,7 +127,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -141,7 +141,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"effect": "catalog:",
},
@@ -153,7 +153,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.74",
"@effect/platform-node-shared": "4.0.0-beta.74",
@@ -174,7 +174,7 @@
},
"packages/kilo-console": {
"name": "@kilocode/kilo-console",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@kilocode/kilo-indexing": "workspace:*",
"@kilocode/kilo-web-ui": "workspace:*",
@@ -197,7 +197,7 @@
},
"packages/kilo-docs": {
"name": "@kilocode/kilo-docs",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@docsearch/css": "^4",
"@docsearch/js": "^4",
@@ -227,7 +227,7 @@
},
"packages/kilo-gateway": {
"name": "@kilocode/kilo-gateway",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@ai-sdk/alibaba": "1.0.17",
"@ai-sdk/anthropic": "3.0.71",
@@ -263,7 +263,7 @@
},
"packages/kilo-i18n": {
"name": "@kilocode/kilo-i18n",
"version": "7.4.16",
"version": "7.4.17",
"devDependencies": {
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
@@ -273,7 +273,7 @@
},
"packages/kilo-indexing": {
"name": "@kilocode/kilo-indexing",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@aws-sdk/client-bedrock-runtime": "3.1005.0",
"@aws-sdk/credential-provider-ini": "3.972.31",
@@ -305,11 +305,11 @@
},
"packages/kilo-jetbrains": {
"name": "@kilocode/kilo-jetbrains",
"version": "7.4.15",
"version": "7.4.16",
},
"packages/kilo-memory": {
"name": "@kilocode/kilo-memory",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"effect": "catalog:",
"zod": "catalog:",
@@ -323,7 +323,7 @@
},
"packages/kilo-sandbox": {
"name": "@kilocode/sandbox",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@anthropic-ai/sandbox-runtime": "catalog:",
"effect": "catalog:",
@@ -338,7 +338,7 @@
},
"packages/kilo-telemetry": {
"name": "@kilocode/kilo-telemetry",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@kilocode/kilo-gateway": "workspace:*",
"posthog-node": "4.4.0",
@@ -352,7 +352,7 @@
},
"packages/kilo-ui": {
"name": "@kilocode/kilo-ui",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "0.13.11",
@@ -389,7 +389,7 @@
},
"packages/kilo-vscode": {
"name": "kilo-code",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@anthropic-ai/sdk": "^0.39.0",
"@kilocode/kilo-gateway": "workspace:*",
@@ -458,7 +458,7 @@
},
"packages/kilo-web-ui": {
"name": "@kilocode/kilo-web-ui",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@kilocode/kilo-ui": "workspace:*",
"@kobalte/core": "catalog:",
@@ -475,7 +475,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@smithy/eventstream-codec": "4.2.14",
"@smithy/util-utf8": "4.2.2",
@@ -493,7 +493,7 @@
},
"packages/opencode": {
"name": "@kilocode/cli",
"version": "7.4.16",
"version": "7.4.17",
"bin": {
"kilo": "./bin/kilo",
"kilocode": "./bin/kilo",
@@ -660,7 +660,7 @@
},
"packages/plugin": {
"name": "@kilocode/plugin",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"effect": "catalog:",
@@ -688,7 +688,7 @@
},
"packages/plugin-atomic-chat": {
"name": "@kilocode/plugin-atomic-chat",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@kilocode/plugin": "workspace:*",
},
@@ -702,7 +702,7 @@
},
"packages/script": {
"name": "@opencode-ai/script",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"semver": "^7.6.3",
},
@@ -713,7 +713,7 @@
},
"packages/sdk/js": {
"name": "@kilocode/sdk",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -728,7 +728,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"drizzle-orm": "catalog:",
@@ -742,7 +742,7 @@
},
"packages/storybook": {
"name": "@opencode-ai/storybook",
"version": "7.4.16",
"version": "7.4.17",
"devDependencies": {
"@opencode-ai/ui": "workspace:*",
"@solidjs/meta": "catalog:",
@@ -765,7 +765,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@kilocode/plugin": "workspace:*",
"@kilocode/sdk": "workspace:*",
@@ -792,7 +792,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {
"@kilocode/sdk": "workspace:*",
"@kobalte/core": "catalog:",
@@ -845,22 +845,22 @@
},
},
"trustedDependencies": [
"web-tree-sitter",
"esbuild",
"tree-sitter-bash",
"protobufjs",
"web-tree-sitter",
"tree-sitter-bash",
],
"patchedDependencies": {
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch",
"@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch",
"@ff-labs/fff-bun@0.9.4": "patches/@ff-labs%2Ffff-bun@0.9.4.patch",
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch",
"@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch",
"@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch",
"virtua@0.49.1": "patches/virtua@0.49.1.patch",
"@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch",
},
"overrides": {
"@effect/platform-node-shared": "4.0.0-beta.74",
+1 -1
View File
@@ -171,6 +171,6 @@
"pacote@21.5.1": "patches/pacote@21.5.1.patch",
"mammoth@1.12.0": "patches/mammoth@1.12.0.patch"
},
"version": "7.4.16",
"version": "7.4.17",
"peerDependencies": {}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.16",
"version": "7.4.17",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
+2
View File
@@ -45,6 +45,8 @@ export type Reply = typeof Reply.Type
export const ReplyBody = Schema.Struct({
reply: Reply,
message: Schema.String.pipe(Schema.optional),
// kilocode_change - set by clients when a human answered the prompt; the server refuses machine approvals of skill-shell batches
interactive: Schema.Boolean.pipe(Schema.optional),
}).annotate({ identifier: "PermissionReplyBody" })
export type ReplyBody = typeof ReplyBody.Type
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.16",
"version": "7.4.17",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.16",
"version": "7.4.17",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+6 -6
View File
@@ -1,7 +1,7 @@
id = "kilo"
name = "Kilo"
description = "The open source coding agent."
version = "7.4.16"
version = "7.4.17"
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.4.16/opencode-darwin-arm64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/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.4.16/opencode-darwin-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-darwin-x64.zip"
cmd = "./opencode"
args = ["acp"]
[agent_servers.opencode.targets.linux-aarch64]
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.16/opencode-linux-arm64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/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.4.16/opencode-linux-x64.tar.gz"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/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.4.16/opencode-windows-x64.zip"
archive = "https://github.com/Kilo-Org/kilocode/releases/download/v7.4.17/opencode-windows-x64.zip"
cmd = "./opencode.exe"
args = ["acp"]
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "7.4.16",
"version": "7.4.17",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-console",
"version": "7.4.16",
"version": "7.4.17",
"private": true,
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-docs",
"version": "7.4.16",
"version": "7.4.17",
"private": true,
"scripts": {
"dev": "next dev --webpack --port 3002",
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812
size 11204
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d
size 11950
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:095ed97dc46498be6fd75483b24357ac0740b6f2c1544f6c103a1dac360e1a7e
size 11202
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-gateway",
"version": "7.4.16",
"version": "7.4.17",
"type": "module",
"license": "MIT",
"description": "Unified Kilo Gateway package for OpenCode - authentication, provider, and API integration",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-i18n",
"version": "7.4.16",
"version": "7.4.17",
"type": "module",
"license": "MIT",
"description": "Kilo-specific i18n translations and overrides",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-indexing",
"version": "7.4.16",
"version": "7.4.17",
"type": "module",
"license": "MIT",
"description": "Standalone indexing engine and host helpers for Kilo Code",
+12
View File
@@ -1,5 +1,15 @@
# Changelog
## 7.5.0
### Minor Changes
- [#12518](https://github.com/Kilo-Org/kilocode/pull/12518) [`452d0eb`](https://github.com/Kilo-Org/kilocode/commit/452d0eb55f740e951cfd906375e22cf97250144c) - Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
### Patch Changes
- [#12571](https://github.com/Kilo-Org/kilocode/pull/12571) [`9950739`](https://github.com/Kilo-Org/kilocode/commit/9950739e36b40a682c0a25173e62f5236e60f81a) - Allow sending prompts while a session is busy and show queued prompts with a remove action.
## 7.4.16
### Patch Changes
@@ -134,7 +144,9 @@
### Changed
- Update the JetBrains CLI pin from Kilo Core 7.4.15 to 7.4.16.
## [7.0.10] - 2026-07-24
## [7.0.10] - 2026-07-24
### Added
@@ -1579,6 +1579,7 @@ object KiloCliDataParser {
sb.append("""{"reply":${escape(reply.reply)}""")
val msg = reply.message
if (msg != null) sb.append(""","message":${escape(msg)}""")
if (reply.interactive) sb.append(""","interactive":true""")
sb.append("}")
return sb.toString()
}
@@ -722,7 +722,10 @@ class SessionController(
LOG.debug { "${ChatLogSummary.sid(sid ?: ref?.key ?: "pending")} kind=permission-auto rid=$id" }
cs.launch {
try {
if (!autoApprove) {
// Skill-shell batches must be answered by a human: the server refuses
// non-interactive approvals, so auto-approve must show the card (whose
// manual reply sets interactive=true) rather than send a machine reply.
if (!autoApprove || restore().meta.raw["skillShell"] == "true") {
edt {
if (disposed) return@edt
model.setState(SessionState.AwaitingPermission(restore()))
@@ -759,9 +762,16 @@ class SessionController(
try {
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID in ids && it.id !in skip }
val count = replyAll(permissions)
if (count == 0) return@launch
// Skill-shell requests are skipped by replyAll; surface one as a card so it
// isn't stranded (never machine-approved, never shown).
val card = skillShellCard(permissions)?.let { toPermission(it) }
if (count == 0 && card == null) return@launch
runEdt {
if (disposed) return@runEdt
if (card != null) {
updateModel { model.setState(SessionState.AwaitingPermission(card)) }
return@runEdt
}
val current = model.state
if (current is SessionState.AwaitingPermission && current.permission.sessionId in ids) {
model.setState(SessionState.Busy(KiloBundle.message("session.status.considering")))
@@ -777,6 +787,8 @@ class SessionController(
var count = 0
for (request in permissions) {
if (!autoApprove) return count
// Skill-shell batches need a human; skip them here (callers surface the card).
if (request.metadata["skillShell"] == "true") continue
sessions.replyPermission(request.id, directory, PermissionReplyDto("once"))
capture("Permission Auto Approved", sessionProps(request.sessionID) + mapOf("tool" to request.permission, "source" to "drain"))
count++
@@ -784,6 +796,11 @@ class SessionController(
return count
}
// A skill-shell request is never machine-approved (the server refuses non-interactive
// approvals); after draining, callers must surface one as a card so a human can answer.
private fun skillShellCard(permissions: List<PermissionRequestDto>): PermissionRequestDto? =
permissions.lastOrNull { it.metadata["skillShell"] == "true" }
private fun updatePermission(id: String, state: PermissionRequestState, message: String? = null) {
assertEdt()
val current = model.state
@@ -1156,11 +1173,15 @@ class SessionController(
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == child }
if (permissions.isEmpty()) return
LOG.debug { "${ChatLogSummary.sid(sid ?: "pending")} kind=child-recovery child=$child permissions=${permissions.size}" }
if (autoApprove) {
// A skill-shell request must surface as a card even under auto-approve (replyAll
// skips it); prefer it over the last pending so a human can answer.
val show = if (autoApprove) {
replyAll(permissions)
return
skillShellCard(permissions) ?: return
} else {
skillShellCard(permissions) ?: permissions.last()
}
val last = toPermission(permissions.last())
val last = toPermission(show)
runEdt {
if (disposed) return@runEdt
if (child !in childIds) return@runEdt
@@ -1201,9 +1222,12 @@ class SessionController(
val permissions = sessions.pendingPermissions(directory).filter { it.sessionID == id }
val questions = sessions.pendingQuestions(directory).filter { it.sessionID == id }
val status = sessions.statuses.value[id]
// replyAll auto-approves the ordinary permissions and skips skill-shell ones. A
// skill-shell request must then fall through to a human card rather than go Busy.
val skillCard = skillShellCard(permissions)
if (permissions.isNotEmpty() && autoApprove) {
val count = replyAll(permissions)
if (count > 0) {
if (count > 0 && skillCard == null) {
runEdt {
if (disposed) return@runEdt
if (sid != id) return@runEdt
@@ -1226,7 +1250,8 @@ class SessionController(
if (sid != id) return@runEdt
updateModel {
if (permissions.isNotEmpty()) {
model.setState(SessionState.AwaitingPermission(toPermission(permissions.last())))
// Prefer a skill-shell request (needs a human) over the last pending.
model.setState(SessionState.AwaitingPermission(toPermission(skillCard ?: permissions.last())))
} else if (questions.isNotEmpty()) {
model.setState(SessionState.AwaitingQuestion(toQuestion(questions.last())))
} else if (status != null) {
@@ -356,7 +356,7 @@ class PermissionView(
card.setActionEnabled(ID_RUN, false)
card.setActionEnabled(ID_DENY, false)
rules.setControlsEnabled(false)
reply(id, PermissionReplyDto(reply = "once"), rulePayload())
reply(id, PermissionReplyDto(reply = "once", interactive = true), rulePayload())
}
@RequiresEdt
@@ -254,6 +254,23 @@ class PromptLifecycleTest : SessionControllerTestBase() {
)
}
fun `test auto approve does not machine-reply a skill shell batch`() {
val (m, _, _) = prompted()
edt { m.setAutoApprove(true) }
// skill-shell batches must be answered by a human; auto-approve must show the card
// instead of sending a non-interactive reply the server would refuse.
emit(
ChatEventDto.PermissionAsked(
"ses_test",
permission("perm1").copy(metadata = mapOf("skillShell" to "true")),
),
)
assertTrue(rpc.permissionReplies.isEmpty())
assertTrue(m.model.state is SessionState.AwaitingPermission)
}
fun `test disabling auto approve before reply restores awaiting permission`() {
val (m, _, _) = prompted()
@@ -310,6 +327,31 @@ class PromptLifecycleTest : SessionControllerTestBase() {
assertEquals("once", rpc.permissionReplies[0].third.reply)
}
fun `test enabling auto approve surfaces a pending skill shell as a card`() {
val (m, _, _) = prompted()
rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true")))
edt { m.setAutoApprove(true) }
flush()
// skill-shell must not be machine-approved; it surfaces as a human card instead
assertTrue(rpc.permissionReplies.isEmpty())
assertTrue(m.model.state is SessionState.AwaitingPermission)
}
fun `test recovery surfaces a pending skill shell as a card under auto approve`() {
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
rpc.pendingPermissionList.add(permission("perm_skill").copy(metadata = mapOf("skillShell" to "true")))
edt { KiloPluginSettings.setAutoApprove(true) }
val m = controller("ses_test")
flush()
assertTrue(rpc.permissionReplies.isEmpty())
assertTrue(m.model.state is SessionState.AwaitingPermission)
}
fun `test auto approve drains pending permissions during recovery`() {
appRpc.state.value = ai.kilocode.rpc.dto.KiloAppStateDto(ai.kilocode.rpc.dto.KiloAppStatusDto.READY, config = ai.kilocode.rpc.dto.ConfigDto(model = "kilo/gpt-5"))
projectRpc.state.value = workspaceReady()
@@ -328,6 +328,8 @@ data class ToolRefDto(
data class PermissionReplyDto(
val reply: String,
val message: String? = null,
// Set when a human answered the prompt; the CLI ignores machine approvals of skill-shell batches.
val interactive: Boolean = false,
)
@Serializable
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-memory",
"version": "7.4.16",
"version": "7.4.17",
"type": "module",
"license": "MIT",
"description": "Project memory storage, indexing, recall, and command helpers for Kilo Code",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/sandbox",
"version": "7.4.16",
"version": "7.4.17",
"type": "module",
"license": "MIT",
"private": true,
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@kilocode/kilo-telemetry",
"version": "7.4.16",
"version": "7.4.17",
"type": "module",
"license": "MIT",
"description": "Telemetry for Kilo CLI - PostHog analytics integration",
@@ -1,4 +1,6 @@
import { describe, test, expect, beforeEach } from "bun:test"
import { arch, platform, release } from "node:os"
import { describe, test, expect, beforeEach, spyOn } from "bun:test"
import { Client } from "../client.js"
import { Identity } from "../identity.js"
import { TelemetryEvent } from "../events.js"
import { Telemetry } from "../telemetry.js"
@@ -83,6 +85,22 @@ describe("TelemetryEvent", () => {
})
describe("Telemetry", () => {
test("includes host OS properties", () => {
const capture = spyOn(Client, "capture").mockImplementation(() => {})
Telemetry.track(TelemetryEvent.CLI_START)
expect(capture).toHaveBeenCalledWith(
TelemetryEvent.CLI_START,
expect.objectContaining({
os_name: platform(),
os_version: release(),
os_arch: arch(),
}),
)
capture.mockRestore()
})
test("indexing helpers are exposed", () => {
expect(typeof Telemetry.trackIndexingStarted).toBe("function")
expect(typeof Telemetry.trackIndexingCompleted).toBe("function")
@@ -96,4 +114,3 @@ describe("Telemetry", () => {
expect(typeof Telemetry.trackSuggestionAccepted).toBe("function")
})
})
+10
View File
@@ -1,3 +1,4 @@
import { release } from "node:os"
import { Client } from "./client.js"
import { Identity } from "./identity.js"
import { TelemetryEvent } from "./events.js"
@@ -6,6 +7,9 @@ export interface TelemetryProperties {
appName: string
appVersion: string
platform: string
os_name: string
os_version: string
os_arch: string
editorName?: string
vscodeVersion?: string
}
@@ -58,6 +62,9 @@ export namespace Telemetry {
appName: "kilo-cli",
appVersion: "unknown",
platform: process.platform,
os_name: process.platform,
os_version: release(),
os_arch: process.arch,
}
export async function init(options: { dataPath: string; version: string; enabled: boolean }): Promise<void> {
@@ -109,6 +116,9 @@ export namespace Telemetry {
appName: props.appName,
appVersion: props.appVersion,
platform: props.platform,
os_name: props.os_name,
os_version: props.os_version,
os_arch: props.os_arch,
})
// Link the anonymous machineId to the authenticated email
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kilocode/kilo-ui",
"version": "7.4.16",
"version": "7.4.17",
"type": "module",
"license": "MIT",
"exports": {
+73
View File
@@ -1,5 +1,78 @@
# kilo-code
## 7.4.17
### Minor Changes
- [#12631](https://github.com/Kilo-Org/kilocode/pull/12631) [`3321216`](https://github.com/Kilo-Org/kilocode/commit/3321216c0157e1a8a1829b0c8e0a1cae8d2f2ad2) - Show the ⌘1-9 (Ctrl+1-9 on Windows/Linux) shortcut badges on every Agent Manager sidebar card while the modifier key is held, making it easy to see which number jumps to which worktree before pressing it.
- [#12598](https://github.com/Kilo-Org/kilocode/pull/12598) [`c6711fc`](https://github.com/Kilo-Org/kilocode/commit/c6711fcf6cea9fdbe78a04b95276d09a2faabfa7) - Let users open Agent Manager terminals in the VS Code terminal or an embedded side panel. The terminal button's dropdown picks the destination; the side panel shares the right-hand inspector with the diff view and keeps running in the background when hidden.
- [#12633](https://github.com/Kilo-Org/kilocode/pull/12633) [`23039c0`](https://github.com/Kilo-Org/kilocode/commit/23039c0fb1e5b32704119ddde10a6a28ccd6bff3) - Support multiple Agent Manager side-panel terminals per context. The panel header is now a tab strip that reuses the main tab bar's terminal tabs: click to switch, drag to reorder, X to close a single terminal, and + to open another one. Terminal numbers fill gaps left by closed terminals, and tabs pick up the live title from the shell or running program (OSC escape codes), so a dev server or build names its own tab.
- [#12632](https://github.com/Kilo-Org/kilocode/pull/12632) [`0d853df`](https://github.com/Kilo-Org/kilocode/commit/0d853df3ec338ac99e025939f74136dec6d9daa1) - Add a prompt navigator rail to the chat transcript. A thin rail of ticks on the left edge shows one tick per prompt you sent; hovering or focusing it expands a card listing those prompts with a short preview of the answer, and clicking jumps the transcript to that turn. It appears in the sidebar, Kilo editor tabs, the sub-agent viewer, and Agent Manager, and never changes the readable width of the chat.
### Patch Changes
- [#12629](https://github.com/Kilo-Org/kilocode/pull/12629) [`0a1c140`](https://github.com/Kilo-Org/kilocode/commit/0a1c14073a4bf14f8ad4e3c8295dc6ae6bfbfdaf) - Keep each Agent Manager panel's terminal destination consistent. A dropdown pick is now remembered per panel and no longer flips when another window rewrites the shared terminal destination setting, so the terminal shortcut keeps opening the terminal type that panel is actually using. The shortcut also no longer dead-ends on worktrees without an active session, and terminals left over from a reloaded webview are cleaned up instead of leaking.
- [#12587](https://github.com/Kilo-Org/kilocode/pull/12587) [`16f8e7e`](https://github.com/Kilo-Org/kilocode/commit/16f8e7ef7fbd47755395539e7df54af3baae0c63) - Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored.
- [#12333](https://github.com/Kilo-Org/kilocode/pull/12333) [`290a5af`](https://github.com/Kilo-Org/kilocode/commit/290a5af56e6ddccd8b4a459883625e30f2ae0344) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Include basic-auth credentials in the Local and Network Console URLs printed by `kilo console`, so users on headless hosts (no `DISPLAY`/`WAYLAND_DISPLAY`, SSH sessions, CI runners) can open the URL in a browser on another machine and reach the Console.
- [#12630](https://github.com/Kilo-Org/kilocode/pull/12630) [`a7f972f`](https://github.com/Kilo-Org/kilocode/commit/a7f972f63bc948d70b73a36a5beab6d694316037) - Open Agent Manager terminals faster and avoid delayed shell prompts.
- [#12560](https://github.com/Kilo-Org/kilocode/pull/12560) [`65c5e9d`](https://github.com/Kilo-Org/kilocode/commit/65c5e9d2c03cea152b140710228075edf9156def) - Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs.
- [#12561](https://github.com/Kilo-Org/kilocode/pull/12561) [`44f5963`](https://github.com/Kilo-Org/kilocode/commit/44f596366931d5336f1cd4dfdd97ef54e0f2fa4c) - Fix settings changes sometimes failing to save and apply in VS Code.
- [#12540](https://github.com/Kilo-Org/kilocode/pull/12540) [`2da8949`](https://github.com/Kilo-Org/kilocode/commit/2da89498138e49f857c354924fdecac85337e742) Thanks [@Githubguy132010](https://github.com/Githubguy132010)! - Preserve parenthesized tilde expressions as literal text in rendered chat messages.
- [#12591](https://github.com/Kilo-Org/kilocode/pull/12591) [`625d2b9`](https://github.com/Kilo-Org/kilocode/commit/625d2b974de1381b4d475808c9215becb263d1f0) - Show prompt input toggle tooltips instantly on hover instead of after a delay.
- [#12460](https://github.com/Kilo-Org/kilocode/pull/12460) [`51d8031`](https://github.com/Kilo-Org/kilocode/commit/51d8031c9997bd5478bcde715562169f732d04d4) - Changes from opencode v1.17.5 to v1.17.9 upstream:
- Core Bugfixes: Improved MCP server compatibility by declaring Kilo's supported client capabilities.
- Core Bugfixes: Plugin client requests now reuse the active server instead of assuming the default local port.
- Core Bugfixes: ACP shell tool calls now show the command and working directory from the start.
- Core Bugfixes: Plugin-provided shell environment variables now apply to PTY sessions.
- Core Bugfixes: OpenAI-compatible providers now accept MCP tool schemas that previously failed validation. (@jquense)
- Core Bugfixes: Cloudflare AI Gateway now receives the configured API key correctly. (@keefetang)
- Core Bugfixes: MCP tools without declared schema properties now work with providers that expect object properties.
- Core Bugfixes: Long-running MCP tools now keep their timeout alive when they report progress. (@Nomadcxx)
- Core Bugfixes: The MCP OAuth callback server now shuts down once authorization finishes or is cancelled.
- Core Bugfixes: MCP tool failures now surface the server's error text instead of a generic failure.
- Core Bugfixes: MCP OAuth error pages now escape provider error text correctly.
- Core Bugfixes: Honor configured agent step limits by forcing a final text response instead of failing mid-run.
- Core Bugfixes: Queue steering prompts before dismissing pending questions so the previous turn cannot resume first.
- Core Bugfixes: Prevent local server credentials from leaking into spawned PTY processes.
- Core Bugfixes: Fix Devstral model detection when provider IDs use different casing. (@Robin1987China)
- Core Bugfixes: Pass configured custom headers to Copilot model requests.
- Core Improvements: MCP servers can now receive the current workspace as a client root.
- Core Improvements: Session timelines load much faster and avoid flicker or scroll jumps.
- Core Improvements: Add `high` and `max` thinking variants for GLM-5.2 across supported providers. (@imranshaiedi-byte)
- Core Improvements: Stop wrapping follow-up user messages in a steering reminder so prompt caching stays effective.
- TUI Bugfixes: MCP debug now uses the SDK's latest protocol version.
- TUI Bugfixes: Only show the background subagent shortcut when the server supports it.
- UI Bugfixes: Render completed Mermaid blocks from diagram source instead of fenced Markdown.
- [#11984](https://github.com/Kilo-Org/kilocode/pull/11984) [`26dac19`](https://github.com/Kilo-Org/kilocode/commit/26dac197fe28294c391c8d437abf06e18e2d22bd) Thanks [@sylwester-liljegren](https://github.com/sylwester-liljegren)! - Mention `@` file references in the chat input placeholder so users know they can add file mentions. Translated across all supported languages.
- [#12593](https://github.com/Kilo-Org/kilocode/pull/12593) [`160b066`](https://github.com/Kilo-Org/kilocode/commit/160b06661acc5f04b21221ab6578c468325f64c5) - Prevent the VS Code backend from eagerly starting native file watchers for every Agent Manager worktree.
- [#12123](https://github.com/Kilo-Org/kilocode/pull/12123) [`3075d35`](https://github.com/Kilo-Org/kilocode/commit/3075d35f13ba9738446ac28fa2eebf054097f2f5) Thanks [@mjnaderi](https://github.com/mjnaderi)! - Keep message and response copy buttons working after switching focus away from VS Code.
- [#12583](https://github.com/Kilo-Org/kilocode/pull/12583) [`1310c12`](https://github.com/Kilo-Org/kilocode/commit/1310c1200ab613b316f27fe4fd59e23e262df02f) Thanks [@noobezlol](https://github.com/noobezlol)! - Keep Windows snapshot diffs parseable and preserve valid files when a stored patch is malformed.
- [#12410](https://github.com/Kilo-Org/kilocode/pull/12410) [`85d65a3`](https://github.com/Kilo-Org/kilocode/commit/85d65a3137ecadfcbda8255bfb4a40daf1f155fb) - Open Kilo chats, settings, and files as tabs in the selected editor pane without creating, locking, or resizing editor panes.
- [#12639](https://github.com/Kilo-Org/kilocode/pull/12639) [`8a47d8b`](https://github.com/Kilo-Org/kilocode/commit/8a47d8b78885fa8fd14c73b3aecdb57e1fc96c9c) - Stop flashing a "Turn interrupted" warning when a follow-up message is queued while the assistant is still working. The running turn now closes with a dedicated "superseded" reason instead of "interrupted" when it hands off to the queued prompt, so the premature-stop warning only appears for real interruptions.
- [#12602](https://github.com/Kilo-Org/kilocode/pull/12602) [`5d87ca5`](https://github.com/Kilo-Org/kilocode/commit/5d87ca598c4c66328f0e476cb1be3fc9b26d05aa) - Remove unused translation keys from the Agent Manager, sidebar webview, shared kilo-i18n, and autocomplete dictionaries across all locales, and add a conservative lint test for unreferenced, unprotected dictionary keys.
- [#12463](https://github.com/Kilo-Org/kilocode/pull/12463) [`1f3383c`](https://github.com/Kilo-Org/kilocode/commit/1f3383cf3de37327b02e0fc2a1c5ac176ca9134f) Thanks [@IamCoder18](https://github.com/IamCoder18)! - Show the `Ctrl+T` variant cycling shortcut in the TUI prompt hint row whenever the active model exposes reasoning variants, as the first hint before the agent and command palette hints
- [#12634](https://github.com/Kilo-Org/kilocode/pull/12634) [`4c5c242`](https://github.com/Kilo-Org/kilocode/commit/4c5c2428927f26c4c818f23a650cfaf5723b7641) - Show the worktree directory name on the Agent Manager worktree hover card
## 7.4.16
### Minor Changes
+7 -11
View File
@@ -43,17 +43,13 @@ export default [
},
{
files: ["webview-ui/agent-manager/AgentManagerApp.tsx"],
// Raised from 3100 → 3200 for the experimental terminal tabs feature.
// ~600 lines of terminal logic were extracted to ./terminal/* and
// ./tab-rendering.tsx; the remaining ~75 lines are signal bindings,
// a stacking-container wrapper required by the hydration invariant
// (canvases must never leave the paint tree — see render.tsx), and
// render-call wiring that must live at the top of
// `AgentManagerContent` alongside the existing selection/session state.
// Raised from 3200 → 3210 for the per-message feedback `FeedbackProvider`
// wiring, which sits inside the provider chain and cannot be extracted
// without adding an intermediate wrapper component.
rules: { complexity: ["error", 74], "max-lines": ["error", 3210] },
// Complexity stays exempt: the top of `AgentManagerContent` wires many
// selection/session handlers that can't be split without threading shared
// reactive state. Line count needs no override — the apply-to-local
// workflow is extracted to ./apply-to-local.tsx, keeping the file under
// the global 3000-line default. Do not add a max-lines override back;
// extract cohesive domains out of the file instead.
rules: { complexity: ["error", 74] },
},
{
files: ["src/agent-manager/AgentManagerProvider.ts"],
+10 -6
View File
@@ -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.4.16",
"version": "7.4.17",
"icon": "assets/icons/logo-outline-black.png",
"galleryBanner": {
"color": "#FFFFFF",
@@ -868,7 +868,8 @@
"tr",
"nl",
"uk",
"it"
"it",
"fa"
],
"enumDescriptions": [
"Auto (VS Code language)",
@@ -891,7 +892,8 @@
"Türkçe",
"Nederlands",
"Українська",
"Italiano"
"Italiano",
"فارسی"
]
},
"kilo-code.new.languageCommitMessage": {
@@ -919,7 +921,8 @@
"tr",
"nl",
"uk",
"it"
"it",
"fa"
],
"enumDescriptions": [
"跟随界面语言 (Sync with UI language)",
@@ -942,7 +945,8 @@
"Türkçe",
"Nederlands",
"Українська",
"Italiano"
"Italiano",
"فارسی"
]
},
"kilo-code.new.model.providerID": {
@@ -1028,7 +1032,7 @@
"Open or focus the VS Code integrated terminal.",
"Open or focus an embedded terminal in the Agent Manager side panel."
],
"description": "Choose where the Agent Manager terminal button and Focus Terminal keyboard shortcut open a terminal."
"description": "Default destination for the Agent Manager terminal button and Focus Terminal keyboard shortcut. The terminal button's dropdown remembers its own choice per Agent Manager panel; this setting only applies to panels that never picked one."
},
"kilo-code.new.indexing.showButtonWhenDisabled": {
"type": "boolean",
+15 -63
View File
@@ -161,6 +161,7 @@ import { AnacondaDesktopBridge } from "./anaconda-desktop/bridge"
import { fetchOpenAIModels, FetchModelsError } from "./shared/fetch-models"
import type { Agent } from "@kilocode/sdk/v2/client"
import { configFeatures } from "./features"
import { fetchSnapshot } from "./kilo-provider/config-snapshot"
import { createAutoApproveBridge } from "./kilo-provider/auto-approve"
import type { KiloProviderOptions } from "./kilo-provider/options"
import { fetchKiloEmbeddingModelCatalog } from "@kilocode/kilo-gateway"
@@ -2562,24 +2563,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
try {
const workspaceDir = this.getWorkspaceDirectory()
const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([
retry(() => this.client!.config.get({ directory: workspaceDir }, { throwOnError: true })),
this.client.global.config.get({ throwOnError: true }),
this.client.config.overlay({ directory: workspaceDir, scope: "project" }, { throwOnError: true }),
])
this.cachedGlobalConfig = global ?? null
const message = {
type: "configLoaded",
config,
globalConfig: global,
projectConfig: overlay?.project,
settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() },
features: configFeatures(config),
}
this.cachedConfigMessage = message
this.postMessage(message)
await this.refreshConfig("configLoaded")
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch config:", error)
}
@@ -2673,29 +2657,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
private async fetchAndSendConfigUpdated(): Promise<void> {
if (!this.client || this.connectionState !== "connected") return
try {
const dir = this.getWorkspaceDirectory()
const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([
retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })),
this.client.global.config.get({ throwOnError: true }),
this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }),
])
this.cachedGlobalConfig = global ?? null
this.cachedConfigMessage = {
type: "configLoaded",
config,
globalConfig: global,
projectConfig: overlay?.project,
settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() },
features: configFeatures(config),
}
this.postMessage({
type: "configUpdated",
config,
globalConfig: global,
projectConfig: overlay?.project,
settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() },
features: configFeatures(config),
})
await this.refreshConfig("configUpdated")
} catch (error) {
console.error("[Kilo New] KiloProvider: Failed to fetch config after update:", error)
}
@@ -3064,28 +3026,7 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
}
try {
const [{ data: merged }, { data: global }, { data: overlay }] = await Promise.all([
retry(() => this.client!.config.get({ directory: dir }, { throwOnError: true })),
this.client.global.config.get({ throwOnError: true }),
this.client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }),
])
this.cachedGlobalConfig = global ?? null
this.cachedConfigMessage = {
type: "configLoaded",
config: merged,
globalConfig: global,
projectConfig: overlay?.project,
settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() },
features: configFeatures(merged),
}
this.postMessage({
type: "configUpdated",
config: merged,
globalConfig: global,
projectConfig: overlay?.project,
settings: { maxCost: this.maxCostSetting(), languageCommitMessage: this.commitMessageLanguageSetting() },
features: configFeatures(merged),
})
await this.refreshConfig("configUpdated", dir)
this.requirements.clear()
await Promise.all([
refreshProviders ? this.fetchAndSendProviders() : Promise.resolve(),
@@ -3113,6 +3054,17 @@ export class KiloProvider implements vscode.WebviewViewProvider, TelemetryProper
this.pending--
}
}
private async refreshConfig(type: "configLoaded" | "configUpdated", dir = this.getWorkspaceDirectory()) {
const snapshot = await fetchSnapshot(this.client!, dir, () => ({
maxCost: this.maxCostSetting(),
languageCommitMessage: this.commitMessageLanguageSetting(),
}))
this.cachedGlobalConfig = snapshot.globalConfig ?? null
this.cachedConfigMessage = { type: "configLoaded", ...snapshot }
this.postMessage({ type, ...snapshot })
}
private postConfigFailure(error: unknown): void {
console.error("[Kilo New] KiloProvider: Failed to update config:", error)
this.postMessage({
@@ -103,6 +103,7 @@ export class AgentManagerProvider implements Disposable {
)
this.terminalRouter = new TerminalRouter({
getClient: () => this.connectionService.getClient(),
getClientAsync: () => this.connectionService.getClientAsync(this.getRoot()),
getServerConfig: () => this.connectionService.getServerConfig() ?? undefined,
getRoot: () => this.getRoot(),
getWorktreePath: (id) => this.getStateManager()?.getWorktree(id)?.path,
@@ -593,6 +594,10 @@ export class AgentManagerProvider implements Disposable {
this.terminalManager.showLocalTerminal()
return null
}
if (m.type === "agentManager.showWorktreeTerminal") {
this.terminalManager.showWorktreeTerminal(m.worktreeId, this.state)
return null
}
if (m.type === "agentManager.openWorktree") {
this.openWorktreeDirectory(m.worktreeId)
return null
@@ -720,6 +725,13 @@ export class AgentManagerProvider implements Disposable {
}
private onRequestState(): void {
// requestState fires from the webview's onMount, and a freshly mounted
// webview has no terminal records — any PTYs the router still tracks
// belong to a previous webview instance (reload or crash) and are
// unreachable orphans. Kill them here rather than leaking shells until
// the panel itself is disposed. In-flight creates from the dying
// instance are reaped by the router's generation guard.
void this.terminalRouter.dispose()
void this.stateReady
?.then(() => {
// When the folder is not a git repo (or has no folder open),
@@ -906,8 +918,9 @@ export class AgentManagerProvider implements Disposable {
case "agentManager.toggleSectionCollapsed":
case "agentManager.moveToSection":
case "agentManager.moveSection":
case "agentManager.terminal.create":
return true
case "agentManager.terminal.create":
return m.worktreeId !== null
default:
return false
}
@@ -34,6 +34,8 @@ export interface Disposable {
*/
export class SessionTerminalManager {
private static readonly LOCAL_KEY = "__local__"
private static readonly SESSION_PREFIX = "session:"
private static readonly WORKTREE_PREFIX = "worktree:"
private terminals = new Map<string, { terminal: TerminalHandle; cwd: string }>()
private disposables: Disposable[] = []
@@ -47,10 +49,10 @@ export class SessionTerminalManager {
) {
this.disposables.push(
host.onTerminalClosed((terminal) => {
for (const [sessionId, entry] of this.terminals) {
for (const [key, entry] of this.terminals) {
if (entry.terminal !== terminal) continue
this.terminals.delete(sessionId)
this.log(`Removed terminal mapping for session ${sessionId} (terminal closed)`)
this.terminals.delete(key)
this.log(`Removed terminal mapping for ${key} (terminal closed)`)
break
}
this.updateContextKey()
@@ -86,7 +88,8 @@ export class SessionTerminalManager {
*/
showTerminal(sessionId: string, state: WorktreeStateManager | undefined): void {
// If terminal already exists, just focus it
if (this.showExisting(sessionId, false)) return
const key = SessionTerminalManager.sessionKey(sessionId)
if (this.showExistingKey(key, false)) return
const repoPath = this.host.repoPath()
const worktreePath = state?.directoryFor(sessionId)
@@ -102,7 +105,7 @@ export class SessionTerminalManager {
const worktree = session?.worktreeId ? state?.getWorktree(session.worktreeId) : undefined
const name = worktree ? `Agent: ${worktree.branch}` : "Agent: local"
this.showOrCreate(sessionId, cwd, name)
this.showOrCreate(key, cwd, name)
}
/**
@@ -110,7 +113,7 @@ export class SessionTerminalManager {
* Used when the user triggers a terminal in local mode without an active session.
*/
showLocalTerminal(): void {
if (this.showExisting(SessionTerminalManager.LOCAL_KEY, false)) return
if (this.showExistingKey(SessionTerminalManager.LOCAL_KEY, false)) return
const cwd = this.host.repoPath()
if (!cwd) {
@@ -122,11 +125,32 @@ export class SessionTerminalManager {
this.showOrCreate(SessionTerminalManager.LOCAL_KEY, cwd, "Agent: local")
}
/**
* Show (or create) a terminal rooted at a worktree directory. Used when
* the worktree has no session to key the terminal off (e.g. all of its
* sessions were closed) so the shortcut never dead-ends on a sessionless
* worktree.
*/
showWorktreeTerminal(worktreeId: string, state: WorktreeStateManager | undefined): void {
const key = SessionTerminalManager.worktreeKey(worktreeId)
if (this.showExistingKey(key, false)) return
const worktree = state?.getWorktree(worktreeId)
const cwd = worktree?.path ?? this.host.repoPath()
if (!cwd) {
this.log(`showWorktreeTerminal: no cwd resolved for worktree ${worktreeId}`)
this.host.showWarning("Open a folder that contains a git repository to use worktrees")
return
}
this.showOrCreate(key, cwd, worktree ? `Agent: ${worktree.branch}` : "Agent: worktree")
}
/**
* Show the existing local terminal if one was previously created (used on context switch).
*/
showExistingLocal(): boolean {
return this.showExisting(SessionTerminalManager.LOCAL_KEY)
return this.showExistingKey(SessionTerminalManager.LOCAL_KEY)
}
/**
@@ -159,34 +183,38 @@ export class SessionTerminalManager {
* Pass preserveFocus=true to keep focus on the current editor (default for session switching).
*/
showExisting(sessionId: string, preserveFocus = true): boolean {
const entry = this.terminals.get(sessionId)
return this.showExistingKey(SessionTerminalManager.sessionKey(sessionId), preserveFocus)
}
private showExistingKey(key: string, preserveFocus = true): boolean {
const entry = this.terminals.get(key)
if (!entry) return false
if (entry.terminal.exitStatus !== undefined) {
this.terminals.delete(sessionId)
this.log(`showExisting: terminal exited for session ${sessionId}, clearing`)
this.terminals.delete(key)
this.log(`showExisting: terminal exited for ${key}, clearing`)
return false
}
entry.terminal.show(preserveFocus)
this.panelOpen = true
this.log(`showExisting: revealed terminal for session ${sessionId}`)
this.log(`showExisting: revealed terminal for ${key}`)
return true
}
activeSession(): string | undefined {
private activeKey(): string | undefined {
const active = this.host.activeTerminal()
if (!active) return undefined
for (const [id, entry] of this.terminals) {
if (entry.terminal === active && entry.terminal.exitStatus === undefined) return id
for (const [key, entry] of this.terminals) {
if (entry.terminal === active && entry.terminal.exitStatus === undefined) return key
}
return undefined
}
prepareContext(sessionId: string): boolean {
if (this.showExisting(sessionId)) return true
const active = this.activeSession()
return !active || active === sessionId
const active = this.activeKey()
return !active || active === SessionTerminalManager.sessionKey(sessionId)
}
dispose(): void {
@@ -254,32 +282,40 @@ export class SessionTerminalManager {
void this.host.setContext("kilo-code.agentTerminalFocus", managed)
}
private showOrCreate(sessionId: string, cwd: string, name: string): void {
let entry = this.terminals.get(sessionId)
private showOrCreate(key: string, cwd: string, name: string): void {
let entry = this.terminals.get(key)
// Clean up exited terminals
if (entry && entry.terminal.exitStatus !== undefined) {
this.terminals.delete(sessionId)
this.terminals.delete(key)
entry = undefined
}
// Recreate if CWD changed
if (entry && entry.cwd !== cwd) {
entry.terminal.dispose()
this.terminals.delete(sessionId)
this.terminals.delete(key)
entry = undefined
this.log(`showTerminal: cwd changed for session ${sessionId}, recreating`)
this.log(`showTerminal: cwd changed for ${key}, recreating`)
}
if (!entry) {
const terminal = this.host.createTerminal({ cwd, name })
entry = { terminal, cwd }
this.terminals.set(sessionId, entry)
this.log(`showTerminal: created terminal for session ${sessionId} (cwd=${cwd})`)
this.terminals.set(key, entry)
this.log(`showTerminal: created terminal for ${key} (cwd=${cwd})`)
}
entry.terminal.show(false)
this.panelOpen = true
this.updateContextKey()
}
private static sessionKey(id: string): string {
return `${SessionTerminalManager.SESSION_PREFIX}${id}`
}
private static worktreeKey(id: string): string {
return `${SessionTerminalManager.WORKTREE_PREFIX}${id}`
}
}
@@ -39,6 +39,7 @@ vi.mock("../SessionTerminalManager", () => ({
SessionTerminalManager: class {
showTerminal() {}
showLocalTerminal() {}
showWorktreeTerminal() {}
syncLocalOnSessionSwitch() {}
syncOnSessionSwitch() {
return false
@@ -216,6 +217,20 @@ describe("AgentManagerProvider worktree creation", () => {
expect(manager.createWorktreeOnDisk).toHaveBeenCalledTimes(1)
})
it("disposes orphaned terminals when a freshly mounted webview requests state", async () => {
const manager = createHarness()
const dispose = vi.fn().mockResolvedValue(undefined)
manager.terminalRouter = { handle: vi.fn().mockReturnValue(false), dispose } as unknown as {
handle: ReturnType<typeof vi.fn>
}
// Avoid the vscode-backed pushEmptyState; the disposal under test is synchronous.
;(manager as unknown as Record<string, unknown>).pushEmptyState = vi.fn()
await manager.onMessage({ type: "agentManager.requestState" })
expect(dispose).toHaveBeenCalledTimes(1)
})
it("routes file search through the active worktree session", async () => {
const manager = createHarness()
manager.activeSessionId = "session-wt"
@@ -113,6 +113,16 @@ export class TerminalManager {
}
}
/** Titles of every live terminal in a context — used by the router to
* pick the lowest free "Terminal N" ordinal. */
titles(worktreeId: string | null): string[] {
const out: string[] = []
for (const entry of this.entries.values()) {
if (entry.worktreeId === worktreeId) out.push(entry.title)
}
return out
}
/** Kill a single terminal. Best-effort — we always drop our bookkeeping.
* The SDK's `pty.remove` returns `{ data, error }` without throwing
* on 4xx/5xx, so we have to check `error` ourselves; otherwise a
@@ -26,6 +26,8 @@ interface ServerConfig {
export interface TerminalRoutingDeps {
/** Shared SDK client. Throws when the CLI backend is not connected. */
getClient(): KiloClient
/** Shared SDK client, connecting the CLI backend when needed. */
getClientAsync(): Promise<KiloClient>
/** Loopback URL + basic-auth password for the running `kilo serve`. */
getServerConfig(): ServerConfig | undefined
/** Workspace root — used as cwd fallback when no worktree is selected (LOCAL). */
@@ -53,7 +55,9 @@ function isTerminalMessage(
export class TerminalRouter {
private manager: TerminalManager
private readonly ordinals = new Map<string, number>()
/** Ordinals reserved by in-flight creates, per context — prevents two
* concurrent creates from grabbing the same "Terminal N" title. */
private readonly reserved = new Map<string, Set<number>>()
private generation = 0
constructor(private readonly deps: TerminalRoutingDeps) {
@@ -100,6 +104,7 @@ export class TerminalRouter {
this.generation++
const manager = this.manager
this.manager = this.createManager()
this.reserved.clear()
return manager.dispose()
}
@@ -117,8 +122,12 @@ export class TerminalRouter {
})
return
}
const title = `Terminal ${this.nextOrdinal(worktreeId)}`
const ordinal = this.reserveOrdinal(worktreeId)
const title = `Terminal ${ordinal}`
try {
// Join the shared backend connection instead of racing its synchronous
// client accessor when this is the first Kilo action in the window.
await this.deps.getClientAsync()
const created = await manager.create({ worktreeId, cwd, title })
if (generation !== this.generation) {
await manager.close(created.terminalId)
@@ -139,6 +148,11 @@ export class TerminalRouter {
const message = err instanceof Error ? err.message : String(err)
this.deps.log(`Terminal create failed: ${message}`)
this.deps.post({ type: "agentManager.terminal.error", createId, message })
} finally {
// Only a current-generation create may release: dispose() already
// cleared this create's reservation, and releasing here would
// delete a *new* panel's reservation for the same number.
if (generation === this.generation) this.releaseOrdinal(worktreeId, ordinal)
}
}
@@ -156,15 +170,40 @@ export class TerminalRouter {
return this.deps.getWorktreePath(worktreeId)
}
/** Per-context counter so default titles are "Terminal 1", "Terminal 2"…
* Not persisted; a webview reload resets counts. */
private nextOrdinal(worktreeId: string | null): number {
/**
* Pick the lowest "Terminal N" ordinal not used by a live terminal or
* an in-flight create in this context, and reserve it until the
* create settles. Gap-filling keeps numbering consistent: closing
* "Terminal 1" of three frees 1 for the next terminal, instead of
* drifting to ever-higher numbers. Not persisted; a webview reload
* resets the live set.
*/
private reserveOrdinal(worktreeId: string | null): number {
const key = worktreeId ?? "__local__"
const next = (this.ordinals.get(key) ?? 0) + 1
this.ordinals.set(key, next)
const used = new Set<number>()
for (const title of this.manager.titles(worktreeId)) {
const match = /^Terminal (\d+)$/.exec(title)
if (match) used.add(Number(match[1]))
}
const pending = this.reserved.get(key)
if (pending) for (const n of pending) used.add(n)
let next = 1
while (used.has(next)) next++
const set = pending ?? new Set<number>()
set.add(next)
this.reserved.set(key, set)
return next
}
/** Return an in-flight create's reservation. */
private releaseOrdinal(worktreeId: string | null, ordinal: number) {
const key = worktreeId ?? "__local__"
const set = this.reserved.get(key)
if (!set) return
set.delete(ordinal)
if (set.size === 0) this.reserved.delete(key)
}
/**
* Build the WebSocket URL for a given PTY.
*
@@ -182,6 +221,9 @@ export class TerminalRouter {
const token = Buffer.from(`kilo:${config.password}`).toString("base64")
const dir = encodeURIComponent(cwd)
const auth = encodeURIComponent(token)
return `${base}/pty/${encodeURIComponent(ptyID)}/connect?directory=${dir}&cursor=-1&auth_token=${auth}`
// A new terminal has one initial attachment. Replay its retained startup
// bytes so xterm can answer shell capability queries emitted before the
// WebSocket connected; tailing from -1 can make shells wait for a timeout.
return `${base}/pty/${encodeURIComponent(ptyID)}/connect?directory=${dir}&cursor=0&auth_token=${auth}`
}
}
@@ -414,6 +414,11 @@ interface ShowLocalTerminalIn {
type: "agentManager.showLocalTerminal"
}
interface ShowWorktreeTerminalIn {
type: "agentManager.showWorktreeTerminal"
worktreeId: string
}
interface OpenWorktreeIn {
type: "agentManager.openWorktree"
worktreeId: string
@@ -780,6 +785,7 @@ export type AgentManagerInMessage =
| StopRunScriptIn
| ShowTerminalIn
| ShowLocalTerminalIn
| ShowWorktreeTerminalIn
| OpenWorktreeIn
| CopyToClipboardIn
| ShowExistingLocalTerminalIn
@@ -56,63 +56,14 @@ export class WorktreeImporter {
}
async branch(branch: string): Promise<void> {
const manager = this.host.manager()
const state = this.host.state()
if (!manager || !state) {
this.host.post({ type: "agentManager.importResult", success: false, message: "Not a git repository" })
return
}
if (this.busy()) return
this.importing = true
try {
this.host.post({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Creating worktree from branch...",
})
const result = await manager.createWorktree({ existingBranch: branch })
const worktree = state.addWorktree({
branch: result.branch,
path: result.path,
parentBranch: result.parentBranch,
remote: result.remote,
branchOwned: false,
})
this.host.push()
try {
this.host.post({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Running setup script...",
branch: result.branch,
worktreeId: worktree.id,
})
await this.host.setup(result.path, result.branch, worktree.id)
const session = await this.host.session(result.path, result.branch, worktree.id)
if (!session) throw new Error("Failed to create session")
state.addSession(session.id, worktree.id)
this.host.register(session.id, result.path)
this.host.ready(session.id, result, worktree.id)
this.host.post({ type: "agentManager.importResult", success: true, message: `Opened branch ${branch}` })
this.host.log(`Imported branch ${branch} as worktree ${worktree.id}`)
} catch (error) {
state.removeWorktree(worktree.id)
await manager.removeWorktree(result.path)
this.host.push()
throw error
}
} catch (error) {
this.importError(error, `Branch "${branch}" is already checked out in another worktree`)
} finally {
this.importing = false
}
await this.run({ branch })
}
async pr(url: string): Promise<void> {
await this.run({ url })
}
private async run(target: { branch: string } | { url: string }): Promise<void> {
const manager = this.host.manager()
const state = this.host.state()
if (!manager || !state) {
@@ -120,11 +71,21 @@ export class WorktreeImporter {
return
}
if (this.busy()) return
this.importing = true
const branch = "branch" in target
const creating = branch ? "Creating worktree from branch..." : "Resolving PR..."
const setup = branch ? "Running setup script..." : "Setting up worktree..."
const duplicate = branch
? `Branch "${target.branch}" is already checked out in another worktree`
: "This PR's branch is already checked out in another worktree"
try {
this.host.post({ type: "agentManager.worktreeSetup", status: "creating", message: "Resolving PR..." })
const result = await manager.createFromPR(url)
const progress = { type: "agentManager.worktreeSetup", status: "creating" } as const
this.host.post({ ...progress, message: creating })
const result = branch
? await manager.createWorktree({ existingBranch: target.branch })
: await manager.createFromPR(target.url)
const success = branch ? `Opened branch ${target.branch}` : `Opened PR branch ${result.branch}`
const log = branch ? `Imported branch ${target.branch}` : `Imported PR ${target.url}`
const worktree = state.addWorktree({
branch: result.branch,
path: result.path,
@@ -133,29 +94,16 @@ export class WorktreeImporter {
branchOwned: false,
})
this.host.push()
try {
this.host.post({
type: "agentManager.worktreeSetup",
status: "creating",
message: "Setting up worktree...",
branch: result.branch,
worktreeId: worktree.id,
})
this.host.post({ ...progress, message: setup, branch: result.branch, worktreeId: worktree.id })
await this.host.setup(result.path, result.branch, worktree.id)
const session = await this.host.session(result.path, result.branch, worktree.id)
if (!session) throw new Error("Failed to create session")
state.addSession(session.id, worktree.id)
this.host.register(session.id, result.path)
this.host.ready(session.id, result, worktree.id)
this.host.post({
type: "agentManager.importResult",
success: true,
message: `Opened PR branch ${result.branch}`,
})
this.host.log(`Imported PR ${url} as worktree ${worktree.id}`)
this.host.post({ type: "agentManager.importResult", success: true, message: success })
this.host.log(`${log} as worktree ${worktree.id}`)
} catch (error) {
state.removeWorktree(worktree.id)
await manager.removeWorktree(result.path)
@@ -163,7 +111,7 @@ export class WorktreeImporter {
throw error
}
} catch (error) {
this.importError(error, "This PR's branch is already checked out in another worktree")
this.importError(error, duplicate)
} finally {
this.importing = false
}
@@ -408,7 +408,7 @@ export type WebviewMessage =
message: Record<string, unknown>
}
| { type: "sessionStatus"; sessionID: string; status: string; attempt?: number; message?: string; next?: number }
| { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" }
| { type: "sessionTurnClosed"; sessionID: string; reason: "completed" | "error" | "interrupted" | "superseded" }
| {
type: "permissionRequest"
permission: {
@@ -0,0 +1,20 @@
import type { KiloClient } from "@kilocode/sdk/v2/client"
import { configFeatures } from "../features"
import { retry } from "../services/cli-backend/retry"
type Client = Pick<KiloClient, "config" | "global">
type Settings = { maxCost: number; languageCommitMessage: string }
export async function fetchSnapshot(client: Client, dir: string, settings: () => Settings) {
const [{ data: config }, { data: global }, { data: overlay }] = await Promise.all([
retry(() => client.config.get({ directory: dir }, { throwOnError: true })),
client.global.config.get({ throwOnError: true }),
client.config.overlay({ directory: dir, scope: "project" }, { throwOnError: true }),
])
return {
config,
globalConfig: global,
projectConfig: overlay?.project,
settings: settings(),
features: configFeatures(config),
}
}
@@ -106,7 +106,7 @@ export async function handlePermissionResponse(
}
const replyResult = await ctx.client.permission
.reply({ requestID: permissionId, reply: response, directory: dir }, { throwOnError: true })
.reply({ requestID: permissionId, reply: response, directory: dir, interactive: true }, { throwOnError: true })
.then(() => "ok" as const)
.catch((error: unknown) => {
if (isNotFoundError(error)) return "stale" as const
@@ -0,0 +1,6 @@
export const dict = {
"server.processExited": "فرآیند CLI با کد {{code}} قبل از راه‌اندازی سرور خاتمه یافت",
"server.startupTimeout": "زمان راه‌اندازی سرور پس از {{seconds}} ثانیه به پایان رسید",
"remote.connected": "Kilo Remote: متصل شد",
"remote.connecting": "Kilo Remote: در حال اتصال…",
} as const
@@ -6,6 +6,7 @@ import { dict as da } from "./da"
import { dict as de } from "./de"
import { dict as en } from "./en"
import { dict as es } from "./es"
import { dict as fa } from "./fa"
import { dict as fr } from "./fr"
import { dict as it } from "./it"
import { dict as ja } from "./ja"
@@ -29,6 +30,7 @@ const bundles: Record<string, Record<string, string>> = {
de,
en,
es,
fa,
fr,
it,
ja,
@@ -0,0 +1,27 @@
// English runtime translations for autocomplete (kilocode:autocomplete.* namespace)
// Source: src/i18n/locales/en/kilocode.json → "autocomplete" section
export const dict = {
"kilocode:autocomplete.statusBar.enabled": "$(kilo-logo) تکمیل خودکار",
"kilocode:autocomplete.statusBar.snoozed": "به تعویق افتاده",
"kilocode:autocomplete.statusBar.warning": "$(warning) تکمیل خودکار",
"kilocode:autocomplete.statusBar.tooltip.basic": "تکمیل خودکار Kilo Code",
"kilocode:autocomplete.statusBar.tooltip.noUsableProvider":
"**هیچ مدل تکمیل خودکاری پیکربندی نشده است**\n\nبرای فعال‌سازی تکمیل خودکار، یک پروفایل با یکی از ارائه‌دهندگان پشتیبانی‌شده زیر اضافه کنید: {{providers}}.\n\n[باز کردن تنظیمات]({{command}})",
"kilocode:autocomplete.statusBar.tooltip.completionSummary":
"{{count}} تکمیل بین {{startTime}} و {{endTime}} انجام شد، با هزینه کل {{cost}}.",
"kilocode:autocomplete.statusBar.tooltip.providerInfo":
"تکمیل خودکار توسط {{model}} از طریق {{provider}} ارائه می‌شود.",
"kilocode:autocomplete.statusBar.cost.zero": "۰.۰۰$",
"kilocode:autocomplete.statusBar.cost.lessThanCent": "<۰.۰۱$",
"kilocode:autocomplete.codeAction.title": "Kilo Code: ویرایش‌های پیشنهادی",
"kilocode:autocomplete.incompatibilityExtensionPopup.message":
"تکمیل خودکار Kilo Code به دلیل تعارض با GitHub Copilot مسدود شده است. برای رفع این مشکل، باید پیشنهادات درون‌خطی Copilot را غیرفعال کنید.",
"kilocode:autocomplete.incompatibilityExtensionPopup.disableCopilot": "غیرفعال کردن Copilot",
"kilocode:autocomplete.incompatibilityExtensionPopup.disableInlineAssist": "غیرفعال کردن تکمیل خودکار",
"kilocode:autocomplete.creditsExhausted.message":
"تکمیل خودکار Kilo Code متوقف شده است. دلایل احتمالی: حساب Kilo شما اعتبار کافی ندارد، یا کلید API پیکربندی‌شده (BYOK) به سقف مجاز خود رسیده است. برای از سرگیری تکمیل خودکار، اعتبار Kilo اضافه کنید یا تنظیمات کلید API خود را بررسی کنید.",
"kilocode:autocomplete.creditsExhausted.addCredits": "افزودن اعتبار",
"kilocode:autocomplete.authError.message":
"تکمیل خودکار Kilo Code به دلیل مشکل احراز هویت متوقف شده است. دلایل احتمالی: وارد Kilo نشده‌اید، یا کلید API (BYOK) شما نامعتبر یا وارد نشده است. لطفاً دوباره وارد شوید یا تنظیمات کلید API ارائه‌دهنده خود را بررسی کنید.",
}
+7
View File
@@ -0,0 +1,7 @@
import { dict as autocompleteDict } from "./autocomplete/fa"
export { autocompleteDict }
export const dict = {
...autocompleteDict,
} as const
@@ -6,6 +6,7 @@ import { dict as de } from "./de"
import { dict as en } from "./en"
import { type dict as enDict } from "./en"
import { dict as es } from "./es"
import { dict as fa } from "./fa"
import { dict as fr } from "./fr"
import { dict as it } from "./it"
import { dict as ja } from "./ja"
@@ -28,6 +29,7 @@ const bundles: Record<string, Record<string, string>> = {
de,
en,
es,
fa,
fr,
it,
ja,
+1 -1
View File
@@ -1,6 +1,6 @@
{
"type": "module",
"version": "7.4.16",
"version": "7.4.17",
"dependencies": {},
"devDependencies": {},
"peerDependencies": {}
@@ -11,6 +11,7 @@ import { describe, it, expect } from "bun:test"
import fs from "node:fs"
import path from "node:path"
import { Project, SyntaxKind } from "ts-morph"
import { WorktreeImporter } from "../../src/agent-manager/worktree-importer"
const ROOT = path.resolve(import.meta.dir, "../..")
const KILO_PROVIDER_FILE = path.join(ROOT, "src/KiloProvider.ts")
@@ -411,6 +412,7 @@ describe("Agent Manager Provider — onMessage routing", () => {
"agentManager.stopRunScript",
"agentManager.showTerminal",
"agentManager.showLocalTerminal",
"agentManager.showWorktreeTerminal",
"agentManager.showExistingLocalTerminal",
"agentManager.requestRepoInfo",
"agentManager.requestState",
@@ -577,11 +579,61 @@ describe("Agent Manager Provider — onMessage routing", () => {
it("worktree import behavior lives in the cohesive importer", () => {
const text = importer()
const providerText = body("onImportMessage")
expect(text).toContain("class WorktreeImporter")
expect(text).toContain("createFromPR")
expect(text).toContain("createWorktree")
expect(providerText).toContain("this.importer")
for (const value of ["createFromPR", "createWorktree", "this.busy()"]) expect(text).toContain(value)
expect(body("onImportMessage")).toContain("this.importer")
})
it("preserves branch and PR import ordering and rollback", async () => {
const run = async (kind: "branch" | "pr", fail?: "setup" | "duplicate") => {
const events: string[] = []
const create = async () => {
events.push("create")
if (fail === "duplicate") throw new Error("already checked out")
return { branch: "topic", path: "/repo/topic", parentBranch: "main" }
}
const importer = new WorktreeImporter({
manager: () =>
({ createWorktree: create, createFromPR: create, removeWorktree: async () => events.push("disk") }) as never,
state: () =>
({
addWorktree: (input: { branchOwned: boolean }) => ({
id: events.push(`add:${input.branchOwned}`) ? "worktree" : "",
}),
addSession: () => events.push("state-session"),
removeWorktree: () => events.push("state-remove"),
}) as never,
post: (msg) => events.push("message" in msg ? String(msg.message) : msg.type),
push: () => events.push("push"),
setup: async () => {
events.push("setup")
if (fail === "setup") throw new Error("setup failed")
},
session: async () => (events.push("session"), { id: "session" }) as never,
register: () => events.push("register"),
ready: () => events.push("ready"),
log: () => events.push("log"),
})
const action = () => (kind === "branch" ? importer.branch("topic") : importer.pr("https://example.test/pull/1"))
await action()
if (fail === "setup") await action()
return events.join("|")
}
for (const kind of ["branch", "pr"] as const) {
const branch = kind === "branch"
const creating = branch ? "Creating worktree from branch..." : "Resolving PR..."
const setup = branch ? "Running setup script..." : "Setting up worktree..."
const success = branch ? "Opened branch topic" : "Opened PR branch topic"
expect(await run(kind)).toBe(
`${creating}|create|add:false|push|${setup}|setup|session|state-session|register|ready|${success}|log`,
)
expect(await run(kind, "setup")).toBe(
`${creating}|create|add:false|push|${setup}|setup|state-remove|disk|push|setup failed|setup failed|${creating}|create|add:false|push|${setup}|setup|state-remove|disk|push|setup failed|setup failed`,
)
const duplicate = branch
? 'Branch "topic" is already checked out in another worktree'
: "This PR's branch is already checked out in another worktree"
expect(await run(kind, "duplicate")).toBe(`${creating}|create|${duplicate}|${duplicate}`)
}
})
})
@@ -891,70 +943,73 @@ describe("Agent Manager — VS Code import boundary", () => {
})
})
// ---------------------------------------------------------------------------
// Provider chain parity — sidebar App.tsx vs AgentManagerApp.tsx
//
// The agent manager reuses ChatView (and therefore MessageList, etc.) from the
// sidebar. Any context provider that ChatView's tree may call useXxx() on must
// also be present in the agent manager's provider chain. A missing provider
// crashes the entire SolidJS component tree silently.
//
// Regression: PR #7473 moved KiloNotifications into MessageList. It calls
// useNotifications(), but NotificationsProvider was only in App.tsx — the agent
// manager rendered a blank screen.
// ---------------------------------------------------------------------------
const APP_FILE = path.join(ROOT, "webview-ui/src/App.tsx")
const AGENT_MANAGER_APP_FILE = path.join(ROOT, "webview-ui/agent-manager/AgentManagerApp.tsx")
const PROVIDER_SHELL_FILE = path.join(ROOT, "webview-ui/src/context/provider-shell.tsx")
describe("Agent Manager — provider chain parity with sidebar", () => {
/**
* Extract provider component names used as JSX elements in a file.
* Matches `<FooProvider` and `<FooProvider>` patterns, returning the names.
*/
function extractProviders(content: string): string[] {
const matches = [...content.matchAll(/<(\w+Provider)\b/g)]
return [...new Set(matches.map((m) => m[1]!))]
describe("Shared webview provider shell", () => {
function ordered(source: string, names: string[]) {
const positions = names.map((name) => source.indexOf(`<${name}`))
expect(
positions.every((position) => position >= 0),
`Missing provider from ${names.join(" -> ")}`,
).toBe(true)
expect(positions).toEqual([...positions].sort((a, b) => a - b))
}
/**
* Providers that the agent manager intentionally omits because it does not
* use the components that depend on them. If a shared component (ChatView,
* MessageList, etc.) starts using one of these, the test will fail and
* force the developer to add the provider to AgentManagerApp.tsx.
*/
const KNOWN_EXCLUSIONS: string[] = [
// These are wrapped by LanguageBridge and DataBridge respectively,
// which the agent manager already includes in its provider chain.
"LanguageProvider",
"DataProvider",
// Agent Manager owns its local session tabs and ChatView only reads this
// optional context in the standard sidebar/editor webview.
"LocalTabsProvider",
// Work-style onboarding is injected only into the sidebar empty state.
"WorkStyleProvider",
]
it("owns the common provider order and bridges", () => {
const source = fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8")
ordered(source, [
"ThemeProvider",
"DialogProvider",
"VSCodeProvider",
"MermaidDownloadBridge",
"ServerProvider",
"LanguageBridge",
"MarkedProvider",
"DiffComponentProvider",
"CodeComponentProvider",
"FileComponentProvider",
"ProviderProvider",
"ConfigProvider",
"SpeechToTextPrewarm",
"DisplayProvider",
"IndexingProvider",
"KiloEmbeddingModelsProvider",
"ImageModelsProvider",
"NotificationsProvider",
"SessionProvider",
"AgentRequirementsProvider",
"MemoryProvider",
"FeedbackProvider",
])
expect(source.indexOf("<Toast.Region")).toBeGreaterThan(source.indexOf("</VSCodeProvider>"))
})
it("agent manager includes all context providers from sidebar App.tsx", () => {
const sidebar = fs.readFileSync(APP_FILE, "utf-8")
const agent = fs.readFileSync(AGENT_MANAGER_APP_FILE, "utf-8")
it("keeps sidebar-only providers in the sidebar root", () => {
const source = fs.readFileSync(APP_FILE, "utf-8")
ordered(source, [
"ProviderShell.Root",
"WorkStyleProvider",
"ProviderShell.Session",
"LocalTabsProvider",
"ProviderShell.Chat",
"DataBridge",
"AppContent",
])
expect(fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8")).not.toMatch(/WorkStyleProvider|LocalTabsProvider/)
})
const sidebarProviders = extractProviders(sidebar)
const agentProviders = extractProviders(agent)
const agentSet = new Set(agentProviders)
const excluded = new Set(KNOWN_EXCLUSIONS)
const missing = sidebarProviders.filter((p) => !agentSet.has(p) && !excluded.has(p))
expect(
missing,
`These providers are in App.tsx but missing from AgentManagerApp.tsx.\n` +
`The agent manager reuses ChatView — any provider that ChatView's component\n` +
`tree depends on must be present in both provider chains.\n\n` +
`Missing providers:\n` +
missing.map((p) => ` - ${p}`).join("\n") +
`\n\nFix: add the missing <${missing[0]}> to AgentManagerApp.tsx's provider chain,\n` +
`or add it to KNOWN_EXCLUSIONS with a justification if it's truly unused.`,
).toEqual([])
it("keeps worktree mode in the Agent Manager root", () => {
const source = fs.readFileSync(AGENT_MANAGER_APP_FILE, "utf-8")
ordered(source, [
"ProviderShell.Root",
"ProviderShell.Session",
"ProviderShell.Chat",
"WorktreeModeProvider",
"DataBridge",
"AgentManagerContent",
])
expect(fs.readFileSync(PROVIDER_SHELL_FILE, "utf-8")).not.toContain("WorktreeModeProvider")
})
})
@@ -39,6 +39,7 @@ import { dict as amTr } from "../../webview-ui/agent-manager/i18n/tr"
import { dict as amNl } from "../../webview-ui/agent-manager/i18n/nl"
import { dict as amUk } from "../../webview-ui/agent-manager/i18n/uk"
import { dict as amIt } from "../../webview-ui/agent-manager/i18n/it"
import { dict as amFa } from "../../webview-ui/agent-manager/i18n/fa"
const PREFIX = "agentManager."
@@ -63,6 +64,7 @@ const locales = {
nl: amNl,
uk: amUk,
it: amIt,
fa: amFa,
}
const appLocales = {
@@ -53,6 +53,7 @@ describe("Agent Manager terminal font", () => {
const message = new Promise<AgentManagerOutMessage>((resolve) => {
const router = new TerminalRouter({
getClient: () => client,
getClientAsync: async () => client,
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
@@ -77,6 +78,7 @@ describe("Agent Manager terminal font", () => {
expect(created.font).toEqual(font)
expect(created.worktreeId).toBeNull()
expect(created.wsUrl).toContain("/pty/pty-1/connect")
expect(created.wsUrl).toContain("cursor=0")
})
it("keeps the created font in terminal state", () => {
@@ -0,0 +1,15 @@
import { expect, test } from "bun:test"
import { readFileSync } from "node:fs"
import { resolve } from "node:path"
const css = readFileSync(resolve(import.meta.dir, "../../webview-ui/agent-manager/agent-manager.css"), "utf8")
test("xterm owns the padding used by FitAddon", () => {
const host = css.match(/\.am-terminal-host\s*\{([^}]*)\}/)?.[1]
const term = css.match(/\.am-terminal-host \[class~="xterm"\]\s*\{([^}]*)\}/)?.[1]
expect(host).toBeDefined()
expect(term).toBeDefined()
expect(host).not.toMatch(/\bpadding\s*:/)
expect(term).toMatch(/\bpadding\s*:\s*8px\s*;/)
})
@@ -21,6 +21,7 @@ describe("Agent Manager terminal routing", () => {
} as unknown as KiloClient
const router = new TerminalRouter({
getClient: () => client,
getClientAsync: async () => client,
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: (id) => (id === "wt-1" ? "/workspace/wt-1" : undefined),
@@ -78,6 +79,7 @@ describe("Agent Manager terminal routing", () => {
} as unknown as KiloClient
const router = new TerminalRouter({
getClient: () => client,
getClientAsync: async () => client,
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
@@ -108,4 +110,174 @@ describe("Agent Manager terminal routing", () => {
await router.dispose()
expect(removed).toContain("pty-new")
})
it("fills numbering gaps left by closed terminals", async () => {
const messages: AgentManagerOutMessage[] = []
const titles: string[] = []
let seq = 0
const client = {
pty: {
create: async ({ title }: { title: string }) => {
titles.push(title)
seq++
return { data: { id: `pty-${seq}`, title } }
},
remove: async () => ({ data: true }),
update: async () => ({ data: true }),
},
} as unknown as KiloClient
const router = new TerminalRouter({
getClient: () => client,
getClientAsync: async () => client,
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
log: () => undefined,
post: (message) => messages.push(message),
getTerminalFont: () => font,
})
const create = (createId: string) =>
router.handle({ type: "agentManager.terminal.create", createId, placement: "side", worktreeId: null })
create("one")
await wait()
create("two")
await wait()
expect(titles).toEqual(["Terminal 1", "Terminal 2"])
// Close "Terminal 1"; the next create reuses the freed number.
const first = messages.find((m) => m.type === "agentManager.terminal.created" && m.createId === "one")
if (first?.type !== "agentManager.terminal.created") throw new Error("missing created message")
router.handle({ type: "agentManager.terminal.close", terminalId: first.terminalId })
await wait()
create("three")
await wait()
expect(titles).toEqual(["Terminal 1", "Terminal 2", "Terminal 1"])
await router.dispose()
})
it("hands out distinct numbers to concurrent creates", async () => {
const messages: AgentManagerOutMessage[] = []
const titles: string[] = []
const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = []
const client = {
pty: {
create: ({ title }: { title: string }) =>
new Promise<{ data: { id: string; title: string } }>((resolve) => {
titles.push(title)
resolvers.push(resolve)
}),
remove: async () => ({ data: true }),
update: async () => ({ data: true }),
},
} as unknown as KiloClient
const router = new TerminalRouter({
getClient: () => client,
getClientAsync: async () => client,
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
log: () => undefined,
post: (message) => messages.push(message),
getTerminalFont: () => font,
})
// Two creates before either settles must not share an ordinal. The
// backend-connection await defers the PTY creates to a microtask.
router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null })
router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null })
await wait()
expect(titles).toEqual(["Terminal 1", "Terminal 2"])
resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } })
resolvers[1]?.({ data: { id: "pty-b", title: titles[1]! } })
await wait()
// A failed create releases its reservation for the next attempt.
await router.dispose()
})
it("does not let a stale create release a new generation's reservation", async () => {
const titles: string[] = []
const resolvers: Array<(value: { data: { id: string; title: string } }) => void> = []
const client = {
pty: {
create: ({ title }: { title: string }) =>
new Promise<{ data: { id: string; title: string } }>((resolve) => {
titles.push(title)
resolvers.push(resolve)
}),
remove: async () => ({ data: true }),
update: async () => ({ data: true }),
},
} as unknown as KiloClient
const router = new TerminalRouter({
getClient: () => client,
getClientAsync: async () => client,
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
log: () => undefined,
post: () => undefined,
getTerminalFont: () => font,
})
// Create A starts before the panel is recreated; its reservation dies
// with dispose(). Create B of the new generation reserves the same
// free number. When A's late completion settles, its release must not
// wipe B's reservation — otherwise create C would duplicate B's title.
router.handle({ type: "agentManager.terminal.create", createId: "a", placement: "side", worktreeId: null })
await router.dispose()
router.handle({ type: "agentManager.terminal.create", createId: "b", placement: "side", worktreeId: null })
await wait()
expect(titles).toEqual(["Terminal 1", "Terminal 1"])
resolvers[0]?.({ data: { id: "pty-a", title: titles[0]! } })
await wait()
router.handle({ type: "agentManager.terminal.create", createId: "c", placement: "side", worktreeId: null })
await wait()
expect(titles).toEqual(["Terminal 1", "Terminal 1", "Terminal 2"])
await router.dispose()
})
it("awaits the shared backend connection before creating a terminal", async () => {
let connected = false
const client = {
pty: {
create: async () => ({ data: { id: "pty-1", title: "Terminal 1" } }),
remove: async () => ({ data: true }),
update: async () => ({ data: true }),
},
} as unknown as KiloClient
const messages: AgentManagerOutMessage[] = []
const router = new TerminalRouter({
getClient: () => {
if (!connected) throw new Error("Not connected")
return client
},
getClientAsync: async () => {
await wait()
connected = true
return client
},
getServerConfig: () => ({ baseUrl: "http://127.0.0.1:4096", password: "secret" }),
getRoot: () => "/workspace",
getWorktreePath: () => undefined,
log: () => undefined,
post: (message) => messages.push(message),
getTerminalFont: () => font,
})
router.handle({
type: "agentManager.terminal.create",
createId: "real",
placement: "tab",
worktreeId: null,
})
expect(messages).toHaveLength(0)
await wait()
await wait()
expect(messages[0]).toMatchObject({ type: "agentManager.terminal.created", createId: "real" })
await router.dispose()
})
})
@@ -1,32 +1,44 @@
import { describe, expect, it } from "bun:test"
import { createSideTerminal } from "../../webview-ui/agent-manager/terminal/side"
import {
createSideTerminal,
readSavedDestination,
resolveVscodeTerminalRequest,
} from "../../webview-ui/agent-manager/terminal/side"
function scene(opts: { destination?: "vscode" | "agentManager"; visible?: boolean; focused?: boolean } = {}) {
function scene(
opts: {
destination?: "vscode" | "agentManager"
saved?: "vscode" | "agentManager"
visible?: boolean
focusedId?: string
} = {},
) {
const calls = {
requestSide: 0,
closeSide: 0,
closed: [] as string[],
hide: 0,
refocus: 0,
openVscode: 0,
persisted: [] as string[],
posted: [] as Array<Record<string, unknown>>,
tracked: [] as string[],
}
let visible = opts.visible ?? false
let focused = opts.focused ?? false
let focusedId = opts.focusedId as string | undefined
const ctl = createSideTerminal({
handlers: {
requestSide: () => {
calls.requestSide++
visible = true
},
closeSide: () => {
calls.closeSide++
visible = false
closeSide: (terminalId) => {
calls.closed.push(terminalId)
focusedId = undefined
return true
},
},
visible: () => visible,
focused: () => focused,
focusedId: () => focusedId,
hide: () => {
calls.hide++
visible = false
@@ -35,19 +47,21 @@ function scene(opts: { destination?: "vscode" | "agentManager"; visible?: boolea
postMessage: (msg) => calls.posted.push(msg as Record<string, unknown>),
track: (button) => calls.tracked.push(button),
openVscode: () => calls.openVscode++,
saved: opts.saved,
save: (destination) => calls.persisted.push(destination),
})
if (opts.destination) ctl.setDestination(opts.destination)
if (opts.destination) ctl.syncDefault(opts.destination)
return { ctl, calls }
}
describe("Agent Manager side terminal controller", () => {
it("toggles the panel and hands focus to the chat only when the terminal had it", () => {
const focused = scene({ destination: "agentManager", visible: true, focused: true })
const focused = scene({ destination: "agentManager", visible: true, focusedId: "terminal:side" })
focused.ctl.toggle()
expect(focused.calls.hide).toBe(1)
expect(focused.calls.refocus).toBe(1)
const elsewhere = scene({ destination: "agentManager", visible: true, focused: false })
const elsewhere = scene({ destination: "agentManager", visible: true })
elsewhere.ctl.toggle()
expect(elsewhere.calls.hide).toBe(1)
expect(elsewhere.calls.refocus).toBe(0)
@@ -58,15 +72,18 @@ describe("Agent Manager side terminal controller", () => {
expect(hidden.calls.hide).toBe(0)
})
it("refocuses the chat after killing a focused terminal, not otherwise", () => {
const focused = scene({ focused: true })
it("kills the focused terminal and refocuses the chat", () => {
const focused = scene({ focusedId: "terminal:two" })
expect(focused.ctl.close()).toBe(true)
expect(focused.calls.closeSide).toBe(1)
expect(focused.calls.closed).toEqual(["terminal:two"])
expect(focused.calls.refocus).toBe(1)
})
const elsewhere = scene({ focused: false })
expect(elsewhere.ctl.close()).toBe(true)
expect(elsewhere.calls.refocus).toBe(0)
it("does nothing on close without a focused terminal", () => {
const item = scene()
expect(item.ctl.close()).toBe(false)
expect(item.calls.closed).toEqual([])
expect(item.calls.refocus).toBe(0)
})
it("routes the primary action by destination", () => {
@@ -88,5 +105,84 @@ describe("Agent Manager side terminal controller", () => {
expect(item.calls.posted).toEqual([
{ type: "updateSetting", key: "agentManager.terminalButtonDestination", value: "agentManager" },
])
expect(item.calls.persisted).toEqual(["agentManager"])
})
it("follows the remote default while the panel has no explicit choice", () => {
const item = scene()
item.ctl.syncDefault("agentManager")
expect(item.ctl.destination()).toBe("agentManager")
item.ctl.syncDefault("vscode")
expect(item.ctl.destination()).toBe("vscode")
})
it("keeps the panel's explicit choice when another window rewrites the shared setting", () => {
const item = scene()
item.ctl.choose("agentManager")
// Echo of the application-scoped setting being rewritten elsewhere:
// worktree window B picked the VS Code terminal, which must not flip
// this panel's routing.
item.ctl.syncDefault("vscode")
expect(item.ctl.destination()).toBe("agentManager")
item.ctl.openPreferred("keyboard_shortcut")
expect(item.calls.requestSide).toBe(1)
expect(item.calls.openVscode).toBe(0)
})
it("restores a saved panel choice and ignores remote defaults", () => {
const item = scene({ saved: "agentManager" })
expect(item.ctl.destination()).toBe("agentManager")
item.ctl.syncDefault("vscode")
expect(item.ctl.destination()).toBe("agentManager")
})
})
describe("readSavedDestination", () => {
it("reads a valid choice and rejects anything else", () => {
expect(readSavedDestination({ terminalDestination: "agentManager" })).toBe("agentManager")
expect(readSavedDestination({ terminalDestination: "vscode" })).toBe("vscode")
expect(readSavedDestination({ terminalDestination: "bogus" })).toBeUndefined()
expect(readSavedDestination({})).toBeUndefined()
expect(readSavedDestination(undefined)).toBeUndefined()
})
})
describe("resolveVscodeTerminalRequest", () => {
const sessions = new Map([
["wt-1", "session-a"],
["wt-2", "session-b"],
])
const forWorktree = (id: string) => sessions.get(id)
it("prefers the current session", () => {
expect(resolveVscodeTerminalRequest("wt-1", "session-current", forWorktree)).toEqual({
type: "agentManager.showTerminal",
sessionId: "session-current",
})
})
it("falls back to a session of the selected worktree when the current session is cleared", () => {
// Terminal tab activation clears the current session; the shortcut
// must still open a terminal for the worktree, not dead-end.
expect(resolveVscodeTerminalRequest("wt-2", undefined, forWorktree)).toEqual({
type: "agentManager.showTerminal",
sessionId: "session-b",
})
})
it("opens a worktree-rooted terminal for sessionless worktrees", () => {
expect(resolveVscodeTerminalRequest("wt-3", undefined, forWorktree)).toEqual({
type: "agentManager.showWorktreeTerminal",
worktreeId: "wt-3",
})
})
it("opens the local terminal for the local context and unassigned selections", () => {
expect(resolveVscodeTerminalRequest("local", undefined, forWorktree)).toEqual({
type: "agentManager.showLocalTerminal",
})
expect(resolveVscodeTerminalRequest(null, undefined, forWorktree)).toEqual({
type: "agentManager.showLocalTerminal",
})
})
})
@@ -14,7 +14,7 @@ function scene(initial: string | null = LOCAL) {
const [selection, setSelection] = createSignal<string | null>(initial)
const state = createTerminalState(selection)
const posted: Array<Record<string, unknown>> = []
const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], hidden: 0 }
const events = { activated: [] as string[], selected: [] as string[], saved: 0, shown: [] as string[], errors: 0 }
const tabs = () => state.current().map((term) => term.id)
const handlers = createTerminalHandlers({
state,
@@ -27,7 +27,6 @@ function scene(initial: string | null = LOCAL) {
findTab: () => undefined,
postMessage: (message) => posted.push(message as Record<string, unknown>),
onShowSide: (key) => events.shown.push(key),
onHideSide: () => events.hidden++,
getSelection: selection,
LOCAL,
REVIEW_TAB_ID: "review",
@@ -40,12 +39,25 @@ function scene(initial: string | null = LOCAL) {
events.selected.push(value)
setSelection(value)
},
showError: () => undefined,
showError: () => events.errors++,
postMessage: (message) => posted.push(message as Record<string, unknown>),
})
return { state, selection, setSelection, posted, events, handlers, dispatch }
}
function createdSide(createId: string, terminalId: string, title = "Terminal 1") {
return {
type: "agentManager.terminal.created",
createId,
placement: "side",
worktreeId: null,
terminalId,
title,
wsUrl: `ws://${terminalId}`,
font,
} satisfies ExtensionMessage
}
describe("Agent Manager terminal state", () => {
it("keeps side terminals out of the tab state and shares root context with unassigned sessions", () => {
createRoot((dispose) => {
@@ -64,21 +76,22 @@ describe("Agent Manager terminal state", () => {
font,
placement: "side",
})
item.state.setSideActive(LOCAL, "terminal:side")
expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"])
expect(item.state.all().map((term) => term.id)).toEqual(["terminal:tab"])
expect(item.state.sides().map((term) => term.id)).toEqual(["terminal:side"])
expect(item.state.side()?.id).toBe("terminal:side")
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side")
item.setSelection(null)
expect(item.state.current()).toEqual([])
expect(item.state.sideKey()).toBe(LOCAL)
expect(item.state.side()?.id).toBe("terminal:side")
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side")
dispose()
})
})
it("deduplicates side creation and reuses the terminal without tab side effects", () => {
it("deduplicates an in-flight reveal and focuses the active terminal on repeat", () => {
createRoot((dispose) => {
const item = scene()
item.handlers.requestSide()
@@ -88,18 +101,8 @@ describe("Agent Manager terminal state", () => {
const request = item.posted[0]!
expect(request).toMatchObject({ type: "agentManager.terminal.create", placement: "side", worktreeId: null })
const createId = String(request.createId)
const created = {
type: "agentManager.terminal.created",
createId,
placement: "side",
worktreeId: null,
terminalId: "terminal:side",
title: "Terminal 1",
wsUrl: "ws://side",
font,
} satisfies ExtensionMessage
expect(item.dispatch(created)).toBe(true)
expect(item.state.side()?.id).toBe("terminal:side")
expect(item.dispatch(createdSide(createId, "terminal:side"))).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:side")
expect(item.events.activated).toEqual([])
expect(item.events.selected).toEqual([])
expect(item.events.saved).toBe(0)
@@ -111,6 +114,74 @@ describe("Agent Manager terminal state", () => {
})
})
it("supports several side terminals per context with newest active", () => {
createRoot((dispose) => {
const item = scene()
item.handlers.addSide()
item.handlers.addSide()
expect(item.posted).toHaveLength(2)
const first = String(item.posted[0]!.createId)
const second = String(item.posted[1]!.createId)
item.dispatch(createdSide(first, "terminal:one", "Terminal 1"))
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one"])
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
item.dispatch(createdSide(second, "terminal:two", "Terminal 2"))
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual(["terminal:one", "terminal:two"])
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:two")
dispose()
})
})
it("switches the active side terminal on select", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" })
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" })
item.state.setSideActive(LOCAL, "terminal:two")
item.handlers.selectSide("terminal:one")
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
expect(item.state.focusRequest()?.id).toBe("terminal:one")
dispose()
})
})
it("moves activation to the last remaining side terminal on close", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" })
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" })
item.state.setSideActive(LOCAL, "terminal:two")
expect(item.handlers.closeSide("terminal:two")).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBe("terminal:one")
expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:two" }])
expect(item.handlers.closeSide("terminal:one")).toBe(true)
expect(item.state.sideActiveFor(LOCAL)).toBeUndefined()
expect(item.state.sidesForContext(LOCAL)).toEqual([])
// Closing an unknown or non-side id is a no-op.
expect(item.handlers.closeSide("terminal:gone")).toBe(false)
expect(item.posted).toHaveLength(2)
dispose()
})
})
it("closes a stale side answer whose create request is unknown", () => {
createRoot((dispose) => {
const item = scene()
// A created message for a createId the webview never sent (e.g. it
// reloaded while the PTY was starting) must not leak the PTY.
item.dispatch(createdSide("stale-id", "terminal:stale"))
expect(item.state.sidesForContext(LOCAL)).toEqual([])
expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:stale" }])
dispose()
})
})
it("creates explicit terminal tabs independently of the side destination", () => {
createRoot((dispose) => {
const item = scene("wt-1")
@@ -124,44 +195,91 @@ describe("Agent Manager terminal state", () => {
})
})
it("cancels a side terminal that is still starting", () => {
it("routes side creates of a worktree context to that worktree", () => {
createRoot((dispose) => {
const item = scene()
item.handlers.requestSide()
const request = item.posted[0]!
expect(item.handlers.closeSide()).toBe(true)
expect(item.state.pendingSide(LOCAL)).toBeUndefined()
item.dispatch({
type: "agentManager.terminal.created",
createId: String(request.createId),
const item = scene("wt-1")
item.handlers.addSide()
expect(item.posted[0]).toMatchObject({
type: "agentManager.terminal.create",
placement: "side",
worktreeId: null,
terminalId: "terminal:late",
title: "Terminal 1",
wsUrl: "ws://late",
font,
worktreeId: "wt-1",
})
expect(item.state.side()).toBeUndefined()
expect(item.posted.at(-1)).toEqual({ type: "agentManager.terminal.close", terminalId: "terminal:late" })
dispose()
})
})
it("closes a side terminal without changing the active chat tab", () => {
it("tracks OSC titles per terminal without touching the terminal records", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, {
id: "terminal:side",
title: "Terminal 1",
wsUrl: "ws://side",
font,
placement: "side",
})
expect(item.handlers.closeSide()).toBe(true)
expect(item.state.side()).toBeUndefined()
expect(item.posted).toEqual([{ type: "agentManager.terminal.close", terminalId: "terminal:side" }])
expect(item.events.hidden).toBe(1)
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" })
const before = item.state.sidesForContext(LOCAL)[0]!
item.state.setTitle("terminal:one", "npm run dev")
expect(item.state.title("terminal:one")).toBe("npm run dev")
// Reference stability: the stored record is untouched so <For> does
// not remount the xterm instance on a title change.
expect(item.state.sidesForContext(LOCAL)[0]).toBe(before)
// Empty titles are ignored; removal drops the override.
item.state.setTitle("terminal:one", " ")
expect(item.state.title("terminal:one")).toBe("npm run dev")
item.state.remove("terminal:one")
expect(item.state.title("terminal:one")).toBeUndefined()
dispose()
})
})
it("reorders side terminals within their context via drag", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:one", title: "Terminal 1", wsUrl: "ws://one", font, placement: "side" })
item.state.add(null, { id: "terminal:two", title: "Terminal 2", wsUrl: "ws://two", font, placement: "side" })
item.state.add(null, { id: "terminal:three", title: "Terminal 3", wsUrl: "ws://three", font, placement: "side" })
item.state.add(null, { id: "terminal:tab", title: "Terminal 4", wsUrl: "ws://tab", font, placement: "tab" })
// Drag the first side terminal onto the third position.
expect(item.state.reorderSideDrag(LOCAL, "terminal:one", "terminal:three")).toBe(true)
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([
"terminal:two",
"terminal:three",
"terminal:one",
])
// Tab terminals are untouched.
expect(item.state.current().map((term) => term.id)).toEqual(["terminal:tab"])
// The order survives switching to another context and back.
item.setSelection("wt-1")
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([
"terminal:two",
"terminal:three",
"terminal:one",
])
item.setSelection(LOCAL)
expect(item.state.sidesForContext(LOCAL).map((term) => term.id)).toEqual([
"terminal:two",
"terminal:three",
"terminal:one",
])
// Unknown ids, tab-placement ids, and foreign contexts are rejected.
expect(item.state.reorderSideDrag(LOCAL, "terminal:gone", "terminal:two")).toBe(false)
expect(item.state.reorderSideDrag(LOCAL, "terminal:tab", "terminal:two")).toBe(false)
expect(item.state.reorderSideDrag("wt-1", "terminal:two", "terminal:three")).toBe(false)
dispose()
})
})
it("reports the focused side terminal only for the current context", () => {
createRoot((dispose) => {
const item = scene()
item.state.add(null, { id: "terminal:side", title: "Terminal 1", wsUrl: "ws://side", font, placement: "side" })
item.state.add(null, { id: "terminal:tab", title: "Terminal 2", wsUrl: "ws://tab", font, placement: "tab" })
expect(item.state.sideFocusedId()).toBeUndefined()
item.state.setFocusedId("terminal:tab")
expect(item.state.sideFocusedId()).toBeUndefined()
item.state.setFocusedId("terminal:side")
expect(item.state.sideFocusedId()).toBe("terminal:side")
dispose()
})
})
@@ -0,0 +1,103 @@
import { describe, expect, it } from "bun:test"
import { createRoot } from "solid-js"
import { createWorktreeDiffs } from "../../webview-ui/agent-manager/worktree-diffs"
import type { WorktreeFileDiff } from "../../webview-ui/src/types/messages"
const diff = (file: string, additions = 1): WorktreeFileDiff => ({
file,
before: "",
after: "",
additions,
deletions: 0,
})
interface Sent {
type: string
sessionId?: string
file?: string
}
// Only `postMessage` is exercised by the diff workflow, so a recording stub is
// enough — the signals and merge/pending logic under test are the real thing.
const vscode = (sent: Sent[]) =>
({ postMessage: (msg: Sent) => sent.push(msg) }) as unknown as Parameters<typeof createWorktreeDiffs>[0]
const withDiffs = (fn: (diffs: ReturnType<typeof createWorktreeDiffs>, sent: Sent[]) => void) => {
createRoot((dispose) => {
const sent: Sent[] = []
fn(createWorktreeDiffs(vscode(sent)), sent)
dispose()
})
}
describe("createWorktreeDiffs", () => {
it("stores full diffs per session", () => {
withDiffs((diffs) => {
diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] })
expect(diffs.diffDatas()["s1"]).toHaveLength(1)
})
})
it("does not replace state when an update produces an identical diff list", () => {
withDiffs((diffs) => {
diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] })
const before = diffs.diffDatas()
diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts")] })
expect(diffs.diffDatas()).toBe(before)
})
})
it("replaces a single file on a diffFile message and clears its pending flag", () => {
withDiffs((diffs) => {
diffs.onWorktreeDiff({ type: "agentManager.worktreeDiff", sessionId: "s1", diffs: [diff("a.ts", 1)] })
diffs.onWorktreeDiffFile({
type: "agentManager.worktreeDiffFile",
sessionId: "s1",
file: "a.ts",
diff: diff("a.ts", 9),
})
expect(diffs.diffDatas()["s1"]![0]!.additions).toBe(9)
expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0)
})
})
it("tracks panel loading via diffLoading", () => {
withDiffs((diffs) => {
diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: true })
expect(diffs.diffLoading()).toBe(true)
diffs.onWorktreeDiffLoading({ type: "agentManager.worktreeDiffLoading", sessionId: "s1", loading: false })
expect(diffs.diffLoading()).toBe(false)
})
})
it("requestDiffFile marks a file pending, posts once, and ignores repeats", () => {
withDiffs((diffs, sent) => {
diffs.requestDiffFile("s1", "a.ts")
diffs.requestDiffFile("s1", "a.ts")
expect(sent.filter((m) => m.type === "agentManager.requestWorktreeDiffFile")).toHaveLength(1)
expect(diffs.diffFileLoadingFor(() => "s1").has("a.ts")).toBe(true)
})
})
it("refreshStaleDiffs requests only files not already loading", () => {
withDiffs((diffs, sent) => {
diffs.requestDiffFile("s1", "a.ts")
diffs.refreshStaleDiffs("s1", new Set(["a.ts", "b.ts"]))
const files = sent.filter((m) => m.type === "agentManager.requestWorktreeDiffFile").map((m) => m.file)
expect(files).toEqual(["a.ts", "b.ts"])
})
})
it("clears the session key once its last pending file resolves", () => {
withDiffs((diffs) => {
diffs.requestDiffFile("s1", "a.ts")
diffs.onWorktreeDiffFile({
type: "agentManager.worktreeDiffFile",
sessionId: "s1",
file: "a.ts",
diff: diff("a.ts"),
})
expect(diffs.diffFileLoadingFor(() => "s1").size).toBe(0)
})
})
})
@@ -41,6 +41,7 @@ import { dict as appTr } from "../../webview-ui/src/i18n/tr"
import { dict as appNl } from "../../webview-ui/src/i18n/nl"
import { dict as appUk } from "../../webview-ui/src/i18n/uk"
import { dict as appIt } from "../../webview-ui/src/i18n/it"
import { dict as appFa } from "../../webview-ui/src/i18n/fa"
// Layer 2: upstream UI (@opencode-ai/ui re-exported via @kilocode/kilo-ui)
import { dict as uiEn } from "../../../ui/src/i18n/en"
@@ -114,6 +115,7 @@ import { dict as cliTr } from "../../src/services/cli-backend/i18n/tr"
import { dict as cliNl } from "../../src/services/cli-backend/i18n/nl"
import { dict as cliUk } from "../../src/services/cli-backend/i18n/uk"
import { dict as cliIt } from "../../src/services/cli-backend/i18n/it"
import { dict as cliFa } from "../../src/services/cli-backend/i18n/fa"
import { dict as hostEn } from "../../src/services/i18n/en"
import { dict as hostZh } from "../../src/services/i18n/zh"
@@ -135,6 +137,7 @@ import { dict as hostTr } from "../../src/services/i18n/tr"
import { dict as hostNl } from "../../src/services/i18n/nl"
import { dict as hostUk } from "../../src/services/i18n/uk"
import { dict as hostIt } from "../../src/services/i18n/it"
import { dict as hostFa } from "../../src/services/i18n/fa"
// ── Locale maps ─────────────────────────────────────────────────────────────
@@ -161,6 +164,7 @@ const appLocales: Record<string, Record<string, string>> = {
nl: appNl,
uk: appUk,
it: appIt,
fa: appFa,
}
const kiloLocales: Record<string, Record<string, string>> = {
@@ -230,6 +234,7 @@ const cliLocales: Record<string, Record<string, string>> = {
nl: cliNl,
uk: cliUk,
it: cliIt,
fa: cliFa,
}
const hostLocales: Record<string, Record<string, string>> = {
@@ -253,6 +258,7 @@ const hostLocales: Record<string, Record<string, string>> = {
nl: hostNl,
uk: hostUk,
it: hostIt,
fa: hostFa,
}
// Merge webview dictionaries in the same priority order as language.tsx
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test"
import type { Config } from "@kilocode/sdk/v2/client"
import { fetchSnapshot } from "../../src/kilo-provider/config-snapshot"
// vscode mock is provided by the shared preload (tests/setup/vscode-mock.ts)
const { KiloProvider } = await import("../../src/KiloProvider")
@@ -17,6 +18,7 @@ type Internals = {
projectUnset?: string[][],
) => Promise<void>
fetchAndSendConfig: () => Promise<void>
fetchAndSendConfigUpdated: () => Promise<void>
fetchAndSendProviders: () => Promise<void>
fetchAndSendAgents: () => Promise<void>
fetchAndSendSkills: () => Promise<void>
@@ -28,17 +30,18 @@ type Internals = {
function createConnection() {
let drains = 0
const patches: unknown[] = []
const config = { snapshot: true }
const global = { snapshot: false }
const project = { default_agent: "code" }
const client = {
global: {
config: {
get: async () => ({ data: {} }),
update: async () => ({ data: {} }),
get: async () => ({ data: global }),
},
},
config: {
get: async () => ({ data: {} }),
update: async () => ({ data: {} }),
overlay: async () => ({ data: { project: {} } }),
get: async () => ({ data: config }),
overlay: async () => ({ data: { project } }),
overlayUpdate: async (patch: unknown) => {
patches.push(patch)
return { data: {} }
@@ -47,6 +50,7 @@ function createConnection() {
}
return {
client,
drains: () => drains,
patches: () => patches,
service: {
@@ -59,6 +63,29 @@ function createConnection() {
}
describe("KiloProvider indexing refresh", () => {
it("shares snapshot payloads across load, SSE refresh, and post-save refresh", async () => {
const conn = createConnection()
const settings = () => ({ maxCost: 0, languageCommitMessage: "sync" })
const snapshot = await fetchSnapshot(conn.client as never, "/repo", settings)
const provider = new KiloProvider({} as never, conn.service as never)
const internal = provider as unknown as Internals
const sent: unknown[] = []
provider.postMessage = (message) => void sent.push(message)
Object.assign(internal, { connectionState: "connected", commitMessageLanguageSetting: () => "sync" })
await internal.fetchAndSendConfig()
await internal.fetchAndSendConfigUpdated()
await internal.handleUpdateConfig({})
expect(sent).toEqual([
{ type: "configLoaded", ...snapshot },
{ type: "configUpdated", ...snapshot },
{ type: "configUpdated", ...snapshot },
])
sent.length = 0
internal.connectionState = "disconnected"
await internal.fetchAndSendConfig()
expect(sent).toEqual([{ type: "configLoaded", ...snapshot }])
})
it("reloadAfterAuthChange fetches config first, then indexing status", async () => {
const provider = new KiloProvider({} as never, {} as never)
const internal = provider as unknown as Internals
@@ -33,6 +33,11 @@ describe("normalizeLocale", () => {
expect(normalizeLocale("ko-KR")).toBe("ko")
})
it("returns 'fa' for Persian", () => {
expect(normalizeLocale("fa")).toBe("fa")
expect(normalizeLocale("fa-IR")).toBe("fa")
})
it("returns 'no' for Norwegian Bokmål", () => {
expect(normalizeLocale("nb")).toBe("no")
expect(normalizeLocale("nb-NO")).toBe("no")
@@ -141,7 +141,9 @@ describe("handlePermissionResponse", () => {
await handlePermissionResponse(fake, "p1", "s1", "once", [], [])
expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }])
expect(replies).toEqual([
{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true },
])
})
it("saves selected rules and replies in the recorded SSE directory", async () => {
@@ -158,7 +160,9 @@ describe("handlePermissionResponse", () => {
deniedAlways: ["rm *"],
},
])
expect(replies).toEqual([{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature" }])
expect(replies).toEqual([
{ requestID: "p1", reply: "reject", directory: "/workspace/.kilo/worktrees/feature", interactive: true },
])
})
it("treats an SDK-wrapped 404 while saving rules as stale", async () => {
@@ -192,7 +196,9 @@ describe("handlePermissionResponse", () => {
await handlePermissionResponse(fake, "p1", "s1", "once", [], [])
expect(replies).toEqual([{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature" }])
expect(replies).toEqual([
{ requestID: "p1", reply: "once", directory: "/workspace/.kilo/worktrees/feature", interactive: true },
])
expect(permDirs.has("p1")).toBe(false)
expect(messages).toEqual([{ type: "permissionError", permissionID: "p1", stale: true }])
})
@@ -0,0 +1,192 @@
import { describe, expect, it } from "bun:test"
import { messageTurns } from "../../webview-ui/src/context/session-queue"
import { transcriptRows } from "../../webview-ui/src/context/transcript-rows"
import type { Message, Part, TextPart } from "../../webview-ui/src/types/messages"
import { capacity, previewText, promptItems, railItems } from "../../webview-ui/src/components/chat/prompt-rail"
const base = {
sessionID: "session",
createdAt: "2026-01-01T00:00:00.000Z",
time: { created: 1 },
}
const user = (id: string, opts: Partial<Message> = {}): Message => ({ ...base, id, role: "user", ...opts })
const assistant = (id: string, parentID: string, opts: Partial<Message> = {}): Message => ({
...base,
id,
parentID,
role: "assistant",
...opts,
})
const text = (id: string, messageID: string, value: string, opts: Partial<TextPart> = {}): Part => ({
id,
messageID,
type: "text",
text: value,
...opts,
})
const tool = (id: string, messageID: string, title: string): Part => ({
id,
messageID,
type: "tool",
tool: "bash",
state: { status: "completed", input: {}, output: "", title },
})
const lookup = (values: Record<string, Part[]>) => (id: string) => values[id] ?? []
describe("previewText", () => {
it("collapses whitespace and keeps plain text", () => {
expect(previewText(" hello\n\nworld ")).toBe("hello world")
})
it("drops fenced code blocks and inline code", () => {
expect(previewText("before `const x = 1` after\n```\nconst y = 2\n```\nend")).toBe("before after end")
})
it("keeps link labels but drops URLs and images", () => {
expect(previewText("see [the docs](https://example.com) and ![shot](img.png) now")).toBe("see the docs and now")
})
it("keeps bracket text inside inline code literal", () => {
expect(previewText("echo `[label](url)` verbatim")).toBe("echo verbatim")
})
it("strips heading, list, and quote markers", () => {
expect(previewText("# Title\n- one\n> two\nthree")).toBe("Title one two three")
})
})
describe("promptItems", () => {
it("emits one item per non-partial user turn with prompt and answer", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const u2 = user("u2")
const a2 = assistant("a2", "u2")
const parts = {
u1: [text("up1", "u1", "add authentication")],
a1: [text("ap1", "a1", "Done, auth is wired up.")],
u2: [text("up2", "u2", "now fix the bug")],
a2: [text("ap2", "a2", "Fixed it.")],
}
const rows = transcriptRows(messageTurns([u1, a1, u2, a2]), lookup(parts))
const items = promptItems(rows)
expect(items).toEqual([
{ key: "u1:user", turn: "u1", queued: false, prompt: "add authentication", answer: "Done, auth is wired up." },
{ key: "u2:user", turn: "u2", queued: false, prompt: "now fix the bug", answer: "Fixed it." },
])
})
it("keeps assistant chunks of one turn joined", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const parts = {
u1: [text("up1", "u1", "go")],
a1: [text("ap1", "a1", "First."), text("ap2", "a1", "Second.")],
}
const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts))
expect(promptItems(rows)[0]?.answer).toBe("First. Second.")
})
it("skips synthetic parts and leaves an empty answer for tool-only turns", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const parts = {
u1: [text("up1", "u1", "run it"), text("up2", "u1", "internal", { synthetic: true })],
a1: [tool("at1", "a1", "Run tests"), text("at2", "a1", "hidden", { synthetic: true })],
}
const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts))
expect(promptItems(rows)[0]).toMatchObject({ prompt: "run it", answer: "" })
})
it("marks queued rows", () => {
const u1 = user("u1")
const u2 = user("u2")
const rows = transcriptRows(messageTurns([u1, u2]), lookup({}), { queued: new Set(["u2"]) })
expect(promptItems(rows).map((item) => item.queued)).toEqual([false, true])
})
it("truncates long prompts and answers", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const parts = {
u1: [text("up1", "u1", "x".repeat(400))],
a1: [text("ap1", "a1", "y".repeat(400))],
}
const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts))
const [item] = promptItems(rows)
expect(item!.prompt.length).toBe(160)
expect(item!.prompt.endsWith("…")).toBe(true)
expect(item!.answer.length).toBe(220)
expect(item!.answer.endsWith("…")).toBe(true)
})
it("omits partial turns (assistant-only leads)", () => {
const a1 = assistant("a1", "u1")
const rows = transcriptRows(messageTurns([a1]), lookup({ a1: [text("p1", "a1", "hello")] }))
expect(promptItems(rows)).toEqual([])
})
it("promotes the answer when the prompt carries no text (image-only message)", () => {
const u1 = user("u1")
const a1 = assistant("a1", "u1")
const parts = {
u1: [{ id: "up1", messageID: "u1", type: "file", mime: "image/png", url: "data:," } as Part],
a1: [text("ap1", "a1", "That screenshot shows the rail overlapping the gutter.")],
}
const rows = transcriptRows(messageTurns([u1, a1]), lookup(parts))
expect(promptItems(rows)[0]).toMatchObject({
prompt: "That screenshot shows the rail overlapping the gutter.",
answer: "",
})
})
it("leaves both empty when neither prompt nor answer has text", () => {
const u1 = user("u1")
const rows = transcriptRows(messageTurns([u1]), lookup({}))
expect(promptItems(rows)[0]).toMatchObject({ prompt: "", answer: "" })
})
})
describe("capacity", () => {
it("counts how many worst-case rows fit the transcript height", () => {
expect(capacity(24 + 76 * 5)).toBe(5)
expect(capacity(100)).toBe(1)
})
it("returns nothing usable for unmeasured or tiny transcripts", () => {
expect(capacity(0)).toBeLessThan(1)
expect(capacity(99)).toBeLessThan(1)
})
})
describe("railItems", () => {
const items = Array.from({ length: 5 }, (_, i) => ({
key: `k${i}`,
turn: `t${i}`,
queued: false,
prompt: `p${i}`,
answer: `a${i}`,
}))
it("passes through when everything fits", () => {
expect(railItems(items, 5)).toEqual(items)
expect(railItems(items, 10)).toEqual(items)
})
it("keeps the newest items when capacity is smaller", () => {
expect(railItems(items, 2)).toEqual(items.slice(-2))
})
it("returns nothing at zero capacity", () => {
expect(railItems(items, 0)).toEqual([])
})
})
@@ -138,6 +138,13 @@ describe("terminal", () => {
).toBe("error")
})
it("hides superseded turns that handed off to a queued follow-up", () => {
expect(
terminal({ reason: "superseded", messages: [message("tool-calls")], todos: [todo("pending")] }),
).toBeUndefined()
expect(terminal({ reason: "superseded", messages: [message("unknown")], todos: [] })).toBeUndefined()
})
it("reports only the latest assistant finish reason", () => {
const user: Message = { id: "u1", sessionID: "s1", role: "user", createdAt: new Date(1).toISOString() }
expect(terminal({ reason: "completed", messages: [message("length"), user], todos: [] })?.finish).toBeUndefined()
@@ -9,6 +9,7 @@ import { describe, it, expect } from "bun:test"
import path from "node:path"
import { Project, SyntaxKind } from "ts-morph"
import { SessionTerminalManager, type TerminalHost } from "../../src/agent-manager/SessionTerminalManager"
import type { WorktreeStateManager } from "../../src/agent-manager/WorktreeStateManager"
const ROOT = path.resolve(import.meta.dir, "../..")
const FILE = path.join(ROOT, "src/agent-manager/SessionTerminalManager.ts")
@@ -130,8 +131,8 @@ describe("SessionTerminalManager structure", () => {
expect(text).toContain("panel command registration skipped")
})
it("resolves the session that owns the active managed terminal", () => {
const text = body("activeSession")
it("resolves the key that owns the active managed terminal", () => {
const text = body("activeKey")
expect(text).toContain("this.host.activeTerminal()")
expect(text).toContain("entry.terminal === active")
})
@@ -139,7 +140,8 @@ describe("SessionTerminalManager structure", () => {
it("rejects context capture from another managed session", () => {
const text = body("prepareContext")
expect(text).toContain("this.showExisting(sessionId)")
expect(text).toContain("this.activeSession()")
expect(text).toContain("this.activeKey()")
expect(text).toContain("SessionTerminalManager.sessionKey(sessionId)")
})
})
@@ -162,3 +164,74 @@ describe("SessionTerminalManager command restoration", () => {
state.manager.dispose()
})
})
describe("SessionTerminalManager worktree terminals", () => {
function scene(opts: { worktreePath?: string; repoPath?: string } = {}) {
const created: Array<{ cwd: string; name: string }> = []
const warnings: string[] = []
let shown = 0
const host: TerminalHost = {
createTerminal(o) {
created.push(o)
return {
show: () => shown++,
dispose() {},
exitStatus: undefined,
}
},
activeTerminal: () => undefined,
repoPath: () => opts.repoPath,
showWarning: (msg) => warnings.push(msg),
setContext() {},
onTerminalClosed: () => ({ dispose() {} }),
onActiveTerminalChanged: () => ({ dispose() {} }),
registerCommand: () => ({ dispose() {} }),
executeCommand: () => Promise.resolve(),
}
const state = {
getWorktree: (id: string) =>
opts.worktreePath ? { id, path: opts.worktreePath, branch: "feature/x" } : undefined,
} as unknown as WorktreeStateManager
const manager = new SessionTerminalManager(() => {}, host)
return { manager, state, created, warnings, shown: () => shown }
}
it("creates a terminal rooted at the worktree path", () => {
const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1", repoPath: "/repo" })
s.manager.showWorktreeTerminal("wt-1", s.state)
expect(s.created).toEqual([{ cwd: "/repo/.kilo/worktrees/wt-1", name: "Agent: feature/x" }])
expect(s.shown()).toBe(1)
})
it("reuses the live terminal on repeat calls", () => {
const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1" })
s.manager.showWorktreeTerminal("wt-1", s.state)
s.manager.showWorktreeTerminal("wt-1", s.state)
expect(s.created).toHaveLength(1)
expect(s.shown()).toBe(2)
})
it("keeps session and worktree terminal keys in separate namespaces", () => {
const s = scene({ worktreePath: "/repo/.kilo/worktrees/wt-1", repoPath: "/repo" })
s.manager.showTerminal("worktree:wt-1", undefined)
s.manager.showWorktreeTerminal("wt-1", s.state)
expect(s.created).toEqual([
{ cwd: "/repo", name: "Agent: local" },
{ cwd: "/repo/.kilo/worktrees/wt-1", name: "Agent: feature/x" },
])
expect(s.shown()).toBe(2)
})
it("falls back to the repo root when the worktree is unknown", () => {
const s = scene({ repoPath: "/repo" })
s.manager.showWorktreeTerminal("gone", s.state)
expect(s.created).toEqual([{ cwd: "/repo", name: "Agent: worktree" }])
})
it("warns and creates nothing when no cwd resolves", () => {
const s = scene({})
s.manager.showWorktreeTerminal("gone", s.state)
expect(s.created).toHaveLength(0)
expect(s.warnings).toHaveLength(1)
})
})
@@ -75,9 +75,12 @@ async function settle(page: Page) {
// Spinner animation captures at an indeterminate frame, causing flaky diffs.
// Permission dock config-preloaded has non-deterministic toggle rendering.
// Sandboxing rows can settle at different scroll heights after settings context updates.
// Side terminal tabs mount live xterm instances whose websocket error text
// lands at indeterminate times.
const SKIP = new Set<string>([
"agentmanager--worktree-item-busy",
"agentmanager--full-screen-diff-agent-edit-scroll",
"agentmanager--side-terminal-panel-tabs",
"composite-webview--permission-dock-config-preloaded",
"settings--sandboxing-allowlist",
"settings--sandboxing-panel",
@@ -75,9 +75,12 @@ async function settle(page: Page) {
// Spinner animation captures at an indeterminate frame, causing flaky diffs.
// Permission dock config-preloaded has non-deterministic toggle rendering.
// Sandboxing rows can settle at different scroll heights after settings context updates.
// Side terminal tabs mount live xterm instances whose websocket error text
// lands at indeterminate times.
const SKIP = new Set<string>([
"agentmanager--worktree-item-busy",
"agentmanager--full-screen-diff-agent-edit-scroll",
"agentmanager--side-terminal-panel-tabs",
"composite-webview--permission-dock-config-preloaded",
"settings--sandboxing-allowlist",
"settings--sandboxing-panel",
@@ -24,8 +24,6 @@ import type {
AgentManagerWorktreeDiffFileMessage,
AgentManagerWorktreeDiffLoadingMessage,
AgentManagerApplyWorktreeDiffResultMessage,
AgentManagerApplyWorktreeDiffStatus,
AgentManagerApplyWorktreeDiffConflict,
AgentManagerWorktreeStatsMessage,
AgentManagerLocalStatsMessage,
WorktreeFileDiff,
@@ -41,7 +39,6 @@ import type {
SessionCreatedMessage,
BranchInfo,
} from "../src/types/messages"
import { IndexingProvider } from "../src/context/indexing"
import {
DragDropProvider,
DragDropSensors,
@@ -51,43 +48,24 @@ import {
createSortable,
} from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { ThemeProvider } from "@kilocode/kilo-ui/theme"
import { DialogProvider, useDialog } from "@kilocode/kilo-ui/context/dialog"
import { useDialog } from "@kilocode/kilo-ui/context/dialog"
import { Dialog } from "@kilocode/kilo-ui/dialog"
import { DropdownMenu } from "@kilocode/kilo-ui/dropdown-menu"
import { MarkedProvider } from "@kilocode/kilo-ui/context/marked"
import { CodeComponentProvider } from "@kilocode/kilo-ui/context/code"
import { DiffComponentProvider } from "@kilocode/kilo-ui/context/diff"
import { FileComponentProvider } from "@kilocode/kilo-ui/context/file"
import { Code } from "@kilocode/kilo-ui/code"
import { Diff } from "@kilocode/kilo-ui/diff"
import { File } from "@kilocode/kilo-ui/file"
import { Toast, showToast } from "@kilocode/kilo-ui/toast"
import { showToast } from "@kilocode/kilo-ui/toast"
import { ResizeHandle } from "@kilocode/kilo-ui/resize-handle"
import { Icon } from "@kilocode/kilo-ui/icon"
import { Button } from "@kilocode/kilo-ui/button"
import { IconButton } from "@kilocode/kilo-ui/icon-button"
import { Spinner } from "@kilocode/kilo-ui/spinner"
import { Tooltip, TooltipKeybind } from "@kilocode/kilo-ui/tooltip"
import { VSCodeProvider, useVSCode } from "../src/context/vscode"
import { ServerProvider } from "../src/context/server"
import { ProviderProvider } from "../src/context/provider"
import { ConfigProvider } from "../src/context/config"
import { DisplayProvider } from "../src/context/display"
import { KiloEmbeddingModelsProvider } from "../src/context/kilo-embedding-models"
import { ImageModelsProvider } from "../src/context/image-models"
import { NotificationsProvider } from "../src/context/notifications"
import { FeedbackProvider } from "../src/context/feedback"
import { MemoryProvider } from "../src/context/memory"
import { SessionProvider, useSession } from "../src/context/session"
import { AgentRequirementsProvider } from "../src/context/agent-requirements"
import { useVSCode } from "../src/context/vscode"
import { useSession } from "../src/context/session"
import { WorktreeModeProvider } from "../src/context/worktree-mode"
import { ProviderShell } from "../src/context/provider-shell"
import { ChatView } from "../src/components/chat"
import { SpeechToTextPrewarm } from "../src/components/speech-to-text/SpeechToTextPrewarm"
import HistoryView from "../src/components/history/HistoryView"
import { NewWorktreeDialog } from "./NewWorktreeDialog"
import { DataBridge, MermaidDownloadBridge } from "../src/App"
import { LanguageBridge } from "../src/context/language-bridge"
import { DataBridge } from "../src/App"
import { useLanguage } from "../src/context/language"
import { createTabFocus } from "../src/utils/tab-navigation"
import {
@@ -126,14 +104,16 @@ import {
createTerminalHandlers,
createTerminalMessageHandler,
createSideTerminal,
readSavedDestination,
resolveVscodeTerminalRequest,
} from "./terminal"
import { focusCurrentTab, renderTab, renderTerminalLayer, renderNewTabButton } from "./tab-rendering"
import { useTabScroll } from "./tab-scroll"
import { DiffPanel } from "./DiffPanel"
import { createRevertFile } from "./revert-file"
import { FullScreenDiffView } from "../diff-viewer/FullScreenDiffView"
import { ApplyDialog } from "./ApplyDialog"
import { groupApplyConflicts } from "./apply-conflicts"
import { createApplyToLocal } from "./apply-to-local"
import { createWorktreeDiffs } from "./worktree-diffs"
import type { ReviewComment } from "../diff-viewer/review-comments"
import { clearReviewComposer, createReviewComposer } from "../diff-viewer/review-annotations"
import type { SidebarSearchMenuRef } from "./SidebarSearchMenu"
@@ -156,7 +136,6 @@ import {
} from "./section-helpers"
import { sectionAwareDetector } from "./section-dnd"
import { ConstrainDragXAxis } from "./constrain-drag-x"
import { mergeWorktreeDiffs } from "../diff-viewer/diff-state"
import { initialMessage, seedInitialVariant } from "./initial-message"
import { createMarkdownRender } from "./review-preferences"
import { createSidebarCollapse } from "./sidebar-collapse"
@@ -183,12 +162,6 @@ interface WorktreeBusyState {
message?: string
branch?: string
}
interface ApplyState {
status: AgentManagerApplyWorktreeDiffStatus
message: string
conflicts: AgentManagerApplyWorktreeDiffConflict[]
}
/** Sidebar selection: LOCAL for local repo, worktree ID for a worktree, or null for an unassigned session. */
type SidebarSelection = typeof LOCAL | string | null
type SidePanel = "diff" | "pr" | "terminal" | null
@@ -241,6 +214,8 @@ const AgentManagerContent: Component = () => {
const [repoBranch, setRepoBranch] = createSignal<string | undefined>()
const [busyWorktrees, setBusyWorktrees] = createSignal<Map<string, WorktreeBusyState>>(new Map())
const [staleWorktreeIds, setStaleWorktreeIds] = createSignal<Set<string>>(new Set())
/** True while the ⌘/Ctrl jump modifier is held — reveals the ⌘1-9 badges on all sidebar items. */
const [held, setHeld] = createSignal(false)
const [worktreesLoaded, setWorktreesLoaded] = createSignal(false)
const [sessionsLoaded, setSessionsLoaded] = createSignal(false)
const [isGitRepo, setIsGitRepo] = createSignal(true)
@@ -282,9 +257,10 @@ const AgentManagerContent: Component = () => {
const [history, setHistory] = createSignal(false)
const [sidePanel, setSidePanel] = createSignal<SidePanel>(null)
const diffOpen = () => sidePanel() === "diff"
const [diffDatas, setDiffDatas] = createSignal<Record<string, WorktreeFileDiff[]>>({})
const [diffLoading, setDiffLoading] = createSignal(false)
const [diffFileLoading, setDiffFileLoading] = createSignal<Record<string, Record<string, true>>>({})
const diffs = createWorktreeDiffs(vscode)
const diffDatas = diffs.diffDatas
const diffLoading = diffs.diffLoading
const setDiffLoading = diffs.setDiffLoading
// The diff and terminal panels each remember their own width: a diff
// benefits from half the window, a terminal only needs about a third.
const TERMINAL_MIN_WIDTH = 360
@@ -331,12 +307,6 @@ const AgentManagerContent: Component = () => {
// Local repo git stats (branch name, diff additions/deletions, commits)
const [localStats, setLocalStats] = createSignal<LocalGitStats | undefined>()
// Per-worktree apply-to-local status
const [applyStates, setApplyStates] = createSignal<Record<string, ApplyState>>({})
const [applyTarget, setApplyTarget] = createSignal<string | undefined>()
const [applySelectedFiles, setApplySelectedFiles] = createSignal<string[]>([])
const [applySelectionTouched, setApplySelectionTouched] = createSignal(false)
const PENDING_PREFIX = "pending:"
const closedDrafts = new Set<string>()
const [activePendingId, setActivePendingId] = createSignal<string | undefined>()
@@ -391,12 +361,6 @@ const AgentManagerContent: Component = () => {
setReviewCommentsByContext((prev) => ({ ...prev, [sel]: comments }))
}
const applyStateForSelection = createMemo(() => {
const sel = selection()
if (!sel || sel === LOCAL) return undefined
return applyStates()[sel]
})
const resolveWorktreeSessionId = (worktreeId: string) => {
const id = session.currentSessionID()
if (id) {
@@ -406,157 +370,19 @@ const AgentManagerContent: Component = () => {
return managedSessions().find((entry) => entry.worktreeId === worktreeId)?.id
}
const applyTargetSessionId = createMemo(() => {
const target = applyTarget()
if (!target) return undefined
return resolveWorktreeSessionId(target)
const apply = createApplyToLocal({
vscode,
dialog,
t,
selection,
local: LOCAL,
worktrees,
diffDatas,
diffLoading,
resolveWorktreeSessionId,
track: metrics.track,
})
const applyDiffs = createMemo(() => {
const target = applyTarget()
if (!target) return [] as WorktreeFileDiff[]
const data = diffDatas()
const current = applyTargetSessionId()
if (current && data[current]) return data[current]!
const ids = managedSessions()
.filter((entry) => entry.worktreeId === target)
.map((entry) => entry.id)
for (const id of ids) {
if (data[id]) return data[id]!
}
return [] as WorktreeFileDiff[]
})
const applyStateForTarget = createMemo(() => {
const target = applyTarget()
if (!target) return undefined
return applyStates()[target]
})
const applyBusyForTarget = createMemo(() => {
const state = applyStateForTarget()
if (!state) return false
return state.status === "checking" || state.status === "applying"
})
const applySelectedSet = createMemo(() => new Set(applySelectedFiles()))
const applySelectionStats = createMemo(() => {
const set = applySelectedSet()
const selected = applyDiffs().filter((diff) => set.has(diff.file))
const additions = selected.reduce((sum, diff) => sum + diff.additions, 0)
const deletions = selected.reduce((sum, diff) => sum + diff.deletions, 0)
return {
total: applyDiffs().length,
selected: selected.length,
additions,
deletions,
}
})
const applyHasSelection = createMemo(() => applySelectionStats().selected > 0)
const applyConflictRows = createMemo(() => groupApplyConflicts(applyStateForTarget()?.conflicts ?? []))
const applyToLocal = (worktreeId: string, selectedFiles: string[]) => {
setApplyStates((prev) => ({
...prev,
[worktreeId]: {
status: "checking",
message: t("agentManager.apply.checking"),
conflicts: [],
},
}))
vscode.postMessage({ type: "agentManager.applyWorktreeDiff", worktreeId, selectedFiles })
}
const resetApplyDialog = () => {
setApplyTarget(undefined)
setApplySelectedFiles([])
setApplySelectionTouched(false)
}
const closeApplyDialog = () => {
resetApplyDialog()
dialog.close()
}
const applySelectAll = () => {
setApplySelectionTouched(true)
setApplySelectedFiles(applyDiffs().map((diff) => diff.file))
}
const applySelectNone = () => {
setApplySelectionTouched(true)
setApplySelectedFiles([])
}
const applyToggleFile = (file: string, checked: boolean) => {
setApplySelectionTouched(true)
setApplySelectedFiles((prev) => {
if (checked) {
if (prev.includes(file)) return prev
const set = new Set(prev)
set.add(file)
return applyDiffs()
.map((diff) => diff.file)
.filter((path) => set.has(path))
}
if (!prev.includes(file)) return prev
return prev.filter((path) => path !== file)
})
}
const triggerApply = () => {
const target = applyTarget()
if (!target) return
if (!applyHasSelection()) return
if (applyBusyForTarget()) return
metrics.track("apply_to_local", "apply_dialog", { fileCount: applySelectedFiles().length })
applyToLocal(target, applySelectedFiles())
}
const openApplyDialog = () => {
const sel = selection()
if (!sel || sel === LOCAL) return
setApplyStates((prev) => {
if (!prev[sel]) return prev
const next = { ...prev }
delete next[sel]
return next
})
setApplyTarget(sel)
setApplySelectionTouched(false)
setApplySelectedFiles([])
const sid = resolveWorktreeSessionId(sel)
if (sid) vscode.postMessage({ type: "agentManager.requestWorktreeDiff", sessionId: sid })
setApplySelectedFiles(applyDiffs().map((diff) => diff.file))
dialog.show(
() => (
<ApplyDialog
diffs={applyDiffs()}
loading={diffLoading()}
selectedFiles={applySelectedSet()}
selectedCount={applySelectionStats().selected}
additions={applySelectionStats().additions}
deletions={applySelectionStats().deletions}
busy={applyBusyForTarget()}
hasSelection={applyHasSelection()}
status={applyStateForTarget()?.status}
message={applyStateForTarget()?.message}
conflictRows={applyConflictRows()}
onSelectAll={applySelectAll}
onSelectNone={applySelectNone}
onToggleFile={applyToggleFile}
onApply={triggerApply}
onClose={closeApplyDialog}
/>
),
resetApplyDialog,
)
}
const openApplyDialog = apply.openApplyDialog
const openWorktreeDirectory = () => {
const sel = selection()
@@ -588,31 +414,6 @@ const AgentManagerContent: Component = () => {
if (sel) runWorktree(sel)
}
createEffect(
on(
() => [applyTarget(), applyDiffs(), applySelectionTouched()] as const,
([target, diffs, touched]) => {
if (!target) return
const files = diffs.map((diff) => diff.file)
if (files.length === 0) {
if (!touched) setApplySelectedFiles([])
return
}
if (!touched) {
setApplySelectedFiles(files)
return
}
const current = applySelectedFiles()
const set = new Set(current)
const next = files.filter((file) => set.has(file))
const same = next.length === current.length && next.every((file, index) => file === current[index])
if (!same) setApplySelectedFiles(next)
},
),
)
const isPending = (id: string) => id.startsWith(PENDING_PREFIX)
reportRemoteSessions(vscode, localSessionIDs, managedSessions, isPending)
@@ -728,14 +529,6 @@ const AgentManagerContent: Component = () => {
if (Object.keys(next).length === Object.keys(prev).length) return prev
return next
})
setApplyStates((prev) => {
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))
if (Object.keys(next).length === Object.keys(prev).length) return prev
return next
})
const target = applyTarget()
if (target && !ids.has(target)) closeApplyDialog()
})
const worktreeSessionIds = createMemo(
@@ -1207,6 +1000,18 @@ const AgentManagerContent: Component = () => {
}
window.addEventListener("keydown", deleteKeyHandler)
// Reveal the ⌘/Ctrl+1-9 jump badges on all sidebar items while the modifier is held.
// Capture phase so the terminal's key handlers can't swallow them; blur resets state
// when the keyup is lost (e.g. Cmd+Tab away).
const modifier = isMac ? "Meta" : "Control"
const modTrack = (e: KeyboardEvent) => {
if (e.key === modifier) setHeld(e.type === "keydown")
}
const modReset = () => setHeld(false)
window.addEventListener("keydown", modTrack, true)
window.addEventListener("keyup", modTrack, true)
window.addEventListener("blur", modReset)
// When the panel regains focus (e.g. returning from terminal), focus the prompt
// and clear any stale body styles left by Kobalte modal overlays (dropdowns/dialogs
// set pointer-events:none and overflow:hidden on body, but cleanup never runs if
@@ -1283,7 +1088,7 @@ const AgentManagerContent: Component = () => {
// a slow create landing after a mode switch must not steal it.
if (sidePanel() === "terminal" && terms.sideKey() === contextKey) terms.requestFocus(terminalId)
},
onDestinationChanged: (destination) => sideCtl.setDestination(destination),
onDestinationChanged: (destination) => sideCtl.syncDefault(destination),
})
const unsubTerminals = vscode.onMessage((msg) => {
terminalDispatch(msg)
@@ -1492,65 +1297,19 @@ const AgentManagerContent: Component = () => {
}
if (msg.type === "agentManager.worktreeDiff") {
const ev = msg as AgentManagerWorktreeDiffMessage
let staleFiles: Set<string> | undefined
setDiffDatas((prev) => {
const existing = prev[ev.sessionId]
const merged = existing
? mergeWorktreeDiffs(existing, ev.diffs)
: { diffs: ev.diffs, stale: new Set<string>() }
staleFiles = merged.stale
const next = merged.diffs
if (existing && existing.length === next.length && existing.every((old, i) => old === next[i])) return prev
return { ...prev, [ev.sessionId]: next }
})
if (staleFiles) refreshStaleDiffs(ev.sessionId, staleFiles)
diffs.onWorktreeDiff(msg as AgentManagerWorktreeDiffMessage)
}
if (msg.type === "agentManager.worktreeDiffFile") {
const ev = msg as AgentManagerWorktreeDiffFileMessage
if (ev.diff) {
setDiffDatas((prev) => {
const existing = prev[ev.sessionId] ?? []
const next = existing.map((item) => (item.file === ev.diff!.file ? ev.diff! : item))
return { ...prev, [ev.sessionId]: next }
})
setDiffFilePending(ev.sessionId, ev.diff.file, false)
return
}
setDiffFilePending(ev.sessionId, ev.file, false)
diffs.onWorktreeDiffFile(msg as AgentManagerWorktreeDiffFileMessage)
}
if (msg.type === "agentManager.worktreeDiffLoading") {
const ev = msg as AgentManagerWorktreeDiffLoadingMessage
setDiffLoading(ev.loading)
diffs.onWorktreeDiffLoading(msg as AgentManagerWorktreeDiffLoadingMessage)
}
if (msg.type === "agentManager.applyWorktreeDiffResult") {
const ev = msg as AgentManagerApplyWorktreeDiffResultMessage
const files = new Set((ev.conflicts ?? []).map((entry) => entry.file).filter(Boolean)).size
const count = ev.conflicts?.length ?? 0
setApplyStates((prev) => ({
...prev,
[ev.worktreeId]: {
status: ev.status,
message: ev.message,
conflicts: ev.conflicts ?? [],
},
}))
if (ev.status === "success") {
showToast({ variant: "success", title: t("agentManager.apply.success"), description: ev.message })
if (applyTarget() === ev.worktreeId) closeApplyDialog()
}
if (ev.status === "conflict") {
const summary =
count > 0 ? t("agentManager.apply.conflictToast", { count, files: Math.max(files, 1) }) : ev.message
showToast({ variant: "error", title: t("agentManager.apply.conflict"), description: summary })
}
if (ev.status === "error") {
showToast({ variant: "error", title: t("agentManager.apply.error"), description: ev.message })
}
apply.onApplyResult(msg as AgentManagerApplyWorktreeDiffResultMessage)
}
if (msg.type === "agentManager.revertWorktreeFileResult") revertCtl.onResult(msg as never)
@@ -1578,6 +1337,9 @@ const AgentManagerContent: Component = () => {
window.removeEventListener("message", handler)
window.removeEventListener("keydown", preventDefaults, true)
window.removeEventListener("keydown", deleteKeyHandler)
window.removeEventListener("keydown", modTrack, true)
window.removeEventListener("keyup", modTrack, true)
window.removeEventListener("blur", modReset)
window.removeEventListener("focus", onWindowFocus)
window.removeEventListener("newTaskRequest", newTaskHandler, true)
drafts.cleanup()
@@ -1704,54 +1466,13 @@ const AgentManagerContent: Component = () => {
vscode.postMessage({ type: "agentManager.setReviewDiffStyle", style })
}
const setDiffFilePending = (sessionId: string, file: string, value: boolean) => {
setDiffFileLoading((prev) => {
const session = prev[sessionId] ?? {}
if (value) {
if (session[file]) return prev
return {
...prev,
[sessionId]: { ...session, [file]: true },
}
}
if (!session[file]) return prev
const next = { ...session }
delete next[file]
if (Object.keys(next).length === 0) {
const result = { ...prev }
delete result[sessionId]
return result
}
return {
...prev,
[sessionId]: next,
}
})
}
const requestDiffFile = (file: string) => {
const sessionId = currentDiffSessionId()
if (!sessionId) return
if (diffFileLoading()[sessionId]?.[file]) return
setDiffFilePending(sessionId, file, true)
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file })
diffs.requestDiffFile(sessionId, file)
}
const refreshStaleDiffs = (sessionId: string, files: Set<string>) => {
const loading = diffFileLoading()[sessionId] ?? {}
for (const file of files) {
if (loading[file]) continue
setDiffFilePending(sessionId, file, true)
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", sessionId, file })
}
}
const diffFileLoadingForCurrent = createMemo(() => {
const sessionId = currentDiffSessionId()
if (!sessionId) return new Set<string>()
return new Set(Object.keys(diffFileLoading()[sessionId] ?? {}))
})
const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(currentDiffSessionId))
const revertCtl = createRevertFile(currentDiffSessionId, vscode, showToast, t)
@@ -2092,9 +1813,6 @@ const AgentManagerContent: Component = () => {
postMessage: (msg) => vscode.postMessage(msg as never),
onRemove: freezeTabs,
onShowSide: showSideTerminal,
onHideSide: () => {
if (sidePanel() === "terminal") setSidePanel(null)
},
getSelection: selection,
LOCAL,
REVIEW_TAB_ID,
@@ -2103,16 +1821,22 @@ const AgentManagerContent: Component = () => {
const sideCtl = createSideTerminal({
handlers: termHandlers,
visible: () => sidePanel() === "terminal",
focused: () => terms.focusedId() !== undefined && terms.focusedId() === terms.side()?.id,
focusedId: () => terms.sideFocusedId(),
hide: () => setSidePanel(null),
refocus: () => window.dispatchEvent(new Event("focusPrompt")),
postMessage: (msg) => vscode.postMessage(msg as never),
track: (button, surface, properties) => metrics.track(button, surface, properties),
openVscode: () => {
const id = session.currentSessionID()
if (id) vscode.postMessage({ type: "agentManager.showTerminal", sessionId: id })
else if (selection() === LOCAL) vscode.postMessage({ type: "agentManager.showLocalTerminal" })
},
// Panel-local pick, immune to cross-window setting echoes (see side.ts).
saved: readSavedDestination(vscode.getState<Record<string, unknown>>()),
save: (d) => vscode.setState({ ...vscode.getState<Record<string, unknown>>(), terminalDestination: d }),
openVscode: () =>
vscode.postMessage(
resolveVscodeTerminalRequest(
selection(),
session.currentSessionID(),
(wt) => managedSessions().find((ms) => ms.worktreeId === wt)?.id,
) as never,
),
})
const handleReviewTabMouseDown = (e: MouseEvent) => {
@@ -2186,8 +1910,8 @@ const AgentManagerContent: Component = () => {
if (!id) return undefined
if (id === REVIEW_TAB_ID) return { id, title: t("session.tab.review") }
if (isTerminalTabId(id)) {
const term = terms.lookup().get(id)
return term ? { id, title: term.title } : undefined
const title = terms.title(id)
return title ? { id, title } : undefined
}
return activeTabs().find((s) => s.id === id)
})
@@ -2215,8 +1939,8 @@ const AgentManagerContent: Component = () => {
const closeActiveTab = () => {
// A focused side terminal owns Cmd+W while its panel is visible —
// closing a chat tab out from under the user's cursor would be
// surprising.
if (sidePanel() === "terminal" && terms.focusedId() && terms.focusedId() === terms.side()?.id) {
// surprising. Only that terminal dies; the panel keeps the rest.
if (sidePanel() === "terminal" && terms.sideFocusedId()) {
if (sideCtl.close()) return
}
if (termHandlers.closeActive()) {
@@ -2269,7 +1993,7 @@ const AgentManagerContent: Component = () => {
>
<div
class="am-sidebar"
classList={{ "am-sidebar-collapsed": sidebarCollapsed() }}
classList={{ "am-sidebar-collapsed": sidebarCollapsed(), "am-show-shortcuts": held() }}
style={{ width: sidebarCollapsed() ? "0px" : `${sidebarWidth()}px` }}
inert={sidebarCollapsed() || undefined}
>
@@ -2741,11 +2465,7 @@ const AgentManagerContent: Component = () => {
const s = stats()
return s && (s.files > 0 || s.additions > 0 || s.deletions > 0)
}
const applyBusy = () => {
const state = applyStateForSelection()
if (!state) return false
return state.status === "checking" || state.status === "applying"
}
const applyBusy = apply.applyBusyForSelection
return (
<>
<Show when={isWorktree()}>
@@ -3092,8 +2812,9 @@ const AgentManagerContent: Component = () => {
state={terms}
contextKey={terms.sideKey}
visible={() => sidePanel() === "terminal"}
onClose={() => sideCtl.close()}
onStart={() => termHandlers.requestSide()}
onSelect={(id) => termHandlers.selectSide(id)}
onClose={(id) => termHandlers.closeSide(id)}
onStart={() => termHandlers.addSide()}
/>
</div>
</div>
@@ -3139,53 +2860,16 @@ const AgentManagerContent: Component = () => {
export const AgentManagerApp: Component = () => {
return (
<ThemeProvider defaultTheme="kilo-vscode">
<DialogProvider>
<VSCodeProvider>
<MermaidDownloadBridge />
<ServerProvider>
<LanguageBridge>
<MarkedProvider>
<DiffComponentProvider component={Diff}>
<CodeComponentProvider component={Code}>
<FileComponentProvider component={File}>
<ProviderProvider>
<ConfigProvider>
<SpeechToTextPrewarm />
<DisplayProvider>
<IndexingProvider>
<KiloEmbeddingModelsProvider>
<ImageModelsProvider>
<NotificationsProvider>
<SessionProvider>
<AgentRequirementsProvider>
<MemoryProvider>
<FeedbackProvider>
<WorktreeModeProvider>
<DataBridge>
<AgentManagerContent />
</DataBridge>
</WorktreeModeProvider>
</FeedbackProvider>
</MemoryProvider>
</AgentRequirementsProvider>
</SessionProvider>
</NotificationsProvider>
</ImageModelsProvider>
</KiloEmbeddingModelsProvider>
</IndexingProvider>
</DisplayProvider>
</ConfigProvider>
</ProviderProvider>
</FileComponentProvider>
</CodeComponentProvider>
</DiffComponentProvider>
</MarkedProvider>
</LanguageBridge>
</ServerProvider>
</VSCodeProvider>
<Toast.Region />
</DialogProvider>
</ThemeProvider>
<ProviderShell.Root>
<ProviderShell.Session>
<ProviderShell.Chat>
<WorktreeModeProvider>
<DataBridge>
<AgentManagerContent />
</DataBridge>
</WorktreeModeProvider>
</ProviderShell.Chat>
</ProviderShell.Session>
</ProviderShell.Root>
)
}
@@ -167,6 +167,13 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
props.onOpenPR?.()
}
/** Worktree directory basename shown in the hover card (e.g. "decorous-taker"). */
const name = () => {
const p = props.worktree.path.replace(/[\\/]+$/, "")
const i = Math.max(p.lastIndexOf("/"), p.lastIndexOf("\\"))
return i >= 0 ? p.slice(i + 1) : p
}
return (
<>
<Show when={props.groupStart}>
@@ -387,6 +394,11 @@ export const WorktreeItem: Component<WorktreeItemProps> = (props) => {
<span class="am-hover-card-keybind">{props.navHint}</span>
</Show>
</div>
<div class="am-hover-card-divider" />
<div class="am-hover-card-row">
<span class="am-hover-card-row-label">{t("agentManager.hoverCard.worktree")}</span>
<span class="am-hover-card-row-value">{name()}</span>
</div>
<Show when={props.worktree.parentBranch}>
<div class="am-hover-card-divider" />
<div class="am-hover-card-row">
@@ -426,6 +426,25 @@ button.am-section-toggle:hover .am-section-label {
color: var(--text-weak);
}
/* Jump modifier held (/Ctrl): reveal shortcut badges on every item, not just the
hovered one. Scoped via :has so items without a badge (beyond 9) keep their stats.
The close button stays hidden so holding the modifier never exposes the delete action. */
.am-show-shortcuts .am-wt-hover-actions:has(.am-shortcut-badge) {
opacity: 1;
visibility: visible;
}
.am-show-shortcuts .am-wt-hover-actions .am-worktree-close {
display: none;
}
.am-show-shortcuts .am-wt-actions-cell:has(.am-shortcut-badge) > .am-worktree-stats,
.am-show-shortcuts .am-wt-actions-cell:has(.am-shortcut-badge) > .am-worktree-stats-skeleton {
opacity: 0;
visibility: hidden;
}
.am-show-shortcuts .am-local-item .am-shortcut-badge {
opacity: 1;
}
.am-worktree-item:has(.am-worktree-rename-input) .am-wt-row2 {
display: none;
}
@@ -4599,6 +4618,59 @@ body.vscode-high-contrast-light {
color: var(--text-weak);
}
/* Side terminal tab strip one row of tabs reusing the top bar's
.am-tab chrome, plus the "+" action. Height matches .am-diff-header
(32px: 4px padding + 24px content) so switching inspector modes does
not shift the panel chrome. The strip itself never scrolls; the tab
list does, so a narrow panel never pushes the "+" action out of view
(same split as .am-tab-list-wrap / .am-tab-add-wrap). */
.am-side-terminal-tabs {
display: flex;
align-items: stretch;
height: 32px;
padding: 4px 4px 0;
gap: 2px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weak-base);
background: var(--surface-base);
position: relative;
z-index: 20;
}
.am-side-terminal-tablist {
display: flex;
align-items: stretch;
gap: 2px;
flex: 1;
min-width: 0;
height: 100%;
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
}
.am-side-terminal-tablist::-webkit-scrollbar {
display: none;
}
/* Each tab shares the available width and shrinks with ellipsis.
touch-action unlocks pointer-based drag reordering (same as
.am-tab-sortable). */
.am-side-terminal-tab {
display: flex;
flex: 0 1 140px;
min-width: 64px;
height: 100%;
touch-action: none;
}
.am-side-terminal-add {
display: flex;
align-items: center;
flex-shrink: 0;
padding: 0 2px;
}
/* Hidden-but-alive side panel host: taken out of the flow so the chat
reclaims the width, but kept painted so hidden side terminals keep
streaming. Anchored to .am-detail-stack (position: relative). */
@@ -4615,14 +4687,15 @@ body.vscode-high-contrast-light {
flex: 1;
min-height: 0;
min-width: 0;
padding: 8px;
background: var(--vscode-terminal-background, #1e1e1e);
}
/* Third-party xterm classes addressed by attribute selector so the
agent-manager "am-* prefix" architecture test does not flag them. */
.am-terminal-host [class="xterm"] {
agent-manager "am-* prefix" architecture test does not flag them.
FitAddon subtracts padding from xterm itself, not its parent host. */
.am-terminal-host [class~="xterm"] {
height: 100%;
padding: 8px;
}
.am-terminal-host [class~="xterm-viewport"] {
@@ -0,0 +1,285 @@
/** @jsxImportSource solid-js */
/**
* Apply-to-local workflow for Agent Manager.
*
* Owns everything about applying a selected worktree's diff into the local
* repo: the per-worktree apply status, the dialog's file selection state, the
* derived memos that drive `ApplyDialog`, and the result/toast handling for
* the backend's `applyWorktreeDiffResult` message. Extracted from
* `AgentManagerApp.tsx` so the app component only wires the workflow in.
*/
import { createEffect, createMemo, createSignal, on, type Accessor } from "solid-js"
import { showToast } from "@kilocode/kilo-ui/toast"
import { groupApplyConflicts } from "./apply-conflicts"
import { ApplyDialog } from "./ApplyDialog"
import type { tracker } from "./telemetry"
import type { useDialog } from "@kilocode/kilo-ui/context/dialog"
import type { useLanguage } from "../src/context/language"
import type { useVSCode } from "../src/context/vscode"
import type { AgentManagerApplyWorktreeDiffResultMessage, WorktreeFileDiff } from "../src/types/messages"
interface ApplyState {
status: AgentManagerApplyWorktreeDiffResultMessage["status"]
message: string
conflicts: NonNullable<AgentManagerApplyWorktreeDiffResultMessage["conflicts"]>
}
interface ApplyToLocalOptions {
vscode: ReturnType<typeof useVSCode>
dialog: ReturnType<typeof useDialog>
t: ReturnType<typeof useLanguage>["t"]
/** Current sidebar selection (LOCAL, a worktree id, or null). */
selection: Accessor<string | null>
/** Sentinel id for the local repo selection. */
local: string
worktrees: Accessor<{ id: string }[]>
diffDatas: Accessor<Record<string, WorktreeFileDiff[]>>
diffLoading: Accessor<boolean>
resolveWorktreeSessionId: (worktreeId: string) => string | undefined
/** Telemetry: metrics.track(name, surface, data). */
track: ReturnType<typeof tracker>["track"]
}
export function createApplyToLocal(opts: ApplyToLocalOptions) {
const { vscode, dialog, t, selection, local, worktrees, diffDatas, diffLoading } = opts
const [applyStates, setApplyStates] = createSignal<Record<string, ApplyState>>({})
const [applyTarget, setApplyTarget] = createSignal<string | undefined>()
const [applySelectedFiles, setApplySelectedFiles] = createSignal<string[]>([])
const [applySelectionTouched, setApplySelectionTouched] = createSignal(false)
const applyStateForSelection = createMemo(() => {
const sel = selection()
if (!sel || sel === local) return undefined
return applyStates()[sel]
})
const applyBusyForSelection = createMemo(() => {
const state = applyStateForSelection()
if (!state) return false
return state.status === "checking" || state.status === "applying"
})
const applyTargetSessionId = createMemo(() => {
const target = applyTarget()
if (!target) return undefined
return opts.resolveWorktreeSessionId(target)
})
const applyDiffs = createMemo(() => {
const target = applyTarget()
if (!target) return [] as WorktreeFileDiff[]
const data = diffDatas()
const current = applyTargetSessionId()
if (current && data[current]) return data[current]!
return [] as WorktreeFileDiff[]
})
const applyStateForTarget = createMemo(() => {
const target = applyTarget()
if (!target) return undefined
return applyStates()[target]
})
const applyBusyForTarget = createMemo(() => {
const state = applyStateForTarget()
if (!state) return false
return state.status === "checking" || state.status === "applying"
})
const applySelectedSet = createMemo(() => new Set(applySelectedFiles()))
const applySelectionStats = createMemo(() => {
const set = applySelectedSet()
const selected = applyDiffs().filter((diff) => set.has(diff.file))
const additions = selected.reduce((sum, diff) => sum + diff.additions, 0)
const deletions = selected.reduce((sum, diff) => sum + diff.deletions, 0)
return {
total: applyDiffs().length,
selected: selected.length,
additions,
deletions,
}
})
const applyHasSelection = createMemo(() => applySelectionStats().selected > 0)
const applyConflictRows = createMemo(() => groupApplyConflicts(applyStateForTarget()?.conflicts ?? []))
const applyToLocal = (worktreeId: string, selectedFiles: string[]) => {
setApplyStates((prev) => ({
...prev,
[worktreeId]: {
status: "checking",
message: t("agentManager.apply.checking"),
conflicts: [],
},
}))
vscode.postMessage({ type: "agentManager.applyWorktreeDiff", worktreeId, selectedFiles })
}
const resetApplyDialog = () => {
setApplyTarget(undefined)
setApplySelectedFiles([])
setApplySelectionTouched(false)
}
const closeApplyDialog = () => {
resetApplyDialog()
dialog.close()
}
const applySelectAll = () => {
setApplySelectionTouched(true)
setApplySelectedFiles(applyDiffs().map((diff) => diff.file))
}
const applySelectNone = () => {
setApplySelectionTouched(true)
setApplySelectedFiles([])
}
const applyToggleFile = (file: string, checked: boolean) => {
setApplySelectionTouched(true)
setApplySelectedFiles((prev) => {
if (checked) {
if (prev.includes(file)) return prev
const set = new Set(prev)
set.add(file)
return applyDiffs()
.map((diff) => diff.file)
.filter((path) => set.has(path))
}
if (!prev.includes(file)) return prev
return prev.filter((path) => path !== file)
})
}
const triggerApply = () => {
const target = applyTarget()
if (!target) return
if (!applyHasSelection()) return
if (applyBusyForTarget()) return
opts.track("apply_to_local", "apply_dialog", { fileCount: applySelectedFiles().length })
applyToLocal(target, applySelectedFiles())
}
const openApplyDialog = () => {
const sel = selection()
if (!sel || sel === local) return
setApplyStates((prev) => {
if (!prev[sel]) return prev
const next = { ...prev }
delete next[sel]
return next
})
setApplyTarget(sel)
setApplySelectionTouched(false)
setApplySelectedFiles([])
const sid = opts.resolveWorktreeSessionId(sel)
if (sid) vscode.postMessage({ type: "agentManager.requestWorktreeDiff", sessionId: sid })
setApplySelectedFiles(applyDiffs().map((diff) => diff.file))
dialog.show(
() => (
<ApplyDialog
diffs={applyDiffs()}
loading={diffLoading()}
selectedFiles={applySelectedSet()}
selectedCount={applySelectionStats().selected}
additions={applySelectionStats().additions}
deletions={applySelectionStats().deletions}
busy={applyBusyForTarget()}
hasSelection={applyHasSelection()}
status={applyStateForTarget()?.status}
message={applyStateForTarget()?.message}
conflictRows={applyConflictRows()}
onSelectAll={applySelectAll}
onSelectNone={applySelectNone}
onToggleFile={applyToggleFile}
onApply={triggerApply}
onClose={closeApplyDialog}
/>
),
resetApplyDialog,
)
}
// Keep the dialog selection in step with the diff set: select everything on
// first load, then drop files that disappear from the diff without clobbering
// a selection the user has already made.
createEffect(
on(
() => [applyTarget(), applyDiffs(), applySelectionTouched()] as const,
([target, diffs, touched]) => {
if (!target) return
const files = diffs.map((diff) => diff.file)
if (files.length === 0) {
if (!touched) setApplySelectedFiles([])
return
}
if (!touched) {
setApplySelectedFiles(files)
return
}
const current = applySelectedFiles()
const set = new Set(current)
const next = files.filter((file) => set.has(file))
const same = next.length === current.length && next.every((file, index) => file === current[index])
if (!same) setApplySelectedFiles(next)
},
),
)
// Drop apply state for worktrees that no longer exist, and close a dialog
// whose target disappeared.
createEffect(() => {
const ids = new Set(worktrees().map((wt) => wt.id))
setApplyStates((prev) => {
const next = Object.fromEntries(Object.entries(prev).filter(([id]) => ids.has(id)))
if (Object.keys(next).length === Object.keys(prev).length) return prev
return next
})
const target = applyTarget()
if (target && !ids.has(target)) closeApplyDialog()
})
// Backend `applyWorktreeDiffResult` message: record the new status and toast.
const onApplyResult = (ev: AgentManagerApplyWorktreeDiffResultMessage) => {
const files = new Set((ev.conflicts ?? []).map((entry) => entry.file).filter(Boolean)).size
const count = ev.conflicts?.length ?? 0
setApplyStates((prev) => ({
...prev,
[ev.worktreeId]: {
status: ev.status,
message: ev.message,
conflicts: ev.conflicts ?? [],
},
}))
if (ev.status === "success") {
showToast({ variant: "success", title: t("agentManager.apply.success"), description: ev.message })
if (applyTarget() === ev.worktreeId) closeApplyDialog()
}
if (ev.status === "conflict") {
const summary =
count > 0 ? t("agentManager.apply.conflictToast", { count, files: Math.max(files, 1) }) : ev.message
showToast({ variant: "error", title: t("agentManager.apply.conflict"), description: summary })
}
if (ev.status === "error") {
showToast({ variant: "error", title: t("agentManager.apply.error"), description: ev.message })
}
}
return {
applyBusyForSelection,
openApplyDialog,
onApplyResult,
}
}
+2 -1
View File
@@ -30,6 +30,7 @@ export const dict = {
"agentManager.section.moveUp": "تحريك لأعلى",
"agentManager.section.moveDown": "تحريك لأسفل",
"agentManager.hoverCard.branch": "الفرع",
"agentManager.hoverCard.worktree": "Worktree",
"agentManager.hoverCard.base": "الأساس",
"agentManager.hoverCard.sessions": "الجلسات",
"agentManager.hoverCard.files": "الملفات",
@@ -58,7 +59,7 @@ export const dict = {
"agentManager.terminal.new": "علامة تبويب جديدة للمحطة الطرفية",
"agentManager.terminal.ended": "انتهت المحطة الطرفية — أغلق علامة التبويب للإخفاء",
"agentManager.terminal.connectionError": "خطأ في اتصال المحطة الطرفية",
"agentManager.terminal.kill": "إنهاء المحطة الطرفية",
"agentManager.terminal.add": "محطة طرفية جديدة",
"agentManager.terminal.empty": "لا توجد محطة طرفية هنا بعد",
"agentManager.terminal.start": "بدء المحطة الطرفية",
"agentManager.terminal.destination": "اختر ما الذي يفتحه زر المحطة الطرفية",
+2 -1
View File
@@ -30,6 +30,7 @@ export const dict = {
"agentManager.section.moveUp": "Mover para Cima",
"agentManager.section.moveDown": "Mover para Baixo",
"agentManager.hoverCard.branch": "BRANCH",
"agentManager.hoverCard.worktree": "Worktree",
"agentManager.hoverCard.base": "Base",
"agentManager.hoverCard.sessions": "Sessões",
"agentManager.hoverCard.files": "Arquivos",
@@ -59,7 +60,7 @@ export const dict = {
"agentManager.terminal.new": "Nova aba de terminal",
"agentManager.terminal.ended": "terminal encerrado — feche a aba para dispensar",
"agentManager.terminal.connectionError": "erro de conexão do terminal",
"agentManager.terminal.kill": "Encerrar terminal",
"agentManager.terminal.add": "Novo terminal",
"agentManager.terminal.empty": "Ainda não há terminal aqui",
"agentManager.terminal.start": "Iniciar terminal",
"agentManager.terminal.destination": "Escolha o que o botão do terminal abre",

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