mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-30 17:14:40 +08:00
Merge branch 'main' into plan-agent-manager-diff-viewer-integration
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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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",
|
||||
|
||||
@@ -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,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,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",
|
||||
@@ -1028,7 +1028,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",
|
||||
|
||||
@@ -106,6 +106,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,
|
||||
@@ -597,6 +598,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
|
||||
@@ -753,6 +758,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),
|
||||
@@ -939,8 +951,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}`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -427,6 +427,11 @@ interface ShowLocalTerminalIn {
|
||||
type: "agentManager.showLocalTerminal"
|
||||
}
|
||||
|
||||
interface ShowWorktreeTerminalIn {
|
||||
type: "agentManager.showWorktreeTerminal"
|
||||
worktreeId: string
|
||||
}
|
||||
|
||||
interface OpenWorktreeIn {
|
||||
type: "agentManager.openWorktree"
|
||||
worktreeId: string
|
||||
@@ -810,6 +815,7 @@ export type AgentManagerInMessage =
|
||||
| StopRunScriptIn
|
||||
| ShowTerminalIn
|
||||
| ShowLocalTerminalIn
|
||||
| ShowWorktreeTerminalIn
|
||||
| OpenWorktreeIn
|
||||
| CopyToClipboardIn
|
||||
| ShowExistingLocalTerminalIn
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"type": "module",
|
||||
"version": "7.4.16",
|
||||
"version": "7.4.17",
|
||||
"dependencies": {},
|
||||
"devDependencies": {},
|
||||
"peerDependencies": {}
|
||||
|
||||
@@ -411,6 +411,7 @@ describe("Agent Manager Provider — onMessage routing", () => {
|
||||
"agentManager.stopRunScript",
|
||||
"agentManager.showTerminal",
|
||||
"agentManager.showLocalTerminal",
|
||||
"agentManager.showWorktreeTerminal",
|
||||
"agentManager.showExistingLocalTerminal",
|
||||
"agentManager.requestRepoInfo",
|
||||
"agentManager.requestState",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -20,9 +20,11 @@ import type {
|
||||
AgentManagerMultiVersionProgressMessage,
|
||||
AgentManagerSendInitialMessage,
|
||||
AgentManagerBranchesMessage,
|
||||
AgentManagerWorktreeDiffMessage,
|
||||
AgentManagerWorktreeDiffFileMessage,
|
||||
AgentManagerWorktreeDiffLoadingMessage,
|
||||
AgentManagerDiffBranchesMessage,
|
||||
AgentManagerApplyWorktreeDiffResultMessage,
|
||||
AgentManagerApplyWorktreeDiffStatus,
|
||||
AgentManagerApplyWorktreeDiffConflict,
|
||||
AgentManagerWorktreeStatsMessage,
|
||||
AgentManagerLocalStatsMessage,
|
||||
WorktreeFileDiff,
|
||||
@@ -123,14 +125,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 +160,6 @@ import { ConstrainDragXAxis } from "./constrain-drag-x"
|
||||
import { DiffScopeControls } from "../diff-viewer/DiffScopeControls"
|
||||
import { scopeCapabilities } from "./diff-scope-state"
|
||||
import { createDiffReviewScope } from "./diff-review-scope"
|
||||
import { handleDiffMessage } from "./diff-messages"
|
||||
import { initialMessage, seedInitialVariant } from "./initial-message"
|
||||
import { createMarkdownRender } from "./review-preferences"
|
||||
import { createSidebarCollapse } from "./sidebar-collapse"
|
||||
@@ -183,12 +186,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 +238,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 +281,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 +331,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 +385,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 +394,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 +438,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 +553,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 +1024,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 +1112,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)
|
||||
@@ -1491,39 +1320,24 @@ const AgentManagerContent: Component = () => {
|
||||
}
|
||||
}
|
||||
|
||||
handleDiffMessage(msg, {
|
||||
setDiffDatas,
|
||||
setDiffFilePending,
|
||||
setDiffLoading,
|
||||
refreshStaleDiffs,
|
||||
review,
|
||||
})
|
||||
if (msg.type === "agentManager.worktreeDiff") {
|
||||
diffs.onWorktreeDiff(msg as AgentManagerWorktreeDiffMessage)
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.worktreeDiffFile") {
|
||||
diffs.onWorktreeDiffFile(msg as AgentManagerWorktreeDiffFileMessage)
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.worktreeDiffLoading") {
|
||||
diffs.onWorktreeDiffLoading(msg as AgentManagerWorktreeDiffLoadingMessage)
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.diffBranches") {
|
||||
review.onBranches(msg as AgentManagerDiffBranchesMessage)
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -1551,6 +1365,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()
|
||||
@@ -1693,64 +1510,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 id = diffScopeId()
|
||||
if (!id) return
|
||||
if (diffFileLoading()[id]?.[file]) return
|
||||
setDiffFilePending(id, file, true)
|
||||
vscode.postMessage({
|
||||
type: "agentManager.requestWorktreeDiffFile",
|
||||
sessionId: currentDiffSessionId()!,
|
||||
file,
|
||||
scope: review.scope(),
|
||||
})
|
||||
diffs.requestDiffFile(id, file)
|
||||
}
|
||||
|
||||
const refreshStaleDiffs = (id: string, files: Set<string>) => {
|
||||
const loading = diffFileLoading()[id] ?? {}
|
||||
for (const file of files) {
|
||||
if (loading[file]) continue
|
||||
setDiffFilePending(id, file, true)
|
||||
vscode.postMessage({
|
||||
type: "agentManager.requestWorktreeDiffFile",
|
||||
sessionId: currentDiffSessionId()!,
|
||||
file,
|
||||
scope: review.scope(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const diffFileLoadingForCurrent = createMemo(() => {
|
||||
const id = diffScopeId()
|
||||
if (!id) return new Set<string>()
|
||||
return new Set(Object.keys(diffFileLoading()[id] ?? {}))
|
||||
})
|
||||
const diffFileLoadingForCurrent = createMemo(() => diffs.diffFileLoadingFor(diffScopeId))
|
||||
|
||||
const revertCtl = createRevertFile(diffScopeId, currentDiffSessionId, () => review.scope(), vscode, showToast, t)
|
||||
|
||||
@@ -2091,9 +1857,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,
|
||||
@@ -2102,16 +1865,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) => {
|
||||
@@ -2185,8 +1954,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)
|
||||
})
|
||||
@@ -2214,8 +1983,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()) {
|
||||
@@ -2268,7 +2037,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}
|
||||
>
|
||||
@@ -2740,11 +2509,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()}>
|
||||
@@ -3100,8 +2865,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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -4608,6 +4627,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). */
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Message handlers for Agent Manager worktree diff pushes.
|
||||
*
|
||||
* Extracted from AgentManagerApp's onMessage switch to keep that handler's
|
||||
* cyclomatic complexity and the file's line count under their lint caps.
|
||||
* Returns true when the message was a diff message (so the caller can skip
|
||||
* further handling).
|
||||
*/
|
||||
|
||||
import type { Setter } from "solid-js"
|
||||
import type {
|
||||
AgentManagerDiffBranchesMessage,
|
||||
AgentManagerWorktreeDiffFileMessage,
|
||||
AgentManagerWorktreeDiffLoadingMessage,
|
||||
AgentManagerWorktreeDiffMessage,
|
||||
WorktreeFileDiff,
|
||||
} from "../src/types/messages"
|
||||
import { mergeWorktreeDiffs } from "../diff-viewer/diff-state"
|
||||
import type { DiffReviewScope } from "./diff-review-scope"
|
||||
|
||||
export interface DiffMessageHandlers {
|
||||
setDiffDatas: Setter<Record<string, WorktreeFileDiff[]>>
|
||||
setDiffFilePending: (id: string, file: string, value: boolean) => void
|
||||
setDiffLoading: Setter<boolean>
|
||||
refreshStaleDiffs: (id: string, files: Set<string>) => void
|
||||
review: DiffReviewScope
|
||||
}
|
||||
|
||||
/** Handle one inbound message; returns true when it was a diff message. */
|
||||
export function handleDiffMessage(msg: { type: string }, h: DiffMessageHandlers): boolean {
|
||||
if (msg.type === "agentManager.worktreeDiff") {
|
||||
const ev = msg as AgentManagerWorktreeDiffMessage
|
||||
let staleFiles: Set<string> | undefined
|
||||
h.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) h.refreshStaleDiffs(ev.sessionId, staleFiles)
|
||||
return true
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.worktreeDiffFile") {
|
||||
const ev = msg as AgentManagerWorktreeDiffFileMessage
|
||||
if (ev.diff) {
|
||||
h.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 }
|
||||
})
|
||||
h.setDiffFilePending(ev.sessionId, ev.diff.file, false)
|
||||
return true
|
||||
}
|
||||
h.setDiffFilePending(ev.sessionId, ev.file, false)
|
||||
return true
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.worktreeDiffLoading") {
|
||||
h.setDiffLoading((msg as AgentManagerWorktreeDiffLoadingMessage).loading)
|
||||
return true
|
||||
}
|
||||
|
||||
if (msg.type === "agentManager.diffBranches") {
|
||||
h.review.onBranches(msg as AgentManagerDiffBranchesMessage)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
@@ -116,5 +116,3 @@ export function createDiffReviewScope(opts: DiffReviewScopeOptions) {
|
||||
currentBranch,
|
||||
}
|
||||
}
|
||||
|
||||
export type DiffReviewScope = ReturnType<typeof createDiffReviewScope>
|
||||
|
||||
@@ -21,6 +21,13 @@ export function composeDiffId(ctx: string, scope: DiffScope): string {
|
||||
return `${ctx}${SEP}${scope}`
|
||||
}
|
||||
|
||||
export function parseDiffId(id: string): { ctx: string; scope: DiffScope } {
|
||||
const idx = id.lastIndexOf(SEP)
|
||||
const scope = id.slice(idx + SEP.length)
|
||||
if (idx !== -1 && isDiffScope(scope)) return { ctx: id.slice(0, idx), scope }
|
||||
return { ctx: id, scope: DEFAULT_DIFF_SCOPE }
|
||||
}
|
||||
|
||||
export function isDiffScope(value: string): value is DiffScope {
|
||||
return value === "branch" || value === "staged" || value === "unstaged" || value === "session"
|
||||
}
|
||||
|
||||
+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",
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ export const dict = {
|
||||
"agentManager.section.moveUp": "Pomjeri gore",
|
||||
"agentManager.section.moveDown": "Pomjeri dolje",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Baza",
|
||||
"agentManager.hoverCard.sessions": "Sesije",
|
||||
"agentManager.hoverCard.files": "Datoteke",
|
||||
@@ -58,7 +59,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nova kartica terminala",
|
||||
"agentManager.terminal.ended": "terminal je završen — zatvorite karticu da biste odbacili",
|
||||
"agentManager.terminal.connectionError": "greška u vezi terminala",
|
||||
"agentManager.terminal.kill": "Prekini terminal",
|
||||
"agentManager.terminal.add": "Novi terminal",
|
||||
"agentManager.terminal.empty": "Ovdje još nema terminala",
|
||||
"agentManager.terminal.start": "Pokreni terminal",
|
||||
"agentManager.terminal.destination": "Odaberite šta otvara dugme terminala",
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ export const dict = {
|
||||
"agentManager.section.moveUp": "Flyt op",
|
||||
"agentManager.section.moveDown": "Flyt ned",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessioner",
|
||||
"agentManager.hoverCard.files": "Filer",
|
||||
@@ -60,7 +61,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Ny terminalfane",
|
||||
"agentManager.terminal.ended": "terminal afsluttet — luk fanen for at fjerne",
|
||||
"agentManager.terminal.connectionError": "forbindelsesfejl til terminal",
|
||||
"agentManager.terminal.kill": "Afslut terminal",
|
||||
"agentManager.terminal.add": "Ny terminal",
|
||||
"agentManager.terminal.empty": "Ingen terminal her endnu",
|
||||
"agentManager.terminal.start": "Start terminal",
|
||||
"agentManager.terminal.destination": "Vælg, hvad terminalknappen åbner",
|
||||
|
||||
@@ -30,6 +30,7 @@ export const dict = {
|
||||
"agentManager.section.moveUp": "Nach oben verschieben",
|
||||
"agentManager.section.moveDown": "Nach unten verschieben",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Basis",
|
||||
"agentManager.hoverCard.sessions": "Sitzungen",
|
||||
"agentManager.hoverCard.files": "Dateien",
|
||||
@@ -59,7 +60,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Neuer Terminal-Tab",
|
||||
"agentManager.terminal.ended": "Terminal beendet — Tab schließen zum Verwerfen",
|
||||
"agentManager.terminal.connectionError": "Verbindungsfehler im Terminal",
|
||||
"agentManager.terminal.kill": "Terminal beenden",
|
||||
"agentManager.terminal.add": "Neues Terminal",
|
||||
"agentManager.terminal.empty": "Hier ist noch kein Terminal",
|
||||
"agentManager.terminal.start": "Terminal starten",
|
||||
"agentManager.terminal.destination": "Auswählen, was die Terminal-Schaltfläche öffnet",
|
||||
|
||||
@@ -32,6 +32,7 @@ export const dict = {
|
||||
"agentManager.section.moveDown": "Move Down",
|
||||
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessions",
|
||||
"agentManager.hoverCard.files": "Files",
|
||||
@@ -60,9 +61,9 @@ export const dict = {
|
||||
"agentManager.sidebarSearch.contexts": "LOCAL & WORKTREES",
|
||||
|
||||
"agentManager.terminal.new": "New Terminal Tab",
|
||||
"agentManager.terminal.add": "New terminal",
|
||||
"agentManager.terminal.ended": "terminal ended — close tab to dismiss",
|
||||
"agentManager.terminal.connectionError": "terminal connection error",
|
||||
"agentManager.terminal.kill": "Kill terminal",
|
||||
"agentManager.terminal.empty": "No terminal here yet",
|
||||
"agentManager.terminal.start": "Start terminal",
|
||||
"agentManager.terminal.destination": "Choose what the terminal button opens",
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ export const dict = {
|
||||
"agentManager.section.moveUp": "Mover hacia arriba",
|
||||
"agentManager.section.moveDown": "Mover hacia abajo",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sesiones",
|
||||
"agentManager.hoverCard.files": "Archivos",
|
||||
@@ -59,7 +60,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nueva pestaña de terminal",
|
||||
"agentManager.terminal.ended": "terminal finalizado — cierra la pestaña para descartar",
|
||||
"agentManager.terminal.connectionError": "error de conexión del terminal",
|
||||
"agentManager.terminal.kill": "Terminar terminal",
|
||||
"agentManager.terminal.add": "Nuevo terminal",
|
||||
"agentManager.terminal.empty": "Aún no hay ningún terminal aquí",
|
||||
"agentManager.terminal.start": "Iniciar terminal",
|
||||
"agentManager.terminal.destination": "Elegir qué abre el botón del terminal",
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ export const dict = {
|
||||
"agentManager.section.moveUp": "Déplacer vers le haut",
|
||||
"agentManager.section.moveDown": "Déplacer vers le bas",
|
||||
"agentManager.hoverCard.branch": "BRANCHE",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessions",
|
||||
"agentManager.hoverCard.files": "Fichiers",
|
||||
@@ -59,7 +60,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nouvel onglet de terminal",
|
||||
"agentManager.terminal.ended": "terminal terminé — fermez l'onglet pour ignorer",
|
||||
"agentManager.terminal.connectionError": "erreur de connexion du terminal",
|
||||
"agentManager.terminal.kill": "Tuer le terminal",
|
||||
"agentManager.terminal.add": "Nouveau terminal",
|
||||
"agentManager.terminal.empty": "Aucun terminal ici pour l'instant",
|
||||
"agentManager.terminal.start": "Démarrer le terminal",
|
||||
"agentManager.terminal.destination": "Choisir ce que le bouton Terminal ouvre",
|
||||
|
||||
+2
-1
@@ -32,6 +32,7 @@ export const dict = {
|
||||
"agentManager.section.moveDown": "Sposta giù",
|
||||
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Sessioni",
|
||||
"agentManager.hoverCard.files": "File",
|
||||
@@ -63,7 +64,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nuova scheda terminale",
|
||||
"agentManager.terminal.ended": "terminale terminato - chiudi la scheda per nasconderlo",
|
||||
"agentManager.terminal.connectionError": "errore di connessione del terminale",
|
||||
"agentManager.terminal.kill": "Termina terminale",
|
||||
"agentManager.terminal.add": "Nuovo terminale",
|
||||
"agentManager.terminal.empty": "Qui non c'è ancora un terminale",
|
||||
"agentManager.terminal.start": "Avvia terminale",
|
||||
"agentManager.terminal.destination": "Scegli cosa apre il pulsante del terminale",
|
||||
|
||||
+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": "ファイル",
|
||||
@@ -59,7 +60,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": "위로 이동",
|
||||
"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
@@ -32,6 +32,7 @@ export const dict = {
|
||||
"agentManager.section.moveDown": "Omlaag verplaatsen",
|
||||
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Basis",
|
||||
"agentManager.hoverCard.sessions": "Sessies",
|
||||
"agentManager.hoverCard.files": "Bestanden",
|
||||
@@ -62,7 +63,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nieuw terminaltabblad",
|
||||
"agentManager.terminal.ended": "terminal beëindigd — sluit tabblad om te negeren",
|
||||
"agentManager.terminal.connectionError": "terminalverbindingsfout",
|
||||
"agentManager.terminal.kill": "Terminal beëindigen",
|
||||
"agentManager.terminal.add": "Nieuwe terminal",
|
||||
"agentManager.terminal.empty": "Hier is nog geen terminal",
|
||||
"agentManager.terminal.start": "Terminal starten",
|
||||
"agentManager.terminal.destination": "Kies wat de terminalknop opent",
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ export const dict = {
|
||||
"agentManager.section.moveUp": "Flytt opp",
|
||||
"agentManager.section.moveDown": "Flytt ned",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Base",
|
||||
"agentManager.hoverCard.sessions": "Økter",
|
||||
"agentManager.hoverCard.files": "Filer",
|
||||
@@ -58,7 +59,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Ny terminalfane",
|
||||
"agentManager.terminal.ended": "terminal avsluttet — lukk fanen for å avvise",
|
||||
"agentManager.terminal.connectionError": "tilkoblingsfeil for terminal",
|
||||
"agentManager.terminal.kill": "Avslutt terminal",
|
||||
"agentManager.terminal.add": "Ny terminal",
|
||||
"agentManager.terminal.empty": "Ingen terminal her ennå",
|
||||
"agentManager.terminal.start": "Start terminal",
|
||||
"agentManager.terminal.destination": "Velg hva terminalknappen åpner",
|
||||
|
||||
+2
-1
@@ -30,6 +30,7 @@ export const dict = {
|
||||
"agentManager.section.moveUp": "Przenieś w górę",
|
||||
"agentManager.section.moveDown": "Przenieś w dół",
|
||||
"agentManager.hoverCard.branch": "BRANCH",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Baza",
|
||||
"agentManager.hoverCard.sessions": "Sesje",
|
||||
"agentManager.hoverCard.files": "Pliki",
|
||||
@@ -59,7 +60,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Nowa karta terminala",
|
||||
"agentManager.terminal.ended": "terminal zakończony — zamknij kartę, aby zamknąć",
|
||||
"agentManager.terminal.connectionError": "błąd połączenia terminala",
|
||||
"agentManager.terminal.kill": "Zakończ terminal",
|
||||
"agentManager.terminal.add": "Nowy terminal",
|
||||
"agentManager.terminal.empty": "Nie ma tu jeszcze terminala",
|
||||
"agentManager.terminal.start": "Uruchom terminal",
|
||||
"agentManager.terminal.destination": "Wybierz, co otwiera przycisk terminala",
|
||||
|
||||
+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": "Файлы",
|
||||
@@ -59,7 +60,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": "เลื่อนขึ้น",
|
||||
"agentManager.section.moveDown": "เลื่อนลง",
|
||||
"agentManager.hoverCard.branch": "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
@@ -32,6 +32,7 @@ export const dict = {
|
||||
"agentManager.section.moveDown": "Aşağı Taşı",
|
||||
|
||||
"agentManager.hoverCard.branch": "DAL",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "Temel",
|
||||
"agentManager.hoverCard.sessions": "Oturumlar",
|
||||
"agentManager.hoverCard.files": "Dosyalar",
|
||||
@@ -63,7 +64,7 @@ export const dict = {
|
||||
"agentManager.terminal.new": "Yeni Terminal Sekmesi",
|
||||
"agentManager.terminal.ended": "terminal sona erdi — kapatmak için sekmeyi kapatın",
|
||||
"agentManager.terminal.connectionError": "terminal bağlantı hatası",
|
||||
"agentManager.terminal.kill": "Terminali sonlandır",
|
||||
"agentManager.terminal.add": "Yeni terminal",
|
||||
"agentManager.terminal.empty": "Burada henüz terminal yok",
|
||||
"agentManager.terminal.start": "Terminali başlat",
|
||||
"agentManager.terminal.destination": "Terminal düğmesinin ne açacağını seçin",
|
||||
|
||||
+2
-1
@@ -32,6 +32,7 @@ export const dict = {
|
||||
"agentManager.section.moveDown": "Перемістити вниз",
|
||||
|
||||
"agentManager.hoverCard.branch": "ГІЛКА",
|
||||
"agentManager.hoverCard.worktree": "Worktree",
|
||||
"agentManager.hoverCard.base": "База",
|
||||
"agentManager.hoverCard.sessions": "Сесії",
|
||||
"agentManager.hoverCard.files": "Файли",
|
||||
@@ -63,7 +64,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": "上移",
|
||||
"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": "上移",
|
||||
"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": "選擇終端機按鈕的開啟目標",
|
||||
|
||||
@@ -3,22 +3,33 @@
|
||||
*
|
||||
* Lives inside the shared `.am-diff-panel-wrapper` host next to the diff
|
||||
* and PR panels, so all three inspector modes share one resize handle
|
||||
* and one width. The header intentionally reuses the `.am-diff-header`
|
||||
* structure and metrics so switching modes does not shift the chrome.
|
||||
* and one width.
|
||||
*
|
||||
* A context can own several side terminals. The header is a tab strip
|
||||
* that reuses the top tab bar's `TerminalTabChrome` (same `am-tab*`
|
||||
* structure, same X close button) plus a `+` action to add terminals.
|
||||
* Tabs are drag-sortable via the same `@thisbeyond/solid-dnd` stack as
|
||||
* the top tab bar; the order lives in the terminal state, so it is
|
||||
* preserved across sidebar context switches for the webview's lifetime.
|
||||
* The strip stays visible even when empty so the `+` action is always
|
||||
* reachable.
|
||||
*
|
||||
* Visibility is opacity-based, never unmount: the xterm render loop
|
||||
* dies when its subtree leaves the paint tree (see `render.tsx`).
|
||||
*/
|
||||
|
||||
import type { Accessor, Component } from "solid-js"
|
||||
import { Show, createEffect } from "solid-js"
|
||||
import { Icon } from "@kilocode/kilo-ui/icon"
|
||||
import { For, Show, createEffect, createSignal } from "solid-js"
|
||||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { IconButton } from "@kilocode/kilo-ui/icon-button"
|
||||
import { Button } from "@kilocode/kilo-ui/button"
|
||||
import { Spinner } from "@kilocode/kilo-ui/spinner"
|
||||
import { Tooltip } from "@kilocode/kilo-ui/tooltip"
|
||||
import { useLanguage } from "../../src/context/language"
|
||||
import { ConstrainDragYAxis, SortableTabContainer } from "../../src/components/chat/TabDnd"
|
||||
import { renderSideTerminalLayer } from "./render"
|
||||
import { TerminalTabChrome } from "./SortableTerminalTab"
|
||||
import type { TerminalStateControls } from "./state"
|
||||
|
||||
interface Props {
|
||||
@@ -27,9 +38,11 @@ interface Props {
|
||||
contextKey: Accessor<string>
|
||||
/** True while the inspector is in terminal mode. */
|
||||
visible: Accessor<boolean>
|
||||
/** Kill the terminal (or cancel its create) and hide. */
|
||||
onClose: () => void
|
||||
/** Empty-state action: create a side terminal for this context. */
|
||||
/** Make a terminal the visible one in the strip. */
|
||||
onSelect: (terminalId: string) => void
|
||||
/** Kill one terminal. */
|
||||
onClose: (terminalId: string) => void
|
||||
/** Create a new side terminal for this context. */
|
||||
onStart: () => void
|
||||
}
|
||||
|
||||
@@ -39,8 +52,26 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
createEffect(() => {
|
||||
panel.inert = !props.visible()
|
||||
})
|
||||
const side = () => props.state.side()
|
||||
const pending = () => props.state.pendingSide(props.contextKey()) !== undefined
|
||||
const [dragging, setDragging] = createSignal<{ id: string; width: number } | undefined>()
|
||||
const sides = () => props.state.sidesForContext(props.contextKey())
|
||||
const ids = () => sides().map((term) => term.id)
|
||||
const pending = () => props.state.pendingSide(props.contextKey())
|
||||
const onDragStart = (event: DragEvent) => {
|
||||
const id = event.draggable?.id
|
||||
if (typeof id !== "string") return
|
||||
// Pin the overlay to the tab's width: the overlay container uses
|
||||
// min-width, so a long OSC title would otherwise overflow it and
|
||||
// shift the visual center off the cursor (the "drag offset" bug).
|
||||
const width = event.draggable?.layout.width ?? event.draggable?.node.getBoundingClientRect().width
|
||||
setDragging({ id, width })
|
||||
}
|
||||
const onDragEnd = () => setDragging(undefined)
|
||||
const onDragOver = (event: DragEvent) => {
|
||||
const from = event.draggable?.id
|
||||
const to = event.droppable?.id
|
||||
if (typeof from !== "string" || typeof to !== "string") return
|
||||
props.state.reorderSideDrag(props.contextKey(), from, to)
|
||||
}
|
||||
return (
|
||||
<section
|
||||
ref={panel}
|
||||
@@ -48,31 +79,86 @@ export const SideTerminalPanel: Component<Props> = (props) => {
|
||||
aria-label={t("agentManager.tab.terminal")}
|
||||
aria-hidden={!props.visible()}
|
||||
>
|
||||
<div class="am-diff-header">
|
||||
<div class="am-diff-header-main">
|
||||
<Icon name="console" size="small" />
|
||||
<span class="am-diff-header-title">{side()?.title ?? t("agentManager.tab.terminal")}</span>
|
||||
</div>
|
||||
<div class="am-diff-header-actions">
|
||||
<Tooltip value={t("agentManager.terminal.kill")} placement="bottom">
|
||||
<div class="am-side-terminal-tabs">
|
||||
<DragDropProvider
|
||||
onDragStart={onDragStart}
|
||||
onDragEnd={onDragEnd}
|
||||
onDragOver={onDragOver}
|
||||
collisionDetector={closestCenter}
|
||||
>
|
||||
<DragDropSensors />
|
||||
<ConstrainDragYAxis />
|
||||
{/* Scrollable tab list — mirrors the top bar's .am-tab-list split
|
||||
so the "+" action never scrolls away. role="tablist" only
|
||||
when tabs exist: axe aria-required-children rejects an empty
|
||||
tablist (and non-tab children like the add button). */}
|
||||
<div
|
||||
class="am-side-terminal-tablist"
|
||||
role={sides().length > 0 ? "tablist" : undefined}
|
||||
aria-label={sides().length > 0 ? t("agentManager.tab.terminal") : undefined}
|
||||
>
|
||||
<SortableProvider ids={ids()}>
|
||||
<For each={sides()}>
|
||||
{(term) => (
|
||||
<SortableTabContainer id={term.id} class="am-side-terminal-tab">
|
||||
<TerminalTabChrome
|
||||
label={props.state.title(term.id) ?? term.title}
|
||||
tooltip={props.state.title(term.id) ?? term.title}
|
||||
active={props.state.sideActiveFor(props.contextKey()) === term.id}
|
||||
role="tab"
|
||||
selected={props.state.sideActiveFor(props.contextKey()) === term.id}
|
||||
onSelect={() => props.onSelect(term.id)}
|
||||
onMiddleClick={(e: MouseEvent) => {
|
||||
if (e.button !== 1) return
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
props.onClose(term.id)
|
||||
}}
|
||||
onClose={(e: MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
props.onClose(term.id)
|
||||
}}
|
||||
/>
|
||||
</SortableTabContainer>
|
||||
)}
|
||||
</For>
|
||||
</SortableProvider>
|
||||
</div>
|
||||
{/* Cursor-following clone of the dragged tab (same pattern as
|
||||
the top tab bar). The overlay is what makes the in-list
|
||||
original use solid-dnd's slot-compensated transform, so the
|
||||
dragged tab tracks the cursor without a jump/offset. The
|
||||
original stays dimmed in its slot via .am-tab-dragging. */}
|
||||
<DragOverlay>
|
||||
<Show when={dragging()}>
|
||||
{(tab) => (
|
||||
<div class="am-tab am-tab-overlay" style={{ width: `${tab().width}px` }}>
|
||||
<span class="am-tab-label">{props.state.title(tab().id) ?? t("agentManager.tab.terminal")}</span>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
</DragOverlay>
|
||||
</DragDropProvider>
|
||||
<div class="am-side-terminal-add">
|
||||
<Tooltip value={t("agentManager.terminal.add")} placement="bottom">
|
||||
<IconButton
|
||||
icon="trash"
|
||||
icon="plus"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.terminal.kill")}
|
||||
onClick={props.onClose}
|
||||
aria-label={t("agentManager.terminal.add")}
|
||||
onClick={props.onStart}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{renderSideTerminalLayer({ state: props.state, contextKey: props.contextKey, visible: props.visible })}
|
||||
<Show when={props.visible() && pending() && !side()}>
|
||||
<Show when={props.visible() && sides().length === 0 && pending()}>
|
||||
<div class="am-side-terminal-state" role="status">
|
||||
<Spinner />
|
||||
<span>{t("common.loading")}</span>
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={props.visible() && !pending() && !side()}>
|
||||
<Show when={props.visible() && sides().length === 0 && !pending()}>
|
||||
<div class="am-side-terminal-state">
|
||||
<span class="am-side-terminal-empty">{t("agentManager.terminal.empty")}</span>
|
||||
<Button variant="primary" size="small" onClick={props.onStart}>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
/**
|
||||
* Draggable tab chrome for an xterm terminal tab.
|
||||
* Tab chrome for xterm terminals.
|
||||
*
|
||||
* Shares the same hover/tooltip/close/right-click mechanism as the
|
||||
* session and review tab variants so users get consistent navigation
|
||||
* hints and context actions regardless of tab kind.
|
||||
* `TerminalTabChrome` is the shared visual tab: console icon, title,
|
||||
* tooltip/keybinding hints, and the X close button — the same
|
||||
* `am-tab*` structure the session tabs use. `SortableTerminalTab`
|
||||
* wraps it with drag-and-drop and a right-click context menu for the
|
||||
* top tab bar; the side terminal panel uses the chrome directly so
|
||||
* both surfaces render identical terminal tabs.
|
||||
*/
|
||||
|
||||
import { Component, Show, type JSX } from "solid-js"
|
||||
@@ -15,6 +18,70 @@ import { useLanguage } from "../../src/context/language"
|
||||
import { SortableTabContainer } from "../../src/components/chat/TabDnd"
|
||||
import { parseBindingTokens } from "../keybind-tokens"
|
||||
|
||||
export const TerminalTabChrome: Component<{
|
||||
label: string
|
||||
tooltip: string
|
||||
keybind?: string
|
||||
closeKeybind?: string
|
||||
active: boolean
|
||||
role?: "tab"
|
||||
selected?: boolean
|
||||
tabIndex?: number
|
||||
onKeyDown?: JSX.EventHandlerUnion<HTMLDivElement, KeyboardEvent>
|
||||
onSelect: () => void
|
||||
onMiddleClick?: (e: MouseEvent) => void
|
||||
onClose: (e: MouseEvent) => void
|
||||
}> = (props) => {
|
||||
const { t } = useLanguage()
|
||||
return (
|
||||
<div class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""}`}>
|
||||
<div
|
||||
class="am-tab-target"
|
||||
role={props.role}
|
||||
aria-selected={props.selected}
|
||||
tabIndex={props.tabIndex}
|
||||
onClick={props.onSelect}
|
||||
onMouseDown={props.onMiddleClick}
|
||||
onKeyDown={props.onKeyDown}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title={props.tooltip}
|
||||
keybind={props.keybind ?? ""}
|
||||
placement="bottom"
|
||||
gutter={8}
|
||||
class="am-tab-tooltip"
|
||||
openDelay={0}
|
||||
>
|
||||
<span class="am-tab-title">
|
||||
<span class="am-tab-icon">
|
||||
<Icon name="console" size="small" />
|
||||
</span>
|
||||
<span class="am-tab-label">{props.label}</span>
|
||||
</span>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.tab.close")}
|
||||
keybind={props.closeKeybind ?? ""}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
class="am-tab-close-wrap"
|
||||
openDelay={0}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.tab.closeTab")}
|
||||
tabIndex={props.active ? 0 : -1}
|
||||
class="am-tab-close"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SortableTerminalTab: Component<{
|
||||
id: string
|
||||
label: string
|
||||
@@ -36,51 +103,20 @@ export const SortableTerminalTab: Component<{
|
||||
<SortableTabContainer id={props.id}>
|
||||
<ContextMenu>
|
||||
<ContextMenu.Trigger as="div" style={{ display: "contents" }}>
|
||||
<div class={`am-tab am-tab-terminal ${props.active ? "am-tab-active" : ""}`}>
|
||||
<div
|
||||
class="am-tab-target"
|
||||
role={props.role}
|
||||
aria-selected={props.selected}
|
||||
tabIndex={props.tabIndex}
|
||||
onClick={props.onSelect}
|
||||
onMouseDown={props.onMiddleClick}
|
||||
onKeyDown={props.onKeyDown}
|
||||
>
|
||||
<TooltipKeybind
|
||||
title={props.tooltip}
|
||||
keybind={props.keybind ?? ""}
|
||||
placement="bottom"
|
||||
gutter={8}
|
||||
class="am-tab-tooltip"
|
||||
openDelay={0}
|
||||
>
|
||||
<span class="am-tab-title">
|
||||
<span class="am-tab-icon">
|
||||
<Icon name="console" size="small" />
|
||||
</span>
|
||||
<span class="am-tab-label">{props.label}</span>
|
||||
</span>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<TooltipKeybind
|
||||
title={t("agentManager.tab.close")}
|
||||
keybind={props.closeKeybind ?? ""}
|
||||
placement="top"
|
||||
gutter={8}
|
||||
class="am-tab-close-wrap"
|
||||
openDelay={0}
|
||||
>
|
||||
<IconButton
|
||||
icon="close-small"
|
||||
size="small"
|
||||
variant="ghost"
|
||||
aria-label={t("agentManager.tab.closeTab")}
|
||||
tabIndex={props.active ? 0 : -1}
|
||||
class="am-tab-close"
|
||||
onClick={props.onClose}
|
||||
/>
|
||||
</TooltipKeybind>
|
||||
</div>
|
||||
<TerminalTabChrome
|
||||
label={props.label}
|
||||
tooltip={props.tooltip}
|
||||
keybind={props.keybind}
|
||||
closeKeybind={props.closeKeybind}
|
||||
active={props.active}
|
||||
role={props.role}
|
||||
selected={props.selected}
|
||||
tabIndex={props.tabIndex}
|
||||
onKeyDown={props.onKeyDown}
|
||||
onSelect={props.onSelect}
|
||||
onMiddleClick={props.onMiddleClick}
|
||||
onClose={props.onClose}
|
||||
/>
|
||||
</ContextMenu.Trigger>
|
||||
<ContextMenu.Portal>
|
||||
<ContextMenu.Content class="am-ctx-menu">
|
||||
|
||||
@@ -46,6 +46,11 @@ interface Props {
|
||||
* layer tracks this as `focusedId` so `Cmd+W` can target the
|
||||
* terminal that actually has the cursor. */
|
||||
onFocusChange?: (focused: boolean) => void
|
||||
/** Reports OSC window-title escape codes (`ESC ] 0/1/2 ; title BEL`)
|
||||
* sent by the shell or running programs — fish sets it to the active
|
||||
* command, oh-my-zsh to user@host:cwd, vim to the file name. The
|
||||
* state layer mirrors it into the tab label. */
|
||||
onTitleChange?: (title: string) => void
|
||||
}
|
||||
|
||||
/** How long the ResizeObserver waits after the last size change before
|
||||
@@ -207,6 +212,10 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
host.addEventListener("focusin", onFocusIn)
|
||||
host.addEventListener("focusout", onFocusOut)
|
||||
|
||||
// OSC 0/1/2 window-title sequences → tab label. xterm parses and
|
||||
// strips these itself, so this only fires for real title codes.
|
||||
const disposeTitle = term.onTitleChange((title) => props.onTitleChange?.(title))
|
||||
|
||||
const ws = new WebSocket(props.wsUrl)
|
||||
ws.binaryType = "arraybuffer"
|
||||
let closed = false
|
||||
@@ -394,6 +403,7 @@ export const TerminalTab: Component<Props> = (props) => {
|
||||
clearTimeout(resizeTimer)
|
||||
ro.disconnect()
|
||||
disposeData.dispose()
|
||||
disposeTitle.dispose()
|
||||
try {
|
||||
ws.close()
|
||||
} catch (err) {
|
||||
|
||||
@@ -23,6 +23,6 @@ export type { TerminalTabState, TerminalStateControls, TerminalHandlerDeps } fro
|
||||
export { renderTerminalTab, renderTerminalLayer, renderSideTerminalLayer } from "./render"
|
||||
export { SideTerminalPanel } from "./SideTerminalPanel"
|
||||
export { TerminalDestinationButton } from "./TerminalDestinationButton"
|
||||
export { createSideTerminal } from "./side"
|
||||
export { createSideTerminal, readSavedDestination, resolveVscodeTerminalRequest } from "./side"
|
||||
export { TerminalTab } from "./TerminalTab"
|
||||
export { SortableTerminalTab } from "./SortableTerminalTab"
|
||||
|
||||
@@ -41,11 +41,13 @@ export function renderTerminalTab(deps: TerminalTabRenderDeps): JSX.Element {
|
||||
const term = deps.terms.lookup().get(deps.id)
|
||||
if (!term) return null
|
||||
const isActive = () => deps.terms.activeId() === deps.id
|
||||
// Label and tooltip read through `terms.title` so OSC title changes
|
||||
// (shell/program escape codes) rename the tab live.
|
||||
return (
|
||||
<SortableTerminalTab
|
||||
id={deps.id}
|
||||
label={term.title}
|
||||
tooltip={term.title}
|
||||
label={deps.terms.title(deps.id) ?? term.title}
|
||||
tooltip={deps.terms.title(deps.id) ?? term.title}
|
||||
keybind={isActive() ? "" : deps.keybind()}
|
||||
closeKeybind={deps.closeKeybind()}
|
||||
active={isActive()}
|
||||
@@ -109,6 +111,7 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS
|
||||
focusSerial={focusSerial(props.state, term.id)}
|
||||
font={term.font}
|
||||
onFocusChange={(focused) => props.state.setFocusedId(focused ? term.id : undefined)}
|
||||
onTitleChange={(title) => props.state.setTitle(term.id, title)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
@@ -126,7 +129,8 @@ export function renderTerminalLayer(props: { state: TerminalStateControls }): JS
|
||||
* terminal stays mounted, visibility is toggled via `opacity` /
|
||||
* `pointer-events` / `inert` only. The layer is scoped to
|
||||
* `contextKey` — side terminals from other contexts stay composed in
|
||||
* the background and never refit.
|
||||
* the background and never refit — and within a context only the
|
||||
* active strip tab's terminal is shown.
|
||||
*/
|
||||
export function renderSideTerminalLayer(props: {
|
||||
state: TerminalStateControls
|
||||
@@ -137,7 +141,10 @@ export function renderSideTerminalLayer(props: {
|
||||
<div class={`am-side-terminal-layer ${props.visible() ? "am-side-terminal-layer-active" : ""}`}>
|
||||
<For each={props.state.sides()}>
|
||||
{(term) => {
|
||||
const active = () => props.visible() && term.contextKey === props.contextKey()
|
||||
const active = () =>
|
||||
props.visible() &&
|
||||
term.contextKey === props.contextKey() &&
|
||||
props.state.sideActiveFor(term.contextKey) === term.id
|
||||
return (
|
||||
<div class={`am-terminal-slot ${active() ? "am-terminal-slot-visible" : ""}`} inert={!active()}>
|
||||
<TerminalTab
|
||||
@@ -147,6 +154,7 @@ export function renderSideTerminalLayer(props: {
|
||||
focusSerial={focusSerial(props.state, term.id)}
|
||||
font={term.font}
|
||||
onFocusChange={(focused) => props.state.setFocusedId(focused ? term.id : undefined)}
|
||||
onTitleChange={(title) => props.state.setTitle(term.id, title)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -7,23 +7,64 @@
|
||||
* embedded terminal behaves like the diff panel: press once to reveal,
|
||||
* press again to hide. Hiding never kills the terminal — only the
|
||||
* explicit close action (or `Cmd+W` while it holds focus) does.
|
||||
*
|
||||
* ## Destination state ownership
|
||||
*
|
||||
* The VS Code setting is application-scoped, so one value is shared by
|
||||
* every window and echoed back via `terminal.destinationChanged`
|
||||
* whenever ANY window rewrites it. Two windows can therefore fight:
|
||||
* picking "VS Code terminal" in worktree window B would silently flip
|
||||
* the routing of the panel in window A. To keep each panel consistent,
|
||||
* an explicit dropdown pick is stored per panel (webview state) and
|
||||
* wins over remote echoes; the setting only drives panels that never
|
||||
* picked a destination themselves (it stays the default for new ones).
|
||||
*/
|
||||
|
||||
import { createSignal } from "solid-js"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { TerminalDestination } from "../../src/types/messages/agent-manager"
|
||||
import { LOCAL } from "../navigate"
|
||||
|
||||
/** Read the panel-local destination choice from raw webview state. */
|
||||
export function readSavedDestination(state: Record<string, unknown> | undefined): TerminalDestination | undefined {
|
||||
const value = state?.terminalDestination
|
||||
return value === "agentManager" || value === "vscode" ? value : undefined
|
||||
}
|
||||
|
||||
export type VscodeTerminalRequest =
|
||||
| { type: "agentManager.showTerminal"; sessionId: string }
|
||||
| { type: "agentManager.showWorktreeTerminal"; worktreeId: string }
|
||||
| { type: "agentManager.showLocalTerminal" }
|
||||
|
||||
/**
|
||||
* Pick the message the terminal button / Focus Terminal shortcut sends
|
||||
* when the destination is the VS Code integrated terminal. The fallback
|
||||
* chain exists so the shortcut never dead-ends: activating a terminal
|
||||
* tab clears the current session, and a worktree may have no sessions
|
||||
* at all. Extracted from AgentManagerApp.tsx (max-lines cap).
|
||||
*/
|
||||
export function resolveVscodeTerminalRequest(
|
||||
selection: string | null,
|
||||
currentSessionID: string | undefined,
|
||||
sessionForWorktree: (worktreeId: string) => string | undefined,
|
||||
): VscodeTerminalRequest {
|
||||
const id = currentSessionID ?? (selection && selection !== LOCAL ? sessionForWorktree(selection) : undefined)
|
||||
if (id) return { type: "agentManager.showTerminal", sessionId: id }
|
||||
if (selection && selection !== LOCAL) return { type: "agentManager.showWorktreeTerminal", worktreeId: selection }
|
||||
return { type: "agentManager.showLocalTerminal" }
|
||||
}
|
||||
|
||||
interface Handlers {
|
||||
requestSide(): void
|
||||
closeSide(): boolean
|
||||
closeSide(terminalId: string): boolean
|
||||
}
|
||||
|
||||
export interface SideTerminalDeps {
|
||||
handlers: Handlers
|
||||
/** True while the right-side inspector shows the terminal. */
|
||||
visible: Accessor<boolean>
|
||||
/** True while the side terminal itself holds DOM focus. */
|
||||
focused: Accessor<boolean>
|
||||
/** Id of the side terminal holding DOM focus, if any. */
|
||||
focusedId: Accessor<string | undefined>
|
||||
/** Leave terminal mode; the terminal stays alive in the background. */
|
||||
hide: () => void
|
||||
/** Move focus back to the chat composer. */
|
||||
@@ -32,10 +73,16 @@ export interface SideTerminalDeps {
|
||||
track: (button: string, surface: string, properties: Record<string, string>) => void
|
||||
/** Open or focus the VS Code integrated terminal for the active context. */
|
||||
openVscode: () => void
|
||||
/** Panel-local choice restored from webview state, if the user ever
|
||||
* picked one in this panel. */
|
||||
saved: TerminalDestination | undefined
|
||||
/** Persist the panel-local choice so it survives webview reloads. */
|
||||
save: (destination: TerminalDestination) => void
|
||||
}
|
||||
|
||||
export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
const [destination, setDestination] = createSignal<TerminalDestination>("vscode")
|
||||
const [local, setLocal] = createSignal<TerminalDestination | undefined>(deps.saved)
|
||||
const [destination, setDestination] = createSignal<TerminalDestination>(deps.saved ?? "vscode")
|
||||
|
||||
/**
|
||||
* Hiding while the terminal holds focus would strand the cursor on
|
||||
@@ -49,7 +96,7 @@ export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
|
||||
const toggle = () => {
|
||||
if (deps.visible()) {
|
||||
const was = deps.focused()
|
||||
const was = deps.focusedId() !== undefined
|
||||
deps.hide()
|
||||
handoff(was)
|
||||
return
|
||||
@@ -57,12 +104,14 @@ export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
deps.handlers.requestSide()
|
||||
}
|
||||
|
||||
/** Kill the current context's side terminal (or cancel its in-flight
|
||||
* create) and hide the panel. */
|
||||
/** Kill the focused side terminal (Cmd/Ctrl+W). The panel stays open
|
||||
* on the remaining terminals, or on the empty state when this was
|
||||
* the last one. */
|
||||
const close = (): boolean => {
|
||||
const was = deps.focused()
|
||||
const done = deps.handlers.closeSide()
|
||||
if (done) handoff(was)
|
||||
const id = deps.focusedId()
|
||||
if (!id) return false
|
||||
const done = deps.handlers.closeSide(id)
|
||||
if (done) handoff(true)
|
||||
return done
|
||||
}
|
||||
|
||||
@@ -78,17 +127,32 @@ export function createSideTerminal(deps: SideTerminalDeps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Dropdown pick. Applied locally right away so the button reacts
|
||||
* without a round trip, then persisted as a VS Code setting; the
|
||||
* extension echoes it back via `terminal.destinationChanged`.
|
||||
* Dropdown pick. The choice is panel-local and sticky: it is kept in
|
||||
* webview state and beats later `terminal.destinationChanged` echoes
|
||||
* caused by other windows rewriting the shared application-scoped
|
||||
* setting. The setting is still written so it stays the default for
|
||||
* panels that never picked a destination (and new panels).
|
||||
* The key is relative to the `kilo-code.new` section, matching every
|
||||
* other `updateSetting` sender.
|
||||
*/
|
||||
const choose = (target: TerminalDestination) => {
|
||||
deps.track("terminal_destination", "tab_toolbar", { destination: target })
|
||||
setLocal(target)
|
||||
setDestination(target)
|
||||
deps.save(target)
|
||||
deps.postMessage({ type: "updateSetting", key: "agentManager.terminalButtonDestination", value: target })
|
||||
}
|
||||
|
||||
return { destination, setDestination, toggle, close, openPreferred, choose }
|
||||
/**
|
||||
* Apply a remote default (initial `agentManager.state` payload or a
|
||||
* live `terminal.destinationChanged` echo). Ignored once the user
|
||||
* picked a destination in this panel — their choice wins over every
|
||||
* echo, including ones triggered by this panel's own `choose` write.
|
||||
*/
|
||||
const syncDefault = (target: TerminalDestination) => {
|
||||
if (local()) return
|
||||
setDestination(target)
|
||||
}
|
||||
|
||||
return { destination, syncDefault, toggle, close, openPreferred, choose }
|
||||
}
|
||||
|
||||
@@ -50,11 +50,9 @@ export interface TerminalFocusRequest {
|
||||
}
|
||||
|
||||
/** A create request for a side terminal that has not been answered yet.
|
||||
* `cancelled` is set when the user closes the panel while the PTY is
|
||||
* still starting; the late `created` answer is then closed again. */
|
||||
* Multiple creates can be in flight for the same context at once. */
|
||||
interface SideRequest {
|
||||
contextKey: string
|
||||
cancelled: boolean
|
||||
}
|
||||
|
||||
export interface TerminalStateControls {
|
||||
@@ -75,10 +73,14 @@ export interface TerminalStateControls {
|
||||
all: Accessor<TerminalTabStateWithContext[]>
|
||||
/** Every side terminal across every context (for the side-panel layer). */
|
||||
sides: Accessor<TerminalTabStateWithContext[]>
|
||||
/** The side terminal of the current context, if any. */
|
||||
side: Accessor<TerminalTabStateWithContext | undefined>
|
||||
/** The side terminal of an arbitrary context, if any. */
|
||||
sideForContext(contextKey: string): TerminalTabStateWithContext | undefined
|
||||
/** Every side terminal of the given context, in creation order. */
|
||||
sidesForContext(contextKey: string): TerminalTabStateWithContext[]
|
||||
/** Id of the active side terminal for a context. */
|
||||
sideActiveFor(contextKey: string): string | undefined
|
||||
/** Mark a side terminal as the visible one for its context. */
|
||||
setSideActive(contextKey: string, terminalId: string): void
|
||||
/** Id of the side terminal holding DOM focus in the current context, if any. */
|
||||
sideFocusedId(): string | undefined
|
||||
/** Context key for the current sidebar selection, or `undefined` when nothing is selected. */
|
||||
currentKey: Accessor<string | undefined>
|
||||
/** Context key for the side panel: like `currentKey` but unassigned
|
||||
@@ -96,6 +98,13 @@ export interface TerminalStateControls {
|
||||
requestFocus(id: string): void
|
||||
/** True when the given remembered tab id points to a live terminal for the given selection. */
|
||||
hasRemembered(selection: string | null, remembered: string | undefined): boolean
|
||||
/** Live display title for a terminal: the OSC-provided title when the
|
||||
* shell/program set one, otherwise the create-time title. */
|
||||
title(terminalId: string): string | undefined
|
||||
/** Record an OSC title change for a terminal. Kept outside the
|
||||
* terminal records so `<For>` reference stability (and therefore the
|
||||
* mounted xterm instances) is preserved. */
|
||||
setTitle(terminalId: string, title: string): void
|
||||
/**
|
||||
* Persist a new order for a context's terminals (webview-memory only —
|
||||
* terminals are ephemeral and never round-trip through the extension
|
||||
@@ -109,12 +118,16 @@ export interface TerminalStateControls {
|
||||
* was applied, false otherwise so the caller can fall through.
|
||||
*/
|
||||
reorderDrag(from: string, to: string): boolean
|
||||
/** Request id of the in-flight side-terminal create for a context. */
|
||||
pendingSide(contextKey: string): string | undefined
|
||||
/**
|
||||
* Apply a drag-over reorder within a context's side terminals (the
|
||||
* side-panel strip). Returns true when both ends are side terminals
|
||||
* of that context.
|
||||
*/
|
||||
reorderSideDrag(contextKey: string, from: string, to: string): boolean
|
||||
/** Request ids of the in-flight side-terminal creates for a context. */
|
||||
pendingSide(contextKey: string): boolean
|
||||
/** Mark a side-terminal create as in flight for a context. */
|
||||
beginSide(contextKey: string, createId: string): void
|
||||
/** Cancel the in-flight create; returns true when one was pending. */
|
||||
cancelSide(contextKey: string): boolean
|
||||
/** Settle a create request; returns it so the caller can validate. */
|
||||
completeSide(createId: string): SideRequest | undefined
|
||||
}
|
||||
@@ -149,10 +162,17 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
const [activeId, setActiveId] = createSignal<string | undefined>()
|
||||
const [focusedId, setFocusedId] = createSignal<string | undefined>()
|
||||
const [focusRequest, setFocusRequest] = createSignal<TerminalFocusRequest | undefined>()
|
||||
// OSC-provided titles, keyed by terminal id. Separate from the terminal
|
||||
// records on purpose: replacing a record would remount its xterm via
|
||||
// <For> reference inequality (see the module comment above).
|
||||
const [titles, setTitles] = createSignal<Record<string, string>>({})
|
||||
// Active side terminal per context.
|
||||
const [actives, setActives] = createSignal<Record<string, string>>({})
|
||||
let focusSerial = 0
|
||||
// In-flight side-terminal creates, keyed both ways: per context (what
|
||||
// the panel shows) and per request id (what the answer carries).
|
||||
const [pending, setPending] = createSignal<Record<string, string>>({})
|
||||
// the panel shows) and per request id (what the answer carries). A
|
||||
// context can have several creates in flight at once.
|
||||
const [pending, setPending] = createSignal<Record<string, string[]>>({})
|
||||
const requests = new Map<string, SideRequest>()
|
||||
|
||||
const currentKey = (): string | undefined => {
|
||||
@@ -195,8 +215,31 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
return out
|
||||
}
|
||||
|
||||
const sideForContext = (key: string) => terminalsByContext()[key]?.find((t) => t.placement === "side")
|
||||
const side = () => sideForContext(sideKey())
|
||||
const sidesForContext = (key: string) => (terminalsByContext()[key] ?? []).filter((t) => t.placement === "side")
|
||||
const sideActiveFor = (key: string) => actives()[key]
|
||||
const sideFocusedId = () => {
|
||||
const id = focusedId()
|
||||
if (!id) return undefined
|
||||
return sidesForContext(sideKey()).some((t) => t.id === id) ? id : undefined
|
||||
}
|
||||
|
||||
const setSideActive = (key: string, terminalId: string) => {
|
||||
setActives((prev) => (prev[key] === terminalId ? prev : { ...prev, [key]: terminalId }))
|
||||
}
|
||||
|
||||
const title = (terminalId: string): string | undefined => {
|
||||
const live = titles()[terminalId]
|
||||
if (live) return live
|
||||
const key = contextFor(terminalId)
|
||||
if (!key) return undefined
|
||||
return terminalsByContext()[key]?.find((t) => t.id === terminalId)?.title
|
||||
}
|
||||
|
||||
const setTitle = (terminalId: string, next: string) => {
|
||||
const trimmed = next.trim()
|
||||
if (!trimmed) return
|
||||
setTitles((prev) => (prev[terminalId] === trimmed ? prev : { ...prev, [terminalId]: trimmed }))
|
||||
}
|
||||
|
||||
const lookup = () => new Map(current().map((t) => [t.id, t]))
|
||||
|
||||
@@ -218,9 +261,6 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
setTerminalsByContext((prev) => {
|
||||
const list = prev[key] ?? []
|
||||
if (list.some((t) => t.id === term.id)) return prev
|
||||
// One side terminal per context; the message handler dedupes via
|
||||
// pending requests, this guard covers stale double answers.
|
||||
if (term.placement === "side" && list.some((t) => t.placement === "side")) return prev
|
||||
const enriched: TerminalTabStateWithContext = { ...term, contextKey: key }
|
||||
return { ...prev, [key]: [...list, enriched] }
|
||||
})
|
||||
@@ -238,6 +278,24 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
return next
|
||||
})
|
||||
if (focusedId() === terminalId) setFocusedId(undefined)
|
||||
// A removed active side terminal hands activation to the last
|
||||
// remaining one of its context, so the panel never shows a dead slot.
|
||||
if (removed?.placement === "side" && actives()[key] === terminalId) {
|
||||
const rest = sidesForContext(key)
|
||||
setActives((prev) => {
|
||||
const next = { ...prev }
|
||||
if (rest.length === 0) delete next[key]
|
||||
else next[key] = rest[rest.length - 1]!.id
|
||||
return next
|
||||
})
|
||||
}
|
||||
if (titles()[terminalId] !== undefined) {
|
||||
setTitles((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[terminalId]
|
||||
return next
|
||||
})
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
@@ -299,34 +357,58 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
return true
|
||||
}
|
||||
|
||||
const pendingSide = (key: string) => pending()[key]
|
||||
|
||||
const beginSide = (key: string, createId: string) => {
|
||||
requests.set(createId, { contextKey: key, cancelled: false })
|
||||
setPending((prev) => ({ ...prev, [key]: createId }))
|
||||
}
|
||||
|
||||
const cancelSide = (key: string): boolean => {
|
||||
const id = pending()[key]
|
||||
if (!id) return false
|
||||
const request = requests.get(id)
|
||||
if (request) request.cancelled = true
|
||||
setPending((prev) => {
|
||||
const next = { ...prev }
|
||||
delete next[key]
|
||||
return next
|
||||
/**
|
||||
* Reorder the side terminals of a context by moving `from` to `to`'s
|
||||
* position (side-panel strip drag-and-drop). Tab terminals keep their
|
||||
* leading positions; only the side subset is reshuffled. The order
|
||||
* lives in the same `terminalsByContext` list, so it survives sidebar
|
||||
* context switches for the lifetime of the webview.
|
||||
*/
|
||||
const reorderSideDrag = (key: string, from: string, to: string): boolean => {
|
||||
const order = sidesForContext(key).map((t) => t.id)
|
||||
const fi = order.indexOf(from)
|
||||
const ti = order.indexOf(to)
|
||||
if (fi === -1 || ti === -1 || fi === ti) return false
|
||||
const next = [...order]
|
||||
next.splice(fi, 1)
|
||||
next.splice(ti, 0, from)
|
||||
setTerminalsByContext((prev) => {
|
||||
const list = prev[key]
|
||||
if (!list || list.length === 0) return prev
|
||||
const tabs = list.filter((t) => t.placement === "tab")
|
||||
const sides = list.filter((t) => t.placement === "side")
|
||||
const byId = new Map(sides.map((t) => [t.id, t]))
|
||||
const moved: TerminalTabStateWithContext[] = []
|
||||
for (const id of next) {
|
||||
const t = byId.get(id)
|
||||
if (t) moved.push(t)
|
||||
}
|
||||
// Fresh terminals that appeared mid-drag keep their tail position.
|
||||
for (const t of sides) if (!moved.includes(t)) moved.push(t)
|
||||
const ordered = [...tabs, ...moved]
|
||||
if (ordered.length === list.length && ordered.every((t, i) => t.id === list[i]!.id)) return prev
|
||||
return { ...prev, [key]: ordered }
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
const pendingSide = (key: string) => (pending()[key]?.length ?? 0) > 0
|
||||
|
||||
const beginSide = (key: string, createId: string) => {
|
||||
requests.set(createId, { contextKey: key })
|
||||
setPending((prev) => ({ ...prev, [key]: [...(prev[key] ?? []), createId] }))
|
||||
}
|
||||
|
||||
const completeSide = (createId: string): SideRequest | undefined => {
|
||||
const request = requests.get(createId)
|
||||
if (!request) return undefined
|
||||
requests.delete(createId)
|
||||
setPending((prev) => {
|
||||
if (prev[request.contextKey] !== createId) return prev
|
||||
const list = (prev[request.contextKey] ?? []).filter((id) => id !== createId)
|
||||
if (list.length === (prev[request.contextKey]?.length ?? 0)) return prev
|
||||
const next = { ...prev }
|
||||
delete next[request.contextKey]
|
||||
if (list.length === 0) delete next[request.contextKey]
|
||||
else next[request.contextKey] = list
|
||||
return next
|
||||
})
|
||||
return request
|
||||
@@ -341,8 +423,10 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
current,
|
||||
all,
|
||||
sides,
|
||||
side,
|
||||
sideForContext,
|
||||
sidesForContext,
|
||||
sideActiveFor,
|
||||
setSideActive,
|
||||
sideFocusedId,
|
||||
currentKey,
|
||||
sideKey,
|
||||
activeId,
|
||||
@@ -352,11 +436,13 @@ export function createTerminalState(selection: Accessor<string | null>): Termina
|
||||
focusRequest,
|
||||
requestFocus,
|
||||
hasRemembered,
|
||||
title,
|
||||
setTitle,
|
||||
reorder,
|
||||
reorderDrag,
|
||||
reorderSideDrag,
|
||||
pendingSide,
|
||||
beginSide,
|
||||
cancelSide,
|
||||
completeSide,
|
||||
}
|
||||
}
|
||||
@@ -376,8 +462,6 @@ export interface TerminalHandlerDeps {
|
||||
onRemove?: () => void
|
||||
/** Reveal the right-side inspector in terminal mode. */
|
||||
onShowSide: (contextKey: string) => void
|
||||
/** Leave terminal mode without killing the terminal. */
|
||||
onHideSide: () => void
|
||||
/** Resolve the current sidebar selection for the new-terminal helper. */
|
||||
getSelection: () => string | null
|
||||
/** Sentinel value for the LOCAL sidebar selection. */
|
||||
@@ -418,31 +502,42 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the side panel and create-or-focus the context's side
|
||||
* terminal. Reuses the existing terminal when one is alive, dedupes
|
||||
* against an in-flight create, and never touches the tab strip or
|
||||
* the chat session.
|
||||
* Always create a fresh side terminal for the current context (the
|
||||
* panel's `+` action and empty state). Multiple creates may be in
|
||||
* flight at once; each lands as its own tab in the panel strip.
|
||||
*/
|
||||
const requestSide = () => {
|
||||
const addSide = () => {
|
||||
const key = deps.state.sideKey()
|
||||
deps.onShowSide(key)
|
||||
const existing = deps.state.sideForContext(key)
|
||||
if (existing) {
|
||||
deps.state.requestFocus(existing.id)
|
||||
return
|
||||
}
|
||||
if (deps.state.pendingSide(key)) return
|
||||
const id = newId()
|
||||
deps.state.beginSide(key, id)
|
||||
const sel = deps.getSelection()
|
||||
deps.postMessage({
|
||||
type: "agentManager.terminal.create",
|
||||
createId: id,
|
||||
placement: "side",
|
||||
worktreeId: sel === null || sel === deps.LOCAL ? null : sel,
|
||||
worktreeId: key === deps.LOCAL ? null : key,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal the side panel and focus the context's active side terminal,
|
||||
* creating one when the context has none. Never touches the tab strip
|
||||
* or the chat session.
|
||||
*/
|
||||
const requestSide = () => {
|
||||
const key = deps.state.sideKey()
|
||||
deps.onShowSide(key)
|
||||
const existing = deps.state.sidesForContext(key)
|
||||
if (existing.length > 0) {
|
||||
const active = deps.state.sideActiveFor(key) ?? existing[existing.length - 1]!.id
|
||||
deps.state.setSideActive(key, active)
|
||||
deps.state.requestFocus(active)
|
||||
return
|
||||
}
|
||||
if (deps.state.pendingSide(key)) return
|
||||
addSide()
|
||||
}
|
||||
|
||||
const closeTerminal = (terminalId: string) => {
|
||||
deps.onRemove?.()
|
||||
const ids = deps.tabIds()
|
||||
@@ -477,19 +572,29 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the current context's side terminal and hide the panel. With
|
||||
* a create still in flight, cancels it instead — the late answer is
|
||||
* closed by the message handler.
|
||||
* Kill one side terminal. The panel stays open on the remaining
|
||||
* terminals (or the empty state when this was the last one) — hiding
|
||||
* is the toggle's job, not the close button's. Active-tab fallback
|
||||
* is handled by the state layer.
|
||||
*/
|
||||
const closeSide = () => {
|
||||
const term = deps.state.side()
|
||||
deps.onHideSide()
|
||||
if (!term) return deps.state.cancelSide(deps.state.sideKey())
|
||||
deps.state.remove(term.id)
|
||||
deps.postMessage({ type: "agentManager.terminal.close", terminalId: term.id })
|
||||
const closeSide = (terminalId: string): boolean => {
|
||||
// Validate before mutating: dropping a non-side record here would
|
||||
// unmount its xterm while the backend PTY leaks (no close sent).
|
||||
const term = deps.state.sides().find((t) => t.id === terminalId)
|
||||
if (!term) return false
|
||||
deps.state.remove(terminalId)
|
||||
deps.postMessage({ type: "agentManager.terminal.close", terminalId })
|
||||
return true
|
||||
}
|
||||
|
||||
/** Make a side terminal the visible one in its panel and focus it. */
|
||||
const selectSide = (terminalId: string) => {
|
||||
const key = deps.state.contextFor(terminalId)
|
||||
if (!key) return
|
||||
deps.state.setSideActive(key, terminalId)
|
||||
deps.state.requestFocus(terminalId)
|
||||
}
|
||||
|
||||
const middleClick = (terminalId: string, e: MouseEvent) => {
|
||||
if (e.button !== 1) return
|
||||
e.preventDefault()
|
||||
@@ -504,7 +609,18 @@ export function createTerminalHandlers(deps: TerminalHandlerDeps) {
|
||||
return true
|
||||
}
|
||||
|
||||
return { closeTerminal, closeSide, middleClick, activate, deactivate, requestNew, requestSide, closeActive }
|
||||
return {
|
||||
closeTerminal,
|
||||
closeSide,
|
||||
selectSide,
|
||||
middleClick,
|
||||
activate,
|
||||
deactivate,
|
||||
requestNew,
|
||||
requestSide,
|
||||
addSide,
|
||||
closeActive,
|
||||
}
|
||||
}
|
||||
|
||||
export interface TerminalMessageHandlerDeps {
|
||||
@@ -545,14 +661,17 @@ function handleCreated(deps: TerminalMessageHandlerDeps, msg: CreatedMessage) {
|
||||
}
|
||||
if (msg.placement === "side") {
|
||||
// Side terminals are answered to a specific pending request. A
|
||||
// missing, cancelled, or context-mismatched request means the user
|
||||
// already moved on — close the PTY again instead of leaking it.
|
||||
// missing or context-mismatched request means the webview was
|
||||
// reloaded (or the context is gone) — close the PTY again instead
|
||||
// of leaking it.
|
||||
const request = deps.state.completeSide(msg.createId)
|
||||
if (!request || request.cancelled || request.contextKey !== contextKey) {
|
||||
if (!request || request.contextKey !== contextKey) {
|
||||
deps.postMessage({ type: "agentManager.terminal.close", terminalId: msg.terminalId })
|
||||
return
|
||||
}
|
||||
deps.state.add(msg.worktreeId, term)
|
||||
// The newest terminal becomes the visible one in its panel.
|
||||
deps.state.setSideActive(contextKey, msg.terminalId)
|
||||
deps.onSideCreated?.(contextKey, msg.terminalId)
|
||||
return
|
||||
}
|
||||
@@ -583,8 +702,6 @@ export function createTerminalMessageHandler(deps: TerminalMessageHandlerDeps) {
|
||||
}
|
||||
if (msg.type === "agentManager.terminal.error") {
|
||||
const request = msg.createId ? deps.state.completeSide(msg.createId) : undefined
|
||||
// Errors for requests the user already cancelled are noise.
|
||||
if (request?.cancelled) return true
|
||||
if (request) deps.onSideError?.(request.contextKey)
|
||||
deps.showError(msg.message)
|
||||
return true
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* Worktree diff data for Agent Manager.
|
||||
*
|
||||
* Owns the per-session diff map, the panel loading flag, and the per-file
|
||||
* pending set, plus the backend message handlers that fill them and the
|
||||
* helpers that request individual files. Extracted from `AgentManagerApp.tsx`
|
||||
* so the app component only routes the diff messages and reads the signals.
|
||||
*/
|
||||
|
||||
import { createSignal, type Accessor } from "solid-js"
|
||||
import { mergeWorktreeDiffs } from "../diff-viewer/diff-state"
|
||||
import { parseDiffId } from "./diff-scope-state"
|
||||
import type { useVSCode } from "../src/context/vscode"
|
||||
import type {
|
||||
AgentManagerWorktreeDiffFileMessage,
|
||||
AgentManagerWorktreeDiffLoadingMessage,
|
||||
AgentManagerWorktreeDiffMessage,
|
||||
WorktreeFileDiff,
|
||||
} from "../src/types/messages"
|
||||
|
||||
/**
|
||||
* Decompose a composite diff id (`ctx#scope`) into the wire fields the
|
||||
* extension expects. Bare ids (no scope separator) parse to the default
|
||||
* branch scope.
|
||||
*/
|
||||
function wire(id: string) {
|
||||
const { ctx, scope } = parseDiffId(id)
|
||||
return { sessionId: ctx, scope }
|
||||
}
|
||||
|
||||
export function createWorktreeDiffs(vscode: ReturnType<typeof useVSCode>) {
|
||||
const [diffDatas, setDiffDatas] = createSignal<Record<string, WorktreeFileDiff[]>>({})
|
||||
const [diffLoading, setDiffLoading] = createSignal(false)
|
||||
const [diffFileLoading, setDiffFileLoading] = createSignal<Record<string, Record<string, true>>>({})
|
||||
|
||||
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,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Lazily load a single file's full diff for the given composite diff id. */
|
||||
const requestDiffFile = (id: string, file: string) => {
|
||||
if (diffFileLoading()[id]?.[file]) return
|
||||
setDiffFilePending(id, file, true)
|
||||
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wire(id) })
|
||||
}
|
||||
|
||||
/** Files the backend flagged as stale in a merged update need a fresh fetch. */
|
||||
const refreshStaleDiffs = (id: string, files: Set<string>) => {
|
||||
const loading = diffFileLoading()[id] ?? {}
|
||||
for (const file of files) {
|
||||
if (loading[file]) continue
|
||||
setDiffFilePending(id, file, true)
|
||||
vscode.postMessage({ type: "agentManager.requestWorktreeDiffFile", file, ...wire(id) })
|
||||
}
|
||||
}
|
||||
|
||||
/** Files currently being fetched for a session, for per-file spinners. */
|
||||
const diffFileLoadingFor = (sessionId: Accessor<string | undefined>) => {
|
||||
const id = sessionId()
|
||||
if (!id) return new Set<string>()
|
||||
return new Set(Object.keys(diffFileLoading()[id] ?? {}))
|
||||
}
|
||||
|
||||
// Backend messages.
|
||||
|
||||
const onWorktreeDiff = (ev: 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)
|
||||
}
|
||||
|
||||
const onWorktreeDiffFile = (ev: 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)
|
||||
}
|
||||
|
||||
const onWorktreeDiffLoading = (ev: AgentManagerWorktreeDiffLoadingMessage) => {
|
||||
setDiffLoading(ev.loading)
|
||||
}
|
||||
|
||||
return {
|
||||
diffDatas,
|
||||
diffLoading,
|
||||
setDiffLoading,
|
||||
requestDiffFile,
|
||||
refreshStaleDiffs,
|
||||
diffFileLoadingFor,
|
||||
onWorktreeDiff,
|
||||
onWorktreeDiffFile,
|
||||
onWorktreeDiffLoading,
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user