mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge branch 'feat/execute-cmds-in-skill-context' into feat/execute-cmds-in-skill-context-vscode
This commit is contained in:
@@ -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.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Keep conversations and workspace files unchanged when a checkpoint cannot be fully restored.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep Nix builds on the Bun version required by the repository.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Update the visible agent mode when cycling modes in Kilo sidebars and pending session tabs.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Fix settings changes sometimes failing to save and apply in VS Code.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Keep RC installations up to date when a newer stable CLI release is published.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Preserve parenthesized tilde expressions as literal text in rendered chat messages.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"kilo-code": patch
|
||||
---
|
||||
|
||||
Show prompt input toggle tooltips instantly on hover instead of after a delay.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": minor
|
||||
---
|
||||
|
||||
Publish a signed GitHub-hosted JetBrains plugin build with the CLI bundled for offline installation.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/kilo-jetbrains": patch
|
||||
---
|
||||
|
||||
Allow sending prompts while a session is busy and show queued prompts with a remove action.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -1,5 +0,0 @@
|
||||
---
|
||||
"@kilocode/cli": patch
|
||||
---
|
||||
|
||||
Emit each agent event once from `kilo run --format json`.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@kilocode/kilo-telemetry": patch
|
||||
---
|
||||
|
||||
Include the host operating system name, version, and architecture in telemetry events.
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-console",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:51adb9e31ce0bc82981b0f20f895ce4ede3f92828546115f929942ef93bf0812
|
||||
size 11204
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c5658ed9e5266311c4239b7cd470d2f772d1cc0b3fd6c6b00337d4f3141a3a4d
|
||||
size 11950
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:095ed97dc46498be6fd75483b24357ac0740b6f2c1544f6c103a1dac360e1a7e
|
||||
size 11202
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
@@ -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()
|
||||
}
|
||||
|
||||
+32
-7
@@ -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) {
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+42
@@ -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,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,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,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")
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "@kilocode/kilo-ui",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"type": "module",
|
||||
"license": "MIT",
|
||||
"exports": {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,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  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
@@ -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
@@ -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
Reference in New Issue
Block a user